diff --git a/.bumpversion.toml b/.bumpversion.toml index 601b14d3f..a015353cb 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.37.1-beta.0" +current_version = "0.37.1-beta.1" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml index 0e22100eb..1286819bc 100644 --- a/.github/workflows/docs-link-check.yml +++ b/.github/workflows/docs-link-check.yml @@ -36,7 +36,9 @@ jobs: permissions: contents: read outputs: + checker_outcome: ${{ steps.lychee.outcome }} exit_code: ${{ steps.lychee.outputs.exit_code }} + status: ${{ steps.validate.outputs.status }} steps: - name: Checkout uses: actions/checkout@v6 @@ -50,6 +52,7 @@ jobs: - name: Check links id: lychee + continue-on-error: true uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: # Restricted to http(s) on purpose. Much of docs/src is generated @@ -68,38 +71,50 @@ jobs: format: json output: ./lychee/out.json jobSummary: false - # The report, not a red build, is the signal for broken links. The - # validation step below still fails the run if the check itself - # breaks. + # The report issue, not a red workflow run, is the signal for link + # findings and checker failures alike. fail: false - name: Validate report + id: validate # lychee does not reserve exit code 2 for broken links: its CLI # parser also exits 2 on an invalid option, before any link was # checked or any report written. Only a parseable report whose - # counts agree with the exit code counts as a link verdict; anything - # else fails here, and the report job below is skipped entirely, so - # the tracking issue is never touched. Exit 2 covers timeouts as - # well as errors, and a timed-out host is exactly the transient - # unavailability this report exists to surface, so both count as - # findings. Requiring total > 0 also catches a glob that silently - # stopped matching any file. - if: steps.lychee.outputs.exit_code == 0 || steps.lychee.outputs.exit_code == 2 + # counts agree with a completed exit code (0 or 2) counts as a link + # verdict. Everything else becomes a checker-error report instead of + # failing the workflow. Exit 2 covers timeouts as well as errors, and a + # timed-out host is exactly the transient unavailability this report + # exists to surface, so both count as findings. Requiring total > 0 + # also catches a glob that silently stopped matching any file. + if: always() env: + CHECKER_OUTCOME: ${{ steps.lychee.outcome }} EXIT_CODE: ${{ steps.lychee.outputs.exit_code }} run: | - jq -e --argjson code "$EXIT_CODE" ' - (.total > 0) and - (if $code == 0 - then .errors == 0 and .timeouts == 0 - and (.error_map | length == 0) and (.timeout_map | length == 0) - else (.errors + .timeouts) > 0 - and ((.error_map | length) + (.timeout_map | length)) > 0 - end) - ' ./lychee/out.json + status=checker-error + if [[ "$CHECKER_OUTCOME" == success ]] && + [[ "$EXIT_CODE" == 0 || "$EXIT_CODE" == 2 ]] && + jq -e --argjson code "$EXIT_CODE" ' + (.total > 0) and + (if $code == 0 + then .errors == 0 and .timeouts == 0 + and (.error_map | length == 0) and (.timeout_map | length == 0) + else (.errors + .timeouts) > 0 + and ((.error_map | length) + (.timeout_map | length)) > 0 + end) + ' ./lychee/out.json + then + if [[ "$EXIT_CODE" == 0 ]]; then + status=healthy + else + status=findings + fi + fi + echo "status=$status" >> "$GITHUB_OUTPUT" + echo "Validated link check as $status" - name: Upload report - if: steps.lychee.outputs.exit_code == 2 + if: steps.validate.outputs.status == 'findings' uses: actions/upload-artifact@v7 with: name: link-report @@ -115,26 +130,11 @@ jobs: permissions: issues: write env: + CHECKER_OUTCOME: ${{ needs.scan.outputs.checker_outcome }} EXIT_CODE: ${{ needs.scan.outputs.exit_code }} + STATUS: ${{ needs.scan.outputs.status }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Classify checker result - # lychee exits 0 when every link resolves and 2 when links fail, - # both already cross-checked against the report by the scan job's - # validation step. Anything else (1 runtime, 3 bad config) means the - # check never produced a link verdict, which must surface as a failed - # run rather than be published as "broken documentation links". - run: | - case "$EXIT_CODE" in - 0|2) - echo "lychee exit code $EXIT_CODE" - ;; - *) - echo "::error::lychee exited with '$EXIT_CODE': the link check did not complete. Leaving the report issue untouched." - exit 1 - ;; - esac - - name: Find existing report issue id: report # Matched on title alone, and through search rather than a listing: @@ -144,7 +144,7 @@ jobs: # Closed issues are included because a healthy run closes the report: # an open-only lookup would forget that identity and the next failing # run would open a duplicate. The oldest match stays the canonical - # report and is reopened below when links break again. + # report and is reopened below when a problem recurs. run: | match=$(gh issue list --repo "$GITHUB_REPOSITORY" --state all \ --search "in:title \"$REPORT_TITLE\" author:app/github-actions" \ @@ -154,14 +154,14 @@ jobs: echo "state=$(jq -r '.state // empty' <<<"$match")" >> "$GITHUB_OUTPUT" - name: Download report - if: env.EXIT_CODE == 2 + if: env.STATUS == 'findings' uses: actions/download-artifact@v8 with: name: link-report path: ./lychee - name: Compose report - if: env.EXIT_CODE == 2 + if: env.STATUS == 'findings' run: | run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" { @@ -185,22 +185,41 @@ jobs: ' ./lychee/out.json } > ./lychee/issue.md + - name: Compose checker error report + if: env.STATUS == 'checker-error' + run: | + mkdir -p ./lychee + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "The documentation link check did not complete in [the latest run]($run_url)." + echo + echo "This issue is rewritten by every scheduled run and closed automatically once a trustworthy run finds that all links resolve." + echo + echo "The checker did not produce a trustworthy link verdict. Treat the previous result, if any, as stale until a later run completes." + echo + echo "* Action outcome: \`$CHECKER_OUTCOME\`" + echo "* Exit code: \`${EXIT_CODE:-not reported}\`" + echo "* Verdict validation: \`failed\`" + } > ./lychee/issue.md + - name: Reopen report issue # A healthy run closes the report, and the issue action below only # rewrites the body of whatever number it is given. Without an - # explicit reopen, the 2 -> 0 -> 2 sequence would keep rewriting a - # closed issue while links are broken. A CLOSED state implies the - # lookup found a canonical issue, so no separate emptiness check. - if: env.EXIT_CODE == 2 && steps.report.outputs.state == 'CLOSED' + # explicit reopen, a later finding or checker error would rewrite a + # closed issue. A CLOSED state implies the lookup found a canonical + # issue, so no separate emptiness check. + if: >- + env.STATUS != 'healthy' && + steps.report.outputs.state == 'CLOSED' env: ISSUE_NUMBER: ${{ steps.report.outputs.number }} run: | run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" gh issue reopen "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --comment "Broken documentation links found again in [the latest run]($run_url)." + --comment "The documentation link checker reported a problem again in [the latest run]($run_url)." - - name: Report broken links - if: env.EXIT_CODE == 2 + - name: Report link-check problem + if: env.STATUS != 'healthy' uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0 with: # Empty on the first failing run, which creates the issue; afterwards @@ -213,7 +232,9 @@ jobs: - name: Close report issue once links are healthy # An OPEN state implies the lookup found a canonical issue; a report # that is already closed needs nothing. - if: env.EXIT_CODE == 0 && steps.report.outputs.state == 'OPEN' + if: >- + env.STATUS == 'healthy' && + steps.report.outputs.state == 'OPEN' env: ISSUE_NUMBER: ${{ steps.report.outputs.number }} run: | diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 74b7d05e6..4f5a927dc 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -69,6 +69,16 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.10" + - name: Add swap for Arm fat LTO + if: matrix.config.platform == 'aarch64' + shell: bash + run: | + swap_file="$RUNNER_TEMP/lancedb-swap" + sudo fallocate --length 16G "$swap_file" + sudo chmod 600 "$swap_file" + sudo mkswap "$swap_file" + sudo swapon "$swap_file" + free -h - uses: ./.github/workflows/build_linux_wheel with: python-minor-version: 10 diff --git a/Cargo.lock b/Cargo.lock index 93e16c06d..04332a496 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3455,8 +3455,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "rand 0.9.5", @@ -4815,8 +4815,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arc-swap", "arrow", @@ -4890,8 +4890,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-buffer", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-buffer", @@ -4927,7 +4927,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-schema", @@ -4936,8 +4936,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrayref", "crunchy", @@ -4947,8 +4947,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-buffer", @@ -4988,8 +4988,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "arrow-array", @@ -5019,8 +5019,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "arrow-array", @@ -5037,8 +5037,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "proc-macro2", "quote", @@ -5047,8 +5047,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-arith", "arrow-array", @@ -5082,8 +5082,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-arith", "arrow-array", @@ -5114,8 +5114,8 @@ dependencies = [ [[package]] name = "lance-index" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arc-swap", "arrow", @@ -5182,8 +5182,8 @@ dependencies = [ [[package]] name = "lance-index-core" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-schema", @@ -5205,8 +5205,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "arrow-array", @@ -5242,8 +5242,8 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-buffer", @@ -5259,8 +5259,8 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "async-trait", @@ -5272,8 +5272,8 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "arrow-ipc", @@ -5303,7 +5303,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "time", "tokio", "tower", "tower-http 0.5.2", @@ -5327,8 +5326,8 @@ dependencies = [ [[package]] name = "lance-select" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-buffer", @@ -5343,8 +5342,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow", "arrow-array", @@ -5354,6 +5353,7 @@ dependencies = [ "async-trait", "aws-credential-types", "aws-sdk-dynamodb", + "blake3", "byteorder", "bytes", "chrono", @@ -5383,8 +5383,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "arrow-array", "arrow-schema", @@ -5397,8 +5397,8 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "11.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.2#35da5d920159b49d1b53032652f7615ab699c160" +version = "11.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.7#e581c49338bc83baf1ea50c5e235bd702f3fbeea" dependencies = [ "frostem", "icu_segmenter", @@ -5411,7 +5411,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" dependencies = [ "ahash", "anyhow", @@ -5447,7 +5447,6 @@ dependencies = [ "datafusion-physical-plan", "datafusion-sql", "futures", - "goosefs-sdk", "half", "hf-hub", "http 1.5.0", @@ -5480,6 +5479,7 @@ dependencies = [ "random_word", "regex", "reqwest 0.12.28", + "roaring", "rstest", "semver", "serde", @@ -5499,7 +5499,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -5524,7 +5524,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" dependencies = [ "arrow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 78474cd16..33bf7e09b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,20 @@ categories = ["database-implementations"] rust-version = "1.91.0" [workspace.dependencies] -lance = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=11.0.0-beta.7", default-features = false, "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=11.0.0-beta.7", default-features = false, "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=11.0.0-beta.7", default-features = false, "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=11.0.0-beta.7", "tag" = "v11.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } ahash = "0.8" # Note that this one does not include pyarrow arrow = { version = "58.0.0", optional = false } diff --git a/deny.toml b/deny.toml index 034b48c25..d94c9d536 100644 --- a/deny.toml +++ b/deny.toml @@ -101,6 +101,13 @@ ignore = [ # https://rustsec.org/advisories/RUSTSEC-2026-0195 { id = "RUSTSEC-2026-0194", reason = "transitive via inferno/lance/opendal; XML from trusted cloud endpoints, not attacker-controlled" }, { id = "RUSTSEC-2026-0195", reason = "transitive via inferno/lance/opendal; XML from trusted cloud endpoints, not attacker-controlled" }, + # smartstring: unmaintained — the repository was archived by its author on + # 2026-05-03. Not a vulnerability. Reached only transitively through polars + # (polars-core/-io/-ops/-time/-utils); nothing in LanceDB depends on it directly. + # The advisory states no safe upgrade is available: upstream recommends + # compact_str/smol_str, so clearing this requires polars to migrate. + # https://rustsec.org/advisories/RUSTSEC-2026-0249 + { id = "RUSTSEC-2026-0249", reason = "smartstring unmaintained via polars; no fixed upstream release" }, ] # --------------------------------------------------------------------------- diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 11e901ad0..091588922 100644 --- a/docs/src/java/java.md +++ b/docs/src/java/java.md @@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`: com.lancedb lancedb-core - 0.37.1-beta.0 + 0.37.1-beta.1 ``` diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index 11fca32d0..3fa3b08db 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -431,9 +431,10 @@ Read the [LsmWriteSpec](../interfaces/LsmWriteSpec.md) currently installed on th Resolves to `undefined` when the MemWAL LSM write path is not enabled (no spec has been set, or it was removed with [Table#unsetLsmWriteSpec](Table.md#unsetlsmwritespec)). -The returned spec — including its `maintainedIndexes` and -`writerConfigDefaults` — mirrors what was passed to -[Table#setLsmWriteSpec](Table.md#setlsmwritespec). +The returned spec mirrors what was passed to +[Table#setLsmWriteSpec](Table.md#setlsmwritespec), except that `maintainedIndexes` always +reports the concrete list resolved when the spec was set — `undefined` +never round-trips. #### Returns @@ -806,6 +807,11 @@ All variants require the table to have an unenforced primary key ([Table#setUnenforcedPrimaryKey](Table.md#setunenforcedprimarykey)); bucket sharding additionally requires it to be the single column being bucketed. +Omitting `maintainedIndexes` maintains every index on the table, resolved +here, failing if one cannot be maintained — name them to install anyway. +Naming them pins an exact set, and a still-building index is rejected +rather than quietly omitted. + #### Parameters * **spec**: [`LsmWriteSpec`](../interfaces/LsmWriteSpec.md) diff --git a/docs/src/js/interfaces/LsmWriteSpec.md b/docs/src/js/interfaces/LsmWriteSpec.md index 8a588df6a..f2ae91186 100644 --- a/docs/src/js/interfaces/LsmWriteSpec.md +++ b/docs/src/js/interfaces/LsmWriteSpec.md @@ -34,7 +34,9 @@ Bucket and identity variants: the sharding column. optional maintainedIndexes: string[]; ``` -Names of indexes the MemWAL should keep up to date during writes. +Indexes the MemWAL keeps up to date. Omit to maintain every supported +index, resolved on install — a snapshot, so indexes created later are not +maintained. Pass `[]` for none. *** diff --git a/docs/src/js/interfaces/TableStatistics.md b/docs/src/js/interfaces/TableStatistics.md index e19cba119..e2e8ef34d 100644 --- a/docs/src/js/interfaces/TableStatistics.md +++ b/docs/src/js/interfaces/TableStatistics.md @@ -44,4 +44,7 @@ The number of rows in the table totalBytes: number; ``` -The total number of bytes in the table +The total size, in bytes, of the table's data files, index files, and +overlay files + +Read from the manifest, so this excludes deletion files and manifests. diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 3df2ac178..20f69e134 100644 --- a/java/lancedb-core/pom.xml +++ b/java/lancedb-core/pom.xml @@ -8,7 +8,7 @@ com.lancedb lancedb-parent - 0.37.1-beta.0 + 0.37.1-beta.1 ../pom.xml diff --git a/java/pom.xml b/java/pom.xml index e8f030b27..4fdf77e81 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.37.1-beta.0 + 0.37.1-beta.1 pom ${project.artifactId} LanceDB Java SDK Parent POM @@ -28,7 +28,7 @@ UTF-8 15.0.0 - 11.0.0-beta.2 + 11.0.0-beta.7 false 2.30.0 1.7 diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 10e12edc8..48e5f5295 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "lancedb-nodejs" edition.workspace = true -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" publish = false license.workspace = true description.workspace = true diff --git a/nodejs/__test__/arrow.test.ts b/nodejs/__test__/arrow.test.ts index c05849cb9..29030d4f8 100644 --- a/nodejs/__test__/arrow.test.ts +++ b/nodejs/__test__/arrow.test.ts @@ -6,7 +6,9 @@ import * as arrow17 from "apache-arrow-17"; import * as arrow18 from "apache-arrow-18"; import { + Vector as CurrentVector, convertToTable, + tableFromIPC as currentTableFromIPC, fromBufferToRecordBatch, fromDataToBuffer, fromRecordBatchToBuffer, @@ -19,6 +21,7 @@ import { FunctionOptions, } from "../lancedb/embedding/embedding_function"; import { EmbeddingFunctionConfig } from "../lancedb/embedding/registry"; +import { sanitizeTable } from "../lancedb/sanitize"; // biome-ignore lint/suspicious/noExplicitAny: skip function sampleRecords(): Array> { @@ -64,7 +67,11 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( tableFromIPC, DataType, Dictionary, + RecordBatch: ArrowRecordBatch, + Table: ArrowTable, Uint8: ArrowUint8, + makeData: arrowMakeData, + vectorFromArray, // biome-ignore lint/suspicious/noExplicitAny: } = arrow; type Schema = ApacheArrow["Schema"]; @@ -1054,6 +1061,114 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( }); describe("when using two versions of arrow", function () { + it("preserves a dictionary shared by multiple fields", async function () { + const values = ["alpha", "beta", "alpha"]; + const dictionaryVector = vectorFromArray(values); + const batch = new ArrowRecordBatch({ + first: dictionaryVector.data[0], + second: dictionaryVector.data[0], + }); + const table = new ArrowTable([batch]); + + const sanitized = sanitizeTable(table); + expect([...sanitized.getChild("first")!]).toEqual(values); + expect([...sanitized.getChild("second")!]).toEqual(values); + const firstType = sanitized.schema.fields[0].type as { + dictionary: unknown; + }; + const secondType = sanitized.schema.fields[1].type as { + dictionary: unknown; + }; + expect(secondType.dictionary).toBe(firstType.dictionary); + expect(sanitized.batches[0].data.children[1].dictionary).toBe( + sanitized.batches[0].data.children[0].dictionary, + ); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("first")!]).toEqual(values); + expect([...actual.getChild("second")!]).toEqual(values); + }); + + it("preserves shared dictionary data from another Arrow version", async function () { + const values = ["alpha", "beta", "alpha"]; + const dictionaryVector = vectorFromArray(values); + const firstBatch = new ArrowRecordBatch({ + label: dictionaryVector.slice(0, 2).data[0], + }); + const secondBatch = new ArrowRecordBatch({ + label: dictionaryVector.slice(2).data[0], + }); + const table = new ArrowTable([firstBatch, secondBatch]); + + const sanitized = sanitizeTable(table); + expect([...sanitized.getChild("label")!]).toEqual(values); + + const dictionaries = sanitized.batches.map( + (batch) => batch.data.children[0].dictionary, + ); + expect(dictionaries[0]).toBeInstanceOf(CurrentVector); + expect(dictionaries[1]).toBe(dictionaries[0]); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("label")!]).toEqual(values); + }); + + it("preserves shared chunks in growing dictionaries", async function () { + const type = new Dictionary(new Utf8(), new Int32(), 42, false); + const firstDictionary = vectorFromArray(["alpha", "beta"], new Utf8()); + const secondDictionary = firstDictionary.concat( + vectorFromArray(["gamma"], new Utf8()), + ); + const firstData = arrowMakeData({ + type, + data: Int32Array.from([0, 1]), + dictionary: firstDictionary, + }); + const secondData = arrowMakeData({ + type, + data: Int32Array.from([2]), + dictionary: secondDictionary, + }); + const table = new ArrowTable([ + new ArrowRecordBatch({ label: firstData }), + new ArrowRecordBatch({ label: secondData }), + ]); + + const sanitized = sanitizeTable(table); + const expected = ["alpha", "beta", "gamma"]; + expect([...sanitized.getChild("label")!]).toEqual(expected); + const firstLocalDictionary = + sanitized.batches[0].data.children[0].dictionary!; + const secondLocalDictionary = + sanitized.batches[1].data.children[0].dictionary!; + expect(secondLocalDictionary.data[0]).toBe( + firstLocalDictionary.data[0], + ); + + const buf = await fromTableToBuffer(sanitized); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("label")!]).toEqual(expected); + }); + + it("can serialize list data from another Arrow version", async function () { + const values = [["anime", "action"], [], null]; + const vector = vectorFromArray( + values, + new List(new Field("item", new Utf8(), true)), + ); + const table = new ArrowTable({ tags: vector }); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + const actualTags = actual.getChild("tags"); + + expect(actualTags?.get(0)?.toJSON()).toEqual(values[0]); + expect(actualTags?.get(1)?.toJSON()).toEqual(values[1]); + expect(actualTags?.get(2)).toBeNull(); + }); + it("can still import data", async function () { const schema = new arrow15.Schema([ new arrow15.Field("id", new arrow15.Int32()), diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 15d6e0804..d263d9cab 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -277,8 +277,16 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( }, numIndices: 0, numRows: 3, - totalBytes: 44, + // Full on-disk size of the two data files, footers and metadata included. + totalBytes: 684, }); + + // Index files count toward totalBytes too (only deletion files and + // manifests are excluded). + await table.createIndex("id", { config: Index.btree() }); + const statsWithIndex = await table.stats(); + expect(statsWithIndex.numIndices).toBe(1); + expect(statsWithIndex.totalBytes).toBeGreaterThan(684); }); it("should overwrite data if asked", async () => { diff --git a/nodejs/lancedb/sanitize.ts b/nodejs/lancedb/sanitize.ts index ae0bc0179..8fb2f1a0a 100644 --- a/nodejs/lancedb/sanitize.ts +++ b/nodejs/lancedb/sanitize.ts @@ -9,7 +9,7 @@ // comes from the exact same library instance. This is not always the case // and so we must sanitize the input to ensure that it is compatible. -import { BufferType, Data } from "apache-arrow"; +import { BufferType, Data, Vector } from "apache-arrow"; import type { IntBitWidth, TKeys, TimeBitWidth } from "apache-arrow/type"; import { Binary, @@ -74,6 +74,20 @@ import { Utf8, } from "./arrow"; +type SanitizationContext = { + types: WeakMap; + vectors: WeakMap; + data: WeakMap>; +}; + +function createSanitizationContext(): SanitizationContext { + return { + types: new WeakMap(), + vectors: new WeakMap(), + data: new WeakMap(), + }; +} + export function sanitizeMetadata( metadataLike?: unknown, ): Map | undefined { @@ -186,6 +200,13 @@ export function sanitizeInterval(typeLike: object) { } export function sanitizeList(typeLike: object) { + return sanitizeListWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeListWithContext( + typeLike: object, + context: SanitizationContext, +) { if (!("children" in typeLike) || !Array.isArray(typeLike.children)) { throw Error( "Expected a List type to have an array-like `children` property", @@ -194,19 +215,35 @@ export function sanitizeList(typeLike: object) { if (typeLike.children.length !== 1) { throw Error("Expected a List type to have exactly one child"); } - return new List(sanitizeField(typeLike.children[0])); + return new List(sanitizeFieldWithContext(typeLike.children[0], context)); } export function sanitizeStruct(typeLike: object) { + return sanitizeStructWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeStructWithContext( + typeLike: object, + context: SanitizationContext, +) { if (!("children" in typeLike) || !Array.isArray(typeLike.children)) { throw Error( "Expected a Struct type to have an array-like `children` property", ); } - return new Struct(typeLike.children.map((child) => sanitizeField(child))); + return new Struct( + typeLike.children.map((child) => sanitizeFieldWithContext(child, context)), + ); } export function sanitizeUnion(typeLike: object) { + return sanitizeUnionWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeUnionWithContext( + typeLike: object, + context: SanitizationContext, +) { if ( !("typeIds" in typeLike) || !("mode" in typeLike) || @@ -226,7 +263,7 @@ export function sanitizeUnion(typeLike: object) { typeLike.mode, // biome-ignore lint/suspicious/noExplicitAny: skip typeLike.typeIds as any, - typeLike.children.map((child) => sanitizeField(child)), + typeLike.children.map((child) => sanitizeFieldWithContext(child, context)), ); } @@ -234,6 +271,19 @@ export function sanitizeTypedUnion( typeLike: object, // eslint-disable-next-line @typescript-eslint/naming-convention UnionType: typeof DenseUnion | typeof SparseUnion, +) { + return sanitizeTypedUnionWithContext( + typeLike, + UnionType, + createSanitizationContext(), + ); +} + +function sanitizeTypedUnionWithContext( + typeLike: object, + // eslint-disable-next-line @typescript-eslint/naming-convention + UnionType: typeof DenseUnion | typeof SparseUnion, + context: SanitizationContext, ) { if (!("typeIds" in typeLike)) { throw Error( @@ -248,7 +298,7 @@ export function sanitizeTypedUnion( return new UnionType( typeLike.typeIds as Int32Array | number[], - typeLike.children.map((child) => sanitizeField(child)), + typeLike.children.map((child) => sanitizeFieldWithContext(child, context)), ); } @@ -262,6 +312,16 @@ export function sanitizeFixedSizeBinary(typeLike: object) { } export function sanitizeFixedSizeList(typeLike: object) { + return sanitizeFixedSizeListWithContext( + typeLike, + createSanitizationContext(), + ); +} + +function sanitizeFixedSizeListWithContext( + typeLike: object, + context: SanitizationContext, +) { if (!("listSize" in typeLike) || typeof typeLike.listSize !== "number") { throw Error("Expected a FixedSizeList type to have a `listSize` property"); } @@ -275,11 +335,18 @@ export function sanitizeFixedSizeList(typeLike: object) { } return new FixedSizeList( typeLike.listSize, - sanitizeField(typeLike.children[0]), + sanitizeFieldWithContext(typeLike.children[0], context), ); } export function sanitizeMap(typeLike: object) { + return sanitizeMapWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeMapWithContext( + typeLike: object, + context: SanitizationContext, +) { if (!("children" in typeLike) || !Array.isArray(typeLike.children)) { throw Error( "Expected a Map type to have an array-like `children` property", @@ -292,7 +359,10 @@ export function sanitizeMap(typeLike: object) { throw Error("Expected a Map type to have exactly one child"); } - return new Map_(sanitizeField(typeLike.children[0]), typeLike.keysSorted); + return new Map_( + sanitizeFieldWithContext(typeLike.children[0], context), + typeLike.keysSorted, + ); } export function sanitizeDuration(typeLike: object) { @@ -303,6 +373,13 @@ export function sanitizeDuration(typeLike: object) { } export function sanitizeDictionary(typeLike: object) { + return sanitizeDictionaryWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeDictionaryWithContext( + typeLike: object, + context: SanitizationContext, +) { if (!("id" in typeLike) || typeof typeLike.id !== "number") { throw Error("Expected a Dictionary type to have an `id` property"); } @@ -316,8 +393,8 @@ export function sanitizeDictionary(typeLike: object) { throw Error("Expected a Dictionary type to have an `isOrdered` property"); } return new Dictionary( - sanitizeType(typeLike.dictionary), - sanitizeType(typeLike.indices) as TKeys, + sanitizeTypeWithContext(typeLike.dictionary, context), + sanitizeTypeWithContext(typeLike.indices, context) as TKeys, typeLike.id, typeLike.isOrdered, ); @@ -325,12 +402,23 @@ export function sanitizeDictionary(typeLike: object) { // biome-ignore lint/suspicious/noExplicitAny: skip export function sanitizeType(typeLike: unknown): DataType { + return sanitizeTypeWithContext(typeLike, createSanitizationContext()); +} + +function sanitizeTypeWithContext( + typeLike: unknown, + context: SanitizationContext, +): DataType { if (typeof typeLike === "string") { return dataTypeFromName(typeLike); } if (typeof typeLike !== "object" || typeLike === null) { throw Error("Expected a Type but object was null/undefined"); } + const cached = context.types.get(typeLike); + if (cached !== undefined) { + return cached; + } if ( !("typeId" in typeLike) || !( @@ -349,6 +437,16 @@ export function sanitizeType(typeLike: unknown): DataType { throw Error("Type's typeId property was not a function or number"); } + const type = sanitizeTypeById(typeLike, typeId, context); + context.types.set(typeLike, type); + return type; +} + +function sanitizeTypeById( + typeLike: object, + typeId: Type, + context: SanitizationContext, +): DataType { switch (typeId) { case Type.NONE: throw Error("Received a Type with a typeId of NONE"); @@ -375,21 +473,21 @@ export function sanitizeType(typeLike: unknown): DataType { case Type.Interval: return sanitizeInterval(typeLike); case Type.List: - return sanitizeList(typeLike); + return sanitizeListWithContext(typeLike, context); case Type.Struct: - return sanitizeStruct(typeLike); + return sanitizeStructWithContext(typeLike, context); case Type.Union: - return sanitizeUnion(typeLike); + return sanitizeUnionWithContext(typeLike, context); case Type.FixedSizeBinary: return sanitizeFixedSizeBinary(typeLike); case Type.FixedSizeList: - return sanitizeFixedSizeList(typeLike); + return sanitizeFixedSizeListWithContext(typeLike, context); case Type.Map: - return sanitizeMap(typeLike); + return sanitizeMapWithContext(typeLike, context); case Type.Duration: return sanitizeDuration(typeLike); case Type.Dictionary: - return sanitizeDictionary(typeLike); + return sanitizeDictionaryWithContext(typeLike, context); case Type.Int8: return new Int8(); case Type.Int16: @@ -433,9 +531,9 @@ export function sanitizeType(typeLike: unknown): DataType { case Type.TimestampSecond: return sanitizeTypedTimestamp(typeLike, TimestampSecond); case Type.DenseUnion: - return sanitizeTypedUnion(typeLike, DenseUnion); + return sanitizeTypedUnionWithContext(typeLike, DenseUnion, context); case Type.SparseUnion: - return sanitizeTypedUnion(typeLike, SparseUnion); + return sanitizeTypedUnionWithContext(typeLike, SparseUnion, context); case Type.IntervalDayTime: return new IntervalDayTime(); case Type.IntervalYearMonth: @@ -454,6 +552,13 @@ export function sanitizeType(typeLike: unknown): DataType { } export function sanitizeField(fieldLike: unknown): Field { + return sanitizeFieldWithContext(fieldLike, createSanitizationContext()); +} + +function sanitizeFieldWithContext( + fieldLike: unknown, + context: SanitizationContext, +): Field { if (fieldLike instanceof Field) { return fieldLike; } @@ -471,7 +576,7 @@ export function sanitizeField(fieldLike: unknown): Field { } let type: DataType; try { - type = sanitizeType(fieldLike.type); + type = sanitizeTypeWithContext(fieldLike.type, context); } catch (error: unknown) { throw Error( `Unable to sanitize type for field: ${fieldLike.name} due to error: ${error}`, @@ -501,6 +606,13 @@ export function sanitizeField(fieldLike: unknown): Field { * than lancedb is using. */ export function sanitizeSchema(schemaLike: SchemaLike): Schema { + return sanitizeSchemaWithContext(schemaLike, createSanitizationContext()); +} + +function sanitizeSchemaWithContext( + schemaLike: SchemaLike, + context: SanitizationContext, +): Schema { if (schemaLike instanceof Schema) { return schemaLike; } @@ -522,7 +634,7 @@ export function sanitizeSchema(schemaLike: SchemaLike): Schema { ); } const sanitizedFields = schemaLike.fields.map((field) => - sanitizeField(field), + sanitizeFieldWithContext(field, context), ); return new Schema(sanitizedFields, metadata); } @@ -544,13 +656,18 @@ export function sanitizeTable(tableLike: TableLike): Table { "The table passed in does not appear to be a table (no 'columns' property)", ); } - const schema = sanitizeSchema(tableLike.schema); - - const batches = tableLike.batches.map(sanitizeRecordBatch); + const context = createSanitizationContext(); + const schema = sanitizeSchemaWithContext(tableLike.schema, context); + const batches = tableLike.batches.map((batch) => + sanitizeRecordBatch(batch, context), + ); return new Table(schema, batches); } -function sanitizeRecordBatch(batchLike: RecordBatchLike): RecordBatch { +function sanitizeRecordBatch( + batchLike: RecordBatchLike, + context: SanitizationContext, +): RecordBatch { if (batchLike instanceof RecordBatch) { return batchLike; } @@ -567,19 +684,43 @@ function sanitizeRecordBatch(batchLike: RecordBatchLike): RecordBatch { "The record batch passed in does not appear to be a record batch (no 'data' property)", ); } - const schema = sanitizeSchema(batchLike.schema); - const data = sanitizeData(batchLike.data); + const schema = sanitizeSchemaWithContext(batchLike.schema, context); + const data = sanitizeData(batchLike.data, context) as Data; return new RecordBatch(schema, data); } + +type DictionaryVectorLike = { + data: readonly DataLike[]; +}; + +type DictionaryDataLike = DataLike & { + dictionary?: DictionaryVectorLike; +}; + function sanitizeData( dataLike: DataLike, - // biome-ignore lint/suspicious/noExplicitAny: -): import("apache-arrow").Data> { + context: SanitizationContext, +): Data { if (dataLike instanceof Data) { return dataLike; } - return new Data( - dataLike.type, + const cachedData = context.data.get(dataLike); + if (cachedData !== undefined) { + return cachedData; + } + const dictionaryLike = (dataLike as DictionaryDataLike).dictionary; + let dictionary: Vector | undefined; + if (dictionaryLike !== undefined) { + dictionary = context.vectors.get(dictionaryLike); + if (dictionary === undefined) { + dictionary = new Vector( + dictionaryLike.data.map((data) => sanitizeData(data, context)), + ); + context.vectors.set(dictionaryLike, dictionary); + } + } + const data = new Data( + sanitizeTypeWithContext(dataLike.type, context), dataLike.offset, dataLike.length, dataLike.nullCount, @@ -589,7 +730,11 @@ function sanitizeData( [BufferType.VALIDITY]: dataLike.nullBitmap, [BufferType.TYPE]: dataLike.typeIds, }, + dataLike.children.map((child) => sanitizeData(child, context)), + dictionary, ); + context.data.set(dataLike, data); + return data; } const constructorsByTypeName = { diff --git a/nodejs/lancedb/table.ts b/nodejs/lancedb/table.ts index 3359a2643..04705475b 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -197,7 +197,11 @@ export interface LsmWriteSpec { column?: string; /** Bucket variant: the number of buckets, in `[1, 1024]`. */ numBuckets?: number; - /** Names of indexes the MemWAL should keep up to date during writes. */ + /** + * Indexes the MemWAL keeps up to date. Omit to maintain every supported + * index, resolved on install — a snapshot, so indexes created later are not + * maintained. Pass `[]` for none. + */ maintainedIndexes?: string[]; /** Default `ShardWriter` configuration recorded in the MemWAL index. */ writerConfigDefaults?: Record; @@ -595,6 +599,11 @@ export abstract class Table { * All variants require the table to have an unenforced primary key * ({@link Table#setUnenforcedPrimaryKey}); bucket sharding additionally * requires it to be the single column being bucketed. + * + * Omitting `maintainedIndexes` maintains every index on the table, resolved + * here, failing if one cannot be maintained — name them to install anyway. + * Naming them pins an exact set, and a still-building index is rejected + * rather than quietly omitted. * @param {LsmWriteSpec} spec The sharding spec to install. * @returns {Promise} * @example @@ -622,9 +631,10 @@ export abstract class Table { * * Resolves to `undefined` when the MemWAL LSM write path is not enabled (no * spec has been set, or it was removed with {@link Table#unsetLsmWriteSpec}). - * The returned spec — including its `maintainedIndexes` and - * `writerConfigDefaults` — mirrors what was passed to - * {@link Table#setLsmWriteSpec}. + * The returned spec mirrors what was passed to + * {@link Table#setLsmWriteSpec}, except that `maintainedIndexes` always + * reports the concrete list resolved when the spec was set — `undefined` + * never round-trips. * @returns {Promise} */ abstract getLsmWriteSpec(): Promise; diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index 3c93ed470..d3792f9c2 100644 --- a/nodejs/npm/darwin-arm64/package.json +++ b/nodejs/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-darwin-arm64", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["darwin"], "cpu": ["arm64"], "main": "lancedb.darwin-arm64.node", diff --git a/nodejs/npm/linux-arm64-gnu/package.json b/nodejs/npm/linux-arm64-gnu/package.json index 5ade5aaa3..44bc309ca 100644 --- a/nodejs/npm/linux-arm64-gnu/package.json +++ b/nodejs/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-arm64-gnu", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["linux"], "cpu": ["arm64"], "main": "lancedb.linux-arm64-gnu.node", diff --git a/nodejs/npm/linux-arm64-musl/package.json b/nodejs/npm/linux-arm64-musl/package.json index 16bb0edd0..e78f0fe6a 100644 --- a/nodejs/npm/linux-arm64-musl/package.json +++ b/nodejs/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-arm64-musl", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["linux"], "cpu": ["arm64"], "main": "lancedb.linux-arm64-musl.node", diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json index 6ee11e4bc..0e27c5f51 100644 --- a/nodejs/npm/linux-x64-gnu/package.json +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-x64-gnu", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["linux"], "cpu": ["x64"], "main": "lancedb.linux-x64-gnu.node", diff --git a/nodejs/npm/linux-x64-musl/package.json b/nodejs/npm/linux-x64-musl/package.json index c2e15bb9f..7bd27ba18 100644 --- a/nodejs/npm/linux-x64-musl/package.json +++ b/nodejs/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-x64-musl", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["linux"], "cpu": ["x64"], "main": "lancedb.linux-x64-musl.node", diff --git a/nodejs/npm/win32-arm64-msvc/package.json b/nodejs/npm/win32-arm64-msvc/package.json index d2820b1a1..5c76024b2 100644 --- a/nodejs/npm/win32-arm64-msvc/package.json +++ b/nodejs/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-win32-arm64-msvc", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": [ "win32" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 601b51380..f8cc7d8e0 100644 --- a/nodejs/npm/win32-x64-msvc/package.json +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-win32-x64-msvc", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "os": ["win32"], "cpu": ["x64"], "main": "lancedb.win32-x64-msvc.node", diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index bdbd3cf79..f7b6670e4 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lancedb/lancedb", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lancedb/lancedb", - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "cpu": [ "x64", "arm64" diff --git a/nodejs/package.json b/nodejs/package.json index 671f3f94d..0416ce81b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.37.1-beta.0", + "version": "0.37.1-beta.1", "main": "dist/index.js", "exports": { ".": "./dist/index.js", diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index 2ac2fecb2..c4ece20e2 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -772,7 +772,8 @@ pub struct LsmWriteSpec { pub column: Option, /// Bucket variant: the number of buckets, in `[1, 1024]`. pub num_buckets: Option, - /// Names of indexes the MemWAL should keep up to date during writes. + /// Indexes the MemWAL keeps up to date. Omitted resolves every + /// maintainable index on install; an empty array means none. pub maintained_indexes: Option>, /// Default `ShardWriter` configuration recorded in the MemWAL index. pub writer_config_defaults: Option>, @@ -782,7 +783,6 @@ impl TryFrom for lancedb::table::LsmWriteSpec { type Error = napi::Error; fn try_from(value: LsmWriteSpec) -> napi::Result { - let maintained = value.maintained_indexes.unwrap_or_default(); let writer_config_defaults = value.writer_config_defaults.unwrap_or_default(); let spec = match value.spec_type.as_str() { "bucket" => { @@ -809,7 +809,7 @@ impl TryFrom for lancedb::table::LsmWriteSpec { } }; Ok(spec - .with_maintained_indexes(maintained) + .with_maintained_indexes(value.maintained_indexes) .with_writer_config_defaults(writer_config_defaults)) } } @@ -827,7 +827,7 @@ impl From for LsmWriteSpec { spec_type: "bucket".to_string(), column: Some(column), num_buckets: Some(num_buckets), - maintained_indexes: Some(maintained_indexes), + maintained_indexes, writer_config_defaults: Some(writer_config_defaults), }, Native::Identity { @@ -838,7 +838,7 @@ impl From for LsmWriteSpec { spec_type: "identity".to_string(), column: Some(column), num_buckets: None, - maintained_indexes: Some(maintained_indexes), + maintained_indexes, writer_config_defaults: Some(writer_config_defaults), }, Native::Unsharded { @@ -848,7 +848,7 @@ impl From for LsmWriteSpec { spec_type: "unsharded".to_string(), column: None, num_buckets: None, - maintained_indexes: Some(maintained_indexes), + maintained_indexes, writer_config_defaults: Some(writer_config_defaults), }, } @@ -1043,7 +1043,10 @@ impl From for IndexStatistics { #[napi(object)] pub struct TableStatistics { - /// The total number of bytes in the table + /// The total size, in bytes, of the table's data files, index files, and + /// overlay files + /// + /// Read from the manifest, so this excludes deletion files and manifests. pub total_bytes: i64, /// The number of rows in the table diff --git a/python/Cargo.toml b/python/Cargo.toml index 5a196e27c..9d36edd5c 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb-python" -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" publish = false edition.workspace = true description = "Python bindings for LanceDB" diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 47e727f99..f87fd3d13 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -355,6 +355,10 @@ class Table: async def set_lsm_write_spec(self, spec: LsmWriteSpec) -> None: ... async def unset_lsm_write_spec(self) -> None: ... async def get_lsm_write_spec(self) -> Optional[LsmWriteSpec]: ... + async def checkpoint_lsm(self) -> None: ... + async def flush_lsm(self) -> None: ... + async def compact_lsm(self) -> None: ... + async def get_lsm_stats(self, include_generation_rows: bool) -> Optional[dict]: ... async def close_lsm_writers(self) -> None: ... @property def tags(self) -> Tags: ... @@ -649,9 +653,10 @@ class LsmWriteSpec: def identity(column: str) -> "LsmWriteSpec": ... @staticmethod def unsharded() -> "LsmWriteSpec": ... - def with_maintained_indexes(self, indexes: List[str]) -> "LsmWriteSpec": - """Return a copy of this spec asking the MemWAL to keep the named - indexes up to date as rows are appended.""" + def with_maintained_indexes(self, indexes: Optional[List[str]]) -> "LsmWriteSpec": + """Set which indexes the MemWAL keeps up to date. None resolves every + index on the table at install, failing if one cannot be maintained; + a list is verbatim, empty means none.""" ... def with_writer_config_defaults(self, defaults: Dict[str, str]) -> "LsmWriteSpec": """Return a copy of this spec recording the given default @@ -666,7 +671,9 @@ class LsmWriteSpec: @property def num_buckets(self) -> Optional[int]: ... @property - def maintained_indexes(self) -> List[str]: ... + def maintained_indexes(self) -> Optional[List[str]]: + """Indexes the MemWAL keeps up to date, or None for every supported one.""" + ... @property def writer_config_defaults(self) -> Dict[str, str]: ... diff --git a/python/python/lancedb/embeddings/jinaai.py b/python/python/lancedb/embeddings/jinaai.py index 9656f041f..f6ab601b3 100644 --- a/python/python/lancedb/embeddings/jinaai.py +++ b/python/python/lancedb/embeddings/jinaai.py @@ -87,12 +87,13 @@ class JinaEmbeddings(EmbeddingFunction): if isinstance(image, bytes): image_dict = {"image": base64.b64encode(image).decode("utf-8")} elif isinstance(image, (str, Path)): - parsed = urlparse.urlparse(image) - # TODO handle drive letter on windows. + parsed = urlparse(str(image)) PIL_Image = attempt_import_or_raise("PIL.Image", "pillow") if parsed.scheme == "file": pil_image = PIL_Image.open(parsed.path) - elif parsed.scheme == "": + elif parsed.scheme == "" or (os.name == "nt" and len(parsed.scheme) == 1): + # A Windows drive letter parses as a one-character scheme + # ("C:\\img.png" -> scheme="c"), so treat it as a local path. pil_image = PIL_Image.open(image if os.name == "nt" else parsed.path) elif parsed.scheme.startswith("http"): pil_image = PIL_Image.open(io.BytesIO(url_retrieve(image))) diff --git a/python/python/lancedb/streaming.py b/python/python/lancedb/streaming.py index 525ed3d63..b27e606a4 100644 --- a/python/python/lancedb/streaming.py +++ b/python/python/lancedb/streaming.py @@ -11,6 +11,11 @@ Provides StreamingDataset, a PyTorch IterableDataset that guarantees: - **Resumability**: state_dict / load_state_dict capture per-split consumption counts so training can resume from an exact mid-epoch position even when the distributed topology changes between runs. + +Transform failures on bad rows (e.g. nulls or NaNs from incomplete data) can +be tolerated with ``on_transform_error="skip"``; see the parameter +documentation on StreamingDataset for how this interacts with the guarantees +above. """ import ctypes @@ -22,7 +27,7 @@ import time from collections import deque from concurrent.futures import ThreadPoolExecutor from multiprocessing import RawArray -from typing import Any, Callable, Iterator, Optional +from typing import Any, Callable, Iterator, Optional, Union from torch.utils.data import IterableDataset, get_worker_info @@ -127,6 +132,49 @@ class StreamingDataset(IterableDataset): Maximum number of transforms to run concurrently. Must be greater than zero. When ``None`` (the default), uses ``os.cpu_count()`` or 1 when the CPU count is unavailable. + on_transform_error: + What to do when the transform raises an exception: + + - ``"raise"`` (the default): the exception propagates and iteration + aborts. + - ``"skip"``: the failing rows are dropped and iteration continues. + - ``"warn"``: like ``"skip"``, but a warning is logged for each + failing batch. + - a callable ``handler(exc) -> bool``: called with the exception; + return ``True`` to skip the failing rows or ``False`` to re-raise. + Useful to skip only expected error types (compatible with + ``webdataset.handlers`` style handlers). + + When a batch fails, the transform is re-invoked on each single-row + slice of the batch so that only the rows that actually fail are + dropped. Transforms should therefore be deterministic and accept + batches of any size (including one row). Skipped rows are counted in + ``rows_skipped``. + + Skipping weakens the elastic-determinism guarantee at the end of the + epoch: splits that lose more rows than others run dry earlier, and + each rank's iterator ends at the last cycle where every split *it + owns* still has a row. Because bad rows are not distributed evenly + across splits, this means one rank's iterator can yield noticeably + fewer or more steps than another rank's *in the same run* — there is + no cross-rank coordination that stops every rank at the same global + step. This is generally safe for asynchronous or single-rank use, + but synchronous distributed training (e.g. ranks that call + ``all_reduce`` every step) can hang or deadlock if one rank's + iterator is exhausted while others are still stepping; callers doing + synchronous multi-rank training with ``on_transform_error != "raise"`` + are responsible for their own cross-rank stopping mechanism (e.g. + broadcasting a stop signal on ``StopIteration``). The final few + global steps can also differ across topologies (bounded by the skew + in bad-row counts across splits). The sequence of samples yielded + from each split remains deterministic. Mid-epoch + checkpoints remain exact provided the transform fails + deterministically; in multi-rank training each rank must save its + own ``state_dict`` and the states must be combined with + ``merge_state_dicts`` before resuming on a different topology. + Prefer the ``filter`` parameter when bad rows can be expressed as a + SQL predicate (e.g. ``"col IS NOT NULL"``) — filtering happens before + splits are built, so every guarantee is fully preserved. worker_info_override: If set, used in place of ``torch.utils.data.get_worker_info()`` to determine the DataLoader worker assignment. Intended for unit tests @@ -152,6 +200,7 @@ class StreamingDataset(IterableDataset): filter: Optional[str] = None, transform: Optional[Callable] = None, transform_parallelism: Optional[int] = None, + on_transform_error: Union[str, Callable[[Exception], bool]] = "raise", connection_factory: Optional[Callable[[str], Any]] = None, worker_info_override=None, ): @@ -167,6 +216,13 @@ class StreamingDataset(IterableDataset): ) if transform_parallelism is not None and transform_parallelism <= 0: raise ValueError("transform_parallelism must be greater than 0") + if on_transform_error not in ("raise", "skip", "warn") and not callable( + on_transform_error + ): + raise ValueError( + "on_transform_error must be 'raise', 'skip', 'warn', or a " + f"callable, got {on_transform_error!r}" + ) self._table = table self._num_splits = num_splits @@ -182,6 +238,7 @@ class StreamingDataset(IterableDataset): self._filter = filter self._transform = transform self._transform_parallelism = transform_parallelism + self._on_transform_error = on_transform_error self._connection_factory = connection_factory self._worker_info_override = worker_info_override @@ -199,19 +256,28 @@ class StreamingDataset(IterableDataset): # in the main process. RawArray is picklable via the forkserver # reduction protocol so it survives the dataset pickle round-trip. # Layout: [unscanned_rows, raw_rows, cooked_rows, consumed_rows, - # bytes_loaded, fetch_time_us, transform_time_us] - self._worker_stats: RawArray = RawArray(ctypes.c_int64, 7) + # bytes_loaded, fetch_time_us, transform_time_us, + # rows_skipped] + self._worker_stats: RawArray = RawArray(ctypes.c_int64, 8) # Cumulative bytes of Arrow buffer data fetched across all iterations. self._bytes_loaded: int = 0 # Cumulative seconds spent in LanceDB I/O and in transform functions. self._fetch_time: float = 0.0 self._transform_time: float = 0.0 + # Cumulative rows dropped by on_transform_error across all iterations. + self._rows_skipped: int = 0 # Number of samples each split has already been consumed. At global # step boundaries all splits have consumed this many samples, so a # single scalar captures the topology-independent checkpoint state. self._resume_offset: int = 0 + # Permutation position each split has consumed through, keyed by + # global split index. Equal to _resume_offset for every split unless + # on_transform_error skipped rows, in which case skipped positions + # push the watermark of the affected splits further ahead. Splits + # this instance has never iterated have no entry. + self._resume_positions: dict[int, int] = {} # Build the permutation table once, deterministically. builder = permutation_builder(table) @@ -275,6 +341,7 @@ class StreamingDataset(IterableDataset): # Set identity transform on each Permutation so __getitems__ returns # the raw RecordBatch. Stage 2 applies the real transform. permutations: list[Permutation] = [] + initial_positions: list[int] = [] for split_idx in my_splits: perm = Permutation.from_tables( self._table, self._perm_table, split=split_idx @@ -282,14 +349,20 @@ class StreamingDataset(IterableDataset): if self._columns is not None: perm = perm.select_columns(self._columns) perm = perm.with_transform(lambda batch: batch) - if self._resume_offset > 0: - perm = perm.with_skip(self._resume_offset) + start_pos = self._resume_positions.get(split_idx, self._resume_offset) + if start_pos > 0: + perm = perm.with_skip(start_pos) + initial_positions.append(start_pos) permutations.append(perm) n = len(permutations) split_sizes = [perm.num_rows for perm in permutations] initial_offset = self._resume_offset local_consumed = [0] * n + # Permutation position each split has consumed through (absolute, + # i.e. counted from the start of the unskipped split). Runs ahead of + # initial + local_consumed when rows are skipped. + pos_consumed = list(initial_positions) batch_size = self._read_batch_size max_prefetch = self._prefetch_batches @@ -302,12 +375,14 @@ class StreamingDataset(IterableDataset): self._transform if self._transform is not None else Transforms.arrow2python ) - # Per-split pipeline state. + # Per-split pipeline state. Batches are paired with the absolute + # permutation position of their first row so that skipped rows can be + # accounted for in pos_consumed. fetch_head = [0] * n - io_pending = [deque() for _ in range(n)] # Future[RecordBatch] - raw_batches = [deque() for _ in range(n)] # RecordBatch — fetched, awaiting tx - tx_pending = [deque() for _ in range(n)] # Future[list[Any]] - cooked = [deque() for _ in range(n)] # rows ready to yield + io_pending = [deque() for _ in range(n)] # (abs_start, Future[RecordBatch]) + raw_batches = [deque() for _ in range(n)] # (abs_start, RecordBatch) + tx_pending = [deque() for _ in range(n)] # Future[list[(abs_pos, row)]] + cooked = [deque() for _ in range(n)] # (abs_pos, row) ready to yield # Limit simultaneous transforms to transform_workers across all splits. tx_semaphore = threading.Semaphore(transform_workers) @@ -330,7 +405,8 @@ class StreamingDataset(IterableDataset): fetch_head[i] += fetch perm_i = permutations[i] indices = list(range(start, start + fetch)) - io_pending[i].append(io_pool.submit(_io_call, perm_i, indices)) + abs_start = initial_positions[i] + start + io_pending[i].append((abs_start, io_pool.submit(_io_call, perm_i, indices))) def _fill_io(i: int) -> None: while len(io_pending[i]) < max_prefetch and fetch_head[i] < split_sizes[i]: @@ -338,15 +414,72 @@ class StreamingDataset(IterableDataset): def _drain_io(i: int) -> None: """Move completed I/O futures into raw_batches non-blockingly.""" - while io_pending[i] and io_pending[i][0].done(): - raw_batches[i].append(io_pending[i].popleft().result()) + while io_pending[i] and io_pending[i][0][1].done(): + abs_start, fut = io_pending[i].popleft() + raw_batches[i].append((abs_start, fut.result())) # ── Stage 2 helpers ─────────────────────────────────────────────────── - def _tx_call_guarded(batch): + on_error = self._on_transform_error + + def _should_skip(exc: Exception) -> bool: + if on_error == "raise": + return False + if callable(on_error): + return bool(on_error(exc)) + return True # "skip" or "warn" + + def _check_row_count(rows: list, num_rows: int) -> None: + if len(rows) != num_rows: + raise ValueError( + f"transform returned {len(rows)} rows for a batch of " + f"{num_rows}; transforms must return exactly one output " + "row per input row. To drop bad rows, raise inside the " + "transform and pass on_transform_error='skip'." + ) + + def _transform_isolated(abs_start, batch, batch_exc): + """Re-run the transform on single-row slices, dropping failures.""" + out = [] + skipped = 0 + first_exc = None + for j in range(batch.num_rows): + try: + rows = list(final_transform(batch.slice(j, 1))) + except Exception as exc: + if not _should_skip(exc): + raise + skipped += 1 + if first_exc is None: + first_exc = exc + continue + _check_row_count(rows, 1) + out.append((abs_start + j, rows[0])) + self._rows_skipped += skipped + if skipped and on_error == "warn": + logger.warning( + "Skipped %d of %d rows whose transform failed (first error: %r)", + skipped, + batch.num_rows, + first_exc if first_exc is not None else batch_exc, + ) + return out + + def _transform_batch(abs_start, batch): + """Apply the transform, returning [(abs_pos, row), ...].""" + try: + rows = list(final_transform(batch)) + except Exception as exc: + if not _should_skip(exc): + raise + return _transform_isolated(abs_start, batch, exc) + _check_row_count(rows, batch.num_rows) + return [(abs_start + j, row) for j, row in enumerate(rows)] + + def _tx_call_guarded(abs_start, batch): try: t0 = time.perf_counter() - result = final_transform(batch) + result = _transform_batch(abs_start, batch) self._transform_time += time.perf_counter() - t0 return result finally: @@ -355,8 +488,8 @@ class StreamingDataset(IterableDataset): def _try_submit_tx(i: int) -> None: """Submit transforms for raw_batches[i] up to available capacity.""" while raw_batches[i] and tx_semaphore.acquire(blocking=False): - batch = raw_batches[i].popleft() - tx_pending[i].append(tx_pool.submit(_tx_call_guarded, batch)) + abs_start, batch = raw_batches[i].popleft() + tx_pending[i].append(tx_pool.submit(_tx_call_guarded, abs_start, batch)) def _drain_tx(i: int) -> None: """Move completed transform futures into cooked non-blockingly.""" @@ -384,11 +517,14 @@ class StreamingDataset(IterableDataset): # Acquire a transform slot (may block briefly if all # transform_workers are busy with other splits). tx_semaphore.acquire() - batch = raw_batches[i].popleft() - tx_pending[i].append(tx_pool.submit(_tx_call_guarded, batch)) + abs_start, batch = raw_batches[i].popleft() + tx_pending[i].append( + tx_pool.submit(_tx_call_guarded, abs_start, batch) + ) elif io_pending[i]: # Block on the oldest in-flight I/O fetch. - raw_batches[i].append(io_pending[i].popleft().result()) + abs_start, fut = io_pending[i].popleft() + raw_batches[i].append((abs_start, fut.result())) _advance(i) else: break # split exhausted @@ -407,15 +543,28 @@ class StreamingDataset(IterableDataset): _fill_io(i) while True: - # Stop when any split is exhausted (all exhaust - # simultaneously: equal split sizes + round-robin). - if any(local_consumed[i] >= split_sizes[i] for i in range(n)): + # A cycle only runs if every split can still produce a + # row. Without skips all splits exhaust simultaneously + # (equal split sizes + round-robin); when + # on_transform_error drops rows a split can run dry + # early, ending the epoch at the last complete cycle. + # This check only sees splits owned by this rank/worker + # (my_splits) — there is no cross-rank coordination, so + # a different rank with fewer skipped rows keeps going; + # see the on_transform_error docstring. + exhausted = False + for i in range(n): + _ensure_cooked(i) + if not cooked[i]: + exhausted = True + break + if exhausted: break for i in range(n): - _ensure_cooked(i) - row = cooked[i].popleft() + pos, row = cooked[i].popleft() local_consumed[i] += 1 + pos_consumed[i] = pos + 1 _advance(i) # After the last split in each cycle: update the @@ -424,21 +573,39 @@ class StreamingDataset(IterableDataset): # even when __iter__ runs in a worker process. if i == n - 1: self._resume_offset = initial_offset + local_consumed[i] + for j, split_idx in enumerate(my_splits): + self._resume_positions[split_idx] = pos_consumed[j] ws = self._worker_stats ws[0] = sum( split_sizes[j] - fetch_head[j] for j in range(n) ) ws[1] = sum( - batch.num_rows for q in raw_batches for batch in q + batch.num_rows + for q in raw_batches + for _, batch in q ) ws[2] = sum(len(q) for q in cooked) ws[3] = sum(local_consumed) ws[4] = self._bytes_loaded ws[5] = int(self._fetch_time * 1_000_000) ws[6] = int(self._transform_time * 1_000_000) + ws[7] = self._rows_skipped yield row finally: + # Final stats flush: the per-cycle write above never runs + # when iteration ends mid-cycle (e.g. a split whose rows + # were all skipped before completing a single cycle), so + # counters like rows_skipped would otherwise be stale. + ws = self._worker_stats + ws[0] = sum(split_sizes[j] - fetch_head[j] for j in range(n)) + ws[1] = 0 # queue-depth properties document 0 when idle + ws[2] = 0 + ws[3] = sum(local_consumed) + ws[4] = self._bytes_loaded + ws[5] = int(self._fetch_time * 1_000_000) + ws[6] = int(self._transform_time * 1_000_000) + ws[7] = self._rows_skipped self._raw_batches_ref = None self._cooked_ref = None self._fetch_head_ref = None @@ -492,7 +659,7 @@ class StreamingDataset(IterableDataset): batches. Returns 0 when not iterating. """ if self._raw_batches_ref is not None: - return sum(batch.num_rows for q in self._raw_batches_ref for batch in q) + return sum(batch.num_rows for q in self._raw_batches_ref for _, batch in q) return int(self._worker_stats[1]) @property @@ -522,6 +689,19 @@ class StreamingDataset(IterableDataset): ) return int(self._worker_stats[0]) + @property + def rows_skipped(self) -> int: + """Number of rows dropped because their transform raised an exception. + + Only ever non-zero when ``on_transform_error`` is set to ``"skip"``, + ``"warn"``, or a callable that returned ``True``. Accumulates across + multiple iterations of the same dataset instance and is never reset + automatically. + """ + if self._raw_batches_ref is not None: + return self._rows_skipped + return int(self._worker_stats[7]) + @property def consumed_rows(self) -> int: """Number of rows already yielded to the caller across all splits. @@ -587,12 +767,27 @@ class StreamingDataset(IterableDataset): every split has been consumed the same number of times (by the round-robin design), so the per-split count is a single uniform value that is identical across all ranks and DataLoader workers. + + ``positions_consumed_per_split`` records how far into each split's + permutation iteration has advanced. It only differs from + ``samples_consumed_per_split`` when ``on_transform_error`` skipped + rows, in which case entries are exact for the splits this instance + iterated and a lower bound (the sample count) for splits owned by + other ranks or workers. Combine the state dicts from all ranks with + [merge_state_dicts][lancedb.streaming.StreamingDataset.merge_state_dicts] + to recover the exact value for every split before resuming on a + different topology. """ + positions = [ + self._resume_positions.get(split, self._resume_offset) + for split in range(self._num_splits) + ] return { "shuffle_seed": self._shuffle_seed, "num_splits": self._num_splits, "epoch": self._epoch, "samples_consumed_per_split": [self._resume_offset] * self._num_splits, + "positions_consumed_per_split": positions, } def load_state_dict(self, state: dict) -> None: @@ -618,3 +813,96 @@ class StreamingDataset(IterableDataset): self._resume_offset = consumed[0] if consumed else 0 else: self._resume_offset = int(consumed) + # Older checkpoints predate positions_consumed_per_split; without + # skipped rows positions equal sample counts, so falling back to + # _resume_offset (the .get default in __iter__) is exact. + positions = state.get("positions_consumed_per_split") + if positions is None: + self._resume_positions = {} + else: + self._resume_positions = { + split: int(pos) for split, pos in enumerate(positions) + } + + @staticmethod + def merge_state_dicts(states: list[dict]) -> dict: + """Merge state dicts saved by different ranks into one exact state. + + Only needed when ``on_transform_error`` skips rows in multi-rank + training: each rank then knows the exact permutation position only for + its own splits, and records a lower bound for the rest. Because + exactly one rank owns each split, the elementwise maximum across all + ranks' ``positions_consumed_per_split`` recovers the exact position of + every split. Without skipped rows every rank's state is already + identical and merging is a no-op. + + Raises ``ValueError`` if the states are empty or were not produced by + the same run (mismatched seed, split count, epoch, or sample counts). + + The merge is always all-to-all and topology-agnostic: collect the + ``state_dict()`` from every rank of the *previous* run into one list, + merge that whole list, and hand the identical merged result to every + rank of the *next* run — regardless of whether the rank count grew, + shrank, or stayed the same. There is no pairwise or subset merging + step, because each split's exact position is only known to whichever + rank owned that split, and the elementwise maximum needs every rank's + contribution to be correct. + + For example, checkpointing 8 ranks and resuming on 4 (the same + pattern applies when growing, e.g. 4 ranks resuming on 8):: + + states = [ds.state_dict() for ds in previous_run_datasets] # 8 + merged = StreamingDataset.merge_state_dicts(states) + for ds in resumed_datasets: # now only 4 ranks + ds.load_state_dict(merged) # same dict on every rank + + The rank count on either side never affects the merge itself, since + ``merge_state_dicts`` only cares about the list of states it is + given. Each split's position is recovered by elementwise maximum; + here rank 0 owned split 0 (and skipped two rows there) while rank 1 + owned split 1 (and skipped one row): + + >>> rank0 = { + ... "shuffle_seed": 0, "num_splits": 2, "epoch": 0, + ... "samples_consumed_per_split": [3, 3], + ... "positions_consumed_per_split": [5, 3], + ... } + >>> rank1 = { + ... "shuffle_seed": 0, "num_splits": 2, "epoch": 0, + ... "samples_consumed_per_split": [3, 3], + ... "positions_consumed_per_split": [3, 4], + ... } + >>> merged = StreamingDataset.merge_state_dicts([rank0, rank1]) + >>> merged["positions_consumed_per_split"] + [5, 4] + """ + if not states: + raise ValueError("merge_state_dicts requires at least one state dict") + first = states[0] + for state in states[1:]: + for key in ("shuffle_seed", "num_splits", "epoch"): + if state[key] != first[key]: + raise ValueError( + f"{key} mismatch across state dicts: " + f"{state[key]} != {first[key]}" + ) + if ( + state["samples_consumed_per_split"] + != first["samples_consumed_per_split"] + ): + raise ValueError( + "samples_consumed_per_split mismatch across state dicts; " + "state_dict() must be called at the same global step " + "boundary on every rank" + ) + merged = dict(first) + all_positions = [ + state.get( + "positions_consumed_per_split", state["samples_consumed_per_split"] + ) + for state in states + ] + merged["positions_consumed_per_split"] = [ + max(per_split) for per_split in zip(*all_positions) + ] + return merged diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index 59e2650eb..c566fc532 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -3976,6 +3976,28 @@ class LanceTable(Table): [`AsyncTable.get_lsm_write_spec`][lancedb.AsyncTable.get_lsm_write_spec].""" return LOOP.run(self._table.get_lsm_write_spec()) + def checkpoint_lsm(self) -> None: + """Synchronous version of + [`AsyncTable.checkpoint_lsm`][lancedb.AsyncTable.checkpoint_lsm].""" + return LOOP.run(self._table.checkpoint_lsm()) + + def flush_lsm(self) -> None: + """Synchronous version of + [`AsyncTable.flush_lsm`][lancedb.AsyncTable.flush_lsm].""" + return LOOP.run(self._table.flush_lsm()) + + def compact_lsm(self) -> None: + """Synchronous version of + [`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm].""" + return LOOP.run(self._table.compact_lsm()) + + def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]: + """Synchronous version of + [`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats].""" + return LOOP.run( + self._table.get_lsm_stats(include_generation_rows=include_generation_rows) + ) + def close_lsm_writers(self) -> None: """Close cached MemWAL shard writers. See [`AsyncTable.close_lsm_writers`][lancedb.AsyncTable.close_lsm_writers].""" @@ -4654,6 +4676,13 @@ class AsyncTable: via [`set_unenforced_primary_key`]; bucket sharding additionally requires it to be the single column being bucketed. + By default the MemWAL maintains every index on the table, resolved + here — a snapshot, so an index created afterwards needs the spec unset + and set again. This fails if one cannot be maintained; name the set + with ``with_maintained_indexes`` to install anyway. That pins an exact + set (a still-building index is rejected, not omitted); ``[]`` maintains + none. + Parameters ---------- spec : LsmWriteSpec @@ -4680,12 +4709,73 @@ class AsyncTable: Returns ``None`` when the MemWAL LSM write path is not enabled (no spec has been set, or it was removed with `unset_lsm_write_spec`). - The returned spec — including its ``maintained_indexes`` and - ``writer_config_defaults`` — mirrors what was passed to - `set_lsm_write_spec`. + The returned spec mirrors what was passed to `set_lsm_write_spec`, + except that ``maintained_indexes`` always reports the concrete list + resolved when the spec was set — ``None`` never round-trips. """ return await self._inner.get_lsm_write_spec() + async def checkpoint_lsm(self) -> None: + """Converge this table's LSM write path into its base table. + + One flush, sealing every memtable into L0, then compaction triggers + until every generation that existed at that moment has reached base. + The loop runs client-side, reading progress from ``get_lsm_stats``. + + Best-effort: generations created *while* it runs are deliberately not + waited on, which is what lets it terminate on a table taking writes. + Idempotent and safe on a cadence. + + There is no deadline, and the caller owns that. It returns when the + target generations are gone, raises on a terminal server fault, and + otherwise waits however long the server takes. A slow table and a + stuck one are the same picture from the client: the compactor pool is + shared across every table on the node, so a checkpoint queued behind + unrelated work looks exactly like one that is merging. Wrap this in + ``asyncio.wait_for`` for a wall-clock bound; abandoning it partway + costs nothing. + """ + return await self._inner.checkpoint_lsm() + + async def flush_lsm(self) -> None: + """Seal every bucket's active memtable into L0. + + Does not touch the base table — moving L0 into base is + `compact_lsm`. On a node that has not claimed this table, this claims + it and replays its WAL log first. + """ + return await self._inner.flush_lsm() + + async def compact_lsm(self) -> None: + """Trigger a background L0 to base compaction pass per bucket. + + Returns once the passes are dispatched, not once they finish: watch + ``get_lsm_stats`` for progress, or use ``checkpoint_lsm`` to loop + until the current L0 has reached base. + """ + return await self._inner.compact_lsm() + + async def get_lsm_stats( + self, *, include_generation_rows: bool = False + ) -> Optional[dict]: + """Read live per-bucket LSM state. + + Answers "how far behind is my fresh tier", "which bucket is hot", and + "why is my fresh-tier vector search brute-force". Mutates no table + state, though on a node that has not claimed this table it claims it, + exactly as a read would. + + Returns ``None`` only when the LSM write path is not enabled. + + Parameters + ---------- + include_generation_rows + Report a row count per L0 generation. Off by default: each count + opens an uncached Lance dataset, and ``checkpoint_lsm`` polls this + needing only generation numbers. + """ + return await self._inner.get_lsm_stats(include_generation_rows) + async def close_lsm_writers(self) -> None: """Drain and close any cached MemWAL shard writers for this table. @@ -6251,7 +6341,9 @@ class TableStatistics: Attributes ---------- total_bytes: int - The total number of bytes in the table. + The total size, in bytes, of the table's data files, index files, and + overlay files. Read from the manifest, so this excludes deletion files + and manifests. num_rows: int The total number of rows in the table. num_indices: int diff --git a/python/python/tests/test_elastic_dataloader.py b/python/python/tests/test_elastic_dataloader.py index 22918082b..734f835c6 100644 --- a/python/python/tests/test_elastic_dataloader.py +++ b/python/python/tests/test_elastic_dataloader.py @@ -1456,6 +1456,408 @@ def test_shuffle_clump_size_yields_all_rows(lance_table): ) +# --------------------------------------------------------------------------- +# on_transform_error tests +# --------------------------------------------------------------------------- + + +class BadRowError(ValueError): + """Raised by the failing transforms below when a batch contains a bad id.""" + + +def _failing_transform(bad_ids: set): + """A transform that raises BadRowError whenever the batch has a bad id. + + Raises on the full batch and on any single-row slice containing a bad id, + so per-row isolation drops exactly the bad rows. + """ + + def transform(batch: pa.RecordBatch) -> list: + ids = batch.column("id").to_pylist() + bad = sorted(set(ids) & bad_ids) + if bad: + raise BadRowError(f"bad ids in batch: {bad}") + return [{"id": i} for i in ids] + + return transform + + +def _sequential_split_members(table) -> list[list[int]]: + """Return each split's ids in yield order for shuffle=False. + + With a single rank and no workers the round-robin yields one row per split + per cycle, so item k of a clean run belongs to split k % NUM_SPLITS. + """ + ds = StreamingDataset(table, num_splits=NUM_SPLITS, shuffle=False) + members: list[list[int]] = [[] for _ in range(NUM_SPLITS)] + for k, row in enumerate(ds): + members[k % NUM_SPLITS].append(row["id"]) + return members + + +def test_on_transform_error_default_raises(lance_table): + """By default a transform exception propagates and aborts iteration.""" + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle_seed=SHUFFLE_SEED, + transform=_failing_transform({7}), + ) + with pytest.raises(BadRowError): + list(ds) + + +def test_on_transform_error_invalid_value(lance_table): + with pytest.raises(ValueError, match="on_transform_error"): + StreamingDataset(lance_table, num_splits=NUM_SPLITS, on_transform_error="bogus") + + +def test_on_transform_error_skip_drops_bad_rows(lance_table): + """With one bad row per split, 'skip' yields every good row exactly once + and counts the dropped rows in rows_skipped.""" + members = _sequential_split_members(lance_table) + bad_ids = {members[i][4] for i in range(NUM_SPLITS)} + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + assert ds.rows_skipped == 0 + + ids = [row["id"] for row in ds] + + assert sorted(ids) == sorted(set(range(NUM_ROWS)) - bad_ids) + assert ds.rows_skipped == NUM_SPLITS + + +def test_on_transform_error_skip_uneven_ends_at_last_complete_cycle(lance_table): + """When one split loses more rows than the others, the epoch ends at the + last cycle where every split still has a row — no crash, no bad rows, and + every step remains one sample per split.""" + members = _sequential_split_members(lance_table) + bad_ids = set(members[0][:3]) # all 3 bad rows in split 0 + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + items = [row["id"] for row in ds] + + rows_per_split = NUM_ROWS // NUM_SPLITS + expected_cycles = rows_per_split - len(bad_ids) + assert len(items) == expected_cycles * NUM_SPLITS + assert len(set(items)) == len(items), "duplicate samples yielded" + assert not set(items) & bad_ids, "a bad row was yielded" + # Split 0 contributed exactly its surviving rows, in order, one per cycle. + survivors = [i for i in members[0] if i not in bad_ids] + assert items[0::NUM_SPLITS] == survivors[:expected_cycles] + + +def test_on_transform_error_warn_logs(lance_table, caplog): + """'warn' skips like 'skip' but logs a warning for the failing batch.""" + members = _sequential_split_members(lance_table) + bad_ids = {members[i][3] for i in range(NUM_SPLITS)} + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="warn", + ) + with caplog.at_level(logging.WARNING, logger="lancedb.streaming"): + items = list(ds) + + assert len(items) == NUM_ROWS - NUM_SPLITS + assert ds.rows_skipped == NUM_SPLITS + assert "Skipped" in caplog.text + assert "BadRowError" in caplog.text + + +def test_on_transform_error_callable_selective(lance_table): + """A callable handler can skip expected errors and re-raise the rest.""" + members = _sequential_split_members(lance_table) + bad_ids = {members[i][0] for i in range(NUM_SPLITS)} + + handled: list[Exception] = [] + + def handler(exc: Exception) -> bool: + handled.append(exc) + return isinstance(exc, BadRowError) + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error=handler, + ) + items = list(ds) + assert len(items) == NUM_ROWS - NUM_SPLITS + assert handled and all(isinstance(exc, BadRowError) for exc in handled) + + def broken_transform(batch: pa.RecordBatch) -> list: + raise TypeError("boom") + + ds2 = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=broken_transform, + on_transform_error=handler, + ) + with pytest.raises(TypeError, match="boom"): + list(ds2) + + +def test_transform_wrong_row_count_raises(lance_table): + """A transform that returns the wrong number of rows is an error even with + on_transform_error='skip' — silent shrinkage would corrupt accounting.""" + + def drops_rows(batch: pa.RecordBatch) -> list: + return batch.column("id").to_pylist()[:-1] + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle_seed=SHUFFLE_SEED, + transform=drops_rows, + on_transform_error="skip", + ) + with pytest.raises(ValueError, match="one output row per input row"): + list(ds) + + +def test_skip_deterministic_across_runs(lance_table): + """With a fixed seed, skipping produces the identical sample sequence on + every run — skips are data-dependent, not run-dependent.""" + bad_ids = {5, 17, 46} + + def run() -> tuple[list[int], int]: + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle_seed=SHUFFLE_SEED, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + return [row["id"] for row in ds], ds.rows_skipped + + ids_a, skipped_a = run() + ids_b, skipped_b = run() + assert ids_a == ids_b + assert skipped_a == skipped_b + assert not set(ids_a) & bad_ids + + +def test_skip_elastic_det_across_world_sizes(lance_table): + """With equal bad-row counts per split, skipping preserves the full + elastic-determinism guarantee: identical global batches at every step for + every compatible world_size.""" + members = _sequential_split_members(lance_table) + bad_ids = {members[i][6] for i in range(NUM_SPLITS)} + + def collect(world_size: int) -> list[frozenset[int]]: + micro = GLOBAL_BATCH_SIZE // world_size + iters = [ + iter( + StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + rank=rank, + world_size=world_size, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + ) + for rank in range(world_size) + ] + _STOP = object() + batches: list[frozenset[int]] = [] + while True: + step_samples: set[int] = set() + exhausted = 0 + for it in iters: + for _ in range(micro): + val = next(it, _STOP) + if val is _STOP: + exhausted += 1 + break + step_samples.add(val["id"]) + if exhausted == len(iters): + break + assert exhausted == 0, ( + "Rank iterators exhausted at different steps despite equal " + "bad-row counts per split" + ) + batches.append(frozenset(step_samples)) + return batches + + reference = collect(1) + assert len(reference) == NUM_ROWS // NUM_SPLITS - 1 + for ws in (2, 3, 4): + assert collect(ws) == reference, f"world_size={ws} diverged" + + +def test_resumability_with_skips_same_topology(lance_table): + """Checkpointing mid-epoch with skipped rows resumes exactly: no sample + repeated, no sample lost, skipped rows stay skipped.""" + members = _sequential_split_members(lance_table) + # Uneven skips: positions diverge across splits (2 bad in split 0, 1 in + # split 5), which only a position-based checkpoint can resume exactly. + bad_ids = {members[0][2], members[0][3], members[5][7]} + kwargs = dict( + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + + reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)] + rows_per_split = NUM_ROWS // NUM_SPLITS + assert len(reference) == (rows_per_split - 2) * NUM_SPLITS + + steps = 3 + ds = StreamingDataset(lance_table, **kwargs) + it = iter(ds) + consumed = [next(it)["id"] for _ in range(steps * NUM_SPLITS)] + checkpoint = ds.state_dict() + it.close() + + # Split 0 skipped positions 2 and 3 within its first 3 yields; split 5's + # bad row is beyond the checkpoint. Everything else is at 3 = the sample + # count. + positions = checkpoint["positions_consumed_per_split"] + assert positions[0] == 5 + assert positions[1:] == [3] * (NUM_SPLITS - 1) + assert checkpoint["samples_consumed_per_split"] == [3] * NUM_SPLITS + + ds2 = StreamingDataset(lance_table, **kwargs) + ds2.load_state_dict(checkpoint) + resumed = [row["id"] for row in ds2] + + assert consumed == reference[: steps * NUM_SPLITS] + assert resumed == reference[steps * NUM_SPLITS :] + + +def test_resumability_with_skips_elastic_merge(lance_table): + """Elastic resume with skips: each rank's checkpoint knows exact positions + only for its own splits; merge_state_dicts recovers the global state, and + a run on a different world_size continues exactly.""" + members = _sequential_split_members(lance_table) + # Bad rows early in split 0 (rank 0) and split 6 (rank 1 of a ws=2 run) so + # both ranks' position vectors diverge before the checkpoint. + bad_ids = {members[0][0], members[0][2], members[6][1]} + kwargs = dict( + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + + reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)] + + steps = 3 + world_size = 2 + micro = GLOBAL_BATCH_SIZE // world_size + datasets = [ + StreamingDataset(lance_table, rank=rank, world_size=world_size, **kwargs) + for rank in range(world_size) + ] + iters = [iter(ds) for ds in datasets] + seen: list[frozenset[int]] = [] + for _ in range(steps): + step_samples = set() + for it in iters: + for _ in range(micro): + step_samples.add(next(it)["id"]) + seen.append(frozenset(step_samples)) + states = [ds.state_dict() for ds in datasets] + for it in iters: + it.close() + + merged = StreamingDataset.merge_state_dicts(states) + expected_positions = [3] * NUM_SPLITS + expected_positions[0] = 5 # skipped positions 0 and 2 + expected_positions[6] = 4 # skipped position 1 + assert merged["positions_consumed_per_split"] == expected_positions + + # The first 3 global batches match the world_size=1 reference. + ref_batches = [ + frozenset(reference[s * NUM_SPLITS : (s + 1) * NUM_SPLITS]) + for s in range(len(reference) // NUM_SPLITS) + ] + assert seen == ref_batches[:steps] + + # Resume on world_size=1 from the merged state. + ds_resume = StreamingDataset(lance_table, **kwargs) + ds_resume.load_state_dict(merged) + resumed = [row["id"] for row in ds_resume] + assert resumed == reference[steps * NUM_SPLITS :] + + +def test_rows_skipped_flushed_when_split_entirely_bad(lance_table): + """A split whose rows all fail never completes a cycle, so the epoch ends + immediately — but rows_skipped must still report the drops after the + iterator exits (the shared-memory counter is flushed on exhaustion).""" + members = _sequential_split_members(lance_table) + bad_ids = set(members[0]) # every row of split 0 is bad + + ds = StreamingDataset( + lance_table, + num_splits=NUM_SPLITS, + shuffle=False, + transform=_failing_transform(bad_ids), + on_transform_error="skip", + ) + assert list(ds) == [] + assert ds.rows_skipped == len(bad_ids) + + +def test_merge_state_dicts_validates_consistency(lance_table): + ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED) + state = ds.state_dict() + other = dict(state, shuffle_seed=SHUFFLE_SEED + 1) + with pytest.raises(ValueError, match="shuffle_seed mismatch"): + StreamingDataset.merge_state_dicts([state, other]) + with pytest.raises(ValueError, match="at least one"): + StreamingDataset.merge_state_dicts([]) + + +def test_load_state_dict_without_positions_key(lance_table): + """Checkpoints from before positions_consumed_per_split existed still + resume exactly (positions equal sample counts when nothing is skipped).""" + reference = [ + row["id"] + for row in StreamingDataset( + lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED + ) + ] + + steps = 4 + ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED) + it = iter(ds) + for _ in range(steps * NUM_SPLITS): + next(it) + checkpoint = ds.state_dict() + it.close() + del checkpoint["positions_consumed_per_split"] + + ds2 = StreamingDataset( + lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED + ) + ds2.load_state_dict(checkpoint) + resumed = [row["id"] for row in ds2] + assert resumed == reference[steps * NUM_SPLITS :] + + def test_num_splits_defaults_to_world_size(lance_table): """Omitting num_splits gives world_size splits (one per rank).""" ds = StreamingDataset( diff --git a/python/python/tests/test_embeddings.py b/python/python/tests/test_embeddings.py index 678270f19..9850669eb 100644 --- a/python/python/tests/test_embeddings.py +++ b/python/python/tests/test_embeddings.py @@ -631,3 +631,23 @@ def test_url_retrieve_downloads_image(): image_bytes = url_retrieve(image_url) img = Image.open(io.BytesIO(image_bytes)) assert img.size[0] > 0 and img.size[1] > 0 + + +def test_jina_generate_image_input_dict_local_path(tmp_path): + """ + JinaEmbeddings._generate_image_input_dict must accept a local image path + (str or Path), not just bytes. Previously it crashed with + `AttributeError: 'function' object has no attribute 'urlparse'` on any + str/Path input because it called `urlparse.urlparse(image)` instead of + `urlparse(image)` (urlparse was imported as a function, not a module). + """ + Image = pytest.importorskip("PIL.Image") + from lancedb.embeddings.jinaai import JinaEmbeddings + + image_path = tmp_path / "test.png" + Image.new("RGB", (4, 4), color="red").save(image_path, format="PNG") + + for image in (str(image_path), image_path): + image_dict = JinaEmbeddings._generate_image_input_dict(image) + assert "image" in image_dict + assert isinstance(image_dict["image"], str) and len(image_dict["image"]) > 0 diff --git a/python/python/tests/test_lsm_write_spec.py b/python/python/tests/test_lsm_write_spec.py index d38918f09..218793b89 100644 --- a/python/python/tests/test_lsm_write_spec.py +++ b/python/python/tests/test_lsm_write_spec.py @@ -83,7 +83,9 @@ def test_lsm_write_spec_repr(): assert s.spec_type == "bucket" assert s.column == "id" assert s.num_buckets == 4 - assert s.maintained_indexes == [] + # A fresh spec defers its maintained set to install time. + assert s.maintained_indexes is None + assert s.with_maintained_indexes([]).maintained_indexes == [] assert "bucket" in repr(s) assert "id" in repr(s) assert "4" in repr(s) @@ -169,18 +171,23 @@ def test_get_lsm_write_spec(tmp_path): table.unset_lsm_write_spec() assert table.get_lsm_write_spec() is None - # Identity round-trips (column recovered from the schema). + # Identity round-trips (column recovered from the schema). Leaving the + # maintained set to be inferred picks up the index on the table, so the + # spec reads back naming it rather than as "infer". table.set_lsm_write_spec(LsmWriteSpec.identity("id")) spec = table.get_lsm_write_spec() assert spec.spec_type == "identity" assert spec.column == "id" + assert spec.maintained_indexes == [idx_name] table.unset_lsm_write_spec() - # Unsharded round-trips (no routing column). - table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + # Unsharded round-trips (no routing column). Opting out is distinct from + # the inferred default. + table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([])) spec = table.get_lsm_write_spec() assert spec.spec_type == "unsharded" assert spec.column is None + assert spec.maintained_indexes == [] @pytest.mark.asyncio diff --git a/python/python/tests/test_merge_insert_lsm.py b/python/python/tests/test_merge_insert_lsm.py index 5674a05ab..e74c21589 100644 --- a/python/python/tests/test_merge_insert_lsm.py +++ b/python/python/tests/test_merge_insert_lsm.py @@ -544,7 +544,7 @@ def test_lsm_read_fts_unmaintained_index_errors(tmp_path): table.create_index("text", config=FTS()) # No maintained indexes: the active memtable FTS arm cannot serve un-compacted # docs, so the search would silently omit them — reject instead. - table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([])) with pytest.raises(Exception, match="maintained"): table.search("fox", query_type="fts", fts_columns="text").to_arrow() @@ -631,7 +631,7 @@ def test_lsm_read_vector_unmaintained_index_errors(tmp_path): ) # Spec with NO maintained indexes: the base vector index's catch-up is untracked, # so the scanner rejects rather than risk dropping compacted-but-unindexed rows. - table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([])) with pytest.raises(Exception, match="maintained"): table.search([1.0] * VECTOR_DIM).to_arrow() diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index eb6eaefaa..2a069c712 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -3713,7 +3713,8 @@ def test_stats(mem_db: DBConnection): stats = table.stats() print(f"{stats=}") assert stats == { - "total_bytes": 60, + # Full on-disk size of the data file, footer and metadata included. + "total_bytes": 633, "num_rows": 2, "num_indices": 0, "fragment_stats": { @@ -3731,6 +3732,13 @@ def test_stats(mem_db: DBConnection): }, } + # Index files count toward total_bytes too (only deletion files and + # manifests are excluded). + table.create_index("id", config=BTree()) + stats_with_index = table.stats() + assert stats_with_index["num_indices"] == 1 + assert stats_with_index["total_bytes"] > stats["total_bytes"] + def test_create_table_empty_list_with_schema(mem_db: DBConnection): """Test creating table with empty list data and schema diff --git a/python/src/index.rs b/python/src/index.rs index 8c81dcecf..dd362373e 100644 --- a/python/src/index.rs +++ b/python/src/index.rs @@ -289,7 +289,7 @@ struct IvfHnswFlatParams { target_partition_size: Option, } -#[pyclass(get_all)] +#[pyclass(module = "lancedb._lancedb", get_all)] /// A description of an index currently configured on a column pub struct IndexConfig { /// The type of the index diff --git a/python/src/session.rs b/python/src/session.rs index 891e61e44..4d58dd269 100644 --- a/python/src/session.rs +++ b/python/src/session.rs @@ -11,7 +11,7 @@ use pyo3::{PyResult, pyclass, pymethods}; /// Sessions allow you to configure cache sizes for index and metadata caches, /// which can significantly impact memory use and performance. They can /// also be re-used across multiple connections to share the same cache state. -#[pyclass(from_py_object)] +#[pyclass(module = "lancedb._lancedb", from_py_object)] #[derive(Clone)] pub struct Session { pub(crate) inner: Arc, diff --git a/python/src/table.rs b/python/src/table.rs index 5b5d6596a..cae6b5d9a 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -28,11 +28,72 @@ use pyo3::{ Bound, FromPyObject, Py, PyAny, PyRef, PyResult, Python, exceptions::{PyRuntimeError, PyValueError}, pyclass, pyfunction, pymethods, - types::{IntoPyDict, PyAnyMethods, PyBytes, PyDict, PyDictMethods}, + types::{IntoPyDict, PyAnyMethods, PyBytes, PyDict, PyDictMethods, PyList, PyListMethods}, }; mod scannable; +/// Convert `LsmStats` to a Python dict, preserving the per-bucket list. +/// +/// Deliberately not flattened to a table-level summary: a table is N +/// buckets on one node, and the per-bucket detail is the reason the +/// endpoint exists — flattening hides the single hot bucket someone opened +/// it to find. +fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult> { + let out = PyDict::new(py); + let buckets = PyList::empty(py); + for b in &stats.buckets { + let e = PyDict::new(py); + e.set_item("shard_id", &b.shard_id)?; + e.set_item("status", &b.status)?; + e.set_item("writer_epoch", b.writer_epoch)?; + e.set_item("manifest_version", b.manifest_version)?; + e.set_item("current_generation", b.current_generation)?; + e.set_item( + "replay_after_wal_entry_position", + b.replay_after_wal_entry_position, + )?; + e.set_item( + "wal_entry_position_last_seen", + b.wal_entry_position_last_seen, + )?; + + let generations = PyList::empty(py); + for g in &b.generations { + let ge = PyDict::new(py); + ge.set_item("generation", g.generation)?; + ge.set_item("bytes", g.bytes)?; + ge.set_item("rows", g.rows)?; + generations.append(ge)?; + } + e.set_item("generations", generations)?; + e.set_item("compacting", b.compacting)?; + + e.set_item( + "memtables", + b.memtables + .as_ref() + .map(|ms| { + let l = PyList::empty(py); + for m in ms { + let d = PyDict::new(py); + d.set_item("generation", m.generation)?; + d.set_item("rows", m.rows)?; + d.set_item("bytes", m.bytes)?; + d.set_item("batches", m.batches)?; + d.set_item("indexes", m.indexes.clone())?; + l.append(d)?; + } + PyResult::Ok(l.unbind()) + }) + .transpose()?, + )?; + buckets.append(e)?; + } + out.set_item("buckets", buckets)?; + Ok(out.unbind()) +} + #[derive(FromPyObject)] enum PredicateArg { Expr(PyExpr), @@ -185,12 +246,22 @@ impl From for MergeResult { } } +/// Render for `__repr__`, so the default reads as Python's `None` rather than +/// Rust's `Some([..])`. +fn fmt_maintained(maintained: &Option>) -> String { + match maintained { + Some(names) => format!("{:?}", names), + None => "None".to_string(), + } +} + /// Specification selecting Lance's MemWAL LSM-style write path for /// `merge_insert`. /// /// Constructed via the `bucket(...)`, `identity(...)`, or `unsharded()` /// classmethods, then optionally chain `with_maintained_indexes(...)` and -/// `with_writer_config_defaults(...)`. +/// `with_writer_config_defaults(...)`. A fresh spec maintains every index the +/// MemWAL supports, resolved on install. #[pyclass(from_py_object)] #[derive(Clone, Debug)] pub struct LsmWriteSpec { @@ -230,11 +301,11 @@ impl LsmWriteSpec { } } - /// Replace the list of indexes the MemWAL should keep up to date as - /// rows are appended. Each name must reference an index that - /// already exists on the table at the time `set_lsm_write_spec` - /// is called. - pub fn with_maintained_indexes(&self, indexes: Vec) -> Self { + /// Set which indexes the MemWAL maintains. `None` (the default) + /// resolves every supported index on install; a list is verbatim, + /// and an empty list maintains nothing. + #[pyo3(signature = (indexes))] + pub fn with_maintained_indexes(&self, indexes: Option>) -> Self { Self { inner: self.inner.clone().with_maintained_indexes(indexes), } @@ -256,23 +327,29 @@ impl LsmWriteSpec { maintained_indexes, writer_config_defaults, } => format!( - "LsmWriteSpec.bucket(column={:?}, num_buckets={}, maintained_indexes={:?}, writer_config_defaults={:?})", - column, num_buckets, maintained_indexes, writer_config_defaults, + "LsmWriteSpec.bucket(column={:?}, num_buckets={}, maintained_indexes={}, writer_config_defaults={:?})", + column, + num_buckets, + fmt_maintained(maintained_indexes), + writer_config_defaults, ), lancedb::table::LsmWriteSpec::Identity { column, maintained_indexes, writer_config_defaults, } => format!( - "LsmWriteSpec.identity(column={:?}, maintained_indexes={:?}, writer_config_defaults={:?})", - column, maintained_indexes, writer_config_defaults, + "LsmWriteSpec.identity(column={:?}, maintained_indexes={}, writer_config_defaults={:?})", + column, + fmt_maintained(maintained_indexes), + writer_config_defaults, ), lancedb::table::LsmWriteSpec::Unsharded { maintained_indexes, writer_config_defaults, } => format!( - "LsmWriteSpec.unsharded(maintained_indexes={:?}, writer_config_defaults={:?})", - maintained_indexes, writer_config_defaults, + "LsmWriteSpec.unsharded(maintained_indexes={}, writer_config_defaults={:?})", + fmt_maintained(maintained_indexes), + writer_config_defaults, ), } } @@ -307,10 +384,10 @@ impl LsmWriteSpec { } } - /// Names of indexes the MemWAL should keep up to date during writes. + /// Indexes the MemWAL keeps up to date, or `None` for every supported one. #[getter] - pub fn maintained_indexes(&self) -> Vec { - self.inner.maintained_indexes().to_vec() + pub fn maintained_indexes(&self) -> Option> { + self.inner.maintained_indexes().map(<[String]>::to_vec) } /// Default `ShardWriter` configuration recorded by this spec. @@ -502,7 +579,7 @@ impl PyBlobFile { } } -#[pyclass(get_all, from_py_object)] +#[pyclass(module = "lancedb._lancedb", get_all, from_py_object)] #[derive(Clone, Debug)] pub struct FtsToken { pub text: String, @@ -1339,6 +1416,51 @@ impl Table { }) } + /// Converge the table's LSM write path into its base table. + /// + /// Best-effort: with writes flowing, new rows may land after the last + /// pass. Errors if the table stops making progress. + pub fn checkpoint_lsm(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + inner.checkpoint_lsm().await.infer_error() + }) + } + + /// Seal every bucket's active memtable into L0. + pub fn flush_lsm(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py( + self_.py(), + async move { inner.flush_lsm().await.infer_error() }, + ) + } + + /// Trigger a background L0 → base pass per bucket. Returns once the + /// passes are dispatched, not once they finish — watch `get_lsm_stats`. + pub fn compact_lsm(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + inner.compact_lsm().await.infer_error() + }) + } + + /// Live LSM state, or `None` when the LSM write path is not enabled. + #[pyo3(signature = (include_generation_rows=false))] + pub fn get_lsm_stats( + self_: PyRef<'_, Self>, + include_generation_rows: bool, + ) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let stats = inner + .get_lsm_stats(include_generation_rows) + .await + .infer_error()?; + Python::attach(|py| stats.map(|s| lsm_stats_to_py(py, &s)).transpose()) + }) + } + pub fn close_lsm_writers(self_: PyRef<'_, Self>) -> PyResult> { let inner = self_.inner_ref()?.clone(); future_into_py(self_.py(), async move { diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 96ea9ec95..23dc86e15 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb" -version = "0.37.1-beta.0" +version = "0.37.1-beta.1" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true @@ -49,8 +49,6 @@ lance-namespace = { workspace = true } lance-namespace-impls = { workspace = true } metrics = { workspace = true, optional = true } metrics-util = { workspace = true, optional = true } -# Pin the GooseFS SDK to the version required by Lance's OpenDAL dependency. -goosefs-sdk = { version = "=0.1.9", optional = true } moka = { workspace = true } pin-project = { workspace = true } tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } @@ -100,7 +98,8 @@ anyhow = "1" lance-testing = { workspace = true } tempfile = "3.5.0" random_word = { version = "0.4.3", features = ["en"] } -tokio = { version = "1.23", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } +roaring = "0.11.4" +tokio = { version = "1.23", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "test-util"] } uuid = { version = "1.7.0", features = ["v4"] } walkdir = "2" aws-sdk-dynamodb = { version = "1.55.0" } @@ -135,7 +134,6 @@ azure = [ ] cos = ["lance/tencent", "lance-io/tencent"] goosefs = [ - "dep:goosefs-sdk", "lance/goosefs", "lance-io/goosefs", "lance-namespace-impls/dir-goosefs", @@ -190,6 +188,9 @@ required-features = ["bedrock"] [[example]] name = "bench_streaming_dataloader" +[[example]] +name = "bench_open_missing_table" + [[example]] name = "simple" diff --git a/rust/lancedb/examples/bench_open_missing_table.rs b/rust/lancedb/examples/bench_open_missing_table.rs new file mode 100644 index 000000000..8e6b16e11 --- /dev/null +++ b/rust/lancedb/examples/bench_open_missing_table.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// Release benchmark for opening a missing table as sibling-table cardinality grows. +// +// The fixture uses real `.lance` directories and marker files. Fixture creation is +// outside the timed section. Defaults intentionally cover 1k, 10k, and 100k siblings +// with 10 warmups and 100 distinct missing-table opens per scale: +// +// ```text +// cargo run --release -p lancedb --example bench_open_missing_table +// ``` +// +// `BENCH_SIBLINGS`, `BENCH_WARMUPS`, and `BENCH_TRIALS` override those defaults. +// Reduced settings are useful only as a smoke test. Performance comparisons require +// the same machine, filesystem, fixture sizes, settings, lockfile, and alternating +// baseline/candidate execution order. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use lancedb::connection::Connection; +use lancedb::{Error, connect}; +use object_store::ObjectStoreExt as _; +use object_store::path::Path; + +const MAX_SIBLINGS: usize = 1_000_000; +const MAX_WARMUPS: usize = 10_000; +const MAX_TRIALS: usize = 100_000; + +fn env_usize(key: &str, default: usize, max: usize) -> Result { + let value = match std::env::var(key) { + Ok(value) => value + .parse() + .with_context(|| format!("invalid {key} value: {value}"))?, + Err(std::env::VarError::NotPresent) => default, + Err(error) => return Err(error).with_context(|| format!("reading {key}")), + }; + if value == 0 || value > max { + bail!("{key} must be between 1 and {max}"); + } + Ok(value) +} + +fn sibling_counts() -> Result> { + let raw = std::env::var("BENCH_SIBLINGS").unwrap_or_else(|_| "1000,10000,100000".into()); + let mut counts = raw + .split(',') + .map(|value| { + value + .trim() + .parse::() + .with_context(|| format!("invalid BENCH_SIBLINGS value: {value}")) + }) + .collect::>>()?; + counts.sort_unstable(); + counts.dedup(); + if counts.is_empty() || counts[0] == 0 || counts[counts.len() - 1] > MAX_SIBLINGS { + bail!("BENCH_SIBLINGS values must be between 1 and {MAX_SIBLINGS}"); + } + Ok(counts) +} + +async fn add_siblings( + store: &object_store::local::LocalFileSystem, + start: usize, + end: usize, +) -> Result<()> { + for index in start..end { + let marker = Path::from(format!("sibling_{index:06}.lance/_marker")); + store + .put(&marker, bytes::Bytes::new().into()) + .await + .with_context(|| format!("creating benchmark marker {marker}"))?; + } + Ok(()) +} + +async fn time_missing_open(db: &Connection, name: &str) -> Result { + let started = Instant::now(); + let result = db.open_table(name).execute().await; + let elapsed = started.elapsed(); + match result { + Err(Error::TableNotFound { .. }) => Ok(elapsed), + Err(error) => bail!("expected TableNotFound for {name}, got {error:?}"), + Ok(_) => bail!("benchmark missing-table name unexpectedly exists: {name}"), + } +} + +fn percentile(sorted: &[Duration], percentile: usize) -> Duration { + let rank = (sorted.len() * percentile).div_ceil(100).saturating_sub(1); + sorted[rank] +} + +#[tokio::main] +async fn main() -> Result<()> { + let counts = sibling_counts()?; + let warmups = env_usize("BENCH_WARMUPS", 10, MAX_WARMUPS)?; + let trials = env_usize("BENCH_TRIALS", 100, MAX_TRIALS)?; + + let fixture = tempfile::tempdir().context("creating benchmark fixture")?; + let database_path = fixture.path(); + let fixture_store = object_store::local::LocalFileSystem::new_with_prefix(database_path) + .context("creating benchmark object store")?; + let db = connect(database_path.to_str().context("non-UTF-8 fixture path")?) + .execute() + .await?; + + println!( + "config: siblings={counts:?} warmups={warmups} trials={trials} profile={} os={} arch={}", + if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + std::env::consts::OS, + std::env::consts::ARCH, + ); + println!("lower is better; fixture setup and teardown are excluded"); + println!("| siblings | samples | p50 | p95 | max |"); + println!("| ---: | ---: | ---: | ---: | ---: |"); + + let mut created = 0; + for sibling_count in counts { + add_siblings(&fixture_store, created, sibling_count).await?; + created = sibling_count; + + for index in 0..warmups { + let name = format!("__missing_warmup_{sibling_count}_{index}"); + let _ = time_missing_open(&db, &name).await?; + } + + let mut samples = Vec::with_capacity(trials); + for index in 0..trials { + let name = format!("__missing_trial_{sibling_count}_{index}"); + samples.push(time_missing_open(&db, &name).await?); + } + samples.sort_unstable(); + + println!( + "| {sibling_count} | {} | {:?} | {:?} | {:?} |", + samples.len(), + percentile(&samples, 50), + percentile(&samples, 95), + samples[samples.len() - 1], + ); + } + + Ok(()) +} diff --git a/rust/lancedb/src/blob.rs b/rust/lancedb/src/blob.rs index e1c18dd84..d59123ec3 100644 --- a/rust/lancedb/src/blob.rs +++ b/rust/lancedb/src/blob.rs @@ -17,7 +17,7 @@ use arrow_array::builder::LargeBinaryBuilder; use arrow_schema::{DataType, Field, Schema}; use lance::dataset::{BlobRangeRequest as LanceBlobRangeRequest, Dataset, WriteParams}; use lance_arrow::FieldExt; -use lance_file::version::LanceFileVersion; +use lance_file::version::{ConcreteFileVersion, LanceFileVersion}; use lance_io::object_store::ObjectStore; use object_store::path::Path; @@ -333,7 +333,10 @@ pub(crate) fn ensure_blob_storage_version(schema: &Schema, params: &mut WritePar .data_storage_version .unwrap_or(LanceFileVersion::Stable) .resolve(); - if resolved < LanceFileVersion::V2_2 { + if matches!( + resolved, + ConcreteFileVersion::V1 | ConcreteFileVersion::V2_0 | ConcreteFileVersion::V2_1 + ) { params.data_storage_version = Some(LanceFileVersion::V2_2); } } @@ -499,7 +502,7 @@ mod tests { ensure_blob_storage_version(&blob_schema(), &mut params); assert_eq!( params.data_storage_version.unwrap().resolve(), - LanceFileVersion::V2_2 + ConcreteFileVersion::V2_2 ); } @@ -512,7 +515,7 @@ mod tests { ensure_blob_storage_version(&blob_schema(), &mut params); assert_eq!( params.data_storage_version.unwrap().resolve(), - LanceFileVersion::V2_2 + ConcreteFileVersion::V2_2 ); } diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index dd53a2d2e..1f2708d4e 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -409,6 +409,11 @@ impl Connection { /// /// The names will be returned in lexicographical order (ascending) /// + /// Listing databases discover physical `*.lance` entries without opening every + /// dataset. The result is a point-in-time discovery snapshot: an entry may still be + /// under creation, may contain only uncommitted storage, or may be concurrently + /// dropped before it is opened. + /// /// The parameters `page_token` and `limit` can be used to paginate the results pub fn table_names(&self) -> TableNamesBuilder { TableNamesBuilder::new(self.internal.clone()) @@ -456,10 +461,9 @@ impl Connection { /// /// # Returns /// Created [`TableRef`], or [`Error::TableNotFound`] if the table does not exist. - /// If the table's storage is present but holds no readable dataset (for example a - /// `.lance` directory left behind by an interrupted drop and re-create, which - /// [`Self::table_names`] still lists) this returns [`Error::TableCorrupted`] - /// instead. + /// On listing databases, a committed Lance manifest is authoritative for table + /// existence. Uncommitted files or a physical `.lance` directory alone do not + /// make a table openable. pub fn open_table(&self, name: impl Into) -> OpenTableBuilder { OpenTableBuilder::new( self.internal.clone(), diff --git a/rust/lancedb/src/connection/create_table.rs b/rust/lancedb/src/connection/create_table.rs index b10141beb..39cc82ec0 100644 --- a/rust/lancedb/src/connection/create_table.rs +++ b/rust/lancedb/src/connection/create_table.rs @@ -438,10 +438,9 @@ mod tests { .await .unwrap() .data_storage_format - .lance_file_version() - .unwrap(); + .lance_file_format(); // Compare resolved versions since Stable/Next are aliases that resolve at storage time - assert_eq!(storage_format.resolve(), data_storage_version.resolve()); + assert_eq!(storage_format, data_storage_version.resolve()); } #[tokio::test] diff --git a/rust/lancedb/src/database/listing.rs b/rust/lancedb/src/database/listing.rs index fb58ed3b9..29fde9b33 100644 --- a/rust/lancedb/src/database/listing.rs +++ b/rust/lancedb/src/database/listing.rs @@ -1291,17 +1291,21 @@ impl Database for ListingDatabase { mod tests { use super::*; use crate::Table; + use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream}; use crate::connection::ConnectRequest; use crate::data::scannable::Scannable; use crate::database::{CreateTableMode, CreateTableRequest}; use crate::query::QueryRequest; use crate::table::{AnyQuery, WriteOptions}; use arrow_array::{Int32Array, RecordBatch, StringArray}; - use arrow_schema::{DataType, Field, Schema}; - use futures::TryStreamExt; - use futures::future::try_join_all; + use arrow_schema::{DataType, Field, Schema, SchemaRef}; + use futures::{TryStreamExt, future::try_join_all, stream::once}; use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; use tempfile::tempdir; + use tokio::sync::Barrier; + use tokio::time::timeout; async fn setup_database() -> (tempfile::TempDir, ListingDatabase) { let tempdir = tempdir().unwrap(); @@ -1325,6 +1329,114 @@ mod tests { (tempdir, db) } + struct BarrierScannable { + batch: RecordBatch, + barrier: Arc, + } + + impl Scannable for BarrierScannable { + fn schema(&self) -> SchemaRef { + self.batch.schema() + } + + fn scan_as_stream(&mut self) -> SendableRecordBatchStream { + let batch = self.batch.clone(); + let schema = batch.schema(); + let barrier = self.barrier.clone(); + Box::pin(SimpleRecordBatchStream { + schema, + stream: once(async move { + barrier.wait().await; + Ok(batch) + }), + }) + } + } + + fn create_request(name: &str, data: Box) -> CreateTableRequest { + CreateTableRequest { + name: name.to_string(), + namespace_path: vec![], + data, + mode: CreateTableMode::Create, + write_options: Default::default(), + location: None, + namespace_client: None, + } + } + + #[tokio::test] + async fn test_create_ignores_uncommitted_storage_without_manifest() { + let (tmp_dir, db) = setup_database().await; + let data_dir = tmp_dir.path().join("test.lance/data"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join("orphan.lance"), b"uncommitted").unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1]))]).unwrap(); + + let table = db + .create_table(create_request("test", Box::new(batch))) + .await + .unwrap(); + assert_eq!(table.count_rows(None).await.unwrap(), 1); + } + + #[tokio::test] + async fn test_concurrent_create_is_arbitrated_by_manifest_commit() { + let uri = format!("memory:///concurrent-create-{}", uuid::Uuid::new_v4()); + let db = crate::connect(&uri).execute().await.unwrap(); + let store: Arc = + Arc::new(object_store::memory::InMemory::new()); + let table_url = url::Url::parse("memory:///database/test.lance").unwrap(); + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1]))]).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + + #[allow(deprecated)] + let request = |batch, barrier| { + let mut request = create_request("test", Box::new(BarrierScannable { batch, barrier })); + request.write_options = WriteOptions { + lance_write_params: Some(lance::dataset::WriteParams { + store_params: Some(ObjectStoreParams { + object_store: Some((store.clone(), table_url.clone())), + ..Default::default() + }), + commit_handler: Some(Arc::new( + lance_table::io::commit::ConditionalPutCommitHandler, + )), + ..Default::default() + }), + }; + request + }; + + let left = db + .database() + .create_table(request(batch.clone(), barrier.clone())); + let right = db.database().create_table(request(batch, barrier)); + let (left, right) = timeout(Duration::from_secs(30), async { tokio::join!(left, right) }) + .await + .expect("concurrent creates deadlocked"); + + let results = [left, right]; + assert_eq!( + results.iter().filter(|result| result.is_ok()).count(), + 1, + "expected one successful create, got {results:?}" + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(Error::TableAlreadyExists { .. }))) + .count(), + 1, + "expected one manifest conflict, got {results:?}" + ); + } + #[tokio::test] async fn test_concurrent_open_table_reuses_connection_object_store() { let tempdir = tempdir().unwrap(); diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 388bed0f7..0d843dd54 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -23,11 +23,13 @@ use crate::table::AddResult; use crate::table::BranchDiff; use crate::table::DeleteResult; use crate::table::DropColumnsResult; +use crate::table::LsmStats; use crate::table::LsmWriteSpec; use crate::table::MergeBranchResult; use crate::table::MergeResult; use crate::table::Tags; use crate::table::UpdateResult; +use crate::table::lsm_stats::GetLsmStatsResponse; use crate::table::merge::MergeFilter; use crate::table::query::create_multi_vector_plan; use crate::table::write_progress::FinishOnDrop; @@ -991,6 +993,18 @@ impl RemoteTable { } } + /// Send an LSM operator request with the transport retry layer **off**. + /// + /// Retry policy on these routes belongs to the checkpoint loop, which + /// reads the status and can tell contention from a lost claim. Leaving the + /// transport layer on would re-ask on its own schedule first, and surface + /// an `Error::Retry` whose status the loop would then have to unwrap. + async fn send_lsm_route(&self, request: RequestBuilder) -> Result<(String, reqwest::Response)> { + let (request_id, response) = self.send(request, false).await?; + let response = self.check_table_response(&request_id, response).await?; + Ok((request_id, response)) + } + /// Build a POST request and attach the read-freshness headers /// (`x-lancedb-min-version`, `x-lancedb-min-timestamp`). fn post_read(&self, uri: &str) -> RequestBuilder { @@ -2468,13 +2482,47 @@ impl BaseTable for RemoteTable { }) } + async fn flush_lsm(&self) -> Result<()> { + let request = self + .client + .post(&format!("/v1/table/{}/flush_lsm/", self.identifier)); + self.send_lsm_route(request).await?; + Ok(()) + } + + async fn compact_lsm(&self) -> Result<()> { + let request = self + .client + .post(&format!("/v1/table/{}/compact_lsm/", self.identifier)); + self.send_lsm_route(request).await?; + Ok(()) + } + + async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result> { + // Read-semantics POST, like `get_lsm_write_spec`. + let request = self + .post_read(&format!("/v1/table/{}/get_lsm_stats/", self.identifier)) + .json(&serde_json::json!({ + "include_generation_rows": include_generation_rows, + })); + let (request_id, response) = self.send_lsm_route(request).await?; + let body = response.text().await.err_to_http(request_id.clone())?; + let parsed: GetLsmStatsResponse = serde_json::from_str(&body).map_err(|e| Error::Http { + source: format!("Failed to parse get_lsm_stats response: {e}").into(), + request_id, + status_code: None, + })?; + // `null` — and only — when the table has no LSM write path. + Ok(parsed.lsm_stats) + } + async fn set_lsm_write_spec(&self, spec: LsmWriteSpec) -> Result<()> { self.check_mutable().await?; // Map the spec onto the server's request DTO. `sharding` is internally - // tagged on `mode` to mirror sophon's `Sharding` enum; `maintained_indexes` - // and `writer_config_defaults` are sent verbatim (an empty list means "no - // maintained indexes", not "default to all"). + // tagged on `mode` to mirror sophon's `Sharding` enum. A null + // `maintained_indexes` asks the server to resolve every maintainable + // index at HEAD; a list is verbatim, an empty one meaning none. let sharding = match &spec { LsmWriteSpec::Bucket { column, @@ -6551,7 +6599,7 @@ mod tests { .unwrap() }); let spec = crate::table::LsmWriteSpec::unsharded() - .with_maintained_indexes(["id_idx"]) + .with_maintained_indexes(vec!["id_idx".to_string()]) .with_writer_config_defaults([("max_memtable_rows", "1000")]); table.set_lsm_write_spec(spec).await.unwrap(); } @@ -6570,7 +6618,8 @@ mod tests { body["sharding"], serde_json::json!({ "mode": "bucket", "column": "id", "num_buckets": 16 }) ); - assert_eq!(body["maintained_indexes"], serde_json::json!([])); + // An unpinned maintained set sends null: resolve server-side. + assert_eq!(body["maintained_indexes"], serde_json::Value::Null); http::Response::builder().status(200).body("{}").unwrap() }); table @@ -6579,6 +6628,23 @@ mod tests { .unwrap(); } + /// `[]` (none) must stay distinguishable on the wire from null (all). + #[tokio::test] + async fn test_set_lsm_write_spec_no_maintained_indexes() { + let table = Table::new_with_handler("my_table", |request| { + let body = request.body().unwrap().as_bytes().unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["maintained_indexes"], serde_json::json!([])); + http::Response::builder().status(200).body("{}").unwrap() + }); + table + .set_lsm_write_spec( + crate::table::LsmWriteSpec::bucket("id", 16).with_maintained_indexes(Vec::new()), + ) + .await + .unwrap(); + } + #[tokio::test] async fn test_set_lsm_write_spec_identity() { let table = Table::new_with_handler("my_table", |request| { @@ -6653,7 +6719,7 @@ mod tests { } => { assert_eq!(column, "id"); assert_eq!(num_buckets, 4); - assert_eq!(maintained_indexes, vec!["id_idx".to_string()]); + assert_eq!(maintained_indexes, Some(vec!["id_idx".to_string()])); assert_eq!( writer_config_defaults .get("durable_write") @@ -6682,6 +6748,499 @@ mod tests { assert!(table.get_lsm_write_spec().await.unwrap().is_none()); } + /// Build a `get_lsm_stats` body for one bucket holding `generations`. + fn stats_body(generations: &[u64], compacting: bool) -> String { + serde_json::json!({ + "lsm_stats": { + "buckets": [{ + "shard_id": "b0", + "status": "Active", + "writer_epoch": 1, + "manifest_version": 1, + "current_generation": generations.iter().max().copied().unwrap_or(0) + 1, + "replay_after_wal_entry_position": 0, + "wal_entry_position_last_seen": 0, + "generations": generations.iter() + .map(|g| serde_json::json!({ "generation": g, "bytes": 1 })) + .collect::>(), + "compacting": compacting, + "memtables": [], + }], + } + }) + .to_string() + } + + /// `flush_lsm` / `compact_lsm` answer 202 with no body at all. + fn accepted() -> http::Response { + http::Response::builder() + .status(202) + .body(String::new()) + .unwrap() + } + + fn ok_json(body: String) -> http::Response { + http::Response::builder().status(200).body(body).unwrap() + } + + /// A flush landing in an empty L0 finishes on the opening stats read + /// alone. Asserting zero compacts is the point: "it returned Ok" is also + /// true of a loop that ran a pointless pass. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_short_circuits_on_empty_l0() { + let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = compacts.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("compact_lsm") { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + panic!("an already-converged table must issue no compact calls"); + } + if path.contains("flush_lsm") { + return accepted(); + } + assert_eq!(path, "/v1/table/my_table/get_lsm_stats/"); + ok_json(stats_body(&[], false)) + }); + + table.checkpoint_lsm().await.unwrap(); + assert_eq!(compacts.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + /// The loop triggers compaction until every generation that existed at + /// the start is gone, one bounded prefix per pass. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_triggers_until_targets_are_drained() { + let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = compacts.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") { + return accepted(); + } + if path.contains("compact_lsm") { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return accepted(); + } + // Each pass drains the oldest generation. + let drained = seen.load(std::sync::atomic::Ordering::SeqCst); + let left: Vec = [1u64, 2, 3].into_iter().skip(drained).collect(); + ok_json(stats_body(&left, false)) + }); + + table.checkpoint_lsm().await.unwrap(); + assert_eq!( + compacts.load(std::sync::atomic::Ordering::SeqCst), + 3, + "one trigger per generation prefix, then stop" + ); + } + + /// Generations created *during* the checkpoint are not waited on, which + /// is what lets the loop terminate on a table taking writes where "L0 is + /// empty" never becomes true. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_ignores_generations_created_while_it_runs() { + let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = compacts.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") { + return accepted(); + } + if path.contains("compact_lsm") { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return accepted(); + } + // Target is 5. One pass drains it; a writer keeps adding above. + let n = seen.load(std::sync::atomic::Ordering::SeqCst); + let body = if n == 0 { + stats_body(&[5], false) + } else { + stats_body(&[6, 7], false) + }; + ok_json(body) + }); + + table.checkpoint_lsm().await.unwrap(); + assert_eq!( + compacts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the loop must not chase generations written after it started" + ); + } + + /// Contention is a 429 and must be retried. The server keeps it off 503 + /// precisely so the client can act on the status alone — reading it as + /// terminal stops the checkpoint early on a healthy node. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_retries_contention() { + let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = compacts.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") { + return accepted(); + } + if path.contains("compact_lsm") { + // First two triggers: every bucket already latched. + if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2 { + return http::Response::builder() + .status(429) + .body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string()) + .unwrap(); + } + return accepted(); + } + let accepted_triggers = seen + .load(std::sync::atomic::Ordering::SeqCst) + .saturating_sub(2); + let left: Vec = if accepted_triggers == 0 { + vec![1] + } else { + vec![] + }; + ok_json(stats_body(&left, false)) + }); + + table + .checkpoint_lsm() + .await + .expect("contention must not abort the checkpoint"); + assert_eq!( + compacts.load(std::sync::atomic::Ordering::SeqCst), + 3, + "assert the retry count, not just the outcome" + ); + } + + /// A transient fault on the poll must not abort the checkpoint. This route + /// meets the most contention — it runs every `POLL_INTERVAL` for the + /// checkpoint's whole life, with the transport retry layer disabled — yet + /// was the one call reached with a bare `?`. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_retries_a_contended_stats_poll() { + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = polls.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") || path.contains("compact_lsm") { + return accepted(); + } + // The opening read lands; the next two polls are latched out. + let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if (1..3).contains(&n) { + return http::Response::builder() + .status(429) + .body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string()) + .unwrap(); + } + ok_json(stats_body(if n < 4 { &[1] } else { &[] }, false)) + }); + + table + .checkpoint_lsm() + .await + .expect("a contended poll must be retried, not surfaced"); + assert_eq!( + polls.load(std::sync::atomic::Ordering::SeqCst), + 5, + "the two rejected polls must be re-issued, not skipped" + ); + } + + /// Contention and a lost claim draw on separate budgets: five straight + /// 429s on `flush`, more than `MAX_REISSUES`, must still converge. On one + /// shared counter this spent the re-issue cap and then reported a lost + /// claim nothing had ever reported. + #[tokio::test(start_paused = true)] + async fn test_contention_does_not_exhaust_the_reissue_budget() { + let flushes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = flushes.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") { + if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 5 { + return http::Response::builder() + .status(429) + .body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string()) + .unwrap(); + } + return accepted(); + } + if path.contains("compact_lsm") { + return accepted(); + } + ok_json(stats_body(&[], false)) + }); + + table + .checkpoint_lsm() + .await + .expect("contention must not be reported as a lost claim"); + assert_eq!( + flushes.load(std::sync::atomic::Ordering::SeqCst), + 6, + "five retries against one seal, then it lands" + ); + } + + /// An exhausted retry budget surfaces the fault that consumed it, not a + /// message the loop invented: "429, nine times" points an operator at a + /// saturated pool, a generic runtime error points them nowhere. + #[tokio::test(start_paused = true)] + async fn test_exhausted_retries_surface_the_underlying_fault() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = calls.clone(); + let table = Table::new_with_handler("my_table", move |_request| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + http::Response::builder() + .status(429) + .body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string()) + .unwrap() + }); + + let err = table.checkpoint_lsm().await.unwrap_err(); + assert!( + matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 429), + "the fault that spent the budget must be the one reported: {err:?}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 9, + "one call plus MAX_RETRIES — the re-issue budget is not spent on top" + ); + } + + /// A draining node is terminal, but the client does not know that from the + /// status: draining and a proxy blip are both 503, and telling them apart + /// takes parsing the body for a namespace code. So it spends the retry + /// budget and then reports what the server said — the drain gate never + /// releases, so the answer does not change, and the operator still reads + /// "WAL node draining" in the error. + #[tokio::test(start_paused = true)] + async fn test_draining_surfaces_after_the_retry_budget() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = calls.clone(); + let table = Table::new_with_handler("my_table", move |_request| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + http::Response::builder() + .status(503) + .body(r#"{"code":19,"error":"WAL node draining"}"#.to_string()) + .unwrap() + }); + + let err = table.checkpoint_lsm().await.unwrap_err(); + let message = err.to_string(); + assert!( + matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 503), + "the 503 must surface as itself: {err:?}" + ); + assert!( + message.contains("WAL node draining"), + "the server's own diagnosis must survive to the caller: {message}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 9, + "one call plus MAX_RETRIES, then it reports rather than spinning" + ); + } + + /// A long stall with nothing compacting must keep waiting, not fail. The + /// client cannot judge this: a checkpoint queued behind unrelated tables + /// on the pod-wide compactor pool reports exactly these numbers — flat + /// generations, an idle latch — as one whose merges are failing. The + /// deadline is the caller's. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_waits_out_a_long_stall_rather_than_failing() { + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = polls.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") || path.contains("compact_lsm") { + return accepted(); + } + // Flat for far longer than any bound this loop ever had, with + // `compacting: false` throughout — then it drains. + let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ok_json(stats_body(if n < 40 { &[1, 2] } else { &[] }, false)) + }); + + table + .checkpoint_lsm() + .await + .expect("a stall is the server being slow, not the client's call to make"); + assert!( + polls.load(std::sync::atomic::Ordering::SeqCst) > 40, + "the loop must have kept polling well past the old ten-poll bound" + ); + } + + /// A pass already owns the latch on every outstanding bucket, so the loop + /// waits rather than piling on triggers it would only refuse. This is the + /// sole thing `compacting` is read for. + #[tokio::test(start_paused = true)] + async fn test_checkpoint_waits_while_a_pass_is_running() { + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_polls = polls.clone(); + let seen_compacts = compacts.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + if path.contains("flush_lsm") { + return accepted(); + } + if path.contains("compact_lsm") { + seen_compacts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return accepted(); + } + // Latched for many polls, then done. + let n = seen_polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ok_json(if n > 15 { + stats_body(&[], false) + } else { + stats_body(&[1], true) + }) + }); + + table + .checkpoint_lsm() + .await + .expect("a running pass is progress, not a stall"); + assert_eq!( + compacts.load(std::sync::atomic::Ordering::SeqCst), + 0, + "never trigger against a bucket already compacting" + ); + } + + /// WAL off ⇒ `None`; WAL on ⇒ a fully populated `Some` with no field + /// defaulting to a zero it did not measure. `include_generation_rows` + /// rides in the body and is off unless asked for. + #[tokio::test] + async fn test_get_lsm_stats_round_trip() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.url().path(), "/v1/table/my_table/get_lsm_stats/"); + let body = request.body().unwrap().as_bytes().unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + body["include_generation_rows"], true, + "the flag must reach the server, not be silently dropped" + ); + let response = serde_json::json!({ + "lsm_stats": { + "buckets": [{ + "shard_id": "b0", + "status": "Active", + "writer_epoch": 3, + "manifest_version": 11, + "current_generation": 9, + "replay_after_wal_entry_position": 100, + "wal_entry_position_last_seen": 140, + "generations": [{ "generation": 8, "bytes": 4096, "rows": 30 }], + "compacting": false, + "memtables": [ + { "generation": 9, "rows": 12, "bytes": 900, "batches": 2, + "indexes": ["vec_idx"] } + ], + }], + } + }); + http::Response::builder() + .status(200) + .body(response.to_string()) + .unwrap() + }); + + let stats = table + .get_lsm_stats(true) + .await + .unwrap() + .expect("a WAL-backed table reports Some"); + let bucket = &stats.buckets[0]; + assert_eq!(bucket.replay_after_wal_entry_position, 100); + assert_eq!(bucket.wal_entry_position_last_seen, 140); + assert!(!bucket.compacting); + assert_eq!(bucket.generations[0].generation, 8); + assert_eq!(bucket.generations[0].rows, Some(30)); + // The line that answers "why is my fresh-tier vector search + // brute-force" — an absent index name is the whole explanation. + let memtables = bucket.memtables.as_ref().unwrap(); + assert_eq!(memtables[0].indexes, vec!["vec_idx".to_string()]); + } + + /// A 404 arrives as `TableNotFound`, not as a lost claim the loop + /// re-issues from flush until its cap. The two are distinguished by + /// status: 404 is "no such table", 421 is "this node holds no claim". + /// They shared 404 once, and the loop chased a name that never existed. + #[tokio::test(start_paused = true)] + async fn test_missing_table_is_not_read_as_a_lost_claim() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = calls.clone(); + let table = Table::new_with_handler("my_table", move |_request| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + http::Response::builder() + .status(404) + .body(r#"{"code":4,"error":"Not found: Table not found: my_table"}"#.to_string()) + .unwrap() + }); + + let err = table.checkpoint_lsm().await.unwrap_err(); + assert!( + matches!(err, Error::TableNotFound { .. }), + "a missing table must say so: {err:?}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "no point re-claiming a table that does not exist" + ); + } + + /// A lost claim — 421, not 404 — does re-issue from flush, the call that + /// re-claims and replays. + #[tokio::test(start_paused = true)] + async fn test_registry_miss_reissues_from_flush() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = calls.clone(); + let table = Table::new_with_handler("my_table", move |request| { + let path = request.url().path().to_string(); + let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if path.contains("flush_lsm") { + // First flush lands; the claim is then lost, and the + // re-issued flush succeeds. + return accepted(); + } + if path.contains("compact_lsm") { + if n < 4 { + return http::Response::builder() + .status(421) + .body(r#"{"code":19,"error":"table not claimed"}"#.to_string()) + .unwrap(); + } + return accepted(); + } + ok_json(stats_body(if n < 6 { &[1] } else { &[] }, false)) + }); + + table + .checkpoint_lsm() + .await + .expect("a lost claim must be recovered by re-flushing, not surfaced"); + } + + #[tokio::test] + async fn test_get_lsm_stats_absent_when_wal_off() { + let table = Table::new_with_handler("my_table", |_request| { + http::Response::builder() + .status(200) + .body(serde_json::json!({ "lsm_stats": null }).to_string()) + .unwrap() + }); + assert!(table.get_lsm_stats(false).await.unwrap().is_none()); + } + #[tokio::test] async fn test_wait_for_index() { let table = _make_table_with_indices(0); diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index 0d8a8e8b9..32b6bcebc 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -50,7 +50,6 @@ use crate::DistanceType; use crate::blob::BlobRangeRequest; use crate::data::scannable::{PeekedScannable, Scannable, estimate_write_partitions}; use crate::database::Database; -use crate::database::listing::LANCE_FILE_EXTENSION; use crate::database::read_freshness::TableFreshness; use crate::embeddings::{EmbeddingDefinition, EmbeddingRegistry, MemoryRegistry}; use crate::error::{Error, Result}; @@ -68,10 +67,12 @@ use self::merge::MergeInsertBuilder; pub mod add_columns; mod add_data; pub mod branch_merge; +pub mod checkpoint; mod create_index; pub mod datafusion; pub(crate) mod dataset; pub mod delete; +pub mod lsm_stats; pub mod merge; pub mod optimize; mod primary_key; @@ -93,8 +94,8 @@ pub use delete::DeleteResult; use futures::future::join_all; pub use lance::dataset::refs::{BranchContents, Ref, TagContents, Tags as LanceTags}; pub use lance::dataset::scanner::DatasetRecordBatchStream; -use lance::dataset::statistics::DatasetStatisticsExt; pub use lance_index::optimize::OptimizeOptions; +pub use lsm_stats::{BucketStats, GenerationStats, LsmStats, MemtableStats}; pub use optimize::{CompactionOptions, OptimizeAction, OptimizeStats}; pub use schema_evolution::{ AddColumnsResult, AlterColumnsResult, DropColumnsResult, FieldMetadataUpdate, @@ -150,55 +151,6 @@ pub(crate) fn map_namespace_lance_error(err: lance::Error, table_name: &str) -> } } -/// Map a `lance::Error::DatasetNotFound` for the table at `uri` into a `lancedb::Error`. -/// -/// Lance reports "there is nothing at this location" and "there is a table directory -/// here but nothing loadable inside it" with the same error. Only the first is a -/// `TableNotFound`: a `.lance` directory left behind by an interrupted drop and -/// re-create is still reported by `Connection::table_names`, so callers need to be able -/// to tell "never existed" from "exists but is broken". -/// -/// See . -async fn map_dataset_not_found( - uri: &str, - name: &str, - params: ReadParams, - err: lance::Error, -) -> Error { - let name = name.to_string(); - let source = Box::new(err); - if table_dir_exists(uri, params).await.unwrap_or(false) { - Error::TableCorrupted { name, source } - } else { - Error::TableNotFound { name, source } - } -} - -/// Whether a table directory is present at `uri`, even though no dataset could be -/// loaded from it. -/// -/// This looks for a `.lance` entry in the parent directory, which is exactly what -/// `ListingDatabase::table_names` lists, so the two APIs agree on whether a table is -/// present. Probing `uri` itself would not work: object stores have no empty -/// directories to probe, and on a local filesystem the interesting case is precisely an -/// empty directory. -async fn table_dir_exists(uri: &str, params: ReadParams) -> Result { - let (object_store, path, _) = DatasetBuilder::from_uri(uri) - .with_read_params(params) - .build_object_store() - .await?; - // Only `*.lance` entries are ever reported as tables, so nothing else can produce - // the list-then-open mismatch this guards against. - if path.extension() != Some(LANCE_FILE_EXTENSION) { - return Ok(false); - } - let (Some(parent), Some(dir_name)) = (path.parent(), path.filename()) else { - return Ok(false); - }; - let entries = object_store.read_dir(parent).await?; - Ok(entries.iter().any(|entry| entry.as_str() == dir_name)) -} - /// Defines the type of column #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ColumnKind { @@ -368,6 +320,8 @@ pub use self::merge::MergeResult; /// date) and [`LsmWriteSpec::with_writer_config_defaults`] (default /// `ShardWriter` configuration recorded in the MemWAL index). /// +/// A fresh spec maintains every index on the table, resolved on install. +/// /// Install a spec with [`Table::set_lsm_write_spec`] and remove it with /// [`Table::unset_lsm_write_spec`]. The actual `merge_insert` dispatch /// onto the MemWAL writer is a follow-up. @@ -382,9 +336,12 @@ pub enum LsmWriteSpec { Bucket { column: String, num_buckets: u32, - /// Names of indexes (already created on the table) that the - /// MemWAL should maintain in-memory as rows are appended. - maintained_indexes: Vec, + /// Indexes the MemWAL maintains in-memory as rows are appended. + /// + /// `None` means every index it can maintain, resolved on install — a + /// snapshot, so indexes created later need the spec unset and re-set. + /// `Some([])` maintains nothing. + maintained_indexes: Option>, /// Default `ShardWriter` configuration recorded in the MemWAL index. writer_config_defaults: HashMap, }, @@ -394,35 +351,41 @@ pub enum LsmWriteSpec { /// distinct value of `column` becomes its own shard. Identity { column: String, - /// Names of indexes (already created on the table) that the - /// MemWAL should maintain in-memory as rows are appended. - maintained_indexes: Vec, + /// Indexes the MemWAL maintains in-memory as rows are appended. + /// + /// `None` means every index it can maintain, resolved on install — a + /// snapshot, so indexes created later need the spec unset and re-set. + /// `Some([])` maintains nothing. + maintained_indexes: Option>, /// Default `ShardWriter` configuration recorded in the MemWAL index. writer_config_defaults: HashMap, }, /// No sharding — every `merge_insert` call writes to a single MemWAL shard. Unsharded { - /// Names of indexes (already created on the table) that the - /// MemWAL should maintain in-memory as rows are appended. - maintained_indexes: Vec, + /// Indexes the MemWAL maintains in-memory as rows are appended. + /// + /// `None` means every index it can maintain, resolved on install — a + /// snapshot, so indexes created later need the spec unset and re-set. + /// `Some([])` maintains nothing. + maintained_indexes: Option>, /// Default `ShardWriter` configuration recorded in the MemWAL index. writer_config_defaults: HashMap, }, } impl LsmWriteSpec { - /// Construct a hash-bucket sharding spec with no maintained indexes. + /// Construct a hash-bucket sharding spec maintaining every index on the table. pub fn bucket(column: impl Into, num_buckets: u32) -> Self { Self::Bucket { column: column.into(), num_buckets, - maintained_indexes: Vec::new(), + maintained_indexes: None, writer_config_defaults: HashMap::new(), } } /// Construct an identity-sharding spec (shard by the raw value of - /// `column`) with no maintained indexes. + /// `column`) maintaining every index on the table. /// /// `column` must be a deterministic function of the unenforced primary /// key: every row with a given primary key must always produce the same @@ -434,28 +397,37 @@ impl LsmWriteSpec { pub fn identity(column: impl Into) -> Self { Self::Identity { column: column.into(), - maintained_indexes: Vec::new(), + maintained_indexes: None, writer_config_defaults: HashMap::new(), } } - /// Construct an unsharded spec with no maintained indexes. + /// Construct an unsharded spec maintaining every index on the table. pub fn unsharded() -> Self { Self::Unsharded { - maintained_indexes: Vec::new(), + maintained_indexes: None, writer_config_defaults: HashMap::new(), } } - /// Replace the list of indexes the MemWAL should keep up to date as - /// rows are appended. Each name must reference an index that already - /// exists on the table at the time `set_lsm_write_spec` is called. - pub fn with_maintained_indexes(mut self, indexes: I) -> Self - where - I: IntoIterator, - S: Into, - { - let v: Vec = indexes.into_iter().map(Into::into).collect(); + /// Set which indexes the MemWAL maintains. + /// + /// `None` (the default) resolves to every index on the table at install, + /// failing if one cannot be maintained — name the set to install anyway. A + /// list is verbatim: each name must already exist and be maintainable, and + /// an empty list maintains nothing. + /// + /// ``` + /// # use lancedb::table::LsmWriteSpec; + /// // Every index the table has when the spec is installed: + /// LsmWriteSpec::unsharded().with_maintained_indexes(None); + /// // Exactly these: + /// LsmWriteSpec::unsharded().with_maintained_indexes(vec!["id_idx".to_string()]); + /// // None at all: + /// LsmWriteSpec::unsharded().with_maintained_indexes(Vec::new()); + /// ``` + pub fn with_maintained_indexes(mut self, indexes: impl Into>>) -> Self { + let indexes = indexes.into(); match &mut self { Self::Bucket { maintained_indexes, .. @@ -465,7 +437,7 @@ impl LsmWriteSpec { } | Self::Unsharded { maintained_indexes, .. - } => *maintained_indexes = v, + } => *maintained_indexes = indexes, } self } @@ -501,8 +473,9 @@ impl LsmWriteSpec { self } - /// Borrow the list of index names this spec asks MemWAL to maintain. - pub fn maintained_indexes(&self) -> &[String] { + /// Borrow the list of index names this spec asks MemWAL to maintain, or + /// `None` when it asks for every index on the table. + pub fn maintained_indexes(&self) -> Option<&[String]> { match self { Self::Bucket { maintained_indexes, .. @@ -512,7 +485,7 @@ impl LsmWriteSpec { } | Self::Unsharded { maintained_indexes, .. - } => maintained_indexes, + } => maintained_indexes.as_deref(), } } @@ -685,6 +658,31 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { message: "get_lsm_write_spec is not supported on this table type".into(), }) } + /// Seal every bucket's active memtable into L0. + /// + /// The default implementation returns `NotSupported`. + async fn flush_lsm(&self) -> Result<()> { + Err(Error::NotSupported { + message: "flush_lsm is not supported on this table type".into(), + }) + } + /// Trigger a background L0 → base compaction pass per bucket. + /// + /// The default implementation returns `NotSupported`. + async fn compact_lsm(&self) -> Result<()> { + Err(Error::NotSupported { + message: "compact_lsm is not supported on this table type".into(), + }) + } + /// Read live LSM state, or `None` when the LSM write path is not + /// enabled for this table. + /// + /// The default implementation returns `NotSupported`. + async fn get_lsm_stats(&self, _include_generation_rows: bool) -> Result> { + Err(Error::NotSupported { + message: "get_lsm_stats is not supported on this table type".into(), + }) + } /// Drain and close any cached MemWAL shard writers for this table. /// /// The default implementation is a no-op; table types that maintain @@ -1685,7 +1683,7 @@ impl Table { /// # async fn example(table: &Table) -> Result<(), Box> { /// table /// .set_lsm_write_spec( - /// LsmWriteSpec::bucket("id", 16).with_maintained_indexes(["id_idx"]), + /// LsmWriteSpec::bucket("id", 16).with_maintained_indexes(vec!["id_idx".to_string()]), /// ) /// .await?; /// # Ok(()) @@ -1707,9 +1705,10 @@ impl Table { /// /// Returns `Ok(None)` when the MemWAL LSM write path is not enabled (no /// spec has been set, or it was removed with [`Table::unset_lsm_write_spec`]). - /// The returned spec — including its [`LsmWriteSpec::maintained_indexes`] and - /// [`LsmWriteSpec::writer_config_defaults`] — mirrors what was passed to - /// [`Table::set_lsm_write_spec`]. + /// The returned spec mirrors what was passed to + /// [`Table::set_lsm_write_spec`], except that + /// [`LsmWriteSpec::maintained_indexes`] always reports the concrete list + /// resolved when the spec was set — `None` never round-trips. /// /// # Example /// @@ -1726,6 +1725,85 @@ impl Table { self.inner.get_lsm_write_spec().await } + /// Converge this table's LSM write path into its base table. + /// + /// One `flush` to seal every memtable into L0, then compaction triggers + /// until every generation that existed at that moment has reached base. + /// The loop runs client-side, reading progress from `get_lsm_stats`, so + /// there is no held socket and nothing to reconcile if you drop this + /// future partway through. + /// + /// **Best-effort.** Generations created *after* the opening flush are + /// deliberately not waited on — that is what lets this terminate on a + /// table taking writes. Idempotent and safe on a cadence: an + /// already-converged table costs two round trips and triggers nothing. + /// + /// **No deadline, and the caller owns that.** It returns when the target + /// generations are gone, propagates a terminal server fault, and + /// otherwise waits however long the server takes. A slow table and a + /// stuck one are the same picture from here: the compactor pool is shared + /// across every table on the node, so a checkpoint queued behind + /// unrelated work is indistinguishable from one that is merging. Wrap + /// this in `tokio::time::timeout` for a wall-clock bound; abandoning it + /// partway costs nothing. + /// + /// # Example + /// + /// ```no_run + /// # use lancedb::Table; + /// # async fn example(table: &Table) -> Result<(), Box> { + /// let before = table.get_lsm_stats(false).await?; + /// table.checkpoint_lsm().await?; + /// let after = table.get_lsm_stats(false).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn checkpoint_lsm(&self) -> Result<()> { + checkpoint::checkpoint_lsm(self).await + } + + /// Seal every bucket's active memtable into L0 without touching the + /// base table. + /// + /// Independently useful: flushing makes memtable rows readable from L0 at + /// a lower per-query cost. On a node that has not claimed this table it + /// claims it and replays the WAL log first — reporting "nothing to flush" + /// without replaying would lie about durable data. + pub async fn flush_lsm(&self) -> Result<()> { + self.inner.flush_lsm().await + } + + /// Run one bounded L0 → base compaction pass per bucket, reporting what + /// it merged and what is left. + /// + /// One pass, not convergence: that bounds each request's cost and gives a + /// caller driving its own cadence a progress signal per round trip. + pub async fn compact_lsm(&self) -> Result<()> { + self.inner.compact_lsm().await + } + + /// Read live per-bucket LSM state. + /// + /// Answers "how far behind is my fresh tier", "which bucket is hot", and + /// "why is my fresh-tier vector search brute-force". Mutates no table + /// state, though on a node that has not claimed this table it claims it, + /// exactly as a read would. + /// + /// `include_generation_rows` reports a row count per L0 generation. Off by + /// default: each count opens an uncached Lance dataset, and + /// `checkpoint_lsm` polls this needing only generation numbers. + /// + /// `Ok(None)` only when the LSM write path is not enabled, matching + /// [`Table::get_lsm_write_spec`]. Stats is fresh-tier only, so with the + /// WAL off there is no manifest to report and a struct of zeros would + /// read as measurements. + /// + /// Do not build a checkpoint's termination on this: the completion + /// predicate lives in the `flush` and `compact` responses. + pub async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result> { + self.inner.get_lsm_stats(include_generation_rows).await + } + /// Drain and close any cached MemWAL shard writers held for this table. /// /// When an [`LsmWriteSpec`] is installed, `merge_insert` opens MemWAL shard @@ -2292,8 +2370,6 @@ impl NativeTable { None => false, }; - // Kept so that a `DatasetNotFound` can be re-checked against storage below. - let recovery_params = params.clone(); let mut builder = DatasetBuilder::from_uri(uri).with_read_params(params); // Set up commit handler when managed_versioning is enabled @@ -2312,7 +2388,12 @@ impl NativeTable { let dataset = match builder.load().await { Ok(dataset) => dataset, Err(e @ lance::Error::DatasetNotFound { .. }) => { - return Err(map_dataset_not_found(uri, name, recovery_params, e).await); + // The manifest load is the existence check. A physical prefix may be + // from a concurrent or abandoned create, so it cannot refine this error. + return Err(Error::TableNotFound { + name: name.to_string(), + source: Box::new(e), + }); } Err(e) => return Err(e.into()), }; @@ -3441,9 +3522,24 @@ impl BaseTable for NativeTable { let num_rows = self.count_rows(None).await?; let num_indices = self.list_indices().await?.len(); let ds = self.dataset.get().await?; - let ds_clone = (*ds).clone(); - let ds_stats = Arc::new(ds_clone).calculate_data_stats().await?; - let total_bytes = ds_stats.fields.iter().map(|f| f.bytes_on_disk).sum::() as usize; + // Sizes come from the manifest. Summing per-field `bytes_on_disk` instead + // would open every data file to read its column metadata, which costs one + // IO per fragment and reports 0 for legacy v1 storage. + // + // The manifest summary covers only the fragments' base data files, so + // overlay files (recorded on each fragment) and index files (recorded in + // the manifest's index section) are added separately. + let mut total_bytes = ds.manifest().summary().total_files_size as usize; + for frag in ds.manifest().fragments.iter() { + for overlay in &frag.overlays { + if let Some(size) = overlay.data_file.file_size_bytes.get() { + total_bytes += size.get() as usize; + } + } + } + for index in ds.load_indices().await?.iter() { + total_bytes += index.total_size_bytes().unwrap_or(0) as usize; + } let frags = ds.get_fragments(); let mut sorted_sizes = join_all( @@ -3515,7 +3611,12 @@ impl BaseTable for NativeTable { #[skip_serializing_none] #[derive(Debug, Deserialize, PartialEq)] pub struct TableStatistics { - /// The total number of bytes in the table + /// The total size, in bytes, of the table's data files, index files, and + /// overlay files + /// + /// Read from the manifest, so this excludes deletion files and manifests, + /// and it excludes any file whose size the manifest does not record + /// (tables and indices written before writers persisted file sizes). pub total_bytes: usize, /// The number of rows in the table @@ -3560,7 +3661,7 @@ pub struct FragmentSummaryStats { #[allow(deprecated)] mod tests { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use arrow_array::{ @@ -3576,6 +3677,7 @@ mod tests { use super::*; use crate::connect; use crate::connection::ConnectBuilder; + use crate::io::object_store::io_tracking::IoTrackingStore; use crate::query::Select; use crate::query::{ExecutableQuery, QueryBase}; use crate::test_utils::connection::new_test_connection; @@ -3641,73 +3743,50 @@ mod tests { ); } - /// Write a table and then break it, leaving the `.lance` directory in place. - /// - /// `remove_all` reproduces an interrupted drop + re-create (the directory is left - /// empty); otherwise only the manifests are removed, leaving the data files behind. - async fn write_then_corrupt_table(dir: &std::path::Path, remove_all: bool) -> String { - let dataset_path = dir.join("test.lance"); - let uri = dataset_path.to_str().unwrap().to_string(); - - let batch = make_test_batches(); - let reader = RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - Dataset::write(reader, &uri, None).await.unwrap(); - - if remove_all { - for entry in std::fs::read_dir(&dataset_path).unwrap() { - let entry = entry.unwrap(); - if entry.file_type().unwrap().is_dir() { - std::fs::remove_dir_all(entry.path()).unwrap(); - } else { - std::fs::remove_file(entry.path()).unwrap(); - } - } - assert_eq!(std::fs::read_dir(&dataset_path).unwrap().count(), 0); - } else { - let versions = dataset_path.join("_versions"); - assert!(versions.is_dir(), "expected manifests under {versions:?}"); - std::fs::remove_dir_all(&versions).unwrap(); - assert!(std::fs::read_dir(&dataset_path).unwrap().count() > 0); - } - - uri - } - #[tokio::test] - async fn test_open_corrupt_empty_dir() { + async fn test_open_not_found_when_empty_directory_exists() { let tmp_dir = tempdir().unwrap(); - let uri = write_then_corrupt_table(tmp_dir.path(), true).await; + let dataset_path = tmp_dir.path().join("test.lance"); + std::fs::create_dir(&dataset_path).unwrap(); - let err = NativeTable::open(&uri).await.unwrap_err(); + let err = NativeTable::open(dataset_path.to_str().unwrap()) + .await + .unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), "got {err:?}" ); } #[tokio::test] - async fn test_open_corrupt_missing_manifest() { + async fn test_open_not_found_when_only_uncommitted_storage_exists() { let tmp_dir = tempdir().unwrap(); - let uri = write_then_corrupt_table(tmp_dir.path(), false).await; + let dataset_path = tmp_dir.path().join("test.lance"); + let data_dir = dataset_path.join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join("orphan.lance"), b"uncommitted").unwrap(); - let err = NativeTable::open(&uri).await.unwrap_err(); + let err = NativeTable::open(dataset_path.to_str().unwrap()) + .await + .unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), "got {err:?}" ); } - /// A table listed by `table_names()` must not be reported as missing by - /// `open_table()`. See . + /// Listing databases discover physical `*.lance` entries. That snapshot is not an + /// authoritative table-existence check: only a committed manifest makes a table + /// openable, and the entry could also be concurrently created or dropped. #[tokio::test] - async fn test_open_table_corrupt_is_still_listed() { + async fn test_table_names_may_include_uncommitted_storage() { let tmp_dir = tempdir().unwrap(); let db = connect(tmp_dir.path().to_str().unwrap()) .execute() .await .unwrap(); - write_then_corrupt_table(tmp_dir.path(), true).await; + std::fs::create_dir(tmp_dir.path().join("test.lance")).unwrap(); assert_eq!( db.table_names().execute().await.unwrap(), @@ -3715,12 +3794,177 @@ mod tests { ); let err = db.open_table("test").execute().await.unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), + "physical storage without a committed manifest is not a table: {err:?}" + ); + } + + #[derive(Debug)] + struct ParentListGuardStore { + inner: Arc, + parent: object_store::path::Path, + parent_list_calls: Arc, + } + + impl std::fmt::Display for ParentListGuardStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ParentListGuardStore") + } + } + + #[async_trait::async_trait] + #[deny(clippy::missing_trait_methods)] + impl object_store::ObjectStore for ParentListGuardStore { + async fn put_opts( + &self, + location: &object_store::path::Path, + payload: object_store::PutPayload, + opts: object_store::PutOptions, + ) -> object_store::Result { + self.inner.put_opts(location, payload, opts).await + } + + async fn put_multipart_opts( + &self, + location: &object_store::path::Path, + opts: object_store::PutMultipartOptions, + ) -> object_store::Result> { + self.inner.put_multipart_opts(location, opts).await + } + + async fn get_opts( + &self, + location: &object_store::path::Path, + options: object_store::GetOptions, + ) -> object_store::Result { + self.inner.get_opts(location, options).await + } + + async fn get_ranges( + &self, + location: &object_store::path::Path, + ranges: &[std::ops::Range], + ) -> object_store::Result> { + self.inner.get_ranges(location, ranges).await + } + + fn delete_stream( + &self, + locations: futures::stream::BoxStream< + 'static, + object_store::Result, + >, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + self.inner.delete_stream(locations) + } + + fn list( + &self, + prefix: Option<&object_store::path::Path>, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list(prefix) + } + + fn list_with_offset( + &self, + prefix: Option<&object_store::path::Path>, + offset: &object_store::path::Path, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list_with_offset(prefix, offset) + } + + async fn list_with_delimiter( + &self, + prefix: Option<&object_store::path::Path>, + ) -> object_store::Result { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list_with_delimiter(prefix).await + } + + async fn copy_opts( + &self, + from: &object_store::path::Path, + to: &object_store::path::Path, + options: object_store::CopyOptions, + ) -> object_store::Result<()> { + self.inner.copy_opts(from, to, options).await + } + + async fn rename_opts( + &self, + from: &object_store::path::Path, + to: &object_store::path::Path, + options: object_store::RenameOptions, + ) -> object_store::Result<()> { + self.inner.rename_opts(from, to, options).await + } + } + + #[derive(Debug)] + struct ParentListGuardWrapper { + parent_list_calls: Arc, + } + + impl WrappingObjectStore for ParentListGuardWrapper { + fn wrap( + &self, + _store_prefix: &str, + inner: Arc, + ) -> Arc { + Arc::new(ParentListGuardStore { + inner, + parent: object_store::path::Path::from("database"), + parent_list_calls: self.parent_list_calls.clone(), + }) + } + } + + #[tokio::test] + async fn test_open_missing_never_lists_database_parent() { + let parent_list_calls = Arc::new(AtomicUsize::new(0)); + let params = ReadParams { + store_options: Some(ObjectStoreParams { + object_store_wrapper: Some(Arc::new(ParentListGuardWrapper { + parent_list_calls: parent_list_calls.clone(), + })), + ..Default::default() + }), + ..Default::default() + }; + + let err = NativeTable::open_with_params( + "memory:///database/missing.lance", + "missing", + Vec::new(), + None, + Some(params), + None, + None, + HashSet::new(), + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(&err, Error::TableNotFound { name, .. } if name == "missing"), "got {err:?}" ); - assert!( - err.to_string().contains("exists but could not be loaded"), - "got {err}" + assert_eq!( + parent_list_calls.load(Ordering::Relaxed), + 0, + "opening one missing table must not enumerate sibling tables" ); } @@ -4958,7 +5202,7 @@ mod tests { // Bucket spec round-trips exactly, including the routing column (recovered // from its field id), maintained indexes, and writer config defaults. let spec = LsmWriteSpec::bucket("id", 4) - .with_maintained_indexes([idx_name]) + .with_maintained_indexes(vec![idx_name.clone()]) .with_writer_config_defaults([("durable_write", "false")]); table.set_lsm_write_spec(spec.clone()).await.unwrap(); assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec)); @@ -4968,15 +5212,125 @@ mod tests { assert_eq!(table.get_lsm_write_spec().await.unwrap(), None); // Identity sharding round-trips (column recovered from the schema). + // A spec left at its default maintains every index on the table, so it + // reads back naming the one on the table rather than as "infer". let spec = LsmWriteSpec::identity("region"); table.set_lsm_write_spec(spec.clone()).await.unwrap(); - assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec)); + assert_eq!( + table.get_lsm_write_spec().await.unwrap(), + Some(spec.with_maintained_indexes(vec![idx_name.clone()])) + ); table.unset_lsm_write_spec().await.unwrap(); // Unsharded round-trips (no routing column). let spec = LsmWriteSpec::unsharded(); table.set_lsm_write_spec(spec.clone()).await.unwrap(); - assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec)); + assert_eq!( + table.get_lsm_write_spec().await.unwrap(), + Some(spec.with_maintained_indexes(vec![idx_name])) + ); + } + + /// The maintained set defaults to every index on the table, resolved at + /// install. An index the memtable cannot build fails the install rather + /// than being dropped: maintaining it would take the table offline for + /// writes, dropping it would hide that from the caller. + #[tokio::test] + async fn test_set_lsm_write_spec_infers_maintained_indexes() { + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("tag", DataType::Utf8, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(arrow_array::Int64Array::from(vec![1, 2, 3])), + Arc::new(StringArray::from(vec!["a", "b", "c"])), + ], + ) + .unwrap(); + let reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema.clone())); + let conn = ConnectBuilder::new(uri) + .read_consistency_interval(Duration::from_secs(0)) + .execute() + .await + .unwrap(); + let table = conn.create_table("t", reader).execute().await.unwrap(); + + table + .create_index(&["id"], Index::BTree(Default::default())) + .name("id_btree".to_string()) + .execute() + .await + .unwrap(); + table + .create_index(&["tag"], Index::Bitmap(Default::default())) + .name("tag_bitmap".to_string()) + .execute() + .await + .unwrap(); + + // Explicitly naming the bitmap index fails before anything commits. + let err = table + .set_lsm_write_spec( + LsmWriteSpec::unsharded().with_maintained_indexes(vec!["tag_bitmap".to_string()]), + ) + .await + .unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { ref message } if message.contains("tag_bitmap")), + "expected the bitmap index to be rejected, got {err:?}" + ); + assert_eq!(table.get_lsm_write_spec().await.unwrap(), None); + + // The default covers every index, so the bitmap fails it too. + let err = table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { ref message } + if message.contains("tag_bitmap") && message.contains("maintained_indexes")), + "expected the inferred set to be rejected, got {err:?}" + ); + assert_eq!(table.get_lsm_write_spec().await.unwrap(), None); + + // Naming the maintainable subset installs. + table + .set_lsm_write_spec( + LsmWriteSpec::unsharded().with_maintained_indexes(vec!["id_btree".to_string()]), + ) + .await + .unwrap(); + assert_eq!( + table + .get_lsm_write_spec() + .await + .unwrap() + .unwrap() + .maintained_indexes(), + Some(["id_btree".to_string()].as_slice()) + ); + + // Opting out entirely is distinct from the default. + table.unset_lsm_write_spec().await.unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(Vec::new())) + .await + .unwrap(); + assert_eq!( + table + .get_lsm_write_spec() + .await + .unwrap() + .unwrap() + .maintained_indexes(), + Some([].as_slice()) + ); } #[tokio::test] @@ -5024,12 +5378,16 @@ mod tests { let res = table.stats().await.unwrap(); println!("{:#?}", res); + // `total_bytes` is the full on-disk size of the 11 data files (this table + // has no index or overlay files), so it is well above the 2000 bytes of + // column data these 250 int32 pairs hold: each file carries its own footer + // and metadata. assert_eq!( res, TableStatistics { num_rows: 250, num_indices: 0, - total_bytes: 2300, + total_bytes: 8925, fragment_stats: FragmentStatistics { num_fragments: 11, num_small_fragments: 11, @@ -5069,4 +5427,196 @@ mod tests { } ) } + + /// `total_bytes` counts more than the base data files: index files and + /// overlay files recorded in the manifest are included too. + #[tokio::test] + pub async fn test_stats_includes_index_and_overlay_files() { + use lance::dataset::WriteDestination; + use lance::dataset::transaction::{DataOverlayGroup, Operation}; + use lance_file::version::stable_file_version; + use lance_file::writer::FileWriterOptions; + use lance_io::utils::CachedFileSize; + use lance_table::format::DataFile; + use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage}; + use roaring::RoaringBitmap; + + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let conn = ConnectBuilder::new(uri) + .read_consistency_interval(Duration::from_secs(0)) + .execute() + .await + .unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("foo", DataType::Int32, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..100)), + Arc::new(Int32Array::from_iter_values(0..100)), + ], + ) + .unwrap(); + let table = conn + .create_table("test_stats_extra_files", batch) + .execute() + .await + .unwrap(); + + let data_only = table.stats().await.unwrap().total_bytes; + assert!(data_only > 0); + + // A scalar index adds index files whose sizes are recorded in the + // manifest's index section. + table + .create_index(&["id"], Index::Auto) + .execute() + .await + .unwrap(); + let with_index = table.stats().await.unwrap().total_bytes; + let dataset = { + let native = table.as_native().unwrap(); + (*native.dataset.get().await.unwrap()).clone() + }; + let index_bytes: usize = dataset + .load_indices() + .await + .unwrap() + .iter() + .map(|idx| idx.total_size_bytes().unwrap_or(0) as usize) + .sum(); + assert!(index_bytes > 0); + assert_eq!(with_index, data_only + index_bytes); + + // Commit an overlay file supplying new `foo` values for the first three + // rows of fragment 0. There is no high-level API that writes overlays + // yet, so write the overlay's data file and commit the `DataOverlay` + // operation by hand. + let read_version = dataset.version().version; + let fragment_id = dataset.get_fragments()[0].id() as u64; + let foo_field_id = dataset.schema().field("foo").unwrap().id; + let overlay_schema = dataset.schema().project_by_ids(&[foo_field_id], true); + let file_version = stable_file_version(); + + let filename = "overlay.lance".to_string(); + let store = dataset.object_store(None).await.unwrap(); + let path = dataset.data_dir().child(filename.clone()); + let obj_writer = store.create(&path).await.unwrap(); + let mut writer = lance_file::versions::create_writer( + file_version, + obj_writer, + overlay_schema, + FileWriterOptions::default(), + ) + .unwrap(); + writer + .write_column(0, Arc::new(Int32Array::from(vec![1000, 1001, 1002])) as _) + .await + .unwrap(); + let summary = writer.finish().await.unwrap(); + let overlay_bytes = summary.size_bytes as usize; + assert!(overlay_bytes > 0); + + let mut data_file = DataFile::new_unstarted(filename, file_version); + data_file.fields = writer + .field_id_to_column_indices() + .iter() + .map(|(field_id, _)| *field_id as i32) + .collect::>() + .into(); + data_file.column_indices = writer + .field_id_to_column_indices() + .iter() + .map(|(_, column_index)| *column_index as i32) + .collect::>() + .into(); + data_file.file_size_bytes = CachedFileSize::new(summary.size_bytes); + + let overlay = DataOverlayFile { + data_file, + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter(0..3)), + committed_version: 0, + }; + Dataset::commit( + WriteDestination::Dataset(Arc::new(dataset)), + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id, + overlays: vec![overlay], + }], + }, + Some(read_version), + None, + None, + Arc::new(Default::default()), + false, + ) + .await + .unwrap(); + + table.checkout_latest().await.unwrap(); + let with_overlay = table.stats().await.unwrap().total_bytes; + assert_eq!(with_overlay, with_index + overlay_bytes); + } + + /// `stats()` must stay manifest-only. Summing per-field `bytes_on_disk` + /// instead opens every data file, so cost would grow with fragment count. + #[tokio::test] + pub async fn test_stats_does_not_read_data_files() { + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + + let conn = ConnectBuilder::new(uri).execute().await.unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from_iter_values(0..10))], + ) + .unwrap(); + + conn.create_table("test_stats_io", batch.clone()) + .execute() + .await + .unwrap(); + let table = conn.open_table("test_stats_io").execute().await.unwrap(); + const NUM_APPENDS: usize = 20; + for _ in 0..NUM_APPENDS { + table.add(batch.clone()).execute().await.unwrap(); + } + + // Reopen through a tracking store so the counters cover `stats()` alone and + // not the writes above. + let (wrapper, io_stats) = IoTrackingStore::new_wrapper(); + let table = conn + .open_table("test_stats_io") + .lance_read_params(ReadParams { + store_options: Some(ObjectStoreParams { + object_store_wrapper: Some(wrapper), + ..Default::default() + }), + ..Default::default() + }) + .execute() + .await + .unwrap(); + io_stats.lock().unwrap().read_iops = 0; + + let stats = table.stats().await.unwrap(); + let read_iops = io_stats.lock().unwrap().read_iops; + + assert_eq!(stats.fragment_stats.num_fragments, NUM_APPENDS + 1); + assert!(stats.total_bytes > 0); + // Reading the fragments' data files would take at least one IOP each. + assert!( + read_iops < stats.fragment_stats.num_fragments as u64, + "stats() issued {} read IOPs across {} fragments", + read_iops, + stats.fragment_stats.num_fragments + ); + } } diff --git a/rust/lancedb/src/table/checkpoint.rs b/rust/lancedb/src/table/checkpoint.rs new file mode 100644 index 000000000..bb76604ed --- /dev/null +++ b/rust/lancedb/src/table/checkpoint.rs @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Converging a table's LSM write path into its base table. +//! +//! `checkpoint_lsm` seals once, then triggers compaction and watches +//! generation numbers until the L0 that existed at the start is gone. +//! +//! The loop runs in the client, not the server: `compact_lsm` dispatches a +//! pass and returns, so nothing holds a socket and a client can vanish +//! mid-operation with nothing to reconcile. Completion is read from +//! generation numbers in the shard manifest — durable state, unlike a count +//! in a compact response, which a concurrent write invalidates. +//! +//! The target set is fixed at the start, so generations created *during* the +//! checkpoint are ignored. That is what lets it terminate under write load, +//! and what makes it best-effort: it converges the fresh tier as of some +//! instant. Idempotent, abandonable at any point, safe on a cadence. +//! +//! No liveness bound — the caller owns the deadline. The compactor pool is +//! shared pod-wide, so a checkpoint queued behind unrelated tables looks +//! exactly like one that is merging. + +use std::collections::HashMap; +use std::future::Future; +use std::time::Duration; + +use crate::{Error, Result, Table}; + +/// The HTTP status a failed request carried, if it carried one. +/// +/// `None` for anything with no retry story: a `TableNotFound` that +/// `check_table_response` already translated, or a connection failure that +/// never reached the server. Both are terminal. +fn status_of(e: &Error) -> Option { + #[cfg(feature = "remote")] + { + match e { + Error::Http { + status_code: Some(status), + .. + } => Some(status.as_u16()), + _ => None, + } + } + #[cfg(not(feature = "remote"))] + { + let _ = e; + None + } +} + +/// 429 (latch held, pool saturated, or the pod replaying its WAL) and 503 (a +/// draining node, or a proxy between here and it). +/// +/// The status is the whole signal: the server deliberately keeps contention +/// off 503, so a latch collision is a 429. A draining node *is* terminal, but +/// it is also a 503 that stays a 503, so retrying spends one budget and then +/// reports the server's own message — cheaper than parsing the body for the +/// namespace code it would take to tell the two apart. +fn is_retryable(e: &Error) -> bool { + matches!(status_of(e), Some(429 | 503)) +} + +/// 421: the owning node holds no claim. Only `flush` re-claims and replays, +/// so this cannot be retried in place — the caller has to start over. +fn is_lost_claim(e: &Error) -> bool { + status_of(e) == Some(421) +} + +/// Interval between `get_lsm_stats` polls. One interval is roughly one +/// compaction pass, the granularity at which the answer can change. +/// +/// Fixed rather than configurable, matching `wait_for_index`. It costs +/// nothing on an already-converged table and at most one interval of tail +/// latency after the final pass lands. +const POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Cap on re-issues from `flush` after a 421, so a crash-looping node cannot +/// turn flush → compact → 421 → flush into a spin. +/// +/// Deliberately not shared with [`MAX_RETRIES`]: a claim that keeps +/// evaporating is a broken node, while contention is routine and wants a real +/// budget. One shared counter let a merely contended table exhaust this cap +/// and then blame a claim it never lost. +const MAX_REISSUES: usize = 3; + +/// Retryable faults tolerated on a *single* request, reset on every success — +/// scattered contention across a long checkpoint must not accumulate toward a +/// cap. Roughly 16s of retrying against the backoff below. +const MAX_RETRIES: usize = 8; + +/// Backoff between retries, doubling up to [`RETRY_BACKOFF_MAX`]. Latch +/// contention clears in about the time one pass takes, so start small; a +/// saturated pool wants the ceiling. +const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(100); +const RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5); + +/// Sleep before re-issuing a retryable request. +async fn backoff(attempt: usize) { + let delay = RETRY_BACKOFF_BASE + .saturating_mul(1u32 << attempt.min(8) as u32) + .min(RETRY_BACKOFF_MAX); + tokio::time::sleep(delay).await; +} + +/// Whether the drain loop finished or needs the table re-claimed first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CheckpointOutcome { + Done, + ReissueFromFlush, +} + +/// What one LSM request produced: its value, or word that the owning node +/// holds no claim and only `flush` can get it back. +enum Attempt { + Ok(T), + ReissueFromFlush, +} + +/// Issue one LSM request, retrying in place while the fault is retryable. +/// +/// The two recoverable faults have separate budgets: contention clears on its +/// own and retries here against [`MAX_RETRIES`], while a 421 needs `flush` to +/// re-claim, which only the caller can drive. +/// +/// An exhausted budget propagates the last error *as itself* rather than a +/// synthesized one — "429 after nine tries" beats "checkpoint failed", and a +/// draining node arrives carrying the server's own message. +async fn issue(mut call: F) -> Result> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut retries = 0; + loop { + let e = match call().await { + Ok(value) => return Ok(Attempt::Ok(value)), + Err(e) => e, + }; + if is_lost_claim(&e) { + return Ok(Attempt::ReissueFromFlush); + } + if !is_retryable(&e) || retries >= MAX_RETRIES { + return Err(e); + } + backoff(retries).await; + retries += 1; + } +} + +/// Drive [`Table::checkpoint_lsm`]: seal once, fix the target watermark +/// from the resulting L0, then trigger and poll until it drains. +pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> { + for reissue in 0..=MAX_REISSUES { + // The seal turns everything written before this call into a + // generation, so the watermark has to be read after it. Idempotent: + // sealing an empty memtable is a no-op, so a re-issue does not churn + // empty generations. + match issue(|| table.flush_lsm()).await? { + Attempt::Ok(()) => {} + Attempt::ReissueFromFlush => { + backoff(reissue).await; + continue; + } + } + + let stats = match issue(|| table.get_lsm_stats(false)).await? { + Attempt::Ok(stats) => stats, + Attempt::ReissueFromFlush => { + backoff(reissue).await; + continue; + } + }; + let Some(stats) = stats else { + // Not WAL-backed; `flush_lsm` would have errored first but for a race. + return Ok(()); + }; + let targets: HashMap = stats + .buckets + .iter() + .filter_map(|b| Some((b.shard_id.clone(), b.newest_generation()?))) + .collect(); + if targets.is_empty() { + return Ok(()); + } + + match drain_to_targets(table, &targets).await? { + CheckpointOutcome::Done => return Ok(()), + CheckpointOutcome::ReissueFromFlush => { + backoff(reissue).await; + continue; + } + } + } + Err(Error::Runtime { + message: "checkpoint_lsm: the owning node kept losing its claim; \ + re-issued from flush the maximum number of times" + .into(), + }) +} + +/// Trigger and poll until no bucket holds a generation at or below its +/// target. +/// +/// No liveness bound, deliberately. The pod-wide compactor pool (a semaphore +/// of 2 by default, shared across every table on the node) is taken *inside* +/// the pass, after the bucket latch, so a checkpoint queued behind unrelated +/// tables is indistinguishable from one that is merging. An idle-poll counter +/// here could only ever have fired on a table that would have finished. +async fn drain_to_targets( + table: &Table, + targets: &HashMap, +) -> Result { + loop { + let stats = match issue(|| table.get_lsm_stats(false)).await? { + Attempt::Ok(stats) => stats, + Attempt::ReissueFromFlush => return Ok(CheckpointOutcome::ReissueFromFlush), + }; + let Some(stats) = stats else { + return Ok(CheckpointOutcome::Done); + }; + // `compacting` is the bucket's compaction latch, held from dispatch + // until the pass ends — including while it waits on the pod-wide + // permit. So it answers one question only: do not pile on. Buckets + // with nothing outstanding are skipped, not counted as idle. + let mut outstanding = 0; + let mut all_compacting = true; + for b in &stats.buckets { + let Some(target) = targets.get(&b.shard_id) else { + continue; + }; + let n = b.outstanding_generations(*target); + if n > 0 { + outstanding += n; + all_compacting &= b.compacting; + } + } + if outstanding == 0 { + return Ok(CheckpointOutcome::Done); + } + + if !all_compacting { + match table.compact_lsm().await { + Ok(()) => {} + Err(e) if is_lost_claim(&e) => return Ok(CheckpointOutcome::ReissueFromFlush), + Err(e) if !is_retryable(&e) => return Err(e), + // A 429 here means the server could latch no bucket at all, + // which the poll above already handles. Not retried in place: + // the latch it would contend for is the one doing the work, so + // fall through and re-read — `POLL_INTERVAL` is the backoff. + Err(_) => {} + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +#[cfg(all(test, feature = "remote"))] +mod tests { + use super::*; + + fn http(status: u16) -> Error { + Error::Http { + source: "server said no".into(), + request_id: "rid".into(), + status_code: reqwest::StatusCode::from_u16(status).ok(), + } + } + + /// Every status the loop acts on. The two predicates are checked together + /// because their overlap is what would be wrong: a status must never be + /// both, and 421 in particular must not read as retryable — retrying it in + /// place re-issues the call that just said the node holds no claim. + #[test] + fn taxonomy_round_trips() { + for status in [429, 503] { + assert!(is_retryable(&http(status)), "{status} must retry"); + assert!( + !is_lost_claim(&http(status)), + "{status} is not a lost claim" + ); + } + assert!(is_lost_claim(&http(421)), "a lost claim must re-claim"); + assert!( + !is_retryable(&http(421)), + "retrying a lost claim in place only asks the same node again" + ); + for status in [400, 404, 409, 500] { + assert!(!is_retryable(&http(status)), "{status} is terminal"); + assert!(!is_lost_claim(&http(status)), "{status} is terminal"); + } + } + + /// An error carrying no status has no retry story and must be terminal — + /// a connection that never reached the server, or a `TableNotFound` that + /// `check_table_response` translated before the loop saw it. + #[test] + fn errors_without_a_status_are_terminal() { + let no_status = Error::Http { + source: "connection reset".into(), + request_id: "rid".into(), + status_code: None, + }; + assert!(!is_retryable(&no_status)); + assert!(!is_lost_claim(&no_status)); + + let translated = Error::TableNotFound { + name: "t".into(), + source: "gone".into(), + }; + assert!(!is_retryable(&translated)); + assert!(!is_lost_claim(&translated)); + } +} diff --git a/rust/lancedb/src/table/lsm_stats.rs b/rust/lancedb/src/table/lsm_stats.rs new file mode 100644 index 000000000..953aea90f --- /dev/null +++ b/rust/lancedb/src/table/lsm_stats.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Live per-bucket LSM state — the shape [`crate::Table::get_lsm_stats`] +//! returns and [`super::checkpoint`] polls. +//! +//! Nothing here is derived: sums and differences (total L0 bytes, WAL lag) +//! are the caller's to compute. There is no "WAL is off" shape — that case is +//! `None`, because a struct of zeros would read as measurements. + +use serde::Deserialize; + +/// One flushed L0 generation. +#[derive(Debug, Clone, Deserialize)] +pub struct GenerationStats { + pub generation: u64, + pub bytes: u64, + /// Present only when `include_generation_rows` was requested. Off by + /// default because each count opens an uncached Lance dataset, and the + /// checkpoint loop polls this route needing only generation numbers. + #[serde(default)] + pub rows: Option, +} + +/// One in-memory memtable. +#[derive(Debug, Clone, Deserialize)] +pub struct MemtableStats { + pub generation: u64, + pub rows: u64, + pub bytes: u64, + pub batches: u64, + /// Names of the indexes this memtable carries. An absent name is the whole + /// answer to "why is my fresh-tier search on that column brute-force". + pub indexes: Vec, +} + +/// Live state of one bucket. A table is N buckets on one node; flattening to +/// a single number hides the one hot bucket that is usually why someone +/// opened this endpoint. +#[derive(Debug, Clone, Deserialize)] +pub struct BucketStats { + pub shard_id: String, + /// `Active` | `Sealed` (drop-table 2PC in flight). + pub status: String, + pub writer_epoch: u64, + pub manifest_version: u64, + pub current_generation: u64, + pub replay_after_wal_entry_position: u64, + pub wal_entry_position_last_seen: u64, + pub generations: Vec, + /// Whether a pass owns this bucket's compaction latch right now. Says *a* + /// driver is running, not *whose*, and the latch is held from dispatch — + /// including while the pass queues for a pod-wide compactor permit. Read + /// it as "do not pile on", never as "mine is progressing". + pub compacting: bool, + /// Oldest first, active last. Absent for a `Sealed` bucket, whose + /// in-memory state is torn down. + #[serde(default)] + pub memtables: Option>, +} + +impl BucketStats { + /// The newest flushed generation, or `None` when L0 is empty. + pub(crate) fn newest_generation(&self) -> Option { + self.generations.iter().map(|g| g.generation).max() + } + + /// How many generations at or below `target` are still in L0. + /// + /// A count, not a boolean: one pass drains a bounded prefix rather than + /// the whole target set, so a boolean would read as "no progress" for + /// every pass but the last. Compaction drains oldest-first, so this + /// decreases monotonically. + pub(crate) fn outstanding_generations(&self, target: u64) -> usize { + self.generations + .iter() + .filter(|g| g.generation <= target) + .count() + } +} + +/// Live LSM state, one entry per bucket. +#[derive(Debug, Clone, Deserialize)] +pub struct LsmStats { + pub buckets: Vec, +} + +/// Server-side JSON envelope for `get_lsm_stats`. `lsm_stats` is null when +/// the table has no LSM write path. +#[derive(Debug, Deserialize)] +pub(crate) struct GetLsmStatsResponse { + #[serde(default)] + pub lsm_stats: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bucket(shard: &str, generations: &[u64], compacting: bool) -> BucketStats { + BucketStats { + shard_id: shard.into(), + status: "Active".into(), + writer_epoch: 1, + manifest_version: 1, + current_generation: generations.iter().max().copied().unwrap_or(0) + 1, + replay_after_wal_entry_position: 0, + wal_entry_position_last_seen: 0, + generations: generations + .iter() + .map(|g| GenerationStats { + generation: *g, + bytes: 1, + rows: None, + }) + .collect(), + compacting, + memtables: None, + } + } + + /// The target watermark is the newest generation at the start, and a + /// generation created after it must not hold the loop open — that is why + /// the predicate terminates under write load. + #[test] + fn newer_generations_do_not_extend_the_target() { + let start = bucket("b0", &[7, 8], false); + let target = start.newest_generation().expect("L0 is non-empty"); + assert_eq!(target, 8); + + // Compaction drained 7 and 8; 9 and 10 arrived while it ran. + let later = bucket("b0", &[9, 10], false); + assert_eq!( + later.outstanding_generations(target), + 0, + "generations above the target are somebody else's problem" + ); + + // Still holding 8 means still outstanding. + assert_eq!( + bucket("b0", &[8, 9], false).outstanding_generations(target), + 1 + ); + } + + /// The metric counts generations, not buckets: a pass drains a bounded + /// prefix, so one bucket going 3 → 2 → 1 → 0 is three steps. + #[test] + fn progress_is_measured_in_generations() { + let target = 3; + let counts: Vec = [&[1u64, 2, 3][..], &[2, 3][..], &[3][..], &[][..]] + .iter() + .map(|gens| bucket("b0", gens, false).outstanding_generations(target)) + .collect(); + assert_eq!(counts, vec![3, 2, 1, 0]); + } + + #[test] + fn empty_l0_has_no_target() { + assert!(bucket("b0", &[], false).newest_generation().is_none()); + } +} diff --git a/rust/lancedb/src/table/merge.rs b/rust/lancedb/src/table/merge.rs index 82a1d1473..3a5b6882d 100644 --- a/rust/lancedb/src/table/merge.rs +++ b/rust/lancedb/src/table/merge.rs @@ -1161,7 +1161,7 @@ mod lsm_tests { .unwrap(); let fts_index = table.list_indices().await.unwrap()[0].name.clone(); table - .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes([fts_index])) + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![fts_index])) .await .unwrap(); @@ -1254,7 +1254,7 @@ mod lsm_tests { .unwrap(); let vec_index = table.list_indices().await.unwrap()[0].name.clone(); table - .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes([vec_index])) + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![vec_index])) .await .unwrap(); diff --git a/rust/lancedb/src/table/merge/lsm.rs b/rust/lancedb/src/table/merge/lsm.rs index 0eb7c0231..87c427b3c 100644 --- a/rust/lancedb/src/table/merge/lsm.rs +++ b/rust/lancedb/src/table/merge/lsm.rs @@ -29,6 +29,7 @@ use arrow_schema::{DataType, Schema as ArrowSchema, SchemaRef}; use lance::Dataset; use lance::dataset::mem_wal::{ DatasetMemWalExt, ShardWriter, ShardWriterConfig, evaluate_sharding_spec, + validate_maintained_indexes, }; use lance::index::DatasetIndexExt; use lance_core::datatypes::Schema as LanceSchema; @@ -37,8 +38,9 @@ use tokio::sync::RwLock; use uuid::Uuid; use crate::error::{Error, Result}; +use crate::index::IndexConfig; use crate::table::merge::{MergeInsertBuilder, MergeResult}; -use crate::table::{LsmWriteSpec, NativeTable}; +use crate::table::{BaseTable, LsmWriteSpec, NativeTable}; /// Spec id of the sole sharding spec installed by [`set_lsm_write_spec`]. /// Must match Lance's `InitializeMemWalBuilder` (`SHARDING_SPEC_ID`). @@ -80,32 +82,44 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec) } } + // Before the builder borrows the dataset clone. `list_indices` merges an + // index's segments into one entry, so the result needs no dedup. + let maintained_indexes = { + let dataset = table.dataset.get().await?; + resolve_maintained_indexes( + &dataset, + &table.list_indices().await?, + spec.maintained_indexes(), + ) + .await? + }; + let mut dataset = (*table.dataset.get().await?).clone(); let mut builder = dataset.initialize_mem_wal(); - let (maintained_indexes, writer_config_defaults) = match spec { + let writer_config_defaults = match spec { LsmWriteSpec::Bucket { column, num_buckets, - maintained_indexes, writer_config_defaults, + .. } => { builder = builder.bucket_sharding(column, num_buckets); - (maintained_indexes, writer_config_defaults) + writer_config_defaults } LsmWriteSpec::Identity { column, - maintained_indexes, writer_config_defaults, + .. } => { builder = builder.identity_sharding(column); - (maintained_indexes, writer_config_defaults) + writer_config_defaults } LsmWriteSpec::Unsharded { - maintained_indexes, writer_config_defaults, + .. } => { builder = builder.unsharded(); - (maintained_indexes, writer_config_defaults) + writer_config_defaults } }; builder = builder.maintained_indexes(maintained_indexes); @@ -117,6 +131,58 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec) Ok(()) } +/// Resolve a spec's maintained-index selection against `indices`, as reported +/// by [`Table::list_indices`](crate::Table::list_indices). +/// +/// `None` means every index on the table, snapshotted now. Lance validates +/// either selection against its shard-writer rules, so a spec that installs is +/// one the MemWAL can open. +/// +/// An unmaintainable index fails an inferred set rather than being dropped from +/// it — dropping would leave the caller believing it is maintained. +async fn resolve_maintained_indexes( + dataset: &Dataset, + indices: &[IndexConfig], + requested: Option<&[String]>, +) -> Result> { + let Some(requested) = requested else { + let all: Vec = indices.iter().map(|index| index.name.clone()).collect(); + validate_maintained_indexes(dataset, &all) + .await + .map_err(|source| Error::InvalidInput { + message: format!( + "cannot maintain every index on this table: {source}. Set \ + maintained_indexes explicitly to choose from {}", + index_name_list(indices), + ), + })?; + return Ok(all); + }; + for name in requested { + if !indices.iter().any(|index| &index.name == name) { + return Err(Error::InvalidInput { + message: format!( + "maintained index '{}' does not exist on this table; it has {}", + name, + index_name_list(indices), + ), + }); + } + } + validate_maintained_indexes(dataset, requested).await?; + Ok(requested.to_vec()) +} + +/// Index names for an error message. +fn index_name_list(indices: &[IndexConfig]) -> String { + if indices.is_empty() { + return "no indexes".to_string(); + } + let mut names: Vec<&str> = indices.iter().map(|index| index.name.as_str()).collect(); + names.sort_unstable(); + format!("[{}]", names.join(", ")) +} + // ============================================================================= // unset_lsm_write_spec // ============================================================================= diff --git a/rust/lancedb/tests/blob_integration.rs b/rust/lancedb/tests/blob_integration.rs index 77d49abd9..b92f961f4 100644 --- a/rust/lancedb/tests/blob_integration.rs +++ b/rust/lancedb/tests/blob_integration.rs @@ -10,7 +10,7 @@ use arrow_array::{ use arrow_schema::{DataType, Field, Fields, Schema}; use futures::TryStreamExt; use lance::Dataset; -use lance_file::version::LanceFileVersion; +use lance_file::version::{ConcreteFileVersion, LanceFileVersion}; use lancedb::{ Connection, Error, Result, Table, blob::{BlobRangeRequest, blob}, @@ -61,7 +61,7 @@ async fn create_inline_blob_table( Ok(table) } -async fn storage_format_version(table: &Table) -> LanceFileVersion { +async fn storage_format_version(table: &Table) -> ConcreteFileVersion { table .as_native() .unwrap() @@ -69,9 +69,14 @@ async fn storage_format_version(table: &Table) -> LanceFileVersion { .await .unwrap() .data_storage_format - .lance_file_version() - .unwrap() - .resolve() + .lance_file_format() +} + +fn supports_blob_v2(version: ConcreteFileVersion) -> bool { + matches!( + version, + ConcreteFileVersion::V2_2 | ConcreteFileVersion::V2_3 + ) } async fn uses_stable_row_ids(table: &Table) -> bool { @@ -112,7 +117,7 @@ async fn declaring_blob_column_bumps_format_and_enables_stable_row_ids() -> Resu .execute() .await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(uses_stable_row_ids(&table).await); Ok(()) } @@ -127,7 +132,7 @@ async fn explicit_stable_row_id_setting_wins_over_blob_default() -> Result<()> { .execute() .await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(!uses_stable_row_ids(&table).await); Ok(()) } @@ -139,7 +144,7 @@ async fn non_blob_table_keeps_default_format_and_row_id_setting() -> Result<()> let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); let table = db.create_empty_table("t", schema).execute().await?; - assert!(storage_format_version(&table).await < LanceFileVersion::V2_2); + assert!(!supports_blob_v2(storage_format_version(&table).await)); assert!(!uses_stable_row_ids(&table).await); Ok(()) } @@ -171,7 +176,7 @@ async fn creating_with_blob_data_bumps_format() -> Result<()> { .unwrap(); let table = db.create_table("t", batch).execute().await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(uses_stable_row_ids(&table).await); assert_eq!(table.count_rows(None).await?, 1); Ok(()) @@ -281,7 +286,7 @@ async fn connection_level_stable_row_id_setting_wins_over_blob_default() -> Resu .execute() .await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(!uses_stable_row_ids(&table).await); Ok(()) } @@ -297,7 +302,7 @@ async fn namespace_create_applies_blob_defaults() -> Result<()> { .execute() .await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(uses_stable_row_ids(&table).await); Ok(()) } @@ -474,7 +479,7 @@ async fn fetch_blobs_round_trips_nested_blob_column() -> Result<()> { let batch = RecordBatch::try_new(schema, vec![Arc::new(info_array) as ArrayRef]).unwrap(); let table = db.create_table("t", batch).execute().await?; - assert!(storage_format_version(&table).await >= LanceFileVersion::V2_2); + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(uses_stable_row_ids(&table).await); let ids = collect_row_ids(&table).await?; @@ -1305,7 +1310,7 @@ async fn optimize_preserves_blob_v2_null_and_empty_distinction() -> Result<()> { .await?; table.add(null_empty_input_batch()).execute().await?; assert!( - storage_format_version(&table).await >= LanceFileVersion::V2_2, + supports_blob_v2(storage_format_version(&table).await), "blob v2 columns require storage >= 2.2" );