diff --git a/.agents/skills/README.md b/.agents/skills/README.md index d4e3dc45d..296ae3f86 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -5,7 +5,3 @@ This directory contains repo-scoped code agent skills for the LanceDB project. Each skill is a folder that contains a required `SKILL.md` and optional bundled resources. Codex discovers skills from `.agents/skills` in the current working directory and parent directories. - -The `lancedb` skill lives in the `plugins/lancedb` plugin (see `plugins/lancedb/skills/lancedb`) -so it can be installed via the plugin marketplaces (`.claude-plugin/marketplace.json` and -`.agents/plugins/marketplace.json`); the `lancedb` entry here is a symlink into that plugin. diff --git a/.agents/skills/lancedb b/.agents/skills/lancedb deleted file mode 120000 index 1a303efd6..000000000 --- a/.agents/skills/lancedb +++ /dev/null @@ -1 +0,0 @@ -../../plugins/lancedb/skills/lancedb \ No newline at end of file diff --git a/.bumpversion.toml b/.bumpversion.toml index 601b14d3f..b0be7dc82 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.37.1-beta.0" +current_version = "0.38.0-beta.2" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.cargo/config.toml b/.cargo/config.toml index 0a4e3990e..95f9e7df4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,6 +9,18 @@ debug = true codegen-units = 16 lto = "thin" +[profile.release-no-lto] +inherits = "release" +debug = true +lto = false +# Prioritize compile time when LTO is not relevant to the measurement. +codegen-units = 16 + +[profile.bench] +inherits = "release" +lto = "thin" +codegen-units = 16 + [target.'cfg(all())'] rustflags = [ "-Wclippy::all", diff --git a/.github/workflows/codex-update-lance-dependency.yml b/.github/workflows/codex-update-lance-dependency.yml index 420daf650..79ef84364 100644 --- a/.github/workflows/codex-update-lance-dependency.yml +++ b/.github/workflows/codex-update-lance-dependency.yml @@ -4,14 +4,14 @@ on: workflow_call: inputs: tag: - description: "Tag name from Lance. If omitted, the skill will use the latest Lance release that needs an update." + description: "Tag name from Lance (e.g. `v7.2.0-beta.1`). If omitted, the newest release is resolved automatically — stable releases are preferred over pre-releases — and the run is skipped if it is not newer than the version currently pinned in Cargo.toml." required: false default: "" type: string workflow_dispatch: inputs: tag: - description: "Tag name from Lance. Leave empty to use the latest Lance release that needs an update." + description: "Tag name from Lance (e.g. `v7.2.0-beta.1`). Leave empty to resolve the newest release automatically — stable releases are preferred over pre-releases — and skip the run if it is not newer than the version currently pinned in Cargo.toml." required: false default: "" type: string diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml new file mode 100644 index 000000000..1286819bc --- /dev/null +++ b/.github/workflows/docs-link-check.yml @@ -0,0 +1,243 @@ +name: Check doc links + +# Checking external links is inherently noisy: third-party sites rate-limit +# automated clients, reject non-browser user agents, and go down temporarily. +# Blocking pull requests on that trades a lot of false failures for very little +# signal, so this runs on a schedule and reports findings in a single tracking +# issue instead of failing anyone's build. +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +# The report lives in one repository-global issue, so runs must not overlap: a +# lookup racing a create produces duplicate issues, and a healthy run closing +# the issue while a failing run only rewrites its body would leave a broken +# report closed. The group is deliberately ref-independent so that a manual +# dispatch serializes against the scheduled run. +concurrency: + group: docs-link-check + cancel-in-progress: false + +permissions: {} + +env: + REPORT_TITLE: "Docs link checker report" + +jobs: + scan: + name: Scan links + runs-on: ubuntu-24.04 + # lychee-action is pinned by SHA, but its wrapper downloads the lychee + # release tarball at run time without verifying a digest, and hands the + # resulting binary a GitHub token. Release assets remain replaceable, so + # that binary is confined to a job whose token can only read public + # content; everything that writes runs in the report job below. + 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 + with: + # workflow_dispatch can run from any ref, but the report is + # repository-global. Always measure the default branch so a manual + # run from a topic branch cannot close a report that main warrants, + # or overwrite it with branch-only findings. + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - 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 + # API reference (the js/ tree comes from `npm run docs` in nodejs) + # and the hand-written pages use mkdocstrings cross-references and + # nav-relative paths that only resolve in the site mkdocs builds, + # not in this checkout, so relative links would be reported as + # broken on every run. + args: >- + --scheme https + --scheme http + --no-progress + --max-retries 3 + --timeout 20 + 'docs/src/**/*.md' + format: json + output: ./lychee/out.json + jobSummary: false + # 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 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: | + 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.validate.outputs.status == 'findings' + uses: actions/upload-artifact@v7 + with: + name: link-report + path: ./lychee/out.json + retention-days: 7 + + report: + name: Update report issue + needs: scan + runs-on: ubuntu-24.04 + # Deliberately no checkout: this job needs the report artifact and the + # issues API, not the repository contents. + 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: Find existing report issue + id: report + # Matched on title alone, and through search rather than a listing: + # the issue action applies labels in a separate call after creating the + # issue, so a label filter misses a half-created report, and this + # repository has far more open issues than one listing page holds. + # 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 a problem recurs. + run: | + match=$(gh issue list --repo "$GITHUB_REPOSITORY" --state all \ + --search "in:title \"$REPORT_TITLE\" author:app/github-actions" \ + --limit 50 --json number,title,state \ + --jq "[.[] | select(.title == \"$REPORT_TITLE\")] | sort_by(.number) | first // empty") + echo "number=$(jq -r '.number // empty' <<<"$match")" >> "$GITHUB_OUTPUT" + echo "state=$(jq -r '.state // empty' <<<"$match")" >> "$GITHUB_OUTPUT" + + - name: Download report + if: env.STATUS == 'findings' + uses: actions/download-artifact@v8 + with: + name: link-report + path: ./lychee + + - name: Compose report + if: env.STATUS == 'findings' + run: | + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "Broken documentation links found by [\`$GITHUB_WORKFLOW\`]($run_url)." + echo + echo "This issue is rewritten by every scheduled run and closed automatically once all links resolve." + echo + echo "Entries can be false positives: some sites rate-limit or block automated clients while working fine in a browser. Confirm before editing the docs, and add persistent offenders to \`--exclude\` in \`.github/workflows/docs-link-check.yml\`." + echo + # Timeouts are reported alongside errors: entries land in + # timeout_map with a status text instead of an HTTP code. + jq -r ' + "\(.errors) of \(.total) links failed, \(.timeouts) timed out.", + "", + ([(.error_map | to_entries[]), (.timeout_map | to_entries[])] + | group_by(.key)[] | + "### Errors in \(.[0].key)", + "", + (map(.value[])[] | "* [\(.status.code // .status.text // "ERR")] <\(.url)> — \(.status.details // .status.text // "unknown error")"), + "") + ' ./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, 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 "The documentation link checker reported a problem again in [the latest run]($run_url)." + + - 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 + # the same issue is updated in place. + issue-number: ${{ steps.report.outputs.number }} + title: ${{ env.REPORT_TITLE }} + content-filepath: ./lychee/issue.md + labels: documentation + + - 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.STATUS == 'healthy' && + steps.report.outputs.state == 'OPEN' + env: + ISSUE_NUMBER: ${{ steps.report.outputs.number }} + run: | + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --comment "All documentation links resolved in [the latest run]($run_url)." 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/.github/workflows/python.yml b/.github/workflows/python.yml index 52582395f..db8919ddc 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -229,7 +229,8 @@ jobs: # Make sure wheels are not included in the Rust cache - name: Delete wheels run: rm -rf target/wheels - pydantic1x: + min-deps: + name: "Minimum dependencies" timeout-minutes: 60 runs-on: "ubuntu-24.04" defaults: @@ -259,8 +260,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install lancedb run: | - pip install "pydantic<2" - pip install pyarrow==16 + pip install "pydantic==2.7.4" "pyarrow==16" pip install --extra-index-url https://pypi.fury.io/lance-format/ --extra-index-url https://pypi.fury.io/lancedb/ -e .[tests] - name: Run tests run: pytest -m "not slow and not s3_test" -x -v --durations=30 python/tests diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1a0b65c4a..471da43e0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -296,16 +296,18 @@ jobs: cargo update -p aws-types --precise 1.3.9 cargo update -p aws-sigv4 --precise 1.3.5 cargo update -p aws-credential-types --precise 1.2.8 - cargo update -p aws-smithy-checksums --precise 0.63.9 + # aws-smithy-checksums must stay at or above 0.63.13: OpenDAL's S3 + # service needs crc-fast ~1.9, and older releases pin it to ~1.3. + cargo update -p aws-smithy-checksums --precise 0.63.13 cargo update -p aws-smithy-runtime --precise 1.9.3 - cargo update -p aws-smithy-http --precise 0.62.4 - cargo update -p aws-smithy-eventstream --precise 0.60.12 + cargo update -p aws-smithy-http --precise 0.62.6 + cargo update -p aws-smithy-eventstream --precise 0.60.14 cargo update -p aws-smithy-http-client --precise 1.1.3 cargo update -p aws-smithy-observability --precise 0.1.4 cargo update -p aws-smithy-query --precise 0.60.8 - cargo update -p aws-smithy-runtime-api --precise 1.9.1 - cargo update -p aws-smithy-async --precise 1.2.6 - cargo update -p aws-smithy-types --precise 1.3.5 + cargo update -p aws-smithy-runtime-api --precise 1.9.3 + cargo update -p aws-smithy-async --precise 1.2.7 + cargo update -p aws-smithy-types --precise 1.3.6 cargo update -p aws-smithy-xml --precise 0.60.11 cargo update -p home --precise 0.5.9 - name: cargo +${{ matrix.msrv }} check diff --git a/AGENTS.md b/AGENTS.md index 21631a2cd..1e072446a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ Common commands: * Run specific test: `cargo test --quiet --features remote -p --test ` * Lint: `cargo clippy --quiet --features remote --tests --examples` * Format Rust: `cargo fmt --all` +* Use repository-defined Cargo profiles instead of ad hoc LTO overrides. +* Use `release-with-debug` for benchmarks and profiling so optimized builds keep debug symbols without a rebuild. +* Use `release-no-lto` only for local debugging, IO-bound benchmarks, or compile-time-sensitive performance investigation where LTO would not affect the measured bottleneck. * Format Python: `ruff format .` * Lint Python: `ruff check .` * Bootstrap Python dev env: `cd python && uv run --extra tests --extra dev maturin develop --extras tests,dev` diff --git a/Cargo.lock b/Cargo.lock index 995424b20..ddb9cf880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -775,7 +775,7 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "http-body 1.1.0", - "lru", + "lru 0.16.4", "percent-encoding", "regex-lite", "sha2 0.11.0", @@ -959,7 +959,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.14", + "h2 0.4.16", "http 0.2.12", "http 1.5.0", "http-body 0.4.6", @@ -1241,6 +1241,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -1734,9 +1740,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "colorchoice" @@ -1964,15 +1970,6 @@ dependencies = [ "spin 0.10.1", ] -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -2174,9 +2171,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.5" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378f0974ae2468eaf63aa036dbe9c926b0dc7ea64c156f2ea618bc2f75b934f0" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ "link-section", "linktime-proc-macro", @@ -2902,6 +2899,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.6.1" @@ -3413,6 +3441,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frostem" +version = "1.20260804.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82eb03a32a1d50555353c85a7b9d3279a6f1e91af9890b789acdf544ed57c8d7" + [[package]] name = "fs_extra" version = "1.3.0" @@ -3421,8 +3455,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "rand 0.9.5", @@ -3779,14 +3813,23 @@ dependencies = [ [[package]] name = "goosefs-sdk" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae079b88ffe7772d12cfc5c40a5a324babb357893d95b5e3a22ae857f236c5f" +checksum = "e1ea4eee6dcbc31b25ab4fd577adc55b677d2bed3aa3016c44c58fbe1b2298a5" dependencies = [ + "arc-swap", "async-trait", "bytes", "dashmap", + "fastrand", + "futures", "hostname", + "io-uring", + "itoa", + "libc", + "lru 0.18.2", + "memmap2 0.9.10", + "moka", "prost", "prost-types", "rand 0.9.5", @@ -3799,6 +3842,7 @@ dependencies = [ "tonic-prost", "tracing", "uuid", + "xxhash-rust", ] [[package]] @@ -3833,9 +3877,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -4144,7 +4188,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.14", + "h2 0.4.16", "http 1.5.0", "http-body 1.1.0", "httparse", @@ -4599,10 +4643,12 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ + "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -4611,15 +4657,25 @@ dependencies = [ "portable-atomic-util", "serde_core", "wasm-bindgen", - "windows-sys 0.61.2", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", ] [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn 2.0.117", @@ -4731,24 +4787,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "getrandom 0.2.17", - "js-sys", - "pem", - "serde", - "serde_json", - "signature 2.2.0", - "simple_asn1", - "zeroize", -] - [[package]] name = "kanaria" version = "0.2.0" @@ -4777,8 +4815,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arc-swap", "arrow", @@ -4794,7 +4832,6 @@ dependencies = [ "async-recursion", "async-trait", "async_cell", - "aws-credential-types", "aws-sdk-dynamodb", "byteorder", "bytes", @@ -4810,7 +4847,6 @@ dependencies = [ "either", "fst", "futures", - "half", "humantime", "itertools 0.14.0", "lance-arrow", @@ -4852,8 +4888,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-buffer", @@ -4875,7 +4911,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-buffer", @@ -4889,7 +4925,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-schema", @@ -4898,8 +4934,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrayref", "crunchy", @@ -4909,8 +4945,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-buffer", @@ -4918,12 +4954,10 @@ dependencies = [ "arrow-schema", "async-trait", "blake3", - "byteorder", "bytes", "datafusion-common", "datafusion-sql", "futures", - "itertools 0.14.0", "lance-arrow", "lance-derive", "libc", @@ -4941,7 +4975,6 @@ dependencies = [ "snafu 0.9.0", "tempfile", "tokio", - "tokio-stream", "tokio-util", "tracing", "twox-hash", @@ -4950,8 +4983,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "arrow-array", @@ -4970,7 +5003,6 @@ dependencies = [ "jsonb", "lance-arrow", "lance-core", - "lance-datagen", "log", "pin-project", "prost", @@ -4981,8 +5013,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "arrow-array", @@ -4999,8 +5031,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "proc-macro2", "quote", @@ -5009,8 +5041,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-arith", "arrow-array", @@ -5035,8 +5067,6 @@ dependencies = [ "num-traits", "prost", "prost-build", - "rand 0.9.5", - "strum 0.26.3", "tokio", "tracing", "xxhash-rust", @@ -5045,8 +5075,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-arith", "arrow-array", @@ -5077,8 +5107,8 @@ dependencies = [ [[package]] name = "lance-index" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arc-swap", "arrow", @@ -5093,7 +5123,6 @@ dependencies = [ "async-trait", "bitvec", "bytes", - "chrono", "crossbeam-queue", "datafusion", "datafusion-common", @@ -5111,7 +5140,6 @@ dependencies = [ "lance-bitpacking", "lance-core", "lance-datafusion", - "lance-datagen", "lance-encoding", "lance-file", "lance-index-core", @@ -5140,13 +5168,12 @@ dependencies = [ "tempfile", "tokio", "tracing", - "uuid", ] [[package]] name = "lance-index-core" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-schema", @@ -5168,8 +5195,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "arrow-array", @@ -5181,10 +5208,8 @@ dependencies = [ "bytes", "chrono", "futures", - "goosefs-sdk", "http 1.5.0", "io-uring", - "lance-arrow", "lance-core", "lance-namespace", "log", @@ -5202,29 +5227,28 @@ dependencies = [ "tokio", "tracing", "url", + "uuid", ] [[package]] name = "lance-linalg" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", - "arrow-buffer", "arrow-schema", "cc", "half", "lance-arrow", "lance-core", "num-traits", - "rand 0.9.5", "rayon", ] [[package]] name = "lance-namespace" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "async-trait", @@ -5236,8 +5260,8 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "arrow-ipc", @@ -5267,7 +5291,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "time", "tokio", "tower", "tower-http 0.5.2", @@ -5277,9 +5300,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.8.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3f0a235e3ed5f8805205649ccc7d7d0f3df23ce1294242c9265ad488d7f19d" +checksum = "0a030196da1c994b63a96a4f0bf5b0cfa459fe6dadc9e962320246ca328da22a" dependencies = [ "reqwest 0.12.28", "serde", @@ -5291,14 +5314,13 @@ dependencies = [ [[package]] name = "lance-select" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-buffer", "arrow-schema", "byteorder", - "bytes", "itertools 0.14.0", "lance-core", "roaring", @@ -5307,8 +5329,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow", "arrow-array", @@ -5318,6 +5340,7 @@ dependencies = [ "async-trait", "aws-credential-types", "aws-sdk-dynamodb", + "blake3", "byteorder", "bytes", "chrono", @@ -5347,8 +5370,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ "arrow-array", "arrow-schema", @@ -5361,13 +5384,13 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "10.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v10.1.0-beta.1#68f4d4c1d0c4871b067557c61fc405078f1ab3b7" +version = "11.0.0-beta.15" +source = "git+https://github.com/lance-format/lance.git?tag=v11.0.0-beta.15#8064b3a27dc4e05a6ab6ceb439fa1be9950e00eb" dependencies = [ + "frostem", "icu_segmenter", "jieba-rs", "lindera", - "rust-stemmers", "serde", "stop-words", "unicode-normalization", @@ -5375,7 +5398,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.37.1-beta.0" +version = "0.38.0-beta.2" dependencies = [ "ahash", "anyhow", @@ -5411,7 +5434,6 @@ dependencies = [ "datafusion-physical-plan", "datafusion-sql", "futures", - "goosefs-sdk", "half", "hf-hub", "http 1.5.0", @@ -5444,6 +5466,7 @@ dependencies = [ "random_word", "regex", "reqwest 0.12.28", + "roaring", "rstest", "semver", "serde", @@ -5463,7 +5486,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.37.1-beta.0" +version = "0.38.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -5488,7 +5511,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.37.1-beta.0" +version = "0.38.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -5645,7 +5668,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "unicode-blocks", "unicode-normalization", @@ -5675,22 +5698,22 @@ dependencies = [ "rkyv", "serde", "serde_json", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "thiserror 2.0.18", ] [[package]] name = "link-section" -version = "0.16.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8600ca3dbe044f07955b443ff606c50f45295b863289bbe7d0844d50cf11e4" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" [[package]] name = "linktime-proc-macro" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" [[package]] name = "linux-raw-sys" @@ -5747,6 +5770,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -6067,7 +6099,7 @@ checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" dependencies = [ "bitflags 2.11.1", "chrono", - "ctor 1.0.5", + "ctor 1.0.12", "futures", "napi-build", "napi-sys", @@ -6091,7 +6123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d5c9c02556ea6dc99dffd36c1ce60141411657438501a125b675776d011ce92" dependencies = [ "convert_case", - "ctor 1.0.5", + "ctor 1.0.12", "napi-derive-backend", "proc-macro2", "quote", @@ -6384,9 +6416,9 @@ dependencies = [ [[package]] name = "object_store_opendal" -version = "0.57.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb12a624a41fce745838d0ef3701ff6c47797c13cd18ad3612fd2a3134fdbd8" +checksum = "88f165780495c17aa3ce86846600504198c3fffd99073521552751c2430fa6ac" dependencies = [ "async-trait", "bytes", @@ -6447,12 +6479,13 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opendal" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c9c85ce253ff87225e7669979d877a20c98a06604ec9d6dd5f4473e08f1ae1" +checksum = "4f20562cc7447fcc915fc5c23df305a412ea80a733c9f2fd9e2d267e2815be6d" dependencies = [ - "ctor 1.0.5", + "ctor 1.0.12", "opendal-core", + "opendal-http-transport-reqwest", "opendal-layer-concurrent-limit", "opendal-layer-logging", "opendal-layer-retry", @@ -6469,24 +6502,22 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f8607c90e2c963a91467f50fb49fbc7fb3d573f88cea219ca59ccd3740b309" +checksum = "ec75551ff4cf3e57da98979f6a937aaa9ddb3915bf68cc17d03df733be6646ed" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures", "http 1.5.0", - "http-body 1.1.0", "jiff", "log", "md-5 0.11.0", "mea", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-core", - "reqwest 0.13.3", "serde", "serde_json", "tokio", @@ -6496,10 +6527,24 @@ dependencies = [ ] [[package]] -name = "opendal-layer-concurrent-limit" -version = "0.57.0" +name = "opendal-http-transport-reqwest" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6f81ba6960e3fae1882f253b114b21d7e444e1534f209c7737a79f6243eb6f" +checksum = "ad4d4f19c3ce01126a30611f8e544eaa217104a278c889ac17c9374fe4f9e4ef" +dependencies = [ + "bytes", + "futures", + "http 1.5.0", + "http-body 1.1.0", + "opendal-core", + "reqwest 0.13.4", +] + +[[package]] +name = "opendal-layer-concurrent-limit" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249ac5b0aa5a7a6c3737342d10456067937f9c9a6f3f02544271f7908ab91081" dependencies = [ "futures", "http 1.5.0", @@ -6509,9 +6554,9 @@ dependencies = [ [[package]] name = "opendal-layer-logging" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ada45c6d81d1aa4c9305d0c7d4bc317c59c85866a0908a2d75a7a978aa5ee2" +checksum = "5c75411ab00f77851ff086b686c1e9ca8175ac18c15afa2cb75b9036436cb06c" dependencies = [ "log", "opendal-core", @@ -6519,9 +6564,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2a25a718afb81fad81cb9a0580a1cb989221fa2317f888c6a37f8dad408eb7" +checksum = "80b7738bd5f233ad8da39af9b9316b9b7a4eaddd91e8e32a1e19b7030688121d" dependencies = [ "backon", "log", @@ -6530,9 +6575,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e91f731724c213af81e9d03517859c8fc47b4578e64ad61ae4f099f10fe36e3" +checksum = "a704141924500f3803c05ed871b53305d2a2f11cb5ef20160c3ee688a1857f66" dependencies = [ "opendal-core", "tokio", @@ -6540,17 +6585,17 @@ dependencies = [ [[package]] name = "opendal-service-azblob" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0030644366ef5d8cbe3a4a5822bf99a4aafddc1666e9d24b44d158d9062fc76a" +checksum = "b3310fbbb48f111c6f590473c2cd15e1b7f8e384444b0d4e328f0464c864d767" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "http 1.5.0", "log", "opendal-core", "opendal-service-azure-common", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-azure-storage", "reqsign-core", "reqsign-file-read-tokio", @@ -6561,17 +6606,18 @@ dependencies = [ [[package]] name = "opendal-service-azdls" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dea4908d490143a9b0b7f7a790e139ff829b06a023f670455ed3d44f664b361" +checksum = "2e3c406729935fe214ce574d68681a1ff7e0b322548f14094912bdbfe50e5c53" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "http 1.5.0", "log", + "mea", "opendal-core", "opendal-service-azure-common", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-azure-storage", "reqsign-core", "reqsign-file-read-tokio", @@ -6581,9 +6627,9 @@ dependencies = [ [[package]] name = "opendal-service-azure-common" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b489f13c42e69d69bdd72952b634356ec43a7881a20259b38b540fcecdf4051" +checksum = "7348c88edf15af435b7be930077746b569fac5e738c1bf6a363b675e7317c9df" dependencies = [ "http 1.5.0", "opendal-core", @@ -6591,15 +6637,15 @@ dependencies = [ [[package]] name = "opendal-service-cos" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8cafe9729213375c7331019b0cb756ad3e1aff7f45cd32c45eae91ebde8901" +checksum = "d533d4582105d269c8aebeee5f0e8bcf960f41b8aab6197df7012254d9f39bf0" dependencies = [ "bytes", "http 1.5.0", "log", "opendal-core", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-core", "reqsign-file-read-tokio", "reqsign-tencent-cos", @@ -6608,9 +6654,9 @@ dependencies = [ [[package]] name = "opendal-service-gcs" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48de101aac565ed06af4b47903c24eafd249075553ec1fb18256751c45148d47" +checksum = "007f3fba63c21e516c956b891e96ff9892d8175662bfb781cdada9d3766a11e6" dependencies = [ "async-trait", "bytes", @@ -6618,7 +6664,7 @@ dependencies = [ "log", "opendal-core", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-core", "reqsign-file-read-tokio", "reqsign-google", @@ -6629,9 +6675,9 @@ dependencies = [ [[package]] name = "opendal-service-goosefs" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e43048bde419947ba826fbdc2f134d6c03f44ebf48bd33a03b72f9fc45fcb4" +checksum = "60871e6386f04d831e6a5bdbc032af4a91aeba49963252d0ef456a2cf36a9b78" dependencies = [ "bytes", "goosefs-sdk", @@ -6643,9 +6689,9 @@ dependencies = [ [[package]] name = "opendal-service-hf" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4922661976a1d40794a2adfbdb888cc3c23097690f825a92f773af38908a848" +checksum = "b41fd41eb7ed03c5e66cefda61e8e117808ffd2908f2916737cb020a6beb02c7" dependencies = [ "bytes", "hf-xet", @@ -6653,22 +6699,21 @@ dependencies = [ "log", "opendal-core", "percent-encoding", - "reqwest 0.13.3", "serde", "serde_json", ] [[package]] name = "opendal-service-oss" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328fa55e8888cbdfe00826bfea2a79042422b720e8369e9e021e46121dea5ace" +checksum = "cd528ec2d49c5ca69e674ffed7b3e0686fb9cfcfea0596870de381467fda4f1b" dependencies = [ "bytes", "http 1.5.0", "log", "opendal-core", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-aliyun-oss", "reqsign-core", "reqsign-file-read-tokio", @@ -6677,18 +6722,18 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.57.0" +version = "0.58.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313d46c9f5ae70bca26b7c3e3fbb9b639292625f28af73aa016f47e788af9deb" +checksum = "58e80cdf192d7eff05feed747894d64f81905ac4eaf132edf7ea270abdd2d663" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", - "crc32c", + "crc-fast", "http 1.5.0", "log", "md-5 0.11.0", "opendal-core", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-aws-v4", "reqsign-core", "reqsign-file-read-tokio", @@ -7583,7 +7628,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -7799,6 +7844,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick_cache" version = "0.6.24" @@ -8220,9 +8275,9 @@ dependencies = [ [[package]] name = "reqsign-aliyun-oss" -version = "3.0.0" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ac2757f3140aa2e213b554148ae0b52733e624fc6723f0cc6bb3d440176c95" +checksum = "a5e6d659fcdbca6fe2d7ef109c2e28499b7be80501f1bb86c10caf5ec8ac1219" dependencies = [ "anyhow", "form_urlencoded", @@ -8236,38 +8291,52 @@ dependencies = [ ] [[package]] -name = "reqsign-aws-v4" -version = "3.0.0" +name = "reqsign-aws-core" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44eaca382e94505a49f1a4849658d153aebf79d9c1a58e5dd3b10361511e9f43" +checksum = "e4af084e1f3cbf3e67e0c972765399bce54ecec804cceba46b39a8331f3c1bff" dependencies = [ - "anyhow", "bytes", "form_urlencoded", + "hex", "http 1.5.0", "log", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-core", "rust-ini", "serde", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1 0.11.0", +] + +[[package]] +name = "reqsign-aws-v4" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac5b3b7cefa28933792b439186459f77f19f9b6edbeab41b8b187150361a206" +dependencies = [ + "bytes", + "http 1.5.0", + "log", + "quick-xml 0.41.0", + "reqsign-aws-core", + "reqsign-core", + "serde", ] [[package]] name = "reqsign-azure-storage" -version = "3.0.0" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a321980405d596bd34aaf95c4722a3de4128a67fd19e74a81a83aa3fdf082e6" +checksum = "2824e7da3c2cc42ac3406c674eb57c89127fdcd97f3a73c608cfc680505ea134" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "bytes", "form_urlencoded", "http 1.5.0", - "jsonwebtoken", "log", "pem", "percent-encoding", @@ -8275,36 +8344,38 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha1 0.10.6", + "sha1 0.11.0", ] [[package]] name = "reqsign-core" -version = "3.0.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10302cf0a7d7e7352ba211fc92c3c5bebf1286153e49cc5aa87348078a8e102" +checksum = "c07dd510b1e1b9b241883e483358147fb2ed2d497a7b39b065ba61eb93deceb0" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "form_urlencoded", "futures", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "http 1.5.0", "jiff", "log", "percent-encoding", - "sha1 0.10.6", - "sha2 0.10.9", + "rsa", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", "windows-sys 0.61.2", ] [[package]] name = "reqsign-file-read-tokio" -version = "3.0.0" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d89295b3d17abea31851cc8de55d843d89c52132c864963c38d41920613dc5" +checksum = "663d9d55abd0df0830ef0ae43708297cc1371cf4e8ca91f3ac813c309cca8c98" dependencies = [ "anyhow", "reqsign-core", @@ -8313,13 +8384,12 @@ dependencies = [ [[package]] name = "reqsign-google" -version = "3.0.0" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35cc609b49c69e76ecaceb775a03f792d1ed3e7755ab3548d4534fd801e3242e" +checksum = "4080a227f82a09f68540ecd028622065d7ac4c0bcb8727a25bdcfc0526235792" dependencies = [ "form_urlencoded", "http 1.5.0", - "jsonwebtoken", "log", "percent-encoding", "reqsign-aws-v4", @@ -8327,15 +8397,14 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha2 0.10.9", "tokio", ] [[package]] name = "reqsign-tencent-cos" -version = "3.0.0" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128f19525861dbded59e1e7c17653a8ed63d573ca04aed708d552dbef5bb32a" +checksum = "764629c90f7c3566a6d4e4641ebab9acd604ce02e16eda4d37c7d7e79e16ed90" dependencies = [ "anyhow", "http 1.5.0", @@ -8357,7 +8426,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.14", + "h2 0.4.16", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -8394,9 +8463,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -8457,7 +8526,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.5.0", - "reqwest 0.13.3", + "reqwest 0.13.4", "thiserror 2.0.18", "tower-service", ] @@ -8597,16 +8666,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -9206,18 +9265,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "siphasher" version = "1.0.3" @@ -9277,7 +9324,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9289,7 +9336,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9492,15 +9539,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" version = "0.28.0" @@ -9523,19 +9561,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "strum_macros" version = "0.28.0" @@ -9730,7 +9755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -10057,7 +10082,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", - "h2 0.4.14", + "h2 0.4.16", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -11161,7 +11186,7 @@ dependencies = [ "more-asserts", "rand 0.10.1", "redb", - "reqwest 0.13.3", + "reqwest 0.13.4", "reqwest-middleware", "serde", "serde_json", @@ -11274,7 +11299,7 @@ dependencies = [ "oneshot", "pin-project", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "shellexpand", @@ -11370,20 +11395,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 35370e58f..925910586 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,21 @@ categories = ["database-implementations"] rust-version = "1.91.0" [workspace.dependencies] -lance = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" } +lancedb = { path = "rust/lancedb", default-features = false } ahash = "0.8" # Note that this one does not include pyarrow arrow = { version = "58.0.0", optional = false } @@ -39,6 +40,7 @@ arrow-schema = "58.0.0" arrow-select = "58.0.0" arrow-cast = "58.0.0" async-trait = "0" +bytes = "1" datafusion = { version = "54.0.0", default-features = false } datafusion-catalog = "54.0.0" datafusion-common = { version = "54.0.0", default-features = false } @@ -52,7 +54,7 @@ env_logger = "0.11" half = { "version" = "2.7.1", default-features = false, features = [ "num-traits", ] } -futures = "0" +futures = "0.3" log = "0.4" metrics = "0.24" metrics-util = "0.19" @@ -65,7 +67,12 @@ url = "2" num-traits = "0.2" regex = "1.10" semver = "1.0.25" -chrono = "0.4" +serde = "1" +serde_json = "1" +tempfile = "3.5.0" +tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } +uuid = { version = "1.7.0", features = ["v4"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } [profile.ci] debug = "line-tables-only" diff --git a/ci/check_breaking_changes.py b/ci/check_breaking_changes.py index bc7a562b8..e31eedf0c 100644 --- a/ci/check_breaking_changes.py +++ b/ci/check_breaking_changes.py @@ -2,6 +2,7 @@ Check whether there are any breaking changes in the PRs between the base and head commits. If there are, assert that we have incremented the minor version. """ + import argparse import os from packaging.version import parse @@ -27,7 +28,7 @@ if __name__ == "__main__": else: print("No breaking changes found.") exit(0) - + last_stable_version = parse(args.last_stable_version) current_version = parse(args.current_version) if current_version.minor <= last_stable_version.minor: diff --git a/ci/check_lance_release.py b/ci/check_lance_release.py index 47f1cdbde..9fff955ac 100755 --- a/ci/check_lance_release.py +++ b/ci/check_lance_release.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Determine whether a newer Lance tag exists and expose results for CI.""" + from __future__ import annotations import argparse @@ -36,8 +37,16 @@ class SemVer: prerelease: Tuple[Union[int, str], ...] def __lt__(self, other: "SemVer") -> bool: # pragma: no cover - simple comparison - if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch): - return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) + if (self.major, self.minor, self.patch) != ( + other.major, + other.minor, + other.patch, + ): + return (self.major, self.minor, self.patch) < ( + other.major, + other.minor, + other.patch, + ) if self.prerelease == other.prerelease: return False if not self.prerelease: @@ -142,7 +151,9 @@ def read_current_version(repo_root: Path) -> str: deps = data["workspace"]["dependencies"] entry = deps["lance"] except KeyError as exc: # pragma: no cover - configuration guard - raise RuntimeError("Failed to locate workspace.dependencies.lance in Cargo.toml") from exc + raise RuntimeError( + "Failed to locate workspace.dependencies.lance in Cargo.toml" + ) from exc if isinstance(entry, str): raw_version = entry diff --git a/ci/mock_openai.py b/ci/mock_openai.py index da3cb6c46..4fcb62ad9 100644 --- a/ci/mock_openai.py +++ b/ci/mock_openai.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The LanceDB Authors """A zero-dependency mock OpenAI embeddings API endpoint for testing purposes.""" + import argparse import json import http.server @@ -22,11 +23,13 @@ class MockOpenAIRequestHandler(http.server.BaseHTTPRequestHandler): data = [] for i in range(num_inputs): - data.append({ - "object": "embedding", - "embedding": [0.1] * 1536, - "index": i, - }) + data.append( + { + "object": "embedding", + "embedding": [0.1] * 1536, + "index": i, + } + ) response = { "object": "list", @@ -35,7 +38,7 @@ class MockOpenAIRequestHandler(http.server.BaseHTTPRequestHandler): "usage": { "prompt_tokens": 0, "total_tokens": 0, - } + }, } self.send_response(200) diff --git a/ci/semver_sort.py b/ci/semver_sort.py index b90ba3319..5f99c6c4f 100644 --- a/ci/semver_sort.py +++ b/ci/semver_sort.py @@ -7,6 +7,7 @@ from packaging.version import parse, InvalidVersion if __name__ == "__main__": import argparse + parser = argparse.ArgumentParser() parser.add_argument("prefix", default="v") args = parser.parse_args() diff --git a/ci/set_lance_version.py b/ci/set_lance_version.py index e8c573ad4..f66761644 100644 --- a/ci/set_lance_version.py +++ b/ci/set_lance_version.py @@ -22,7 +22,7 @@ def run_command(command: str) -> str: def get_latest_stable_version() -> str: version_line = run_command("cargo info lance | grep '^version:'") # Example output: "version: 0.35.0 (latest 0.37.0)" - match = re.search(r'\(latest ([0-9.]+)\)', version_line) + match = re.search(r"\(latest ([0-9.]+)\)", version_line) if match: return match.group(1) # Fallback: use the first version after 'version:' @@ -69,7 +69,7 @@ def extract_default_features(line: str) -> bool: """ import re - match = re.search(r'default-features\s*=\s*false', line) + match = re.search(r"default-features\s*=\s*false", line) return match is not None @@ -104,7 +104,7 @@ def dict_to_toml_line(package_name: str, config: dict) -> str: # This shouldn't happen with our current usage parts.append(f'"{key}" = {json.dumps(value)}') - return f'{package_name} = {{ {", ".join(parts)} }}\n' + return f"{package_name} = {{ {', '.join(parts)} }}\n" def update_cargo_toml(line_updater): diff --git a/ci/validate_stable_lance.py b/ci/validate_stable_lance.py index 4edd4c522..240e64174 100644 --- a/ci/validate_stable_lance.py +++ b/ci/validate_stable_lance.py @@ -12,7 +12,7 @@ with open("Cargo.toml", "rb") as f: elif isinstance(dep, dict): # Version doesn't have the beta tag in it, so we instead look # at the git tag. - version = dep.get('tag', dep.get('version')) + version = dep.get("tag", dep.get("version")) else: raise ValueError("Unexpected type for dependency: " + str(dep)) diff --git a/deny.toml b/deny.toml index 034b48c25..3672321d0 100644 --- a/deny.toml +++ b/deny.toml @@ -101,6 +101,19 @@ 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" }, + + # h2 0.3: empty DATA frames can be queued without limit. The patched + # h2 0.4 line is locked to 0.4.16, but no patched 0.3 release exists. + # The old copy is pulled in by aws-smithy's legacy hyper 0.14 client. + # https://rustsec.org/advisories/RUSTSEC-2026-0258 + { id = "RUSTSEC-2026-0258", reason = "h2 0.3 via legacy aws-smithy/hyper 0.14; no patched 0.3 release" }, ] # --------------------------------------------------------------------------- @@ -164,6 +177,11 @@ multiple-versions = "warn" # Wildcard version requirements (`foo = "*"`) are a footgun — they let any # future release in without review. Ban them outright. wildcards = "deny" +# Lint every dependency declared by a workspace member against the shared +# `[workspace.dependencies]` table: any crate used by more than one member must +# go through `workspace = true`, and entries nothing uses are an error. This +# keeps versions from drifting between the core crate and the bindings. +workspace-dependencies = { duplicates = "deny", unused = "deny" } # Internal workspace crates reference each other via `path = "..."`, which # cargo-deny sees as a wildcard version. That's fine for private workspace # members (not published to crates.io), so allow it specifically for paths. diff --git a/docs/requirements.txt b/docs/requirements.txt index e5f3867cb..89de1cb71 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,5 +5,5 @@ mkdocs-autorefs>=0.5,<=1.0 mkdocstrings[python]>=0.24,<1.0 griffe>=0.40,<1.0 mkdocs-render-swagger-plugin>=0.1.0 -pydantic>=2.0,<3.0 -mkdocs-redirects>=1.2.0 \ No newline at end of file +pydantic>=2.7.4,<3 +mkdocs-redirects>=1.2.0 diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 11e901ad0..452bd54f4 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.38.0-beta.2 ``` @@ -55,6 +55,38 @@ LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder() | `region(String)` | AWS region (default: "us-east-1") | No | | `config(String, String)` | Additional configuration parameters | No | +### Opening a Table with Vended Credentials + +When the catalog vends temporary object store credentials, open the table through the +namespace client. The Lance dataset builder fetches the table location and storage options +from the catalog and refreshes the credentials when they expire. + +```java +import com.lancedb.LanceDbNamespaceClientBuilder; +import org.lance.Dataset; +import org.lance.namespace.LanceNamespace; + +import java.util.Arrays; + +LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder() + .apiKey(System.getenv("LANCEDB_API_KEY")) + .database(System.getenv("LANCEDB_DATABASE")) + // Set the endpoint for a LanceDB Enterprise deployment. + // .endpoint("https://your-enterprise-endpoint") + .build(); + +try (Dataset dataset = Dataset.open() + .namespaceClient(namespaceClient) + .tableId(Arrays.asList("my_namespace", "my_table")) + .build()) { + System.out.println("Rows: " + dataset.countRows()); +} +``` + +Do not call `describeTable()` and then open the returned location with `Dataset.open(uri)`. +Opening through `namespaceClient()` is what applies the vended storage options and enables +automatic credential refresh. No object store credentials need to be passed by the application. + ## Metadata Operations ### Creating a Namespace Path diff --git a/docs/src/js/classes/Connection.md b/docs/src/js/classes/Connection.md index fa4e0748a..e4cbc1e96 100644 --- a/docs/src/js/classes/Connection.md +++ b/docs/src/js/classes/Connection.md @@ -386,6 +386,29 @@ Drop an existing table. *** +### dropTableAsync() + +```ts +abstract dropTableAsync(name, namespacePath?): Promise +``` + +Start dropping a table and return its cleanup job. + +The table may become unavailable before its data files are removed. Wait +on the returned job to know when cleanup has finished. + +#### Parameters + +* **name**: `string` + +* **namespacePath?**: `string`[] + +#### Returns + +`Promise`<[`Job`](Job.md)> + +*** + ### getJob() ```ts diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index 11fca32d0..06dc8479e 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -69,14 +69,34 @@ abstract addColumns(newColumnTransforms): Promise Add new columns with defined values. +The `{ computed }` form stores the expression rather than evaluating it +now: the column is committed with no values, and rows get them from +[Table#refreshColumn](Table.md#refreshcolumn). Declaring one therefore costs the same on a +large table as on an empty one. + +A refresh does not revisit rows it has already filled, so mutating an +input leaves the value computed at fill time; recomputing means dropping +the column and declaring it again. While a declaration reads a column, +that column cannot be renamed, retyped or dropped. + +On LanceDB Cloud and Enterprise the expression is planned by the +server, and the refresh runs as a server job -- see +[Table#refreshColumnAsync](Table.md#refreshcolumnasync). + #### Parameters -* **newColumnTransforms**: `Field`<`any`> \| `Field`<`any`>[] \| `Schema`<`any`> \| [`AddColumnsSql`](../interfaces/AddColumnsSql.md)[] +* **newColumnTransforms**: + \| `Field`<`any`> + \| `Field`<`any`>[] + \| `Schema`<`any`> + \| [`AddColumnsSql`](../interfaces/AddColumnsSql.md)[] + \| `object` Either: - An array of objects with column names and SQL expressions to calculate values - A single Arrow Field defining one column with its data type (column will be initialized with null values) - An array of Arrow Fields defining columns with their data types (columns will be initialized with null values) - An Arrow Schema defining columns with their data types (columns will be initialized with null values) + - `{ computed }`, declaring columns defined by a SQL expression whose type and inputs are derived from it #### Returns @@ -85,6 +105,13 @@ Add new columns with defined values. A promise that resolves to an object containing the new version number of the table after adding the columns. +#### Example + +```ts +await table.addColumns({ computed: [{ name: "doubled", valueSql: "x * 2" }] }); +const { rowsFilled } = await table.refreshColumn("doubled"); +``` + *** ### alterColumns() @@ -186,6 +213,39 @@ version of the table. *** +### checkpointLsm() + +```ts +abstract checkpointLsm(): Promise +``` + +Converge this table's LSM write path into its base table. + +Seals once, then triggers compaction and polls until the L0 that existed +at the start is gone. 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, and safe to run on a cadence. + +There is no liveness bound — the compactor pool is shared across tables, +so a checkpoint queued behind unrelated work looks exactly like one that +is merging. The caller owns the deadline. + +#### Returns + +`Promise`<`void`> + +#### Example + +```ts +const before = await table.getLsmStats(); +await table.checkpointLsm(); +const after = await table.getLsmStats(); +``` + +*** + ### close() ```ts @@ -223,6 +283,24 @@ It is a no-op when no writers are cached. *** +### compactLsm() + +```ts +abstract compactLsm(): Promise +``` + +Trigger a background L0 → base compaction pass per bucket. + +Returns once the passes are *dispatched*, not once they finish — watch +[Table#getLsmStats](Table.md#getlsmstats) for progress, or use +[Table#checkpointLsm](Table.md#checkpointlsm) to wait for convergence. + +#### Returns + +`Promise`<`void`> + +*** + ### countRows() ```ts @@ -421,6 +499,48 @@ Drop an index from the table. *** +### flushLsm() + +```ts +abstract flushLsm(): Promise +``` + +Seal every bucket's active memtable into a new L0 generation. + +Returns once the seal is committed. Sealing an empty memtable is a no-op, +so this is safe to call repeatedly. + +#### Returns + +`Promise`<`void`> + +*** + +### getLsmStats() + +```ts +abstract getLsmStats(includeGenerationRows?): Promise +``` + +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. + +Resolves to `undefined` only when the LSM write path is not enabled. + +#### Parameters + +* **includeGenerationRows?**: `boolean` + Also count rows per L0 generation. + Off by default because each count opens an uncached Lance dataset. + +#### Returns + +`Promise`<`undefined` \| [`LsmStats`](../interfaces/LsmStats.md)> + +*** + ### getLsmWriteSpec() ```ts @@ -431,9 +551,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 @@ -717,6 +838,67 @@ for await (const batch of table.query()) { *** +### refreshColumn() + +```ts +abstract refreshColumn(column): Promise +``` + +Fill the rows of a computed column that hold no value yet. + +Rows appended since the last refresh are filled by the next one; rows +already filled are left as they are, so the call is idempotent and does +not observe a mutated input. Local tables only: a remote refresh runs +as a server job, through [Table#refreshColumnAsync](Table.md#refreshcolumnasync). + +#### Parameters + +* **column**: `string` + The name of the computed column to fill. + +#### Returns + +`Promise`<[`RefreshColumnResult`](../interfaces/RefreshColumnResult.md)> + +A promise that resolves to the +number of rows filled and the new version number of the table. + +*** + +### refreshColumnAsync() + +```ts +abstract refreshColumnAsync(column): Promise +``` + +Like [Table#refreshColumn](Table.md#refreshcolumn), but returns a handle to the refresh +job instead of blocking until it completes. + +The job may already be complete when returned; callers must not assume +the column is filled until [Job.wait](Job.md#wait) resolves. Invalid input -- +an unknown column, or one that is not computed -- rejects here rather +than failing the job. On local tables the job runs in-process; on +LanceDB Cloud and Enterprise it is the server's backfill job. + +#### Parameters + +* **column**: `string` + The name of the computed column to fill. + +#### Returns + +`Promise`<[`Job`](Job.md)> + +#### Example + +```ts +const job = await table.refreshColumnAsync("doubled"); +await job.wait(); +console.log(await job.status()); // "finished" +``` + +*** + ### restore() ```ts @@ -806,6 +988,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/globals.md b/docs/src/js/globals.md index 7455a81ce..462907cfd 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -58,6 +58,7 @@ - [BranchDiff](interfaces/BranchDiff.md) - [BranchIndexSummary](interfaces/BranchIndexSummary.md) - [BranchRowCountSummary](interfaces/BranchRowCountSummary.md) +- [BucketStats](interfaces/BucketStats.md) - [ClientConfig](interfaces/ClientConfig.md) - [ColumnAlteration](interfaces/ColumnAlteration.md) - [ColumnOrdering](interfaces/ColumnOrdering.md) @@ -81,6 +82,7 @@ - [FtsToken](interfaces/FtsToken.md) - [FullTextQuery](interfaces/FullTextQuery.md) - [FullTextSearchOptions](interfaces/FullTextSearchOptions.md) +- [GenerationStats](interfaces/GenerationStats.md) - [HnswPqOptions](interfaces/HnswPqOptions.md) - [HnswSqOptions](interfaces/HnswSqOptions.md) - [IndexConfig](interfaces/IndexConfig.md) @@ -94,7 +96,9 @@ - [JobInfo](interfaces/JobInfo.md) - [ListNamespacesOptions](interfaces/ListNamespacesOptions.md) - [ListNamespacesResponse](interfaces/ListNamespacesResponse.md) +- [LsmStats](interfaces/LsmStats.md) - [LsmWriteSpec](interfaces/LsmWriteSpec.md) +- [MemtableStats](interfaces/MemtableStats.md) - [MergeBlocker](interfaces/MergeBlocker.md) - [MergeBranchResult](interfaces/MergeBranchResult.md) - [MergePreview](interfaces/MergePreview.md) @@ -105,6 +109,7 @@ - [OptimizeOptions](interfaces/OptimizeOptions.md) - [OptimizeStats](interfaces/OptimizeStats.md) - [QueryExecutionOptions](interfaces/QueryExecutionOptions.md) +- [RefreshColumnResult](interfaces/RefreshColumnResult.md) - [RemovalStats](interfaces/RemovalStats.md) - [RenameTableOptions](interfaces/RenameTableOptions.md) - [RestNamespaceConfig](interfaces/RestNamespaceConfig.md) diff --git a/docs/src/js/interfaces/BucketStats.md b/docs/src/js/interfaces/BucketStats.md new file mode 100644 index 000000000..3f5095672 --- /dev/null +++ b/docs/src/js/interfaces/BucketStats.md @@ -0,0 +1,116 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BucketStats + +# Interface: BucketStats + +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. + +## Properties + +### compacting + +```ts +compacting: boolean; +``` + +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". + +*** + +### currentGeneration + +```ts +currentGeneration: number; +``` + +The generation the active memtable will become. + +*** + +### generations + +```ts +generations: GenerationStats[]; +``` + +Flushed L0 generations not yet merged into the base table. + +*** + +### manifestVersion + +```ts +manifestVersion: number; +``` + +Version of the shard manifest these numbers were read from. + +*** + +### memtables? + +```ts +optional memtables: MemtableStats[]; +``` + +Oldest first, active last. Absent for a `"Sealed"` bucket, whose +in-memory state is torn down. + +*** + +### replayAfterWalEntryPosition + +```ts +replayAfterWalEntryPosition: number; +``` + +WAL position replay resumes from. + +*** + +### shardId + +```ts +shardId: string; +``` + +The shard this bucket writes. + +*** + +### status + +```ts +status: string; +``` + +`"Active"` or `"Sealed"` (drop-table 2PC in flight). + +*** + +### walEntryPositionLastSeen + +```ts +walEntryPositionLastSeen: number; +``` + +Highest WAL position the writer has seen. The difference against +`replayAfterWalEntryPosition` is the WAL lag. + +*** + +### writerEpoch + +```ts +writerEpoch: number; +``` + +Epoch of the writer that currently owns the shard. diff --git a/docs/src/js/interfaces/GenerationStats.md b/docs/src/js/interfaces/GenerationStats.md new file mode 100644 index 000000000..19dd2afda --- /dev/null +++ b/docs/src/js/interfaces/GenerationStats.md @@ -0,0 +1,40 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / GenerationStats + +# Interface: GenerationStats + +One flushed L0 generation. + +## Properties + +### bytes + +```ts +bytes: number; +``` + +On-disk size of the generation. + +*** + +### generation + +```ts +generation: number; +``` + +The generation number. Increases as memtables are sealed into L0. + +*** + +### rows? + +```ts +optional rows: number; +``` + +Present only when `includeGenerationRows` was requested. Off by default +because each count opens an uncached Lance dataset. diff --git a/docs/src/js/interfaces/LsmStats.md b/docs/src/js/interfaces/LsmStats.md new file mode 100644 index 000000000..76a2f50db --- /dev/null +++ b/docs/src/js/interfaces/LsmStats.md @@ -0,0 +1,22 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / LsmStats + +# Interface: LsmStats + +Live per-bucket LSM state, as returned by `Table#getLsmStats`. + +Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are +the caller's to compute. + +## Properties + +### buckets + +```ts +buckets: BucketStats[]; +``` + +One entry per bucket backing this table. 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/MemtableStats.md b/docs/src/js/interfaces/MemtableStats.md new file mode 100644 index 000000000..fdc1e4467 --- /dev/null +++ b/docs/src/js/interfaces/MemtableStats.md @@ -0,0 +1,60 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / MemtableStats + +# Interface: MemtableStats + +One in-memory memtable. + +## Properties + +### batches + +```ts +batches: number; +``` + +Record batches currently buffered. + +*** + +### bytes + +```ts +bytes: number; +``` + +Estimated in-memory size. + +*** + +### generation + +```ts +generation: number; +``` + +The generation this memtable will become once sealed. + +*** + +### indexes + +```ts +indexes: string[]; +``` + +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". + +*** + +### rows + +```ts +rows: number; +``` + +Rows currently buffered. diff --git a/docs/src/js/interfaces/RefreshColumnResult.md b/docs/src/js/interfaces/RefreshColumnResult.md new file mode 100644 index 000000000..d2854fda6 --- /dev/null +++ b/docs/src/js/interfaces/RefreshColumnResult.md @@ -0,0 +1,23 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / RefreshColumnResult + +# Interface: RefreshColumnResult + +## Properties + +### rowsFilled + +```ts +rowsFilled: number; +``` + +*** + +### version + +```ts +version: number; +``` 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/docs/src/python/python.md b/docs/src/python/python.md index 36044d35d..a99c0236a 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -31,7 +31,7 @@ is also an [asynchronous API client](#connections-asynchronous). ## Namespaces (Synchronous) A namespace-backed connection resolves tables through a -[Lance namespace](https://lancedb.github.io/lance-namespace/) service instead of +[Lance namespace](https://lance-format.github.io/lance-namespace/) service instead of listing a storage directory. ::: lancedb.connect_namespace @@ -52,6 +52,44 @@ listing a storage directory. ::: lancedb.table.Branches +::: lancedb.LsmWriteSpec + +## Functions and Jobs + +::: lancedb.functions.FunctionArtifact + +::: lancedb.functions.FunctionParameter + +::: lancedb.functions.FunctionResultField + +::: lancedb.functions.FunctionOutput + +::: lancedb.functions.FunctionSignature + +::: lancedb.functions.PythonEnvironmentSpec + +::: lancedb.functions.FunctionVersion + +::: lancedb.functions.PythonRuntimeSpec + +::: lancedb.functions.FunctionVersionRef + +::: lancedb.functions.ApplicationInput + +::: lancedb.functions.FunctionApplication + +::: lancedb.functions.InputBinding + +::: lancedb.functions.OutputMapping + +::: lancedb.functions.FunctionBinding + +::: lancedb.functions.RefreshColumnResult + +::: lancedb.job.Job + +::: lancedb.job.AsyncJob + ## Expressions Type-safe expression builder for filters and projections. Use these instead @@ -151,8 +189,9 @@ The same option is available on `lancedb.tokenize(...)` and the deprecated ```python import lancedb -tokens = list(lancedb.tokenize("acme makes searchable data", - custom_stop_words=["acme"])) +tokens = list( + lancedb.tokenize("acme makes searchable data", custom_stop_words=["acme"]) +) ``` ::: lancedb.tokenize diff --git a/java/README.md b/java/README.md index d3560ba4d..c46c8174b 100644 --- a/java/README.md +++ b/java/README.md @@ -29,6 +29,48 @@ LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder() .build(); ``` +## MemWAL LSM write path + +Most table operations reach LanceDB through the `LanceNamespace` above, which is +generated from the Lance Namespace specification. The MemWAL LSM routes are not part +of that specification, so they are issued through a separate client: + +```java +import com.lancedb.LanceDbRestClient; +import com.lancedb.LanceDbTableLsm; +import com.lancedb.LsmWriteSpec; + +LanceDbRestClient client = LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("your_lancedb_cloud_api_key") + .database("your_database_name") + .buildRestClient(); + +LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table"); + +// Route future merge_insert upserts through the MemWAL, hash-bucketed by `id`. +lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16)); + +// ... merge_insert traffic ... + +// Converge the fresh tier into the base table. +lsm.checkpointLsm(); + +// Inspect live per-bucket state. +lsm.getLsmStats().ifPresent(stats -> stats.buckets().forEach(bucket -> + System.out.println(bucket.shardId() + ": " + bucket.generations().size() + " L0 generations"))); + +client.close(); +``` + +`maintainedIndexes` is tri-state, and the null default is the opposite of what a Java +reader usually expects: + +| Value | Meaning | +| --- | --- | +| unset (null) | Maintain **every** index the MemWAL can, resolved on install | +| `Collections.emptyList()` | Maintain **none** | +| `Arrays.asList("id_idx")` | Maintain exactly those | + ## Development Build: diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 3df2ac178..60c1549e3 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.38.0-beta.2 ../pom.xml @@ -33,6 +33,20 @@ arrow-memory-netty + + + org.apache.httpcomponents.client5 + httpclient5 + 5.2.1 + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.1 + + org.junit.jupiter junit-jupiter diff --git a/java/lancedb-core/src/main/java/com/lancedb/BucketStats.java b/java/lancedb-core/src/main/java/com/lancedb/BucketStats.java new file mode 100644 index 000000000..2a8060c5d --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/BucketStats.java @@ -0,0 +1,194 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * 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. + */ +public class BucketStats { + private static final String CONTEXT = "bucket stats"; + + private final String shardId; + private final String status; + private final long writerEpoch; + private final long manifestVersion; + private final long currentGeneration; + private final long replayAfterWalEntryPosition; + private final long walEntryPositionLastSeen; + private final List generations; + private final boolean compacting; + private final List memtables; + + BucketStats( + String shardId, + String status, + long writerEpoch, + long manifestVersion, + long currentGeneration, + long replayAfterWalEntryPosition, + long walEntryPositionLastSeen, + List generations, + boolean compacting, + List memtables) { + this.shardId = shardId; + this.status = status; + this.writerEpoch = writerEpoch; + this.manifestVersion = manifestVersion; + this.currentGeneration = currentGeneration; + this.replayAfterWalEntryPosition = replayAfterWalEntryPosition; + this.walEntryPositionLastSeen = walEntryPositionLastSeen; + this.generations = Collections.unmodifiableList(generations); + this.compacting = compacting; + this.memtables = memtables == null ? null : Collections.unmodifiableList(memtables); + } + + /** The shard this bucket writes. */ + public String shardId() { + return shardId; + } + + /** {@code "Active"} or {@code "Sealed"} (drop-table 2PC in flight). */ + public String status() { + return status; + } + + /** Epoch of the writer that currently owns the shard. */ + public long writerEpoch() { + return writerEpoch; + } + + /** Version of the shard manifest these numbers were read from. */ + public long manifestVersion() { + return manifestVersion; + } + + /** The generation the active memtable will become. */ + public long currentGeneration() { + return currentGeneration; + } + + /** WAL position replay resumes from. */ + public long replayAfterWalEntryPosition() { + return replayAfterWalEntryPosition; + } + + /** + * Highest WAL position the writer has seen. The difference against {@link + * #replayAfterWalEntryPosition()} is the WAL lag. + */ + public long walEntryPositionLastSeen() { + return walEntryPositionLastSeen; + } + + /** Flushed L0 generations not yet merged into the base table. */ + public List generations() { + return generations; + } + + /** + * 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". + */ + public boolean compacting() { + return compacting; + } + + /** Oldest first, active last. Empty for a {@code "Sealed"} bucket, whose state is torn down. */ + public Optional> memtables() { + return Optional.ofNullable(memtables); + } + + /** The newest flushed generation, or empty when L0 is empty. */ + OptionalLong newestGeneration() { + OptionalLong newest = OptionalLong.empty(); + for (GenerationStats generation : generations) { + if (!newest.isPresent() || generation.generation() > newest.getAsLong()) { + newest = OptionalLong.of(generation.generation()); + } + } + return newest; + } + + /** + * How many generations at or below {@code 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. + */ + long outstandingGenerations(long target) { + long count = 0; + for (GenerationStats generation : generations) { + if (generation.generation() <= target) { + count++; + } + } + return count; + } + + static BucketStats fromJson(JsonNode node) { + JsonFields.requiredObject(node, CONTEXT); + List generations = new ArrayList(); + for (JsonNode generation : JsonFields.requiredArray(node, "generations", CONTEXT)) { + generations.add(GenerationStats.fromJson(generation)); + } + + JsonNode memtablesNode = JsonFields.optionalArray(node, "memtables", CONTEXT); + List memtables = null; + if (memtablesNode != null) { + memtables = new ArrayList(); + for (JsonNode memtable : memtablesNode) { + memtables.add(MemtableStats.fromJson(memtable)); + } + } + + return new BucketStats( + JsonFields.requiredText(node, "shard_id", CONTEXT), + JsonFields.requiredText(node, "status", CONTEXT), + JsonFields.requiredLong(node, "writer_epoch", CONTEXT), + JsonFields.requiredLong(node, "manifest_version", CONTEXT), + JsonFields.requiredLong(node, "current_generation", CONTEXT), + JsonFields.requiredLong(node, "replay_after_wal_entry_position", CONTEXT), + JsonFields.requiredLong(node, "wal_entry_position_last_seen", CONTEXT), + generations, + JsonFields.requiredBoolean(node, "compacting", CONTEXT), + memtables); + } + + @Override + public String toString() { + return "BucketStats{shardId=" + + shardId + + ", status=" + + status + + ", currentGeneration=" + + currentGeneration + + ", generations=" + + generations + + ", compacting=" + + compacting + + "}"; + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/GenerationStats.java b/java/lancedb-core/src/main/java/com/lancedb/GenerationStats.java new file mode 100644 index 000000000..12222407c --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/GenerationStats.java @@ -0,0 +1,64 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.OptionalLong; + +/** One flushed L0 generation. */ +public class GenerationStats { + private static final String CONTEXT = "generation stats"; + + private final long generation; + private final long bytes; + private final Long rows; + + GenerationStats(long generation, long bytes, Long rows) { + this.generation = generation; + this.bytes = bytes; + this.rows = rows; + } + + /** The generation number. Increases as memtables are sealed into L0. */ + public long generation() { + return generation; + } + + /** On-disk size of the generation. */ + public long bytes() { + return bytes; + } + + /** + * Rows in this generation, present only when {@code includeGenerationRows} was requested. Off by + * default because each count opens an uncached Lance dataset. + */ + public OptionalLong rows() { + return rows == null ? OptionalLong.empty() : OptionalLong.of(rows); + } + + static GenerationStats fromJson(JsonNode node) { + JsonFields.requiredObject(node, CONTEXT); + return new GenerationStats( + JsonFields.requiredLong(node, "generation", CONTEXT), + JsonFields.requiredLong(node, "bytes", CONTEXT), + JsonFields.optionalLong(node, "rows", CONTEXT)); + } + + @Override + public String toString() { + return "GenerationStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}"; + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/JsonFields.java b/java/lancedb-core/src/main/java/com/lancedb/JsonFields.java new file mode 100644 index 000000000..b78e2411a --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/JsonFields.java @@ -0,0 +1,109 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Strict readers for decoding LanceDB JSON responses. + * + *

Every reader fails closed: a missing, null, or wrong-typed field throws rather than + * defaulting. That mirrors the serde decoding the Rust client applies to the same payloads in + * {@code rust/lancedb/src/table/lsm_stats.rs}, where a required field has no default and a + * malformed response is an error rather than a zero. + * + *

The alternative — Jackson's {@code path()}, which yields a missing node that reads as an empty + * array or a zero — is unsafe here because {@link LanceDbTableLsm#checkpointLsm()} decides + * convergence from these numbers. A defaulted {@code generations} array is indistinguishable from a + * drained one, so a malformed response would report a checkpoint that never happened. + */ +final class JsonFields { + private JsonFields() {} + + /** The node itself, once confirmed to be a JSON object. */ + static JsonNode requiredObject(JsonNode node, String context) { + if (node == null || !node.isObject()) { + throw new IllegalStateException(context + " is not a JSON object: " + node); + } + return node; + } + + static String requiredText(JsonNode owner, String field, String context) { + JsonNode value = required(owner, field, context); + if (!value.isTextual()) { + throw new IllegalStateException(fieldIs(context, field, "a string", value)); + } + return value.asText(); + } + + static long requiredLong(JsonNode owner, String field, String context) { + JsonNode value = required(owner, field, context); + if (!value.isIntegralNumber()) { + throw new IllegalStateException(fieldIs(context, field, "an integer", value)); + } + return value.asLong(); + } + + static boolean requiredBoolean(JsonNode owner, String field, String context) { + JsonNode value = required(owner, field, context); + if (!value.isBoolean()) { + throw new IllegalStateException(fieldIs(context, field, "a boolean", value)); + } + return value.asBoolean(); + } + + static JsonNode requiredArray(JsonNode owner, String field, String context) { + JsonNode value = required(owner, field, context); + if (!value.isArray()) { + throw new IllegalStateException(fieldIs(context, field, "an array", value)); + } + return value; + } + + /** Null when the field is absent or JSON null, mirroring a serde {@code Option}. */ + static Long optionalLong(JsonNode owner, String field, String context) { + JsonNode value = owner.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isIntegralNumber()) { + throw new IllegalStateException(fieldIs(context, field, "an integer", value)); + } + return value.asLong(); + } + + /** Null when the field is absent or JSON null, mirroring a serde {@code Option}. */ + static JsonNode optionalArray(JsonNode owner, String field, String context) { + JsonNode value = owner.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isArray()) { + throw new IllegalStateException(fieldIs(context, field, "an array", value)); + } + return value; + } + + private static JsonNode required(JsonNode owner, String field, String context) { + JsonNode value = owner.get(field); + if (value == null || value.isNull()) { + throw new IllegalStateException(context + " is missing required field '" + field + "'"); + } + return value; + } + + private static String fieldIs(String context, String field, String expected, JsonNode value) { + return context + " field '" + field + "' is not " + expected + ": " + value; + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java index 5e31aaaa1..da241dfd5 100644 --- a/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java @@ -136,29 +136,48 @@ public class LanceDbNamespaceClientBuilder { * @throws IllegalStateException if required parameters are missing */ public LanceNamespace build() { - // Validate required fields + validate(); + + // Build configuration map + Map config = new HashMap<>(additionalConfig); + config.put("header.x-lancedb-database", database); + config.put("header.x-api-key", apiKey); + config.put("uri", resolveUri()); + + return LanceNamespace.connect("rest", config, null); + } + + /** + * Build a {@link LanceDbRestClient} for the same endpoint. + * + *

Needed only for LanceDB routes that the Lance Namespace specification does not cover — the + * MemWAL LSM write path, reached through {@link LanceDbTableLsm}. Every other table operation + * belongs on the {@link LanceNamespace} from {@link #build()}. + * + *

The returned client owns an HTTP connection pool; close it when you are done with it. + * + * @return A configured LanceDbRestClient + * @throws IllegalStateException if required parameters are missing + */ + public LanceDbRestClient buildRestClient() { + validate(); + return new LanceDbRestClient(resolveUri(), apiKey, database); + } + + private void validate() { if (apiKey == null) { throw new IllegalStateException("API key is required"); } if (database == null) { throw new IllegalStateException("Database is required"); } + } - // Build configuration map - Map config = new HashMap<>(additionalConfig); - config.put("header.x-lancedb-database", database); - config.put("header.x-api-key", apiKey); - - // Determine base URL - String uri; + /** The custom endpoint when set, else the LanceDB Cloud URL for this database and region. */ + private String resolveUri() { if (endpoint.isPresent()) { - uri = endpoint.get(); - } else { - String effectiveRegion = region.orElse(DEFAULT_REGION); - uri = String.format(CLOUD_URL_PATTERN, database, effectiveRegion); + return endpoint.get(); } - config.put("uri", uri); - - return LanceNamespace.connect("rest", config, null); + return String.format(CLOUD_URL_PATTERN, database, region.orElse(DEFAULT_REGION)); } } diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbRestClient.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbRestClient.java new file mode 100644 index 000000000..baafbb9df --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbRestClient.java @@ -0,0 +1,119 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; + +/** + * Minimal HTTP client for LanceDB Cloud and Enterprise routes that the Lance Namespace + * specification does not cover. + * + *

Most table operations reach LanceDB through {@link org.lance.namespace.LanceNamespace}, which + * is generated from the namespace spec. A handful of routes — the MemWAL LSM write path in + * particular — are served by the same endpoint but are not part of that spec, so they are issued + * directly here. See {@link LanceDbTableLsm}. + * + *

Obtain one from {@link LanceDbNamespaceClientBuilder#buildRestClient()}. + */ +public class LanceDbRestClient implements Closeable { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String baseUri; + private final String apiKey; + private final String database; + private final CloseableHttpClient http; + + LanceDbRestClient(String baseUri, String apiKey, String database) { + this.baseUri = baseUri.endsWith("/") ? baseUri.substring(0, baseUri.length() - 1) : baseUri; + this.apiKey = apiKey; + this.database = database; + // Automatic retries off, deliberately. The default strategy retries 429 and 503 — + // exactly the two statuses LanceDbTableLsm.checkpointLsm() acts on — which would + // silently double its explicit retry budget and would also retry compact_lsm in + // place, where the loop is designed to fall through to a fresh stats poll instead. + // The checkpoint loop owns the 421/429/503 transitions; the transport must not. + this.http = HttpClients.custom().disableAutomaticRetries().build(); + } + + /** + * POST {@code path}, sending {@code body} as JSON when it is non-null. + * + * @param path Absolute request path, beginning with {@code /}. + * @param body Object to serialize as the request body, or null to send no body. + * @return The parsed response body, or null when the response carried no content. + * @throws HttpException if the server returned a non-2xx status. + */ + public JsonNode post(String path, Object body) { + HttpPost request = new HttpPost(baseUri + path); + request.setHeader("x-api-key", apiKey); + request.setHeader("x-lancedb-database", database); + try { + if (body != null) { + request.setEntity( + new StringEntity(MAPPER.writeValueAsString(body), ContentType.APPLICATION_JSON)); + } + return http.execute( + request, + response -> { + String text = + response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity()); + int status = response.getCode(); + if (status < 200 || status >= 300) { + throw new HttpException(status, "LanceDB request to " + path + " failed: " + text); + } + return text.isEmpty() ? null : MAPPER.readTree(text); + }); + } catch (IOException e) { + throw new UncheckedIOException("LanceDB request to " + path + " failed", e); + } + } + + @Override + public void close() throws IOException { + http.close(); + } + + /** + * A non-2xx response. + * + *

The status is exposed because callers act on it: {@link LanceDbTableLsm#checkpointLsm()} + * treats 429 and 503 as retryable and 421 as a lost node claim. + */ + public static class HttpException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public HttpException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + /** The HTTP status the failed response carried. */ + public int statusCode() { + return statusCode; + } + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbTableLsm.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbTableLsm.java new file mode 100644 index 000000000..23b18199e --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbTableLsm.java @@ -0,0 +1,394 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * The MemWAL LSM write path for one LanceDB Cloud or Enterprise table. + * + *

Installing an {@link LsmWriteSpec} routes {@code mergeInsert} upserts through Lance's MemWAL — + * an LSM-style append — instead of the standard merge path. Rows land in an in-memory memtable, + * seal into L0 generations, and are merged into the base table by compaction. + * + *

These routes are not part of the Lance Namespace specification, so they are issued directly + * rather than through {@link org.lance.namespace.LanceNamespace}. + * + *

{@code
+ * LanceDbRestClient client = LanceDbNamespaceClientBuilder.newBuilder()
+ *     .apiKey("your_lancedb_cloud_api_key")
+ *     .database("your_database_name")
+ *     .buildRestClient();
+ *
+ * LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
+ * lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
+ * // ... merge_insert traffic ...
+ * lsm.checkpointLsm();
+ * }
+ */ +public class LanceDbTableLsm { + + /** + * Interval between {@code get_lsm_stats} polls during a checkpoint. One interval is roughly one + * compaction pass, the granularity at which the answer can change. + */ + private static final long POLL_INTERVAL_MS = 5_000L; + + /** + * Cap on re-issues from {@code flushLsm} after a 421, so a crash-looping node cannot turn flush → + * compact → 421 → flush into a spin. + * + *

Deliberately not shared with {@link #MAX_RETRIES}: a claim that keeps evaporating is a + * broken node, while contention is routine and wants a real budget. + */ + private static final int MAX_REISSUES = 3; + + /** + * Retryable faults tolerated on a single request, reset on every success — scattered + * contention across a long checkpoint must not accumulate toward a cap. + */ + private static final int MAX_RETRIES = 8; + + private static final long RETRY_BACKOFF_BASE_MS = 100L; + private static final long RETRY_BACKOFF_MAX_MS = 5_000L; + + private final LanceDbRestClient client; + private final String tableIdentifier; + + /** + * Bind the LSM routes for one table. + * + * @param client Transport for the LanceDB endpoint. + * @param tableIdentifier The table's full identifier, {@code $}-delimited when it sits inside a + * namespace, such as {@code analytics$events}. + */ + public LanceDbTableLsm(LanceDbRestClient client, String tableIdentifier) { + if (client == null) { + throw new IllegalArgumentException("Client cannot be null"); + } + if (tableIdentifier == null || tableIdentifier.trim().isEmpty()) { + throw new IllegalArgumentException("Table identifier cannot be null or empty"); + } + this.client = client; + this.tableIdentifier = tableIdentifier; + } + + /** + * Install an {@link LsmWriteSpec} on this table, selecting the MemWAL LSM write path for future + * {@code mergeInsert} calls. + * + *

All variants require the table to have an unenforced primary key; bucket sharding + * additionally requires it to be the single column being bucketed. + */ + public void setLsmWriteSpec(LsmWriteSpec spec) { + if (spec == null) { + throw new IllegalArgumentException("Spec cannot be null"); + } + client.post(route("set_lsm_write_spec"), spec.toRequestBody()); + } + + /** + * Remove the {@link LsmWriteSpec} from this table, reverting to the standard {@code mergeInsert} + * write path. + * + *

Errors if no spec is currently set. + */ + public void unsetLsmWriteSpec() { + client.post(route("unset_lsm_write_spec"), null); + } + + /** + * Read the {@link LsmWriteSpec} currently installed on this table. + * + *

Empty when the LSM write path is not enabled. The returned spec mirrors what was installed, + * except that {@link LsmWriteSpec#maintainedIndexes()} always reports the concrete list resolved + * when the spec was set — a null selection never round-trips. + */ + public Optional getLsmWriteSpec() { + JsonNode response = client.post(route("get_lsm_write_spec"), null); + if (response == null || !response.hasNonNull("lsm_write_spec")) { + return Optional.empty(); + } + return Optional.of(LsmWriteSpec.fromJson(response.get("lsm_write_spec"))); + } + + /** + * Seal every bucket's active memtable into a new L0 generation. + * + *

Returns once the seal is committed. Sealing an empty memtable is a no-op, so this is safe to + * call repeatedly. + */ + public void flushLsm() { + client.post(route("flush_lsm"), null); + } + + /** + * Trigger a background L0 → base compaction pass per bucket. + * + *

Returns once the passes are dispatched, not once they finish — watch {@link + * #getLsmStats}, or use {@link #checkpointLsm} to wait for convergence. + */ + public void compactLsm() { + client.post(route("compact_lsm"), null); + } + + /** + * 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. + * + *

Empty only when the LSM write path is not enabled — that is, when the server sends an absent + * or null {@code lsm_stats}. A stats object that is present is decoded strictly, and a malformed + * one throws rather than decoding to something empty, because {@link #checkpointLsm} reads + * convergence out of these numbers and cannot tell a defaulted array from a drained one. + * + * @param includeGenerationRows Also count rows per L0 generation. Off by default because each + * count opens an uncached Lance dataset. + * @throws IllegalStateException if the response is absent or does not decode. + */ + public Optional getLsmStats(boolean includeGenerationRows) { + Map body = new LinkedHashMap(); + body.put("include_generation_rows", includeGenerationRows); + JsonNode response = client.post(route("get_lsm_stats"), body); + if (response == null) { + throw new IllegalStateException("get_lsm_stats returned an empty response body"); + } + JsonNode stats = response.get("lsm_stats"); + if (stats == null || stats.isNull()) { + return Optional.empty(); + } + return Optional.of(LsmStats.fromJson(stats)); + } + + /** Equivalent to {@code getLsmStats(false)}. */ + public Optional getLsmStats() { + return getLsmStats(false); + } + + /** + * Converge this table's LSM write path into its base table. + * + *

Seals once, fixes a target watermark from the resulting L0, then triggers compaction and + * polls until that L0 is gone. 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. + * + *

The loop runs here, not on the server: {@link #compactLsm} 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. + * + *

No liveness bound — the caller owns the deadline. The compactor pool is shared across + * tables, so a checkpoint queued behind unrelated work looks exactly like one that is merging. + */ + public void checkpointLsm() { + for (int reissue = 0; reissue <= MAX_REISSUES; reissue++) { + // 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. + if (issueVoid(this::flushLsm)) { + backoff(reissue); + continue; + } + + Attempt> stats = issue(() -> getLsmStats(false)); + if (stats.lostClaim) { + backoff(reissue); + continue; + } + if (!stats.value.isPresent()) { + // Not WAL-backed; flushLsm would have errored first but for a race. + return; + } + + Map targets = newestGenerations(stats.value.get()); + if (targets.isEmpty()) { + return; + } + + if (drainToTargets(targets)) { + return; + } + backoff(reissue); + } + throw new IllegalStateException( + "checkpointLsm: the owning node kept losing its claim; re-issued from flush the maximum " + + "number of times"); + } + + /** + * Trigger and poll until no bucket holds a generation at or below its target. + * + * @return true when the drain finished, false when the table needs re-claiming from flush. + */ + private boolean drainToTargets(Map targets) { + while (true) { + Attempt> stats = issue(() -> getLsmStats(false)); + if (stats.lostClaim) { + return false; + } + if (!stats.value.isPresent()) { + return true; + } + + // `compacting` is the bucket's compaction latch, held from dispatch until the pass + // ends — including while it waits on a pod-wide permit. So it answers one question + // only: do not pile on. Buckets with nothing outstanding are skipped, not counted + // as idle. + long outstanding = 0; + boolean allCompacting = true; + for (BucketStats bucket : stats.value.get().buckets()) { + Long target = targets.get(bucket.shardId()); + if (target == null) { + continue; + } + long remaining = bucket.outstandingGenerations(target); + if (remaining > 0) { + outstanding += remaining; + allCompacting &= bucket.compacting(); + } + } + if (outstanding == 0) { + return true; + } + + if (!allCompacting) { + try { + compactLsm(); + } catch (LanceDbRestClient.HttpException e) { + if (isLostClaim(e)) { + return false; + } + if (!isRetryable(e)) { + throw 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_MS is + // the backoff. + } + } + sleep(POLL_INTERVAL_MS); + } + } + + /** The newest generation held by each bucket, skipping buckets holding none. */ + private static Map newestGenerations(LsmStats stats) { + Map targets = new HashMap(); + for (BucketStats bucket : stats.buckets()) { + OptionalLong newest = bucket.newestGeneration(); + if (newest.isPresent()) { + targets.put(bucket.shardId(), newest.getAsLong()); + } + } + return targets; + } + + /** + * 429 (latch held, pool saturated, or the pod replaying its WAL) and 503 (a draining node, or a + * proxy between here and it). + */ + private static boolean isRetryable(LanceDbRestClient.HttpException e) { + return e.statusCode() == 429 || e.statusCode() == 503; + } + + /** + * 421: the owning node holds no claim. Only {@code flush} re-claims and replays, so this cannot + * be retried in place — the caller has to start over. + */ + private static boolean isLostClaim(LanceDbRestClient.HttpException e) { + return e.statusCode() == 421; + } + + /** + * 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 {@link #MAX_RETRIES}, while a 421 needs {@code 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". + */ + private static Attempt issue(Call call) { + int retries = 0; + while (true) { + try { + return new Attempt(call.run(), false); + } catch (LanceDbRestClient.HttpException e) { + if (isLostClaim(e)) { + return new Attempt(null, true); + } + if (!isRetryable(e) || retries >= MAX_RETRIES) { + throw e; + } + backoff(retries); + retries++; + } + } + } + + /** {@link #issue} for a call with no return value. Returns true when the claim was lost. */ + private static boolean issueVoid(Runnable call) { + return issue( + () -> { + call.run(); + return Boolean.TRUE; + }) + .lostClaim; + } + + /** Sleep before re-issuing a retryable request. Doubles up to {@link #RETRY_BACKOFF_MAX_MS}. */ + private static void backoff(int attempt) { + long delay = RETRY_BACKOFF_BASE_MS << Math.min(attempt, 8); + sleep(Math.min(delay, RETRY_BACKOFF_MAX_MS)); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting on the LSM checkpoint", e); + } + } + + private String route(String operation) { + return "/v1/table/" + tableIdentifier + "/" + operation + "/"; + } + + /** What one LSM request produced: its value, or word that the owning node holds no claim. */ + private static final class Attempt { + private final T value; + private final boolean lostClaim; + + private Attempt(T value, boolean lostClaim) { + this.value = value; + this.lostClaim = lostClaim; + } + } + + @FunctionalInterface + private interface Call { + T run(); + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LsmStats.java b/java/lancedb-core/src/main/java/com/lancedb/LsmStats.java new file mode 100644 index 000000000..3496ebc96 --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LsmStats.java @@ -0,0 +1,56 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Live per-bucket LSM state, as returned by {@link LanceDbTableLsm#getLsmStats()}. + * + *

Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are the caller's to + * compute. There is no "LSM is off" shape — that case is an empty {@link java.util.Optional}, + * because a stats object of zeros would read as measurements. + */ +public class LsmStats { + private static final String CONTEXT = "lsm stats"; + + private final List buckets; + + LsmStats(List buckets) { + this.buckets = Collections.unmodifiableList(buckets); + } + + /** One entry per bucket. */ + public List buckets() { + return buckets; + } + + static LsmStats fromJson(JsonNode node) { + JsonFields.requiredObject(node, CONTEXT); + List buckets = new ArrayList(); + for (JsonNode bucket : JsonFields.requiredArray(node, "buckets", CONTEXT)) { + buckets.add(BucketStats.fromJson(bucket)); + } + return new LsmStats(buckets); + } + + @Override + public String toString() { + return "LsmStats{buckets=" + buckets + "}"; + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LsmWriteSpec.java b/java/lancedb-core/src/main/java/com/lancedb/LsmWriteSpec.java new file mode 100644 index 000000000..da0966910 --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LsmWriteSpec.java @@ -0,0 +1,260 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Specification selecting Lance's MemWAL LSM-style write path for {@code mergeInsert}. + * + *

Construct via {@link #bucket}, {@link #identity}, or {@link #unsharded}, then optionally chain + * {@link #withMaintainedIndexes} and {@link #withWriterConfigDefaults}. Install it with {@link + * LanceDbTableLsm#setLsmWriteSpec} and remove it with {@link LanceDbTableLsm#unsetLsmWriteSpec}. + * + *

This is deliberately not {@code org.lance.memwal.InitializeMemWalParams}. That type is Lance's + * own, and its maintained-index default is the opposite of this one: it defaults to maintaining + * nothing, while a fresh spec here maintains every index. It also cannot express + * the null that asks the server to resolve the set. + */ +public class LsmWriteSpec { + + /** How writes are routed to MemWAL shards. */ + public enum Sharding { + /** Hash-bucket writes by a scalar column. */ + BUCKET("bucket"), + /** Shard by the raw value of a scalar column. */ + IDENTITY("identity"), + /** Route every write to a single shard. */ + UNSHARDED("unsharded"); + + private final String wireName; + + Sharding(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } + + static Sharding fromWireName(String name) { + for (Sharding s : values()) { + if (s.wireName.equals(name)) { + return s; + } + } + throw new IllegalArgumentException("Unknown sharding mode: " + name); + } + } + + private final Sharding sharding; + private final String column; + private final Integer numBuckets; + private final List maintainedIndexes; + private final Map writerConfigDefaults; + + private LsmWriteSpec( + Sharding sharding, + String column, + Integer numBuckets, + List maintainedIndexes, + Map writerConfigDefaults) { + this.sharding = sharding; + this.column = column; + this.numBuckets = numBuckets; + this.maintainedIndexes = maintainedIndexes; + this.writerConfigDefaults = writerConfigDefaults; + } + + /** + * Hash-bucket sharding by a scalar column, maintaining every index on the table. + * + *

Iceberg-compatible Murmur3-x86-32 (seed 0) is used, so each row's {@code bucket(column, + * numBuckets)} value is stable across processes. + * + * @param column A non-nested column with a supported scalar type. + * @param numBuckets The number of buckets, in {@code [1, 1024]}. + */ + public static LsmWriteSpec bucket(String column, int numBuckets) { + if (column == null || column.trim().isEmpty()) { + throw new IllegalArgumentException("Column cannot be null or empty"); + } + return new LsmWriteSpec( + Sharding.BUCKET, column, numBuckets, null, new HashMap()); + } + + /** + * Identity sharding — shard by the raw value of {@code column} — maintaining every index on the + * table. + * + *

{@code column} must be a deterministic function of the unenforced primary key: every row + * with a given primary key must always produce the same {@code column} value, or upserts of that + * key can land in different shards and a stale version can win. + */ + public static LsmWriteSpec identity(String column) { + if (column == null || column.trim().isEmpty()) { + throw new IllegalArgumentException("Column cannot be null or empty"); + } + return new LsmWriteSpec(Sharding.IDENTITY, column, null, null, new HashMap()); + } + + /** No sharding — every write goes to a single MemWAL shard — maintaining every index. */ + public static LsmWriteSpec unsharded() { + return new LsmWriteSpec(Sharding.UNSHARDED, null, null, null, new HashMap()); + } + + /** + * Set the indexes the MemWAL keeps up to date as rows are appended. + * + *

Pass {@code null} — the default for a fresh spec — to maintain every index the MemWAL can, + * resolved when the spec is installed. That is a snapshot: indexes created later are not + * maintained until the spec is unset and set again. Pass an empty list to maintain none. + * + *

Note that {@code null} and the empty list mean opposite things here. + */ + public LsmWriteSpec withMaintainedIndexes(List maintainedIndexes) { + return new LsmWriteSpec( + sharding, + column, + numBuckets, + maintainedIndexes == null ? null : new ArrayList(maintainedIndexes), + writerConfigDefaults); + } + + /** + * Set default {@code ShardWriter} configuration recorded in the MemWAL index. + * + *

A sparse override map — only the keys you set are recorded. Recognized keys include {@code + * durable_write}, {@code max_wal_buffer_size}, {@code max_memtable_size}, {@code + * max_memtable_rows}, {@code max_memtable_batches}, {@code manifest_scan_batch_size}, {@code + * max_unflushed_memtable_bytes}, and {@code enable_memtable}. Duration knobs carry an {@code _ms} + * suffix, such as {@code max_wal_flush_interval_ms}. + */ + public LsmWriteSpec withWriterConfigDefaults(Map writerConfigDefaults) { + if (writerConfigDefaults == null) { + throw new IllegalArgumentException("writerConfigDefaults cannot be null"); + } + return new LsmWriteSpec( + sharding, + column, + numBuckets, + maintainedIndexes, + new HashMap(writerConfigDefaults)); + } + + /** How writes are routed to shards. */ + public Sharding sharding() { + return sharding; + } + + /** The sharding column for {@link Sharding#BUCKET} and {@link Sharding#IDENTITY}, else null. */ + public String column() { + return column; + } + + /** The bucket count for {@link Sharding#BUCKET}, else null. */ + public Integer numBuckets() { + return numBuckets; + } + + /** + * The indexes the MemWAL maintains, or null to have the server resolve every maintainable index + * on install. An empty list means none. + */ + public List maintainedIndexes() { + return maintainedIndexes == null ? null : Collections.unmodifiableList(maintainedIndexes); + } + + /** Default {@code ShardWriter} configuration recorded in the MemWAL index. */ + public Map writerConfigDefaults() { + return Collections.unmodifiableMap(writerConfigDefaults); + } + + /** Render this spec as the {@code set_lsm_write_spec} request body. */ + Map toRequestBody() { + Map shardingBody = new LinkedHashMap(); + shardingBody.put("mode", sharding.wireName()); + if (column != null) { + shardingBody.put("column", column); + } + if (numBuckets != null) { + shardingBody.put("num_buckets", numBuckets); + } + + Map body = new LinkedHashMap(); + body.put("sharding", shardingBody); + // Null is meaningful: it asks the server to resolve every maintainable index. + body.put("maintained_indexes", maintainedIndexes); + body.put("writer_config_defaults", writerConfigDefaults); + return body; + } + + /** + * Rebuild a spec from a {@code get_lsm_write_spec} response body. + * + *

The server always reports a concrete maintained-index list, so a null selection never + * round-trips. + */ + static LsmWriteSpec fromJson(JsonNode node) { + JsonNode shardingNode = node.get("sharding"); + if (shardingNode == null || shardingNode.get("mode") == null) { + throw new IllegalStateException("get_lsm_write_spec response has no sharding mode"); + } + Sharding sharding = Sharding.fromWireName(shardingNode.get("mode").asText()); + + String column = shardingNode.hasNonNull("column") ? shardingNode.get("column").asText() : null; + Integer numBuckets = + shardingNode.hasNonNull("num_buckets") ? shardingNode.get("num_buckets").asInt() : null; + + List maintainedIndexes = new ArrayList(); + JsonNode indexesNode = node.get("maintained_indexes"); + if (indexesNode != null && indexesNode.isArray()) { + for (JsonNode index : indexesNode) { + maintainedIndexes.add(index.asText()); + } + } + + Map defaults = new HashMap(); + JsonNode defaultsNode = node.get("writer_config_defaults"); + if (defaultsNode != null && defaultsNode.isObject()) { + defaultsNode + .fieldNames() + .forEachRemaining(name -> defaults.put(name, defaultsNode.get(name).asText())); + } + + return new LsmWriteSpec(sharding, column, numBuckets, maintainedIndexes, defaults); + } + + @Override + public String toString() { + return "LsmWriteSpec{sharding=" + + sharding + + ", column=" + + column + + ", numBuckets=" + + numBuckets + + ", maintainedIndexes=" + + maintainedIndexes + + ", writerConfigDefaults=" + + writerConfigDefaults + + "}"; + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/MemtableStats.java b/java/lancedb-core/src/main/java/com/lancedb/MemtableStats.java new file mode 100644 index 000000000..777e915aa --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/MemtableStats.java @@ -0,0 +1,99 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** One in-memory memtable. */ +public class MemtableStats { + private static final String CONTEXT = "memtable stats"; + + private final long generation; + private final long rows; + private final long bytes; + private final long batches; + private final List indexes; + + MemtableStats(long generation, long rows, long bytes, long batches, List indexes) { + this.generation = generation; + this.rows = rows; + this.bytes = bytes; + this.batches = batches; + this.indexes = Collections.unmodifiableList(indexes); + } + + /** The generation this memtable will become once sealed. */ + public long generation() { + return generation; + } + + /** Rows currently buffered. */ + public long rows() { + return rows; + } + + /** Estimated in-memory size. */ + public long bytes() { + return bytes; + } + + /** Record batches currently buffered. */ + public long batches() { + return batches; + } + + /** + * 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". + */ + public List indexes() { + return indexes; + } + + static MemtableStats fromJson(JsonNode node) { + JsonFields.requiredObject(node, CONTEXT); + List indexes = new ArrayList(); + for (JsonNode index : JsonFields.requiredArray(node, "indexes", CONTEXT)) { + if (!index.isTextual()) { + throw new IllegalStateException(CONTEXT + " has a non-string index name: " + index); + } + indexes.add(index.asText()); + } + return new MemtableStats( + JsonFields.requiredLong(node, "generation", CONTEXT), + JsonFields.requiredLong(node, "rows", CONTEXT), + JsonFields.requiredLong(node, "bytes", CONTEXT), + JsonFields.requiredLong(node, "batches", CONTEXT), + indexes); + } + + @Override + public String toString() { + return "MemtableStats{generation=" + + generation + + ", rows=" + + rows + + ", bytes=" + + bytes + + ", batches=" + + batches + + ", indexes=" + + indexes + + "}"; + } +} diff --git a/java/lancedb-core/src/test/java/com/lancedb/LanceDbTableLsmTest.java b/java/lancedb-core/src/test/java/com/lancedb/LanceDbTableLsmTest.java new file mode 100644 index 000000000..e84fa5421 --- /dev/null +++ b/java/lancedb-core/src/test/java/com/lancedb/LanceDbTableLsmTest.java @@ -0,0 +1,570 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the MemWAL LSM routes, run against a scripted local HTTP server. + * + *

The wire assertions mirror the Rust mocked-endpoint tests in {@code + * rust/lancedb/src/remote/table.rs}, which are the contract these routes have to match. + */ +public class LanceDbTableLsmTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private HttpServer server; + private LanceDbRestClient client; + private LanceDbTableLsm lsm; + + private final List requestPaths = Collections.synchronizedList(new ArrayList()); + private final List requestBodies = Collections.synchronizedList(new ArrayList()); + private final Map> replies = new ConcurrentHashMap>(); + + @BeforeEach + public void setUp() throws IOException { + start(); + } + + /** Tear down and restart the scripted server, for a test that scripts several exchanges. */ + private void setUpFresh() { + try { + client.close(); + server.stop(0); + requestPaths.clear(); + requestBodies.clear(); + replies.clear(); + start(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + String path = exchange.getRequestURI().getPath(); + requestPaths.add(path); + requestBodies.add(readAll(exchange.getRequestBody())); + + Reply reply = nextReply(path); + byte[] out = reply.body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(reply.status, out.length == 0 ? -1 : out.length); + if (out.length > 0) { + exchange.getResponseBody().write(out); + } + exchange.close(); + }); + server.start(); + + client = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-key") + .database("test-db") + .endpoint("http://127.0.0.1:" + server.getAddress().getPort()) + .buildRestClient(); + lsm = new LanceDbTableLsm(client, "my_table"); + } + + @AfterEach + public void tearDown() throws IOException { + client.close(); + server.stop(0); + } + + // =========================================================================== + // set / unset / get spec + // =========================================================================== + + @Test + public void testSetLsmWriteSpecUnsharded() throws Exception { + enqueue("set_lsm_write_spec", 200, ""); + + lsm.setLsmWriteSpec(LsmWriteSpec.unsharded()); + + assertEquals("/v1/table/my_table/set_lsm_write_spec/", requestPaths.get(0)); + JsonNode body = MAPPER.readTree(requestBodies.get(0)); + assertEquals("unsharded", body.get("sharding").get("mode").asText()); + assertFalse(body.get("sharding").has("column")); + assertFalse(body.get("sharding").has("num_buckets")); + } + + @Test + public void testSetLsmWriteSpecBucket() throws Exception { + enqueue("set_lsm_write_spec", 200, ""); + + lsm.setLsmWriteSpec( + LsmWriteSpec.bucket("id", 16).withMaintainedIndexes(Arrays.asList("id_idx"))); + + JsonNode body = MAPPER.readTree(requestBodies.get(0)); + assertEquals("bucket", body.get("sharding").get("mode").asText()); + assertEquals("id", body.get("sharding").get("column").asText()); + assertEquals(16, body.get("sharding").get("num_buckets").asInt()); + assertEquals(1, body.get("maintained_indexes").size()); + assertEquals("id_idx", body.get("maintained_indexes").get(0).asText()); + } + + @Test + public void testSetLsmWriteSpecIdentity() throws Exception { + enqueue("set_lsm_write_spec", 200, ""); + + lsm.setLsmWriteSpec(LsmWriteSpec.identity("tenant")); + + JsonNode body = MAPPER.readTree(requestBodies.get(0)); + assertEquals("identity", body.get("sharding").get("mode").asText()); + assertEquals("tenant", body.get("sharding").get("column").asText()); + assertFalse(body.get("sharding").has("num_buckets")); + } + + /** + * The tri-state that motivated a LanceDB-owned spec type: a null selection asks the server to + * resolve every maintainable index, while an empty list asks for none. They must not collapse. + */ + @Test + public void testMaintainedIndexesNullAndEmptyAreDistinctOnTheWire() throws Exception { + enqueue("set_lsm_write_spec", 200, ""); + + lsm.setLsmWriteSpec(LsmWriteSpec.unsharded()); + JsonNode fresh = MAPPER.readTree(requestBodies.get(0)); + assertTrue(fresh.has("maintained_indexes"), "the key must be present"); + assertTrue(fresh.get("maintained_indexes").isNull(), "a fresh spec sends null, not []"); + + lsm.setLsmWriteSpec( + LsmWriteSpec.unsharded().withMaintainedIndexes(Collections.emptyList())); + JsonNode none = MAPPER.readTree(requestBodies.get(1)); + assertTrue(none.get("maintained_indexes").isArray()); + assertEquals(0, none.get("maintained_indexes").size()); + } + + @Test + public void testSetLsmWriteSpecWriterConfigDefaults() throws Exception { + enqueue("set_lsm_write_spec", 200, ""); + + Map defaults = new HashMap(); + defaults.put("max_memtable_rows", "50000"); + lsm.setLsmWriteSpec(LsmWriteSpec.unsharded().withWriterConfigDefaults(defaults)); + + JsonNode body = MAPPER.readTree(requestBodies.get(0)); + assertEquals("50000", body.get("writer_config_defaults").get("max_memtable_rows").asText()); + } + + @Test + public void testUnsetLsmWriteSpec() { + enqueue("unset_lsm_write_spec", 200, ""); + + lsm.unsetLsmWriteSpec(); + + assertEquals("/v1/table/my_table/unset_lsm_write_spec/", requestPaths.get(0)); + assertEquals("", requestBodies.get(0)); + } + + @Test + public void testGetLsmWriteSpec() { + enqueue( + "get_lsm_write_spec", + 200, + "{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"bucket\",\"column\":\"id\"," + + "\"num_buckets\":16},\"maintained_indexes\":[\"id_idx\"]," + + "\"writer_config_defaults\":{\"durable_write\":\"true\"}}}"); + + Optional spec = lsm.getLsmWriteSpec(); + + assertTrue(spec.isPresent()); + assertEquals(LsmWriteSpec.Sharding.BUCKET, spec.get().sharding()); + assertEquals("id", spec.get().column()); + assertEquals(Integer.valueOf(16), spec.get().numBuckets()); + assertEquals(Arrays.asList("id_idx"), spec.get().maintainedIndexes()); + assertEquals("true", spec.get().writerConfigDefaults().get("durable_write")); + } + + @Test + public void testGetLsmWriteSpecAbsent() { + enqueue("get_lsm_write_spec", 200, "{\"lsm_write_spec\":null}"); + + assertFalse(lsm.getLsmWriteSpec().isPresent()); + } + + // =========================================================================== + // stats + // =========================================================================== + + @Test + public void testGetLsmStats() throws Exception { + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L))); + + Optional got = lsm.getLsmStats(true); + + assertEquals("/v1/table/my_table/get_lsm_stats/", requestPaths.get(0)); + assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean()); + assertTrue(got.isPresent()); + BucketStats decoded = got.get().buckets().get(0); + assertEquals("shard-0", decoded.shardId()); + assertEquals("Active", decoded.status()); + assertEquals(1, decoded.writerEpoch()); + assertEquals(2, decoded.manifestVersion()); + assertEquals(9, decoded.currentGeneration()); + assertFalse(decoded.compacting()); + assertEquals(Arrays.asList(7L, 8L), generationNumbers(decoded)); + assertEquals(1024, decoded.generations().get(0).bytes()); + assertFalse(decoded.generations().get(0).rows().isPresent(), "rows absent unless requested"); + assertFalse(decoded.memtables().isPresent(), "absent memtables stay absent"); + } + + /** The optional fields decode when the server does send them. */ + @Test + public void testGetLsmStatsDecodesOptionalFields() { + enqueue( + "get_lsm_stats", + 200, + "{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\"," + + "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9," + + "\"replay_after_wal_entry_position\":3,\"wal_entry_position_last_seen\":11," + + "\"generations\":[{\"generation\":7,\"bytes\":1024,\"rows\":42}]," + + "\"compacting\":true,\"memtables\":[{\"generation\":8,\"rows\":5," + + "\"bytes\":64,\"batches\":2,\"indexes\":[\"id_idx\"]}]}]}}"); + + BucketStats decoded = lsm.getLsmStats(true).get().buckets().get(0); + + assertEquals(3, decoded.replayAfterWalEntryPosition()); + assertEquals(11, decoded.walEntryPositionLastSeen()); + assertTrue(decoded.compacting()); + assertEquals(42, decoded.generations().get(0).rows().getAsLong()); + assertTrue(decoded.memtables().isPresent()); + MemtableStats memtable = decoded.memtables().get().get(0); + assertEquals(8, memtable.generation()); + assertEquals(5, memtable.rows()); + assertEquals(64, memtable.bytes()); + assertEquals(2, memtable.batches()); + assertEquals(Arrays.asList("id_idx"), memtable.indexes()); + } + + @Test + public void testGetLsmStatsAbsentWhenLsmDisabled() { + enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}"); + + assertFalse(lsm.getLsmStats().isPresent()); + } + + @Test + public void testGetLsmStatsDefaultsToExcludingGenerationRows() throws Exception { + enqueue("get_lsm_stats", 200, stats()); + + lsm.getLsmStats(); + + assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean()); + } + + // =========================================================================== + // flush / compact + // =========================================================================== + + @Test + public void testFlushAndCompactRoutes() { + enqueue("flush_lsm", 200, ""); + enqueue("compact_lsm", 200, ""); + + lsm.flushLsm(); + lsm.compactLsm(); + + assertEquals("/v1/table/my_table/flush_lsm/", requestPaths.get(0)); + assertEquals("/v1/table/my_table/compact_lsm/", requestPaths.get(1)); + } + + @Test + public void testHttpErrorCarriesStatus() { + enqueue("flush_lsm", 404, "no such table"); + + LanceDbRestClient.HttpException e = + assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.flushLsm()); + assertEquals(404, e.statusCode()); + } + + // =========================================================================== + // checkpoint + // =========================================================================== + + @Test + public void testCheckpointReturnsWhenLsmDisabled() { + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}"); + + lsm.checkpointLsm(); + + assertEquals(0, countCalls("compact_lsm"), "nothing to compact when the LSM path is off"); + } + + @Test + public void testCheckpointReturnsWhenNoGenerationsOutstanding() { + enqueue("flush_lsm", 200, ""); + // A bucket with no L0 generations yields no target, so the drain never starts. + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false))); + + lsm.checkpointLsm(); + + assertEquals(0, countCalls("compact_lsm")); + } + + @Test + public void testCheckpointConvergesOnceTargetGenerationsAreGone() { + enqueue("flush_lsm", 200, ""); + // Watermark read: shard-0 holds generations 7 and 8, so target = 8. + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L))); + // First drain poll: both still outstanding, nothing compacting -> dispatch a pass. + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L))); + // Second drain poll: drained past the target -> done. + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 9L))); + enqueue("compact_lsm", 200, ""); + + lsm.checkpointLsm(); + + assertEquals(1, countCalls("compact_lsm"), "one pass dispatched"); + assertEquals(3, countCalls("get_lsm_stats"), "watermark read plus two drain polls"); + } + + @Test + public void testCheckpointDoesNotPileOnWhileEveryTargetBucketIsCompacting() { + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L))); + // Still compacting on the first poll, so no pass is dispatched; then it drains. + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L))); + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 5L))); + + lsm.checkpointLsm(); + + assertEquals(0, countCalls("compact_lsm"), "a latched bucket is left alone"); + } + + @Test + public void testCheckpointRetriesFromFlushAfterLostClaim() { + // 421 on the watermark read: the node lost its claim, so the whole thing restarts + // from flush rather than retrying the read in place. + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 421, "no claim"); + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false))); + + lsm.checkpointLsm(); + + assertEquals(2, countCalls("flush_lsm"), "re-issued from flush"); + } + + @Test + public void testCheckpointRetriesRetryableStatusInPlace() { + enqueue("flush_lsm", 429, "latch held"); + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false))); + + lsm.checkpointLsm(); + + assertEquals(2, countCalls("flush_lsm"), "429 retried in place, not re-issued"); + } + + @Test + public void testCheckpointPropagatesTerminalStatus() { + enqueue("flush_lsm", 400, "bad request"); + + LanceDbRestClient.HttpException e = + assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm()); + assertEquals(400, e.statusCode()); + assertEquals(1, countCalls("flush_lsm"), "a terminal status is not retried"); + } + + @Test + public void testCheckpointGivesUpAfterRepeatedLostClaims() { + enqueue("flush_lsm", 421, "no claim"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> lsm.checkpointLsm()); + assertTrue(e.getMessage().contains("kept losing its claim"), e.getMessage()); + assertEquals(4, countCalls("flush_lsm"), "the initial attempt plus MAX_REISSUES"); + } + + // =========================================================================== + // strict decoding + // =========================================================================== + + /** + * A stats payload that does not decode must fail closed. Every one of these bodies used to be + * read as "no buckets", which is indistinguishable from a drained table, so {@code checkpointLsm} + * reported convergence for a checkpoint that never ran. + */ + @Test + public void testCheckpointRejectsMalformedStats() { + Map malformed = new LinkedHashMap(); + malformed.put("no response body at all", ""); + malformed.put("stats object with no buckets", "{\"lsm_stats\":{}}"); + malformed.put("bucket missing its required fields", "{\"lsm_stats\":{\"buckets\":[{}]}}"); + malformed.put( + "bucket missing generations", + "{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\"," + + "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9," + + "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0," + + "\"compacting\":false}]}}"); + malformed.put( + "generation with a non-numeric generation number", + "{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\"," + + "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9," + + "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0," + + "\"generations\":[{\"generation\":\"7\",\"bytes\":1024}]," + + "\"compacting\":false}]}}"); + + for (Map.Entry each : malformed.entrySet()) { + setUpFresh(); + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 200, each.getValue()); + + assertThrows( + IllegalStateException.class, + () -> lsm.checkpointLsm(), + each.getKey() + " must not report convergence"); + } + } + + /** The one shape that legitimately means "this table has no LSM write path". */ + @Test + public void testCheckpointTreatsNullStatsAsNotWalBacked() { + enqueue("flush_lsm", 200, ""); + enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}"); + + lsm.checkpointLsm(); + + assertEquals(1, countCalls("get_lsm_stats")); + } + + // =========================================================================== + // retry budget + // =========================================================================== + + /** + * The transport must not retry on the checkpoint loop's behalf. Apache HttpClient's default + * strategy retries exactly 429 and 503 — the two statuses {@code isRetryable} owns — which + * doubled every budget here and also retried {@code compact_lsm} in place, where the loop is + * built to fall through to a fresh stats poll instead. + */ + @Test + public void testCheckpointRetryBudgetIsNotDoubledByTheTransport() { + enqueue("flush_lsm", 429, "latch held"); + + LanceDbRestClient.HttpException e = + assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm()); + + assertEquals(429, e.statusCode(), "the exhausted budget propagates the last error as itself"); + assertEquals(9, countCalls("flush_lsm"), "the initial request plus MAX_RETRIES, and no more"); + } + + // =========================================================================== + // harness + // =========================================================================== + + private static List generationNumbers(BucketStats bucket) { + List numbers = new ArrayList(); + for (GenerationStats generation : bucket.generations()) { + numbers.add(generation.generation()); + } + return numbers; + } + + /** Build an {@code lsm_stats} response body from bucket fragments. */ + private static String stats(String... buckets) { + return "{\"lsm_stats\":{\"buckets\":[" + String.join(",", buckets) + "]}}"; + } + + private static String bucket(String shardId, boolean compacting, Long... generations) { + StringBuilder gens = new StringBuilder(); + for (Long generation : generations) { + if (gens.length() > 0) { + gens.append(","); + } + gens.append("{\"generation\":").append(generation).append(",\"bytes\":1024}"); + } + return "{\"shard_id\":\"" + + shardId + + "\",\"status\":\"Active\",\"writer_epoch\":1,\"manifest_version\":2," + + "\"current_generation\":9,\"replay_after_wal_entry_position\":0," + + "\"wal_entry_position_last_seen\":0,\"generations\":[" + + gens + + "],\"compacting\":" + + compacting + + "}"; + } + + /** Queue a reply for an operation. The last queued reply repeats once the queue drains. */ + private void enqueue(String operation, int status, String body) { + replies.computeIfAbsent(operation, key -> new ArrayDeque()).add(new Reply(status, body)); + } + + private Reply nextReply(String path) { + String operation = operationOf(path); + Deque queued = replies.get(operation); + if (queued == null || queued.isEmpty()) { + return new Reply(200, ""); + } + return queued.size() > 1 ? queued.poll() : queued.peek(); + } + + private long countCalls(String operation) { + return requestPaths.stream().filter(path -> operationOf(path).equals(operation)).count(); + } + + /** {@code /v1/table/my_table/flush_lsm/} -> {@code flush_lsm}. */ + private static String operationOf(String path) { + String[] segments = path.split("/"); + return segments.length == 0 ? "" : segments[segments.length - 1]; + } + + private static String readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + private static final class Reply { + private final int status; + private final String body; + + private Reply(int status, String body) { + this.status = status; + this.body = body; + } + } +} diff --git a/java/pom.xml b/java/pom.xml index 1b96cbcab..92e6344f3 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.37.1-beta.0 + 0.38.0-beta.2 pom ${project.artifactId} LanceDB Java SDK Parent POM @@ -28,7 +28,7 @@ UTF-8 15.0.0 - 10.1.0-beta.1 + 11.0.0-beta.15 false 2.30.0 1.7 diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 10e12edc8..3c0b24db3 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.38.0-beta.2" publish = false license.workspace = true description.workspace = true @@ -16,12 +16,12 @@ crate-type = ["cdylib"] async-trait.workspace = true arrow-ipc.workspace = true arrow-array.workspace = true -arrow-buffer = "58.0.0" +arrow-buffer.workspace = true half.workspace = true arrow-schema.workspace = true env_logger.workspace = true futures.workspace = true -lancedb = { path = "../rust/lancedb", default-features = false } +lancedb.workspace = true lance-namespace.workspace = true napi = { version = "3.8.3", default-features = false, features = [ "napi9", @@ -29,8 +29,8 @@ napi = { version = "3.8.3", default-features = false, features = [ "chrono_date", "serde-json", ] } -chrono = { version = "0.4", default-features = false, features = ["clock"] } -serde_json = "1" +chrono.workspace = true +serde_json.workspace = true napi-derive = "3.5.2" # Prevent dynamic linking of lzma, which comes from datafusion lzma-sys = { version = "0.1", features = ["static"] } diff --git a/nodejs/__test__/arrow.test.ts b/nodejs/__test__/arrow.test.ts index 9e20e3c04..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"]; @@ -197,6 +204,35 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( expect(table.getChild("d")?.toJSON()).toEqual([9n, 10n, null]); }); + it("will use a provided FixedSizeList schema with typed array values", function () { + const schema = new Schema([ + new Field("text", new Utf8(), false), + new Field( + "vector", + new FixedSizeList(3, new Field("item", new Float32(), false)), + false, + ), + ]); + + const table = makeArrowTable( + [ + { + text: "foo", + vector: new Float32Array([1, 2, 3]), + }, + ], + { schema }, + ); + + expect(table.getChild("text")?.toJSON()).toEqual(["foo"]); + expect( + table + .getChild("vector") + ?.toJSON() + .map((value) => value.toJSON()), + ).toEqual([[1, 2, 3]]); + }); + it("will assume the column `vector` is FixedSizeList by default", async function () { const schema = new Schema([ new Field("a", new Float(Precision.DOUBLE), true), @@ -1025,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__/connection.test.ts b/nodejs/__test__/connection.test.ts index 68180471a..af471b478 100644 --- a/nodejs/__test__/connection.test.ts +++ b/nodejs/__test__/connection.test.ts @@ -89,6 +89,16 @@ describe("given a connection", () => { await db.createTable("test4", [{ id: 1 }, { id: 2 }]); }); + it("should return a completed job when dropping a local table", async () => { + await db.createTable("async-drop", [{ id: 1 }]); + + const job = await db.dropTableAsync("async-drop"); + expect(job.id).toBeNull(); + await expect(job.status()).resolves.toBe("finished"); + await job.wait(); + await expect(db.tableNames()).resolves.toEqual([]); + }); + it("should fail if creating table twice, unless overwrite is true", async () => { let tbl = await db.createTable("test", [{ id: 1 }, { id: 2 }]); await expect(tbl.countRows()).resolves.toBe(2); diff --git a/nodejs/__test__/embedding.test.ts b/nodejs/__test__/embedding.test.ts index e56e80631..06184751e 100644 --- a/nodejs/__test__/embedding.test.ts +++ b/nodejs/__test__/embedding.test.ts @@ -11,8 +11,11 @@ import { Float16, Float32, Float64, + Int32, Schema, Utf8, + fromDataToBuffer, + tableFromIPC, } from "../lancedb/arrow"; import { EmbeddingFunction, LanceSchema } from "../lancedb/embedding"; import { getRegistry, register } from "../lancedb/embedding/registry"; @@ -184,6 +187,63 @@ describe("embedding functions", () => { const vector0 = JSON.parse(JSON.stringify(arr[0].vector)); expect(vector0).toEqual([1, 2, 3]); }); + + it("should append generated vectors to a non-nullable schema", async () => { + @register("non_nullable_schema_test") + class MockEmbeddingFunction extends EmbeddingFunction { + ndims() { + return 3; + } + embeddingDataType(): Float { + return new Float64(); + } + async computeSourceEmbeddings(data: string[]) { + return data.map(() => [1, 2, 3]); + } + } + + const schema = new Schema([ + new Field("id", new Int32()), + new Field("text", new Utf8()), + new Field("type", new Utf8()), + new Field( + "vector", + new FixedSizeList(3, new Field("item", new Float64())), + ), + ]); + const func = new MockEmbeddingFunction(); + const db = await connect(tmpDir.name); + const table = await db.createEmptyTable("test_non_nullable", schema, { + embeddingFunction: { + function: func, + sourceColumn: "text", + }, + }); + + const data = [ + { id: 1, text: "Carrot", type: "vegetable" }, + { id: 2, text: "Apple", type: "fruit" }, + ]; + const buffer = await fromDataToBuffer( + data, + undefined, + await table.schema(), + ); + const generatedTable = tableFromIPC(buffer); + const vectorField = generatedTable.schema.fields.find( + (field) => field.name === "vector", + ); + expect(vectorField?.nullable).toBe(false); + + await table.add(data); + + const rows = await table.query().toArray(); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect([...row.vector]).toEqual([1, 2, 3]); + } + }); + it("should error when appending to a table with an unregistered embedding function", async () => { @register("mock") class MockEmbeddingFunction extends EmbeddingFunction { diff --git a/nodejs/__test__/package.test.ts b/nodejs/__test__/package.test.ts new file mode 100644 index 000000000..7743d73d6 --- /dev/null +++ b/nodejs/__test__/package.test.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import packageJson = require("../package.json"); + +describe("package metadata", () => { + it("requires Node.js type declarations compatible with the runtime", () => { + expect(packageJson.engines.node).toBe(">= 18"); + expect(packageJson.peerDependencies["@types/node"]).toBe(">=18"); + expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({ + optional: true, + }); + }); +}); diff --git a/nodejs/__test__/query.test.ts b/nodejs/__test__/query.test.ts index da001b1eb..5f3e68b16 100644 --- a/nodejs/__test__/query.test.ts +++ b/nodejs/__test__/query.test.ts @@ -110,6 +110,81 @@ describe("Query outputSchema", () => { }); }); +describe("Search pagination", () => { + let tmpDir: tmp.DirResult; + let table: Table; + + beforeEach(async () => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), false), + new Field("text", new Utf8(), false), + new Field( + "vector", + new FixedSizeList(2, new Field("item", new Float32())), + false, + ), + ]); + const data = makeArrowTable( + [ + { id: 1n, text: "common", vector: [0, 0] }, + { id: 2n, text: "common common", vector: [1, 1] }, + { id: 3n, text: "common common common", vector: [2, 2] }, + { id: 4n, text: "common common common common", vector: [3, 3] }, + ], + { schema }, + ); + table = await db.createTable("test", data); + }); + + afterEach(() => { + tmpDir.removeCallback(); + }); + + it("applies offset after the vector search limit", async () => { + const allResults = await table + .vectorSearch([0, 0]) + .select(["id"]) + .limit(4) + .toArray(); + const secondPage = await table + .vectorSearch([0, 0]) + .select(["id"]) + .limit(2) + .offset(2) + .toArray(); + + expect(allResults).toHaveLength(4); + expect(secondPage).toHaveLength(2); + expect(secondPage.map((row) => row.id)).toEqual( + allResults.slice(2, 4).map((row) => row.id), + ); + }); + + it("applies offset after the full-text search limit", async () => { + await table.createIndex("text", { config: Index.fts() }); + + const allResults = await table + .search("common", "fts") + .select(["id"]) + .limit(4) + .toArray(); + const secondPage = await table + .search("common", "fts") + .select(["id"]) + .limit(2) + .offset(2) + .toArray(); + + expect(allResults).toHaveLength(4); + expect(secondPage).toHaveLength(2); + expect(secondPage.map((row) => row.id)).toEqual( + allResults.slice(2, 4).map((row) => row.id), + ); + }); +}); + describe("Query orderBy", () => { let tmpDir: tmp.DirResult; let table: Table; diff --git a/nodejs/__test__/remote.test.ts b/nodejs/__test__/remote.test.ts index 89a9e992c..e766b3d2a 100644 --- a/nodejs/__test__/remote.test.ts +++ b/nodejs/__test__/remote.test.ts @@ -170,6 +170,38 @@ describe("remote connection", () => { ); }); + it("surfaces JSON server errors from remote table operations", async () => { + await withMockDatabase( + (req, res) => { + const path = req.url ?? ""; + if (path.endsWith("/describe/")) { + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ + name: "broken_table", + version: 1, + schema: { fields: [] }, + }), + ); + return; + } + + if (path.endsWith("/count_rows/")) { + res + .writeHead(400, { "Content-Type": "application/json" }) + .end(JSON.stringify({ error: "count rows failed" })); + return; + } + + res.writeHead(404).end(); + }, + async (db) => { + const table = await db.openTable("broken_table"); + + await expect(table.countRows()).rejects.toThrow("count rows failed"); + }, + ); + }); + it("should pass on requested extra headers", async () => { await withMockDatabase( (req, res) => { diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 4cad365af..80c50f1ac 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -86,6 +86,44 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( await expect(table.countRows()).resolves.toBe(3); }); + it("should support a foreign Float64 vector schema end to end", async () => { + const conn = await connect(tmpDir.name); + const schema = new arrow.Schema([ + new arrow.Field("resource_id", new arrow.Int32(), false), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 3, + new arrow.Field("value", new arrow.Float64(), true), + ), + false, + ), + ]); + const data = [ + { + // biome-ignore lint/style/useNamingConvention: matches the reported schema + resource_id: 0, + vector: [0.1, 0.1, 0.1], + }, + ]; + + const resources = await conn.createTable("resources", data, { schema }); + + const existing = await resources + .query() + .where("resource_id = 0") + .limit(1) + .toArray(); + expect(existing).toHaveLength(1); + + const matched = await resources + .search(Float64Array.from(data[0].vector)) + .limit(1) + .toArray(); + expect(matched).toHaveLength(1); + expect(matched[0]["resource_id"]).toBe(0); + }); + it("should support branches", async () => { await table.add([{ id: 1 }]); expect(await table.countRows()).toBe(1); @@ -239,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 () => { @@ -3294,3 +3340,120 @@ describe("LSM merge insert", () => { await expect(table.query().useLsm(true).toArray()).rejects.toThrow(); }); }); + +describe("LSM convergence and stats", () => { + let tmpDir: tmp.DirResult; + + beforeEach(() => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + }); + afterEach(() => tmpDir.removeCallback()); + + async function lsmTable(conn: Connection): Promise { + const table = await conn.createEmptyTable( + "t", + new arrow.Schema([new arrow.Field("id", new arrow.Utf8(), false)]), + ); + await table.setUnenforcedPrimaryKey("id"); + await table.setLsmWriteSpec({ specType: "unsharded" }); + return table; + } + + // These four route through the server that owns the MemWAL, so a local table + // rejects them rather than answering. What is asserted here is that the + // bindings reach the core at all; the behavior against a real endpoint is + // covered by the mocked endpoint tests in rust/lancedb/src/remote/table.rs. + it("rejects flushLsm on a local table", async () => { + const conn = await connect(tmpDir.name); + const table = await lsmTable(conn); + + await expect(table.flushLsm()).rejects.toThrow(/not supported/i); + }); + + it("rejects compactLsm on a local table", async () => { + const conn = await connect(tmpDir.name); + const table = await lsmTable(conn); + + await expect(table.compactLsm()).rejects.toThrow(/not supported/i); + }); + + it("rejects getLsmStats on a local table", async () => { + const conn = await connect(tmpDir.name); + const table = await lsmTable(conn); + + await expect(table.getLsmStats()).rejects.toThrow(/not supported/i); + await expect(table.getLsmStats(true)).rejects.toThrow(/not supported/i); + }); + + it("rejects checkpointLsm on a local table", async () => { + const conn = await connect(tmpDir.name); + const table = await lsmTable(conn); + + // checkpointLsm seals first, so it surfaces flushLsm's rejection. + await expect(table.checkpointLsm()).rejects.toThrow(/not supported/i); + }); +}); + +describe("computed columns", () => { + let tmpDir: tmp.DirResult; + beforeEach(() => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + }); + afterEach(() => tmpDir.removeCallback()); + + it("declares a column and fills it on refresh", async () => { + const db = await connect(tmpDir.name); + const table = await db.createTable("computed", [{ x: 1 }, { x: 2 }]); + + await table.addColumns({ + computed: [{ name: "doubled", valueSql: "x * 2" }], + }); + let rows = await table.query().toArray(); + expect(rows.map((r) => r.doubled)).toEqual([null, null]); + + const result = await table.refreshColumn("doubled"); + expect(result.rowsFilled).toBe(2); + + rows = await table.query().toArray(); + expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]); + }); + + it("returns a job handle from refreshColumnAsync", async () => { + const db = await connect(tmpDir.name); + const table = await db.createTable("computed_job", [{ x: 1 }, { x: 2 }]); + + await table.addColumns({ + computed: [{ name: "doubled", valueSql: "x * 2" }], + }); + + const job = await table.refreshColumnAsync("doubled"); + expect(job.id).toBeNull(); + await job.wait(); + expect(await job.status()).toBe("finished"); + + const rows = await table.query().toArray(); + expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]); + + // Bad input rejects at the call, not through the job. + await expect(table.refreshColumnAsync("x")).rejects.toThrow( + "not a computed column", + ); + }); + + it("fills rows added since the last refresh", async () => { + const db = await connect(tmpDir.name); + const table = await db.createTable("computed_append", [{ x: 1 }]); + + await table.addColumns({ + computed: [{ name: "doubled", valueSql: "x * 2" }], + }); + await table.refreshColumn("doubled"); + await table.add([{ x: 5 }]); + + const result = await table.refreshColumn("doubled"); + expect(result.rowsFilled).toBe(1); + + const rows = await table.query().toArray(); + expect(rows.map((r) => r.doubled).sort()).toEqual([10, 2]); + }); +}); diff --git a/nodejs/lancedb/connection.ts b/nodejs/lancedb/connection.ts index e63a7ae65..a81dc0442 100644 --- a/nodejs/lancedb/connection.ts +++ b/nodejs/lancedb/connection.ts @@ -327,6 +327,14 @@ export abstract class Connection { */ abstract dropTable(name: string, namespacePath?: string[]): Promise; + /** + * Start dropping a table and return its cleanup job. + * + * The table may become unavailable before its data files are removed. Wait + * on the returned job to know when cleanup has finished. + */ + abstract dropTableAsync(name: string, namespacePath?: string[]): Promise; + /** * Drop all tables in the database. * @param {string[]} namespacePath The namespace path to drop tables from (defaults to root namespace). @@ -705,6 +713,10 @@ export class LocalConnection extends Connection { return this.inner.dropTable(name, namespacePath ?? []); } + async dropTableAsync(name: string, namespacePath?: string[]): Promise { + return this.inner.dropTableAsync(name, namespacePath ?? []); + } + async dropAllTables(namespacePath?: string[]): Promise { return this.inner.dropAllTables(namespacePath ?? []); } diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index 319222421..6a5bfe3b4 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -50,6 +50,7 @@ export { MergeResult, AddResult, AddColumnsResult, + RefreshColumnResult, AlterColumnsResult, UpdateFieldMetadataResult, DeleteResult, @@ -146,6 +147,10 @@ export { FtsToken, TokenizeTableOptions, LsmWriteSpec, + LsmStats, + BucketStats, + GenerationStats, + MemtableStats, ColumnAlteration, FieldMetadataUpdate, } from "./table"; 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..964c2cea3 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -31,8 +31,10 @@ import { IndexConfig, IndexStatistics, Job, + LsmStats, Branches as NativeBranches, OptimizeStats, + RefreshColumnResult, TableStatistics, Tags, UpdateFieldMetadataResult, @@ -49,6 +51,12 @@ import { import { sanitizeType } from "./sanitize"; import { IntoSql, toSQL } from "./util"; export { IndexConfig } from "./native"; +export { + BucketStats, + GenerationStats, + LsmStats, + MemtableStats, +} from "./native"; /** * Progress snapshot for a write operation, delivered to the `progress` @@ -197,7 +205,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; @@ -521,18 +533,75 @@ export abstract class Table { abstract vectorSearch(vector: IntoVector | MultiVector): VectorQuery; /** * Add new columns with defined values. + * + * The `{ computed }` form stores the expression rather than evaluating it + * now: the column is committed with no values, and rows get them from + * {@link Table#refreshColumn}. Declaring one therefore costs the same on a + * large table as on an empty one. + * + * A refresh does not revisit rows it has already filled, so mutating an + * input leaves the value computed at fill time; recomputing means dropping + * the column and declaring it again. While a declaration reads a column, + * that column cannot be renamed, retyped or dropped. + * + * On LanceDB Cloud and Enterprise the expression is planned by the + * server, and the refresh runs as a server job -- see + * {@link Table#refreshColumnAsync}. * @param {AddColumnsSql[] | Field | Field[] | Schema} newColumnTransforms Either: * - An array of objects with column names and SQL expressions to calculate values * - A single Arrow Field defining one column with its data type (column will be initialized with null values) * - An array of Arrow Fields defining columns with their data types (columns will be initialized with null values) * - An Arrow Schema defining columns with their data types (columns will be initialized with null values) + * - `{ computed }`, declaring columns defined by a SQL expression whose type and inputs are derived from it * @returns {Promise} A promise that resolves to an object * containing the new version number of the table after adding the columns. + * @example + * ```ts + * await table.addColumns({ computed: [{ name: "doubled", valueSql: "x * 2" }] }); + * const { rowsFilled } = await table.refreshColumn("doubled"); + * ``` */ abstract addColumns( - newColumnTransforms: AddColumnsSql[] | Field | Field[] | Schema, + newColumnTransforms: + | AddColumnsSql[] + | Field + | Field[] + | Schema + | { computed: AddColumnsSql[] }, ): Promise; + /** + * Fill the rows of a computed column that hold no value yet. + * + * Rows appended since the last refresh are filled by the next one; rows + * already filled are left as they are, so the call is idempotent and does + * not observe a mutated input. Local tables only: a remote refresh runs + * as a server job, through {@link Table#refreshColumnAsync}. + * @param {string} column The name of the computed column to fill. + * @returns {Promise} A promise that resolves to the + * number of rows filled and the new version number of the table. + */ + abstract refreshColumn(column: string): Promise; + + /** + * Like {@link Table#refreshColumn}, but returns a handle to the refresh + * job instead of blocking until it completes. + * + * The job may already be complete when returned; callers must not assume + * the column is filled until {@link Job.wait} resolves. Invalid input -- + * an unknown column, or one that is not computed -- rejects here rather + * than failing the job. On local tables the job runs in-process; on + * LanceDB Cloud and Enterprise it is the server's backfill job. + * @param {string} column The name of the computed column to fill. + * @example + * ```ts + * const job = await table.refreshColumnAsync("doubled"); + * await job.wait(); + * console.log(await job.status()); // "finished" + * ``` + */ + abstract refreshColumnAsync(column: string): Promise; + /** * Alter the name or nullability of columns. * @param {ColumnAlteration[]} columnAlterations One or more alterations to @@ -595,6 +664,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 +696,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; @@ -638,6 +713,59 @@ export abstract class Table { * @returns {Promise} */ abstract closeLsmWriters(): Promise; + /** + * Seal every bucket's active memtable into a new L0 generation. + * + * Returns once the seal is committed. Sealing an empty memtable is a no-op, + * so this is safe to call repeatedly. + * @returns {Promise} + */ + abstract flushLsm(): Promise; + /** + * Trigger a background L0 → base compaction pass per bucket. + * + * Returns once the passes are *dispatched*, not once they finish — watch + * {@link Table#getLsmStats} for progress, or use + * {@link Table#checkpointLsm} to wait for convergence. + * @returns {Promise} + */ + abstract compactLsm(): Promise; + /** + * Converge this table's LSM write path into its base table. + * + * Seals once, then triggers compaction and polls until the L0 that existed + * at the start is gone. 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, and safe to run on a cadence. + * + * There is no liveness bound — the compactor pool is shared across tables, + * so a checkpoint queued behind unrelated work looks exactly like one that + * is merging. The caller owns the deadline. + * @returns {Promise} + * @example + * ```ts + * const before = await table.getLsmStats(); + * await table.checkpointLsm(); + * const after = await table.getLsmStats(); + * ``` + */ + abstract checkpointLsm(): Promise; + /** + * 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. + * + * Resolves to `undefined` only when the LSM write path is not enabled. + * @param {boolean} includeGenerationRows Also count rows per L0 generation. + * Off by default because each count opens an uncached Lance dataset. + * @returns {Promise} + */ + abstract getLsmStats( + includeGenerationRows?: boolean, + ): Promise; /** Retrieve the version of the table */ abstract version(): Promise; @@ -1078,8 +1206,22 @@ export class LocalTable extends Table { // TODO: Support BatchUDF async addColumns( - newColumnTransforms: AddColumnsSql[] | Field | Field[] | Schema, + newColumnTransforms: + | AddColumnsSql[] + | Field + | Field[] + | Schema + | { computed: AddColumnsSql[] }, ): Promise { + // Columns defined by an expression are declared, not materialized here. + if ( + typeof newColumnTransforms === "object" && + !Array.isArray(newColumnTransforms) && + "computed" in newColumnTransforms + ) { + return await this.inner.addComputedColumns(newColumnTransforms.computed); + } + // Handle single Field -> convert to array of Fields if (newColumnTransforms instanceof Field) { newColumnTransforms = [newColumnTransforms]; @@ -1114,6 +1256,14 @@ export class LocalTable extends Table { throw new Error("Invalid input type for addColumns"); } + async refreshColumn(column: string): Promise { + return await this.inner.refreshColumn(column); + } + + async refreshColumnAsync(column: string): Promise { + return await this.inner.refreshColumnAsync(column); + } + async alterColumns( columnAlterations: ColumnAlteration[], ): Promise { @@ -1176,6 +1326,24 @@ export class LocalTable extends Table { return await this.inner.closeLsmWriters(); } + async flushLsm(): Promise { + return await this.inner.flushLsm(); + } + + async compactLsm(): Promise { + return await this.inner.compactLsm(); + } + + async checkpointLsm(): Promise { + return await this.inner.checkpointLsm(); + } + + async getLsmStats( + includeGenerationRows: boolean = false, + ): Promise { + return (await this.inner.getLsmStats(includeGenerationRows)) ?? undefined; + } + async version(): Promise { return await this.inner.version(); } diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index 3c93ed470..c2be3aeac 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.38.0-beta.2", "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..901405fe4 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.38.0-beta.2", "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..415e60c78 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.38.0-beta.2", "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..22416dbcb 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.38.0-beta.2", "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..77d6a5dd5 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.38.0-beta.2", "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..0dea90f81 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.38.0-beta.2", "os": [ "win32" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 601b51380..0be0b457b 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.38.0-beta.2", "os": ["win32"], "cpu": ["x64"], "main": "lancedb.win32-x64-msvc.node", diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 8e30b0fab..999b3f16f 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.38.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lancedb/lancedb", - "version": "0.37.1-beta.0", + "version": "0.38.0-beta.2", "cpu": [ "x64", "arm64" @@ -55,7 +55,13 @@ "openai": "4.29.2" }, "peerDependencies": { + "@types/node": ">=18", "apache-arrow": ">=15.0.0 <=18.1.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@aws-crypto/crc32": { diff --git a/nodejs/package.json b/nodejs/package.json index f3f719af2..8291d3dc8 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.37.1-beta.0", + "version": "0.38.0-beta.2", "main": "dist/index.js", "exports": { ".": "./dist/index.js", @@ -101,6 +101,12 @@ "openai": "4.29.2" }, "peerDependencies": { + "@types/node": ">=18", "apache-arrow": ">=15.0.0 <=18.1.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } } diff --git a/nodejs/src/connection.rs b/nodejs/src/connection.rs index c45321aba..c9f5e10ea 100644 --- a/nodejs/src/connection.rs +++ b/nodejs/src/connection.rs @@ -334,6 +334,22 @@ impl Connection { .default_error() } + /// Start dropping a table and return its cleanup job. + #[napi(catch_unwind)] + pub async fn drop_table_async( + &self, + name: String, + namespace_path: Option>, + ) -> napi::Result { + let ns = namespace_path.unwrap_or_default(); + let job = self + .get_inner()? + .drop_table_async(&name, &ns) + .await + .default_error()?; + Ok(crate::job::Job::new(job)) + } + #[napi(catch_unwind)] pub async fn drop_all_tables(&self, namespace_path: Option>) -> napi::Result<()> { let ns = namespace_path.unwrap_or_default(); diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index 2ac2fecb2..b15491202 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -347,6 +347,40 @@ impl Table { Ok(res.into()) } + #[napi(catch_unwind)] + pub async fn add_computed_columns( + &self, + columns: Vec, + ) -> napi::Result { + let table = self.inner_ref()?; + let mut builder = table.add_columns(); + for column in columns { + builder = builder.computed(column.name, column.value_sql); + } + let res = builder.execute().await.default_error()?; + Ok(res.into()) + } + + #[napi(catch_unwind)] + pub async fn refresh_column(&self, column: String) -> napi::Result { + let res = self + .inner_ref()? + .refresh_column(column) + .await + .default_error()?; + Ok(res.into()) + } + + #[napi(catch_unwind)] + pub async fn refresh_column_async(&self, column: String) -> napi::Result { + let job = self + .inner_ref()? + .refresh_column_async(column) + .await + .default_error()?; + Ok(crate::job::Job::new(job)) + } + #[napi(catch_unwind)] pub async fn add_columns_with_schema( &self, @@ -463,6 +497,34 @@ impl Table { self.inner_ref()?.close_lsm_writers().await.default_error() } + #[napi(catch_unwind)] + pub async fn flush_lsm(&self) -> napi::Result<()> { + self.inner_ref()?.flush_lsm().await.default_error() + } + + #[napi(catch_unwind)] + pub async fn compact_lsm(&self) -> napi::Result<()> { + self.inner_ref()?.compact_lsm().await.default_error() + } + + #[napi(catch_unwind)] + pub async fn checkpoint_lsm(&self) -> napi::Result<()> { + self.inner_ref()?.checkpoint_lsm().await.default_error() + } + + #[napi(catch_unwind)] + pub async fn get_lsm_stats( + &self, + include_generation_rows: bool, + ) -> napi::Result> { + let stats = self + .inner_ref()? + .get_lsm_stats(include_generation_rows) + .await + .default_error()?; + Ok(stats.map(LsmStats::from)) + } + #[napi(catch_unwind)] pub async fn version(&self) -> napi::Result { self.inner_ref()? @@ -772,7 +834,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 +845,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 +871,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 +889,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 +900,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,13 +910,136 @@ 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), }, } } } +/// One flushed L0 generation. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct GenerationStats { + /// The generation number. Increases as memtables are sealed into L0. + pub generation: i64, + /// On-disk size of the generation. + pub bytes: i64, + /// Present only when `includeGenerationRows` was requested. Off by default + /// because each count opens an uncached Lance dataset. + pub rows: Option, +} + +impl From for GenerationStats { + fn from(g: lancedb::table::GenerationStats) -> Self { + Self { + generation: g.generation as i64, + bytes: g.bytes as i64, + rows: g.rows.map(|r| r as i64), + } + } +} + +/// One in-memory memtable. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MemtableStats { + /// The generation this memtable will become once sealed. + pub generation: i64, + /// Rows currently buffered. + pub rows: i64, + /// Estimated in-memory size. + pub bytes: i64, + /// Record batches currently buffered. + pub batches: i64, + /// 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, +} + +impl From for MemtableStats { + fn from(m: lancedb::table::MemtableStats) -> Self { + Self { + generation: m.generation as i64, + rows: m.rows as i64, + bytes: m.bytes as i64, + batches: m.batches as i64, + indexes: m.indexes, + } + } +} + +/// 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. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct BucketStats { + /// The shard this bucket writes. + pub shard_id: String, + /// `"Active"` or `"Sealed"` (drop-table 2PC in flight). + pub status: String, + /// Epoch of the writer that currently owns the shard. + pub writer_epoch: i64, + /// Version of the shard manifest these numbers were read from. + pub manifest_version: i64, + /// The generation the active memtable will become. + pub current_generation: i64, + /// WAL position replay resumes from. + pub replay_after_wal_entry_position: i64, + /// Highest WAL position the writer has seen. The difference against + /// `replayAfterWalEntryPosition` is the WAL lag. + pub wal_entry_position_last_seen: i64, + /// Flushed L0 generations not yet merged into the base table. + 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. + pub memtables: Option>, +} + +impl From for BucketStats { + fn from(b: lancedb::table::BucketStats) -> Self { + Self { + shard_id: b.shard_id, + status: b.status, + writer_epoch: b.writer_epoch as i64, + manifest_version: b.manifest_version as i64, + current_generation: b.current_generation as i64, + replay_after_wal_entry_position: b.replay_after_wal_entry_position as i64, + wal_entry_position_last_seen: b.wal_entry_position_last_seen as i64, + generations: b.generations.into_iter().map(Into::into).collect(), + compacting: b.compacting, + memtables: b + .memtables + .map(|ms| ms.into_iter().map(Into::into).collect()), + } + } +} + +/// Live per-bucket LSM state, as returned by `Table#getLsmStats`. +/// +/// Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are +/// the caller's to compute. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct LsmStats { + /// One entry per bucket backing this table. + pub buckets: Vec, +} + +impl From for LsmStats { + fn from(stats: lancedb::table::LsmStats) -> Self { + Self { + buckets: stats.buckets.into_iter().map(Into::into).collect(), + } + } +} + /// Statistics about a compaction operation. #[napi(object)] #[derive(Clone, Debug)] @@ -1043,7 +1228,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 @@ -1193,6 +1381,21 @@ pub struct AddColumnsResult { pub version: i64, } +#[napi(object)] +pub struct RefreshColumnResult { + pub rows_filled: i64, + pub version: i64, +} + +impl From for RefreshColumnResult { + fn from(value: lancedb::table::RefreshColumnResult) -> Self { + Self { + rows_filled: value.rows_filled as i64, + version: value.version as i64, + } + } +} + impl From for AddColumnsResult { fn from(value: lancedb::table::AddColumnsResult) -> Self { Self { diff --git a/plugins/lancedb/skills/lancedb/SKILL.md b/plugins/lancedb/skills/lancedb/SKILL.md index 8b8f761c2..47537f31a 100644 --- a/plugins/lancedb/skills/lancedb/SKILL.md +++ b/plugins/lancedb/skills/lancedb/SKILL.md @@ -20,18 +20,16 @@ Do NOT assume local-only table helpers exist on remote tables. If the user asks 1. Identify the SDK: Python, TypeScript, or both. 2. Identify the table mode: local/embedded OSS, remote Enterprise/Cloud, or portable across both. If the user says "LanceDB Enterprise", choose the remote table path. If the task involves jobs in any way (listing, inspecting, creating, or canceling jobs), it is always the remote path and requires a remote server connection — see "Connecting to the LanceDB remote server" below before doing anything else. -3. Read the matching language branch before writing or changing code: - - Python patterns: `references/python/patterns.md` - - Python API quick reference: `references/python/api_reference.md` - - Python performance guidance: `references/python/performance.md` - - TypeScript patterns: `references/typescript/patterns.md` - - TypeScript API quick reference: `references/typescript/api_reference.md` - - TypeScript performance guidance: `references/typescript/performance.md` +3. Read the matching topic reference before writing or changing code: - Column metadata authoring (both SDKs): `references/column_metadata.md` - Branch operations (both SDKs): `references/branch_ops.md` - Remote server connection resolution (jobs, raw REST): `references/remote_connect.md` - Job operations REST API (list/describe/cancel/query_events): `references/remote_jobs.md` -4. Start with `patterns.md` for the selected SDK. Read `api_reference.md` when choosing method names or return collectors. Read `performance.md` when the task involves ingestion, indexing, filtering, query tuning, diagnostics, or large datasets. Read `column_metadata.md` when the task is documenting, tagging, classifying, or grouping table columns (field descriptions, `lancedb:tag:*` tags, logical column families). Read `branch_ops.md` when the task involves branch lifecycle (list/create/delete), writing to a non-main branch, or verifying a change stayed off main. Read `remote_connect.md` when the task involves jobs or direct REST access to an Enterprise deployment, and `remote_jobs.md` for the job REST methods themselves (list, describe, cancel, query_events). + + There is no bundled per-language guide. For exact method names, signatures, and options, look them up in the canonical sources instead of relying on memory: + - Python: `docs/src/python/python.md` (the hand-maintained API reference) and the source under `python/python/lancedb/` when working inside the LanceDB repo; otherwise . + - TypeScript: the generated typedoc under `docs/src/js/` and the source under `nodejs/lancedb/` when working inside the LanceDB repo; otherwise . +4. Apply the SDK invariants in "Per-SDK Invariants" below. Read `column_metadata.md` when the task is documenting, tagging, classifying, or grouping table columns (field descriptions, `lancedb:tag:*` tags, logical column families). Read `branch_ops.md` when the task involves branch lifecycle (list/create/delete), writing to a non-main branch, or verifying a change stayed off main. Read `remote_connect.md` when the task involves jobs or direct REST access to an Enterprise deployment, and `remote_jobs.md` for the job REST methods themselves (list, describe, cancel, query_events). 5. For Python schemas, favor Pydantic models and validate records before writing. Use PyArrow schemas when Arrow-native, streaming, or highly dynamic data makes them materially better suited. 6. Prefer `search()` or `query()` builders with explicit `select()` and `limit()` for reads. 7. Avoid table-level full materialization in remote or portable code. This is the main local-vs-remote read pitfall. @@ -54,6 +52,23 @@ The unsafe pattern is table-level or unbounded collection, plus local-only datas - Python: `table.to_pandas()`, `table.to_arrow()`, `table.to_polars()`; `table.to_lance()` is local/OSS-only dataset access, not materialization - TypeScript: `await table.toArrow()`, `await table.query().toArray()` without `limit()` +## Per-SDK Invariants + +Python: + +- Result collectors: default to `.to_list()` (plain dicts, no extra dependency) or `.to_arrow()` (PyArrow ships with LanceDB). Use `.to_pandas()` / `.to_polars()` only when the project already declares that dependency — do not assume pandas or polars is installed. +- Plain scans differ by client: the sync client has no `.query()` method — use `table.search()` with no argument; the async client uses `await async_table.query()`. + +TypeScript: + +- Collect bounded results with `.toArray()` (objects) or `.toArrow()` (Arrow) after `select()` and `limit()`. +- For large reads, stream batches instead of collecting: `for await (const batch of table.query().where(...).select(...).limit(...)) { ... }`. + +Both SDKs: + +- Ingest in bulk or in batches of thousands of rows; never write per-row in a loop — each write creates a version and fragment, slowing ingestion and later queries. +- Build a vector index once brute-force search is too slow (rule of thumb: beyond roughly 100K vectors locally), and scalar indexes for filtered columns and merge/upsert keys. Use index defaults unless the task states recall/latency requirements. + ## Enterprise: never drop-then-reuse the same table name LanceDB Enterprise/Cloud splits a **control plane** (DDL: create/drop/rename) from a **data plane** (query nodes that serve reads). Query nodes cache the resolved dataset for a table name for up to `table_cache_ttl` — **default 300 seconds (5 minutes)**. After you drop or overwrite a table, the control plane updates immediately but the data plane keeps serving the *old* dataset until that cache entry expires. During the window the two planes disagree. diff --git a/plugins/lancedb/skills/lancedb/references/python/api_reference.md b/plugins/lancedb/skills/lancedb/references/python/api_reference.md deleted file mode 100644 index bbb209630..000000000 --- a/plugins/lancedb/skills/lancedb/references/python/api_reference.md +++ /dev/null @@ -1,138 +0,0 @@ -# Python API Reference - -Quick method reference for Python LanceDB code. Cross-check source for non-trivial claims. - -## Connect - -If you're connecting to a remote database, use this: -```python -import lancedb - -db = lancedb.connect("db://my-db", api_key=api_key, host_override=host_override) # remote -``` -(values may be found in LANCEDB_API_KEY and LANCEDB_HOST_OVERRIDE, either in env vars or a .env file) - -If you're connecting to a local table using OSS LanceDB, use this: -```python -db = lancedb.connect("./camelot-db") # local/OSS -``` -If you're not sure which, or if you can't find the api_key or host_override params, ask the user. - -**Place the local database directory next to the script/entrypoint that opens it** (i.e. resolve the path relative to the script, `Path(__file__).parent / "camelot-db"`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from. - -**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package name, which is confusing to read and easy to shadow in scripts. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`. - -Async: - -```python -db = await lancedb.connect_async("./camelot-db") -``` - -## Table Reads - -| Task | Preferred API | -| --- | --- | -| Vector search | `table.search(query_vector).limit(k)` | -| Full scan with filters/projection (sync) | `table.search().where(...).select(...).limit(...)` | -| Full scan with filters/projection (async) | `table.query().where(...).select(...).limit(...)` | -| Filter | `.where("col > 10")` | -| Projection | `.select(["id", "text"])` | -| Bound result count | `.limit(20)` | -| Collect bounded result as Python objects (default, no extra deps) | `.to_list()` on query/search result | -| Collect bounded result as Arrow (default, `pyarrow` always available) | `.to_arrow()` on query/search result | -| Collect bounded result as pandas (only if project uses pandas) | `.to_pandas()` on query/search result | -| Collect bounded result as Polars (only if project uses polars) | `.to_polars()` on query/search result | - -## Sync vs Async Scan API - -The plain-scan entry point differs between the sync and async clients. **Verified against `lancedb` 0.34.0** — re-check if the pinned version changes: - -- **Sync** (`lancedb.connect(...)`): the table has **no `.query()` method**. Use `.search()` with no argument for a plain scan; it returns a query builder that supports `.where()`, `.select()`, `.limit()`, and the `.to_list()` / `.to_arrow()` / `.to_pandas()` / `.to_polars()` collectors. - ```python - rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() - ``` -- **Async** (`lancedb.connect_async(...)`): the table has **both** `.query()` and `.search()`. Use `.query()` for a plain scan. - ```python - rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() - ``` - -Do not call `table.query()` on a sync table — it raises `AttributeError`. - -## Local vs Remote Table Methods - -| API | Local table | Remote table | Agent guidance | -| --- | --- | --- | --- | -| `table.search(...)` | Yes | Yes | Preferred read path (sync + async) | -| `table.query()` | Async only | Async only | Sync scan path is `table.search()`; `.query()` is the async scan builder | -| `table.to_pandas()` | Yes | No / unsafe for portability | Avoid in portable code | -| `table.to_arrow()` | Yes | No / unsafe for portability | Avoid in portable code | -| `table.to_polars()` | Yes | No / unsafe for portability | Avoid in portable code | -| `table.to_lance()` | Yes | No | Local/OSS escape hatch only | - -## Indexes - -Use `create_index(...)` for vector indexes and modern index configs. Use scalar indexes for filtered or merge keys. - -Common calls: - -```python -table.create_index("vector") -table.create_scalar_index("status") -table.create_fts_index("text") -``` - -Check source docs before specifying advanced index config names or parameters. - -## Filtering And Recall Knobs - -```python -table.search(query_vector).where("status = 'ready'") # pre-filter by default -table.search(query_vector).where("status = 'ready'", prefilter=False) -table.search(query_vector).limit(10).refine_factor(20) -table.search(query_vector).limit(10).nprobes(50) -``` - -Use post-filtering only when fewer than `limit` results are acceptable. - -## Diagnostics - -```python -print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan()) -print(table.index_stats("vector_idx")) -``` - -Use these before changing indexes or search tuning. - -## Column (Field) Metadata - -```python -schema = table.schema # sync property; async: await table.schema() -meta = schema.field("category").metadata # dict[bytes, bytes] — Arrow metadata is bytes-keyed -res = table.update_field_metadata( # varargs: one dict per field; works local + remote - {"path": "category", "metadata": {"lancedb:description": "...", "lancedb:tag:field_type": "label"}} -) -res.version # new table version -``` - -Merges by default; a `None` value deletes that key; `"replace": True` swaps the whole map. Nested fields use dot-paths (`"a.b.c"`). `replace_field_metadata` is deprecated. See `references/column_metadata.md` for key conventions (`lancedb:description`, `lancedb:tag:`, `lancedb:logical-column`) and the authoring workflow. - -## Branches - -```python -table.branches.list() # non-main branches; {} = only main -exp = table.branches.create("exp") # fork off main -> handle scoped to the branch -wip = table.branches.checkout("wip") # existing branch -> scoped handle (version= pins read-only) -wip = db.open_table("t", branch="wip") # or open scoped directly -table.branches.delete("stale") # removes only the branch pointer -table.current_branch() # None = main -``` - -There is no global switch — scoping is per table handle: any read/write on a branch handle lands on that branch; the original handle keeps targeting main. See `references/branch_ops.md` for the model and isolation checks. - -## Maintenance - -```python -table.optimize() -``` - -Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration. diff --git a/plugins/lancedb/skills/lancedb/references/python/patterns.md b/plugins/lancedb/skills/lancedb/references/python/patterns.md deleted file mode 100644 index 4d6be43ef..000000000 --- a/plugins/lancedb/skills/lancedb/references/python/patterns.md +++ /dev/null @@ -1,173 +0,0 @@ -# Python Patterns - -Use these patterns when writing Python code with `lancedb`. - -## Before Writing Code - -Choose the output type from what the project actually depends on. **Do not assume `pandas` or `polars` is installed** — they are heavy dependencies that many LanceDB projects do not use. `pyarrow`, by contrast, ships as a LanceDB dependency and is always available, so it is a safe default to lean on. - -Default output (after applying `select()` and `limit()`): - -- **Python objects**: `.to_list()` — a list of dicts, no extra dependencies. Prefer this for scripts, examples, and agent-generated code unless there is a reason to do otherwise. -- **PyArrow**: `.to_arrow()` — a `pyarrow.Table`, when the surrounding code is Arrow-native or you need columnar/zero-copy handoff. - -Only reach for a DataFrame when the project *already* declares that dependency: - -- Pandas projects (pandas in `pyproject.toml`/requirements): `.to_pandas()`. -- Polars projects (polars declared): `.to_polars()`. - -If unsure, check the dependency manifest or the imports in surrounding files. When in doubt, use `.to_list()` or `.to_arrow()`. - -## Schema Design and Validation - -Favor `LanceModel` and Pydantic validation for Python schemas. They keep field -types readable, validate source records before a write, and map directly to a -LanceDB schema. Use `Vector(dimension)` for fixed-size vectors: - -```python -from lancedb.pydantic import LanceModel, Vector - -class Document(LanceModel): - id: int - text: str - vector: Vector(384, nullable=False) - -rows = [Document.model_validate(row) for row in source_rows] -table = db.create_table("documents", schema=Document) -table.add(rows) -``` - -Use PyArrow schemas instead when the pipeline is already Arrow-native, needs -record-batch streaming, or has runtime schema requirements that would make a -Pydantic model harder to understand. Declare Pydantic as a direct project -dependency when application code imports it, even if LanceDB also depends on it. - -## Recommended Patterns - -### Bounded search or query - -Use this for application reads, examples, notebooks, and agent-generated scripts: - -```python -results = ( - table.search(query_vector) - .where("status = 'ready'") - .select(["id", "text"]) - .limit(20) - .to_list() # or .to_arrow(); .to_pandas()/.to_polars() only if the project uses them -) -``` - -Why: `search()` works across local and remote tables and on both the sync and async clients. `select()` avoids fetching unused columns. `limit()` prevents accidental full-table reads. `.to_list()` and `.to_arrow()` avoid assuming pandas/polars is installed (see "Before Writing Code"). - -For a **plain scan** (no query vector), the entry point differs by client: - -```python -# Sync client: no .query() method — use .search() with no argument. -rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() - -# Async client: use .query(). -rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() -``` - -`table.query()` on a sync table raises `AttributeError` (verified on `lancedb` 0.34.0). See the "Sync vs Async Scan API" section in `api_reference.md`. - -### Bounded query result conversion - -It is fine to collect bounded query/search results: - -```python -arrow_table = table.search().select(["id"]).limit(100).to_arrow() # sync plain scan -rows = table.search(query_vector).limit(10).to_list() -df = table.search(query_vector).limit(10).to_pandas() # only if pandas is a project dep -``` - -### Local-only Lance dataset API - -`table.to_lance()` does not itself materialize the full dataset. It returns the underlying `lance.LanceDataset`, making the table accessible through the PyLance dataset API. Use it when the task is explicitly local/OSS and needs Lance dataset methods not exposed by LanceDB: - -```python -# Local/OSS only: RemoteTable does not expose table.to_lance(). -ds = table.to_lance() -for batch in ds.to_batches(columns=["id", "text"], batch_size=10_000): - process(batch) -``` - -### Async Python - -Keep the same shape and bound the result before collecting: - -```python -results = await ( - async_table.query() - .where("status = 'ready'") - .select(["id", "text"]) - .limit(20) - .to_list() # or .to_arrow() -) -``` - -## Anti-Patterns - -**Avoid the following anti-patterns in your code.** - -### Table-level full materialization - -Avoid whole-table collectors in portable or large-table code: - -```python -df = table.to_pandas() -arrow_table = table.to_arrow() -polars_df = table.to_polars() -``` - -Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory. - -`table.to_lance()` is different: it is not a full materialization call, but it is still local/OSS-only and should not appear in code meant to run against remote Enterprise tables. - -### Unbounded result collection - -Avoid query/search collection without a meaningful limit: - -```python -rows = table.search().to_list() # unbounded plain scan -rows = table.search(query_vector).to_list() # unbounded vector search -``` - -Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead. - -### Per-row writes - -Avoid loops that write one row per call: - -```python -for row in rows: - table.add([row]) # one commit + fragment per row -``` - -Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs: - -```python -table.add(rows) # single commit -# for very large inputs, add batches of several thousand rows -``` - -After the final successful write to an embedded OSS table, call -`table.optimize()`. Skip this for Enterprise/Cloud tables because their -maintenance is automatic. - -### Drop-then-reuse the same table name (Enterprise/Cloud) - -Avoid dropping or overwriting a remote table and then reusing that name right away: - -```python -db.drop_table("my_table") -table = db.create_table("my_table", data=rows) # reads 500 for ~5 min -table = db.create_table("my_table", data=rows, mode="overwrite") # same problem -``` - -Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `list_tables()` and fail if it already exists, then `rename_table(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there. - -### Guessing performance fixes - -Avoid changing `nprobes`, `refine_factor`, or index types before checking the query plan and index stats. Diagnose first, then tune one knob at a time. diff --git a/plugins/lancedb/skills/lancedb/references/python/performance.md b/plugins/lancedb/skills/lancedb/references/python/performance.md deleted file mode 100644 index 5fd27440b..000000000 --- a/plugins/lancedb/skills/lancedb/references/python/performance.md +++ /dev/null @@ -1,131 +0,0 @@ -# Python Performance Guidance - -Use this when writing Python code that ingests data, queries large tables, builds indexes, or investigates latency. - -## Ingestion - -### Recommended: validate schemas and records with Pydantic - -Favor `LanceModel` for readable Python schema definitions and validate source -records before writing. Use PyArrow directly for Arrow-native or streaming -pipelines where it is the clearer representation. - -```python -from lancedb.pydantic import LanceModel, Vector - -class Document(LanceModel): - id: int - text: str - vector: Vector(384, nullable=False) - -rows = [Document.model_validate(row) for row in source_rows] -table = db.create_table("documents", schema=Document) -table.add(rows) -``` - -### Recommended: bulk ingestion for materialized data - -```python -table.add(arrow_table) -table.add(df) -table.add(pa.dataset("data/", format="parquet")) -``` - -For very large initial loads, create the table empty first, then call `add(...)`. Passing data directly to `create_table(name, data)` can skip the auto-parallel write path. - -### Recommended: iterator ingestion for generated or streamed data - -```python -def batches(): - for raw in source: - vectors = model.encode(raw["text"]) - yield pa.RecordBatch.from_pydict({**raw, "vector": vectors}) - -table.add(batches()) -``` - -Use chunks of several thousand rows or more when practical. Tiny batches and per-row writes create many small fragments. - -### Anti-pattern: per-row `add()` - -```python -for row in rows: - table.add([row]) -``` - -Each call creates a version and fragment. This slows ingestion and later queries. - -## Indexing - -- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index. -- Use `IVF_PQ` as the general-purpose default. Enterprise builds this automatically. -- Use scalar indexes for filtered columns and merge/upsert keys. -- Use `BTREE` for mostly distinct numeric/string/temporal columns, `BITMAP` for booleans and low-cardinality columns, and `LABEL_LIST` for list membership queries. -- Keep full-text defaults unless phrase queries require position data. - -## Querying - -Always be explicit: - -```python -table.search(query_vector).select(["id", "title"]).limit(20) -``` - -- `select()` reduces bytes read and transferred. -- `limit()` prevents accidental full-table materialization. -- Pre-filtering is the default and guarantees returned rows satisfy the predicate. -- Use post-filtering only when fewer than `limit` results are acceptable. - -## Recall Tuning - -Tune one knob at a time: - -- Quantized indexes: raise `refine_factor` to rescore more candidates on full vectors. -- HNSW-backed indexes: raise `ef`; start around `1.5 * k`, increase toward `10 * k` if recall is short. -- IVF candidate breadth: `nprobes` is auto-tuned; override only when a selective pre-filter leaves too few neighbors. - -## Maintenance - -After every successful embedded OSS/local ingestion, call `table.optimize()`. -Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction -and cleanup are handled automatically based on the Enterprise cluster -configuration. - -Why local maintenance is needed: - -- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency. -- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage. -- Indexes may have newly added rows that are not yet fully optimized into the index structure. - -For local/OSS tables, run `optimize()` after the final successful ingestion -write. Also run it after later batches of update/delete operations or on a -regular maintenance schedule: - -```python -table.optimize() -``` - -If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window: - -```python -from datetime import timedelta - -table.optimize(cleanup_older_than=timedelta(days=1)) -``` - -Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions. - -## Diagnostics - -Before changing code or indexes, inspect: - -```python -print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan()) -print(table.index_stats("vector_idx")) -``` - -Look for high scan bytes, missing indexes, fragmented data, and unindexed rows. - -## Python Multiprocessing - -When using multiprocessing, use `spawn` rather than `fork`. LanceDB is multi-threaded internally, and `fork` plus a multi-threaded process is unsafe. diff --git a/plugins/lancedb/skills/lancedb/references/typescript/api_reference.md b/plugins/lancedb/skills/lancedb/references/typescript/api_reference.md deleted file mode 100644 index c98f39a0c..000000000 --- a/plugins/lancedb/skills/lancedb/references/typescript/api_reference.md +++ /dev/null @@ -1,105 +0,0 @@ -# TypeScript API Reference - -Quick method reference for TypeScript LanceDB code. Cross-check source for non-trivial claims. - -## Connect - -```typescript -import * as lancedb from "@lancedb/lancedb"; - -const db = await lancedb.connect("./camelot-db"); -``` - -**Place the local database directory next to the script/entrypoint that opens it** (resolve the path relative to the module, e.g. via `import.meta.dirname` / `__dirname`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from. - -**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package/namespace, which is confusing to read. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`. - -Remote connections use `db://...` plus Enterprise/Cloud credentials and deployment settings. Check current source/docs for exact connection options. - -## Table Reads - -| Task | Preferred API | -| --- | --- | -| Vector search | `table.search(queryVector).limit(k)` | -| Full scan with filters/projection | `table.query().where(...).select(...).limit(...)` | -| Filter | `.where("col > 10")` | -| Projection | `.select(["id", "text"])` | -| Bound result count | `.limit(20)` | -| Collect bounded result as objects | `.toArray()` on query/search result | -| Collect bounded result as Arrow | `.toArrow()` on query/search result | -| Stream result batches | `for await (const batch of table.query()...)` | - -## Local vs Remote Safety - -| API | Agent guidance | -| --- | --- | -| `table.search(...)` | Preferred read path | -| `table.query()` | Preferred scan/filter path | -| `await table.toArrow()` | Avoid in portable or large-table code | -| `await table.query().toArray()` with no `limit()` | Avoid; unbounded collection | -| `await table.query().toArrow()` with no `limit()` | Avoid; unbounded collection | - -## Indexes - -```typescript -await table.createIndex("vector"); -await table.createIndex("status"); -``` - -Use vector indexes for large vector search workloads and scalar indexes for filtered columns or merge/upsert keys. Check source/docs before specifying advanced index options. - -## Filtering And Recall Knobs - -```typescript -await table.search(queryVector).where("status = 'ready'").limit(10).toArray(); -await table.search(queryVector).limit(10).refineFactor(20).toArray(); -await table.search(queryVector).limit(10).nprobes(50).toArray(); -await table.search(queryVector).limit(10).ef(100).toArray(); -await table.search(queryVector).where("status = 'ready'").postfilter().limit(10).toArray(); -``` - -Use `postfilter()` only when fewer than `limit` results are acceptable. - -## Diagnostics - -```typescript -console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan()); -console.log(await table.indexStats("vector_idx")); -``` - -Use these before changing indexes or search tuning. - -## Column (Field) Metadata - -```typescript -const schema = await table.schema(); -const meta = schema.fields.find((f) => f.name === "category")?.metadata; // Map -const res = await table.updateFieldMetadata([ - { path: "category", metadata: { "lancedb:description": "...", "lancedb:tag:field_type": "label" } }, -]); -res.version; // new table version -``` - -Merges by default; a `null` value deletes that key; `replace: true` swaps the whole map. Nested fields use dot-paths (`"a.b.c"`). See `references/column_metadata.md` for key conventions (`lancedb:description`, `lancedb:tag:`, `lancedb:logical-column`) and the authoring workflow. - -## Branches - -```typescript -const branches = await table.branches(); // async manager -await branches.list(); // non-main branches; {} = only main -const exp = await branches.create("exp"); // fork off main -> Table scoped to the branch -const wip = await branches.checkout("wip"); // existing branch -> scoped Table (version arg pins read-only) -const wip2 = await db.openTable("t", { branch: "wip" }); // or open scoped directly -await branches.delete("stale"); // removes only the branch pointer -table.currentBranch(); // null = main -``` - -There is no global switch — scoping is per table handle: any read/write on a branch handle lands on that branch; the original handle keeps targeting main. See `references/branch_ops.md` for the model and isolation checks. - -## Maintenance - -```typescript -await table.optimize(); -``` - -Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration. diff --git a/plugins/lancedb/skills/lancedb/references/typescript/patterns.md b/plugins/lancedb/skills/lancedb/references/typescript/patterns.md deleted file mode 100644 index 1aa380968..000000000 --- a/plugins/lancedb/skills/lancedb/references/typescript/patterns.md +++ /dev/null @@ -1,100 +0,0 @@ -# TypeScript Patterns - -Use these patterns when writing TypeScript code with `@lancedb/lancedb`. - -## Recommended Patterns - -### Bounded query - -Use this for application reads, scripts, and examples: - -```typescript -const rows = await table - .query() - .where("status = 'ready'") - .select(["id", "text"]) - .limit(20) - .toArray(); -``` - -### Bounded vector search - -```typescript -const rows = await table - .search(queryVector) - .select(["id", "text"]) - .limit(20) - .toArray(); -``` - -### Batch streaming for larger reads - -When the task needs many rows, avoid collecting everything at once: - -```typescript -for await (const batch of table - .query() - .where("status = 'ready'") - .select(["id", "text"]) - .limit(10_000)) { - process(batch); -} -``` - -## Anti-Patterns - -**Avoid the following anti-patterns in your code.** - -### Table-level full materialization - -Avoid whole-table collectors in portable or large-table code: - -```typescript -const tableArrow = await table.toArrow(); -``` - -Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory. - -### Unbounded result collection - -Avoid query/search collection without a meaningful limit: - -```typescript -const rows = await table.query().toArray(); // unbounded plain scan -const rows = await table.search(queryVector).toArray(); // unbounded vector search -``` - -Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead. - -### Per-row writes - -Avoid loops that write one row per call: - -```typescript -for (const row of rows) { - await table.add([row]); // one commit + fragment per row -} -``` - -Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs: - -```typescript -await table.add(rows); // single commit -// for very large inputs, add in chunks of several thousand rows -``` - -### Drop-then-reuse the same table name (Enterprise/Cloud) - -Avoid dropping or overwriting a remote table and then reusing that name right away: - -```typescript -await db.dropTable("my_table"); -const table = await db.createTable("my_table", rows); // reads 500 for ~5 min -const table = await db.createTable("my_table", rows, { mode: "overwrite" }); // same problem -``` - -Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `tableNames()` and fail if it already exists, then `renameTable(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there. - -### Guessing performance fixes - -Avoid changing `nprobes`, `refineFactor`, `ef`, or index settings before checking `analyzePlan()` and `indexStats(...)`. Diagnose first, then tune one knob at a time. diff --git a/plugins/lancedb/skills/lancedb/references/typescript/performance.md b/plugins/lancedb/skills/lancedb/references/typescript/performance.md deleted file mode 100644 index 9bf07e9ae..000000000 --- a/plugins/lancedb/skills/lancedb/references/typescript/performance.md +++ /dev/null @@ -1,78 +0,0 @@ -# TypeScript Performance Guidance - -Use this when writing TypeScript code that ingests data, queries large tables, builds indexes, or investigates latency. - -## Ingestion - -- Prefer bulk or batched writes. -- Avoid per-row write loops; they create many small commits/fragments. -- For generated data, accumulate reasonable batches before adding. -- For file-backed data, prefer APIs that stream from Arrow/Parquet-style inputs when available. - -## Indexing - -- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index. -- Use the general-purpose vector index defaults unless the task has explicit recall/latency requirements. -- Build scalar indexes for filtered columns and merge/upsert keys. -- Use full-text index phrase options only when phrase queries require them. - -## Querying - -Always be explicit: - -```typescript -await table.search(queryVector).select(["id", "title"]).limit(20).toArray(); -``` - -- `select()` reduces bytes read and transferred. -- `limit()` prevents accidental full-table collection. -- Pre-filtering is the default behavior. Use `postfilter()` only when fewer than `limit` results are acceptable. - -## Recall Tuning - -Tune one knob at a time: - -- Quantized indexes: raise `refineFactor(...)` to rescore more candidates on full vectors. -- HNSW-backed indexes: raise `ef(...)`; start around `1.5 * k`, increase toward `10 * k` if recall is short. -- IVF candidate breadth: `nprobes(...)` is usually auto-tuned; override only when a selective pre-filter leaves too few neighbors. - -## Maintenance - -After every successful embedded OSS/local ingestion, call `table.optimize()`. -Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction -and cleanup are handled automatically based on the Enterprise cluster -configuration. - -Why local maintenance is needed: - -- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency. -- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage. -- Indexes may have newly added rows that are not yet fully optimized into the index structure. - -For local/OSS tables, run `optimize()` after the final successful ingestion -write. Also run it after later batches of update/delete operations or on a -regular maintenance schedule: - -```typescript -await table.optimize(); -``` - -If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window: - -```typescript -const olderThan = new Date(Date.now() - 24 * 60 * 60 * 1000); -await table.optimize({ cleanupOlderThan: olderThan }); -``` - -Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions. - -## Diagnostics - -Before changing code or indexes, inspect: - -```typescript -console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan()); -console.log(await table.indexStats("vector_idx")); -``` - -Look for high scan cost, missing indexes, fragmented data, and unindexed rows. diff --git a/plugins/lancedb/skills/lancedb/scripts/check_materialization.py b/plugins/lancedb/skills/lancedb/scripts/check_materialization.py index cbd8abc04..6b0f117a1 100644 --- a/plugins/lancedb/skills/lancedb/scripts/check_materialization.py +++ b/plugins/lancedb/skills/lancedb/scripts/check_materialization.py @@ -64,7 +64,9 @@ def scan_python(path: Path, text: str) -> list[Finding]: def statement_around(text: str, start: int, end: int) -> str: before = max(text.rfind(";", 0, start), text.rfind("\n\n", 0, start)) - after_candidates = [pos for pos in (text.find(";", end), text.find("\n\n", end)) if pos != -1] + after_candidates = [ + pos for pos in (text.find(";", end), text.find("\n\n", end)) if pos != -1 + ] after = min(after_candidates) if after_candidates else len(text) return text[before + 1 : after].strip() diff --git a/python/Cargo.toml b/python/Cargo.toml index cc706e712..5af99eac3 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.38.0-beta.2" publish = false edition.workspace = true description = "Python bindings for LanceDB" @@ -15,10 +15,10 @@ name = "_lancedb" crate-type = ["cdylib"] [dependencies] -arrow = { version = "58.0.0", features = ["pyarrow"] } -async-trait = "0.1" -bytes = "1" -lancedb = { path = "../rust/lancedb", default-features = false } +arrow = { workspace = true, features = ["pyarrow"] } +async-trait.workspace = true +bytes.workspace = true +lancedb.workspace = true datafusion-common.workspace = true lance-core.workspace = true lance-namespace.workspace = true @@ -26,24 +26,24 @@ lance-namespace-impls.workspace = true lance-io.workspace = true env_logger.workspace = true log.workspace = true -pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39", "chrono"] } -chrono = { version = "0.4", default-features = false, features = ["clock"] } +pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310", "chrono"] } +chrono.workspace = true pyo3-async-runtimes = { version = "0.28", features = [ "attributes", "tokio-runtime", ] } -pin-project = "1.1.5" +pin-project.workspace = true futures.workspace = true -serde = "1" -serde_json = "1" +serde.workspace = true +serde_json.workspace = true snafu.workspace = true -tokio = { version = "1.40", features = ["sync", "rt-multi-thread"] } +tokio.workspace = true libc = "0.2" [build-dependencies] pyo3-build-config = { version = "0.28", features = [ "extension-module", - "abi3-py39", + "abi3-py310", ] } [features] diff --git a/python/pyproject.toml b/python/pyproject.toml index cb175bd6d..ae42172c0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "overrides>=0.7; python_version<'3.12'", "packaging>=23.0", "pyarrow>=16", - "pydantic>=1.10", + "pydantic>=2.7.4,<3", "tqdm>=4.27.0", "lance-namespace>=0.3.2" ] @@ -60,7 +60,7 @@ tests = [ "pytest-asyncio>=0.21", "duckdb>=0.9.0", "pytz>=2023.3", - "polars>=0.19, <=1.3.0", + "polars>=0.19, <=1.32.3", "pyarrow<25", "pyarrow-stubs>=16.0", "pylance==9.0.0rc1", @@ -140,6 +140,7 @@ include = [ "python/lancedb/remote/errors.py", "python/lancedb/embeddings/__init__.py", "python/lancedb/_lancedb.pyi", + "python/type_tests/connect.py", ] exclude = ["python/tests/"] pythonVersion = "3.13" diff --git a/python/python/lancedb/__init__.py b/python/python/lancedb/__init__.py index 235049f97..a8a336a6d 100644 --- a/python/python/lancedb/__init__.py +++ b/python/python/lancedb/__init__.py @@ -12,6 +12,7 @@ __version__ = importlib.metadata.version("lancedb") from ._lancedb import connect as lancedb_connect from ._lancedb import FtsToken +from ._lancedb import LsmWriteSpec from ._lancedb import tokenize as _tokenize from .common import URI, sanitize_uri from urllib.parse import urlparse @@ -21,6 +22,12 @@ from .remote.db import RemoteDBConnection from .expr import Expr, col, lit, func from .schema import blob, vector, BlobType from .job import AsyncJob, Job +from .functions import ( + FunctionApplication as FunctionApplication, + FunctionBinding as FunctionBinding, + FunctionVersion as FunctionVersion, + PythonRuntimeSpec as PythonRuntimeSpec, +) from .table import AsyncTable, Table from .types import BaseTokenizerType from ._lancedb import Session @@ -518,6 +525,7 @@ __all__ = [ "Job", "LanceDBConnection", "LanceNamespaceDBConnection", + "LsmWriteSpec", "RemoteDBConnection", "Session", "Table", diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 47e727f99..ea5d3e972 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -198,6 +198,9 @@ class Connection(object): async def drop_table( self, name: str, namespace_path: Optional[List[str]] = None ) -> None: ... + async def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: ... async def drop_all_tables( self, namespace_path: Optional[List[str]] = None ) -> None: ... @@ -335,6 +338,14 @@ class Table: ) -> list[FtsToken]: ... async def delete(self, filter: Union[str, PyExpr]) -> DeleteResult: ... async def add_columns(self, columns: list[tuple[str, str]]) -> AddColumnsResult: ... + async def add_computed_columns( + self, columns: list[tuple[str, str]] + ) -> AddColumnsResult: ... + async def add_function_columns( + self, application_json: str, output_name: Optional[str] + ) -> AddColumnsResult: ... + async def refresh_column(self, column: str) -> RefreshColumnResult: ... + async def refresh_column_async(self, column: str) -> Job: ... async def add_columns_with_schema(self, schema: pa.Schema) -> AddColumnsResult: ... async def alter_columns( self, columns: list[dict[str, Any]] @@ -355,6 +366,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 +664,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,13 +682,19 @@ 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]: ... class AddColumnsResult: version: int +class RefreshColumnResult: + rows_filled: int + version: int + class AlterColumnsResult: version: int diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index eeae8bf50..14b6c0b0d 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -524,6 +524,12 @@ class DBConnection(EnforceOverrides): namespace_path = [] raise NotImplementedError + def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: + """Start dropping a table and return its cleanup job.""" + raise NotImplementedError + def rename_table( self, cur_name: str, @@ -1186,6 +1192,20 @@ class LanceDBConnection(DBConnection): ) ) + @override + def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: + """Start dropping a table and return its cleanup job. + + The table may become unavailable before its data files are removed. + Call :meth:`Job.wait` to wait for cleanup to finish. + """ + if namespace_path is None: + namespace_path = [] + job = LOOP.run(self._conn.drop_table_async(name, namespace_path=namespace_path)) + return Job(job if isinstance(job, AsyncJob) else AsyncJob(job)) + @override def drop_all_tables(self, namespace_path: Optional[List[str]] = None): if namespace_path is None: @@ -1963,6 +1983,23 @@ class AsyncConnection(object): if f"Table '{name}' was not found" not in str(e): raise e + async def drop_table_async( + self, + name: str, + *, + namespace_path: Optional[List[str]] = None, + ) -> AsyncJob: + """Start dropping a table and return its cleanup job. + + The table may become unavailable before its data files are removed. + Await :meth:`AsyncJob.wait` to wait for cleanup to finish. + """ + if namespace_path is None: + namespace_path = [] + return AsyncJob( + await self._inner.drop_table_async(name, namespace_path=namespace_path) + ) + async def drop_all_tables(self, namespace_path: Optional[List[str]] = None): """Drop all tables from the database. diff --git a/python/python/lancedb/embeddings/base.py b/python/python/lancedb/embeddings/base.py index f711e5b7d..149b9c089 100644 --- a/python/python/lancedb/embeddings/base.py +++ b/python/python/lancedb/embeddings/base.py @@ -26,7 +26,6 @@ class EmbeddingFunction(BaseModel, ABC): 3. ndims() which returns the number of dimensions of the vector column """ - __slots__ = ("__weakref__",) # pydantic 1.x compatibility max_retries: int = ( 7 # Setting 0 disables retires. Maybe this should not be enabled by default, ) diff --git a/python/python/lancedb/embeddings/bedrock.py b/python/python/lancedb/embeddings/bedrock.py index dc2badceb..dc93a2da9 100644 --- a/python/python/lancedb/embeddings/bedrock.py +++ b/python/python/lancedb/embeddings/bedrock.py @@ -7,8 +7,7 @@ from functools import cached_property from typing import List, Union import numpy as np - -from lancedb.pydantic import PYDANTIC_VERSION +from pydantic import ConfigDict from ..util import attempt_import_or_raise from .base import TextEmbeddingFunction @@ -67,13 +66,7 @@ class BedRockText(TextEmbeddingFunction): source_input_type: str = "search_document" query_input_type: str = "search_query" - if PYDANTIC_VERSION.major < 2: # Pydantic 1.x compat - - class Config: - keep_untouched = (cached_property,) - else: - model_config = dict() - model_config["ignored_types"] = (cached_property,) + model_config = ConfigDict(ignored_types=(cached_property,)) def ndims(self): # return len(self._generate_embedding("test")) diff --git a/python/python/lancedb/embeddings/gemini_text.py b/python/python/lancedb/embeddings/gemini_text.py index 32f2d4d04..d3bf79af4 100644 --- a/python/python/lancedb/embeddings/gemini_text.py +++ b/python/python/lancedb/embeddings/gemini_text.py @@ -7,8 +7,7 @@ from functools import cached_property from typing import List, Optional, Union import numpy as np - -from lancedb.pydantic import PYDANTIC_VERSION +from pydantic import ConfigDict from ..util import attempt_import_or_raise from .base import TextEmbeddingFunction @@ -87,13 +86,7 @@ class GeminiText(TextEmbeddingFunction): query_task_type: str = "retrieval_query" source_task_type: str = "retrieval_document" - if PYDANTIC_VERSION.major < 2: # Pydantic 1.x compat - - class Config: - keep_untouched = (cached_property,) - else: - model_config = dict() - model_config["ignored_types"] = (cached_property,) + model_config = ConfigDict(ignored_types=(cached_property,)) def ndims(self): if self.dim: diff --git a/python/python/lancedb/embeddings/imagebind.py b/python/python/lancedb/embeddings/imagebind.py index 84bb0a123..c8051f14d 100644 --- a/python/python/lancedb/embeddings/imagebind.py +++ b/python/python/lancedb/embeddings/imagebind.py @@ -7,14 +7,13 @@ from typing import List, Union import numpy as np import pyarrow as pa +from pydantic import ConfigDict from ..util import attempt_import_or_raise from .base import EmbeddingFunction from .registry import register from .utils import AUDIO, IMAGES, TEXT -from lancedb.pydantic import PYDANTIC_VERSION - @register("imagebind") class ImageBindEmbeddings(EmbeddingFunction): @@ -31,13 +30,7 @@ class ImageBindEmbeddings(EmbeddingFunction): device: str = "cpu" normalize: bool = False - if PYDANTIC_VERSION.major < 2: # Pydantic 1.x compat - - class Config: - keep_untouched = (cached_property,) - else: - model_config = dict() - model_config["ignored_types"] = (cached_property,) + model_config = ConfigDict(ignored_types=(cached_property,)) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/python/python/lancedb/embeddings/instructor.py b/python/python/lancedb/embeddings/instructor.py index 675a0139c..37ae1c296 100644 --- a/python/python/lancedb/embeddings/instructor.py +++ b/python/python/lancedb/embeddings/instructor.py @@ -101,8 +101,7 @@ class InstructorEmbeddingFunction(TextEmbeddingFunction): @weak_lru(maxsize=1) def ndims(self): - model = self.get_model() - return model.encode("foo").shape[0] + return len(self.generate_embeddings([[self.source_instruction, "foo"]])[0]) def compute_query_embeddings(self, query: str, *args, **kwargs) -> List[np.array]: return self.generate_embeddings([[self.query_instruction, query]]) 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/embeddings/transformers.py b/python/python/lancedb/embeddings/transformers.py index c8a65b9ca..3f42edfdd 100644 --- a/python/python/lancedb/embeddings/transformers.py +++ b/python/python/lancedb/embeddings/transformers.py @@ -7,8 +7,7 @@ from typing import List, Any import numpy as np -from pydantic import PrivateAttr -from lancedb.pydantic import PYDANTIC_VERSION +from pydantic import ConfigDict, PrivateAttr from ..util import attempt_import_or_raise from .base import EmbeddingFunction @@ -59,13 +58,7 @@ class TransformersEmbeddingFunction(EmbeddingFunction): ) self._model.to(self.device) - if PYDANTIC_VERSION.major < 2: # Pydantic 1.x compat - - class Config: - keep_untouched = (cached_property,) - else: - model_config = dict() - model_config["ignored_types"] = (cached_property,) + model_config = ConfigDict(ignored_types=(cached_property,)) def ndims(self): self._ndims = self._model.config.hidden_size diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py new file mode 100644 index 000000000..df781f665 --- /dev/null +++ b/python/python/lancedb/functions.py @@ -0,0 +1,380 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Canonical values exchanged with LanceDB Enterprise Function services. + +These immutable models contain client/wire state only. Catalog persistence, +environment bake, secret resolution, and execution are owned by Sophon. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any, Optional + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + conint, + field_validator, + model_validator, +) + +_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1) +_UInt32 = conint(strict=True, ge=0, le=2**32 - 1) +_UInt64 = conint(strict=True, ge=0, le=2**64 - 1) + + +class _FrozenDict(dict): + def _immutable(self, *args, **kwargs): + raise TypeError("remote canonical values are immutable") + + __setitem__ = _immutable + __delitem__ = _immutable + clear = _immutable + pop = _immutable + popitem = _immutable + setdefault = _immutable + update = _immutable + + def __ior__(self, other): + self._immutable() + + +def _freeze_value(value): + if isinstance(value, Mapping): + return _FrozenDict({key: _freeze_value(child) for key, child in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze_value(child) for child in value) + return value + + +def _validate_literal(value): + if isinstance(value, float): + raise ValueError( + "floating-point Function literals are not part of the Slice 1 " + "canonical wire contract" + ) + if isinstance(value, int) and not isinstance(value, bool): + if not -(2**63) <= value <= 2**64 - 1: + raise ValueError( + "Function integer literal is outside the canonical JSON range" + ) + elif isinstance(value, Mapping): + for child in value.values(): + _validate_literal(child) + elif isinstance(value, (list, tuple)): + for child in value: + _validate_literal(child) + return value + + +def _known_wire_value(value): + if isinstance(value, _RemoteValue): + return value._known_dict() + if isinstance(value, Mapping): + return {key: _known_wire_value(child) for key, child in value.items()} + if isinstance(value, (list, tuple)): + return [_known_wire_value(child) for child in value] + return value + + +class _RemoteValue(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + @model_validator(mode="after") + def _freeze_mappings(self): + for name, value in self.__dict__.items(): + object.__setattr__(self, name, _freeze_value(value)) + return self + + @classmethod + def from_json(cls, payload: str): + return cls.model_validate_json(payload) + + def _known_dict(self) -> dict[str, Any]: + known = {} + for name, field in self.__class__.model_fields.items(): + value = getattr(self, name) + if value is None: + continue + if not field.is_required(): + default_factory = field.default_factory + if default_factory is not None and value == default_factory(): + continue + if default_factory is None and value == field.default: + continue + known[name] = _known_wire_value(value) + return known + + def _copy(self, *, update: Mapping[str, Any]): + update = {name: _freeze_value(value) for name, value in update.items()} + return self.model_copy(update=update) + + def to_canonical_json(self) -> str: + return json.dumps( + self._known_dict(), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +class _OpenRemoteValue(_RemoteValue): + """Forward-readable value whose extras stay out of canonical encoding.""" + + if _PYDANTIC_V2: + model_config = {"extra": "allow", "frozen": True} + else: + + class Config: + allow_mutation = False + extra = "allow" + + def _unknown_field_names(self) -> set[str]: + if _PYDANTIC_V2: + return set((self.__pydantic_extra__ or {}).keys()) + return set(self.__dict__) - set(self.__fields__) + + +class FunctionArtifact(_RemoteValue): + """Content-addressed Python artifact identity.""" + + kind: str + digest: str + entrypoint: str + + +class FunctionParameter(_RemoteValue): + name: str + arrow_type: str + nullable: bool + + +class FunctionResultField(_OpenRemoteValue): + name: str + arrow_type: str + nullable: bool + + +class FunctionOutput(_OpenRemoteValue): + """Scalar or ordered named-struct output; unknown kinds remain decodable.""" + + kind: str + arrow_type: Optional[str] = None + nullable: Optional[bool] = None + fields: tuple[FunctionResultField, ...] = () + + +class FunctionSignature(_RemoteValue): + inputs: tuple[FunctionParameter, ...] + output: FunctionOutput + + +class PythonEnvironmentSpec(_RemoteValue): + """One Sophon-managed Python environment source.""" + + kind: str + packages: tuple[str, ...] = () + path: Optional[str] = None + modules: tuple[str, ...] = () + image: Optional[str] = None + + +class PythonRuntimeSpec(_RemoteValue): + """Remote runtime definition with non-secret environment values. + + V1 supports ``kind="python"``. Newer runtime kinds remain readable, while + their unknown payload fields are intentionally not retained by the client. + """ + + kind: str + python_version: Optional[str] = None + environment: Optional[PythonEnvironmentSpec] = None + env: Optional[Mapping[str, str]] = None + + @model_validator(mode="after") + def _validate_runtime_kind(self): + if self.kind == "python": + if self.python_version is None: + raise ValueError("python runtime requires python_version") + if self.environment is None: + raise ValueError("python runtime requires environment") + else: + object.__setattr__(self, "python_version", None) + object.__setattr__(self, "environment", None) + object.__setattr__(self, "env", None) + return self + + +class FunctionVersion(_RemoteValue): + """An exact immutable Function version returned by Enterprise. + + Scheduling resources, priority, concurrency, and retry policy belong to + the submitting Job and are not part of this identity. + """ + + name: str + version: str + artifact: FunctionArtifact + signature: FunctionSignature + runtime: PythonRuntimeSpec + runtime_digest: str + environment_digest: str + required_secrets: tuple[str, ...] = () + created_at: str + + +class FunctionVersionRef(_OpenRemoteValue): + name: str + version: str + + +class ApplicationInput(_OpenRemoteValue): + """One parameter value. + + Slice 1 freezes integers, strings, booleans, nulls, arrays, and objects. + Floating-point literal encoding is deferred until Python authoring is + introduced with a language-neutral numeric representation. + """ + + parameter: str + kind: str + value: Any + + @field_validator("value") + @classmethod + def _validate_value(cls, value): + return _validate_literal(value) + + +class FunctionApplication(_OpenRemoteValue): + """Immutable pre-declaration application of an exact Function version.""" + + function: FunctionVersionRef + inputs: tuple[ApplicationInput, ...] + output: FunctionOutput + group_id: str + columns: Mapping[str, str] = Field(default_factory=dict) + + def _known_dict(self) -> dict[str, Any]: + value = super()._known_dict() + for name in self._unknown_field_names(): + value.pop(name, None) + return value + + def _ensure_declarable(self) -> None: + unknown = {f"application.{name}" for name in self._unknown_field_names()} + unknown.update( + f"function.{name}" for name in self.function._unknown_field_names() + ) + for index, input_value in enumerate(self.inputs): + unknown.update( + f"inputs[{index}].{name}" for name in input_value._unknown_field_names() + ) + unknown.update(f"output.{name}" for name in self.output._unknown_field_names()) + for index, field in enumerate(self.output.fields): + unknown.update( + f"output.fields[{index}].{name}" + for name in field._unknown_field_names() + ) + if unknown: + raise ValueError( + "Function application contains fields from a newer contract: " + f"{sorted(unknown)!r}" + ) + + def rename(self, *, columns: Mapping[str, str]) -> FunctionApplication: + """Return a copy with result-field to table-column aliases.""" + if self.output.kind != "named_struct": + raise ValueError("rename(columns=...) requires a named-struct application") + result_fields = {field.name for field in self.output.fields} + unknown = set(columns) - result_fields + if unknown: + raise ValueError(f"unknown Function result fields: {sorted(unknown)!r}") + merged = dict(self.columns) + merged.update(columns) + destinations = tuple( + merged.get(field.name, field.name) for field in self.output.fields + ) + if len(set(destinations)) != len(destinations): + raise ValueError("FunctionApplication rename destinations must be unique") + return self._copy(update={"columns": merged}) + + +class InputBinding(_RemoteValue): + parameter: str + field_id: _Int32 + field_path: str + arrow_type: str + nullable: bool + + +class OutputMapping(_RemoteValue): + """One stable result-field mapping. + + Assignment state is outside the Slice 1 client contract. During the NULL + transition Lance exposes no public cell-flag identifier to persist here. + """ + + result_field: str + output_name: str + output_field_id: _Int32 + output_ordinal: _UInt32 + arrow_type: str + nullable: bool + + +class FunctionBinding(_RemoteValue): + """Immutable grouped binding persisted by the Enterprise table service.""" + + binding_id: str + revision: _UInt64 + function: FunctionVersionRef + group_id: str + inputs: tuple[InputBinding, ...] + outputs: tuple[OutputMapping, ...] + input_schema: Optional[Mapping[str, Any]] = None + output_schema: Optional[Mapping[str, Any]] = None + + +class RefreshColumnResult(_RemoteValue): + """Terminal result of a remote Function-column refresh Job.""" + + rows_assigned: _UInt64 + rows_failed: _UInt64 + rows_remaining: _UInt64 + source_version: _UInt64 + published_version: Optional[_UInt64] = None + + @property + def rows_filled(self) -> int: + """Deprecated compatibility alias for :attr:`rows_assigned`.""" + return self.rows_assigned + + @property + def version(self) -> Optional[int]: + """Deprecated compatibility alias for :attr:`published_version`.""" + return self.published_version + + +__all__ = [ + "ApplicationInput", + "FunctionApplication", + "FunctionArtifact", + "FunctionBinding", + "FunctionOutput", + "FunctionParameter", + "FunctionResultField", + "FunctionSignature", + "FunctionVersion", + "FunctionVersionRef", + "InputBinding", + "OutputMapping", + "PythonEnvironmentSpec", + "PythonRuntimeSpec", + "RefreshColumnResult", +] diff --git a/python/python/lancedb/namespace.py b/python/python/lancedb/namespace.py index b151395cc..0e60bd218 100644 --- a/python/python/lancedb/namespace.py +++ b/python/python/lancedb/namespace.py @@ -49,6 +49,7 @@ from lancedb._lancedb import ( ) from lancedb.background_loop import LOOP from lancedb.db import AsyncConnection, DBConnection +from lancedb.job import AsyncJob, Job from lance_namespace import ( LanceNamespace, connect as namespace_connect, @@ -624,6 +625,18 @@ class LanceNamespaceDBConnection(DBConnection): namespace_path = [] LOOP.run(self._inner.drop_table(name, namespace_path=namespace_path)) + @override + def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: + """Start dropping a table and return its cleanup job.""" + if namespace_path is None: + namespace_path = [] + job = LOOP.run( + self._inner.drop_table_async(name, namespace_path=namespace_path) + ) + return Job(job if isinstance(job, AsyncJob) else AsyncJob(job)) + @override def rename_table( self, @@ -1134,6 +1147,14 @@ class AsyncLanceNamespaceDBConnection: namespace_path = [] await self._inner.drop_table(name, namespace_path=namespace_path) + async def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> AsyncJob: + """Start dropping a table and return its cleanup job.""" + if namespace_path is None: + namespace_path = [] + return await self._inner.drop_table_async(name, namespace_path=namespace_path) + async def rename_table( self, cur_name: str, diff --git a/python/python/lancedb/py.typed b/python/python/lancedb/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/python/python/lancedb/py.typed @@ -0,0 +1 @@ + diff --git a/python/python/lancedb/pydantic.py b/python/python/lancedb/pydantic.py index c4dedc0e6..528d865d7 100644 --- a/python/python/lancedb/pydantic.py +++ b/python/python/lancedb/pydantic.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The LanceDB Authors -"""Pydantic (v1 / v2) adapter for LanceDB""" +"""Pydantic adapter for LanceDB.""" from __future__ import annotations @@ -14,9 +14,6 @@ from enum import Enum from typing import ( TYPE_CHECKING, Any, - Callable, - Dict, - Generator, List, Type, Union, @@ -24,17 +21,9 @@ from typing import ( GenericAlias, ) -import numpy as np import pyarrow as pa import pydantic -from packaging.version import Version - -PYDANTIC_VERSION = Version(pydantic.__version__) -try: - from pydantic_core import CoreSchema, core_schema -except ImportError: - if PYDANTIC_VERSION.major >= 2: - raise +from pydantic_core import CoreSchema, core_schema if TYPE_CHECKING: from pydantic.fields import FieldInfo @@ -131,28 +120,18 @@ def Vector( ), ) - @classmethod - def __get_validators__(cls) -> Generator[Callable, None, None]: - yield cls.validate - - # For pydantic v1 - @classmethod - def validate(cls, v): - if not isinstance(v, (list, range, np.ndarray)) or len(v) != dim: - raise TypeError("A list of numbers or numpy.ndarray is needed") - return cls(v) - - if PYDANTIC_VERSION.major < 2: - - @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]): - field_schema["items"] = {"type": "number"} - field_schema["maxItems"] = dim - field_schema["minItems"] = dim - return FixedSizeList +def _raise_bare_vector_error(*_args): + raise TypeError("Vector must be parameterized with a dimension, e.g. Vector(128).") + + +# Pydantic otherwise inspects the bare factory as a field type and produces +# misleading errors about its internal annotations. +setattr(Vector, "__get_pydantic_core_schema__", _raise_bare_vector_error) + + def MultiVector( dim: int, value_type: pa.DataType = pa.float32(), nullable: bool = True ) -> Type: @@ -223,31 +202,6 @@ def MultiVector( ), ) - @classmethod - def __get_validators__(cls) -> Generator[Callable, None, None]: - yield cls.validate - - # For pydantic v1 - @classmethod - def validate(cls, v): - if not isinstance(v, (list, range)): - raise TypeError("A list of vectors is needed") - for vec in v: - if not isinstance(vec, (list, range, np.ndarray)) or len(vec) != dim: - raise TypeError(f"Each vector must be a list of {dim} numbers") - return cls(v) - - if PYDANTIC_VERSION.major < 2: - - @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]): - field_schema["items"] = { - "type": "array", - "items": {"type": "number"}, - "minItems": dim, - "maxItems": dim, - } - return MultiVectorList @@ -293,20 +247,10 @@ def _py_type_to_arrow_type(py_type: Type[Any], field: FieldInfo) -> pa.DataType: ) -if PYDANTIC_VERSION.major < 2: - - def _pydantic_model_to_fields(model: pydantic.BaseModel) -> List[pa.Field]: - return [ - _pydantic_to_field(name, field) for name, field in model.__fields__.items() - ] - -else: - - def _pydantic_model_to_fields(model: pydantic.BaseModel) -> List[pa.Field]: - return [ - _pydantic_to_field(name, field) - for name, field in model.model_fields.items() - ] +def _pydantic_model_to_fields(model: pydantic.BaseModel) -> List[pa.Field]: + return [ + _pydantic_to_field(name, field) for name, field in model.model_fields.items() + ] def _pydantic_type_to_arrow_type(tp: Any, field: FieldInfo) -> pa.DataType: @@ -499,8 +443,6 @@ class LanceModel(pydantic.BaseModel): @classmethod def safe_get_fields(cls): - if PYDANTIC_VERSION.major < 2: - return cls.__fields__ return cls.model_fields @classmethod @@ -538,23 +480,9 @@ def get_extras(field_info: FieldInfo, key: str) -> Any: """ Get the extra metadata from a Pydantic FieldInfo. """ - if PYDANTIC_VERSION.major >= 2: - return (field_info.json_schema_extra or {}).get(key) - return (field_info.field_info.extra or {}).get("json_schema_extra", {}).get(key) + return (field_info.json_schema_extra or {}).get(key) -if PYDANTIC_VERSION.major < 2: - - def model_to_dict(model: pydantic.BaseModel) -> Dict[str, Any]: - """ - Convert a Pydantic model to a dictionary. - """ - return model.dict() - -else: - - def model_to_dict(model: pydantic.BaseModel) -> Dict[str, Any]: - """ - Convert a Pydantic model to a dictionary. - """ - return model.model_dump() +def model_to_dict(model: pydantic.BaseModel) -> dict[str, Any]: + """Convert a Pydantic model to a dictionary.""" + return model.model_dump() diff --git a/python/python/lancedb/query.py b/python/python/lancedb/query.py index 095a7b5ff..bd5e2cea2 100644 --- a/python/python/lancedb/query.py +++ b/python/python/lancedb/query.py @@ -32,8 +32,6 @@ from typing_extensions import Annotated from lancedb._lancedb import fts_query_to_json from lancedb.background_loop import LOOP -from lancedb.pydantic import PYDANTIC_VERSION - from . import __version__ from .arrow import AsyncRecordBatchReader from .dependencies import pandas as pd @@ -827,12 +825,7 @@ class Query(pydantic.BaseModel): # This tells pydantic to allow custom types (needed for the `vector` query since # pa.Array wouln't be allowed otherwise) - if PYDANTIC_VERSION.major < 2: # Pydantic 1.x compat - - class Config: - arbitrary_types_allowed = True - else: - model_config = {"arbitrary_types_allowed": True} + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) class LanceQueryBuilder(ABC): @@ -2235,6 +2228,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder): reranker=self._reranker, limit=self._limit, with_row_ids=True, + offset=self._offset, ) return self._finish_hybrid_results(results) @@ -2256,6 +2250,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder): reranker, limit: int, with_row_ids: bool, + offset: Optional[int] = None, ) -> pa.Table: if norm == "rank": vector_results = LanceHybridQueryBuilder._rank(vector_results, "_distance") @@ -2332,7 +2327,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder): score_i = results.column_names.index("_score") results = results.set_column(score_i, "_score", original_scores) - results = results.slice(length=limit) + results = results.slice(offset=offset or 0, length=limit) if not with_row_ids: results = results.drop(["_rowid"]) @@ -2679,8 +2674,12 @@ class LanceHybridQueryBuilder(LanceQueryBuilder): # Apply common configurations if self._limit: - self._vector_query.limit(self._limit) - self._fts_query.limit(self._limit) + # The final offset/limit window is sliced out of the combined, + # reranked results, so each sub-query must fetch enough rows to + # cover the skipped prefix as well as the window itself. + sub_query_limit = self._limit + (self._offset or 0) + self._vector_query.limit(sub_query_limit) + self._fts_query.limit(sub_query_limit) if self._columns: self._vector_query.select(self._columns) self._fts_query.select(self._columns) @@ -3245,12 +3244,7 @@ class AsyncStandardQuery(AsyncQueryBase): if ordering is None: self._inner.order_by(None) else: - self._inner.order_by( - [ - o.model_dump() if hasattr(o, "model_dump") else o.dict() - for o in ordering - ] - ) + self._inner.order_by([o.model_dump() for o in ordering]) return self def fast_search(self) -> Self: diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index 332886590..16ad65dcb 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -23,7 +23,7 @@ import pyarrow as pa from ..common import DATA from ..db import DBConnection, LOOP -from ..job import Job +from ..job import AsyncJob, Job if TYPE_CHECKING: from .._lancedb import JobDescription, JobInfo @@ -663,6 +663,16 @@ class RemoteDBConnection(DBConnection): namespace_path = [] LOOP.run(self._conn.drop_table(name, namespace_path=namespace_path)) + @override + def drop_table_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: + """Start dropping a table and return its cleanup job.""" + if namespace_path is None: + namespace_path = [] + job = LOOP.run(self._conn.drop_table_async(name, namespace_path=namespace_path)) + return Job(job if isinstance(job, AsyncJob) else AsyncJob(job)) + @override def rename_table( self, diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index acc2f4c9d..b97f8f194 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -49,6 +49,7 @@ from lancedb.index import ( LabelList, ) from lancedb.job import Job +from lancedb.functions import FunctionApplication from lancedb.remote.db import LOOP from lancedb.table import IndexConfigType, KNOWN_METRICS import pyarrow as pa @@ -958,8 +959,21 @@ class RemoteTable(Table): def count_rows(self, filter: Optional[str] = None) -> int: return LOOP.run(self._table.count_rows(filter)) - def add_columns(self, transforms: Dict[str, str]) -> AddColumnsResult: - return LOOP.run(self._table.add_columns(transforms)) + def add_columns( + self, + transforms: Dict[str, str | FunctionApplication] + | FunctionApplication + | None = None, + *, + computed: Dict[str, str] | None = None, + ) -> AddColumnsResult: + return LOOP.run(self._table.add_columns(transforms, computed=computed)) + + def refresh_column(self, column: str): + return LOOP.run(self._table.refresh_column(column)) + + def refresh_column_async(self, column: str) -> Job: + return Job(LOOP.run(self._table.refresh_column_async(column))) def alter_columns( self, *alterations: Iterable[Dict[str, str]] @@ -979,17 +993,39 @@ class RemoteTable(Table): return LOOP.run(self._table.set_unenforced_primary_key(columns)) def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None: - """Not supported on LanceDB Cloud.""" + """Install an LsmWriteSpec.""" return LOOP.run(self._table.set_lsm_write_spec(spec)) def unset_lsm_write_spec(self) -> None: - """Not supported on LanceDB Cloud.""" + """Remove the LsmWriteSpec.""" return LOOP.run(self._table.unset_lsm_write_spec()) def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]: """Read the installed LsmWriteSpec, or ``None``.""" 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: """No-op on LanceDB Cloud (no local shard writers).""" return LOOP.run(self._table.close_lsm_writers()) 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 ae36bac7a..913ab5289 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -72,6 +72,7 @@ from .index import ( FTS, ) from .expr import Expr +from .functions import FunctionApplication from .merge import LanceMergeInsertBuilder from .pydantic import LanceModel, model_to_dict from .query import ( @@ -108,6 +109,11 @@ def _should_push_down_query_table( return namespace_client is not None and "QueryTable" in pushdown_operations +def _polars_predicate_pushdown_barrier(frame: Any) -> Any: + """Return a Polars frame unchanged while blocking predicate pushdown.""" + return frame + + _MODEL_BACKED_TOKENIZER_PREFIXES = ("jieba", "lindera") _MODEL_BACKED_TOKENIZER_ERRORS = ( "unknown base tokenizer", @@ -171,6 +177,7 @@ if TYPE_CHECKING: CompactionStats, Tag, AddColumnsResult, + RefreshColumnResult, AddResult, AlterColumnsResult, UpdateFieldMetadataResult, @@ -427,6 +434,20 @@ def _cast_to_target_schema( return pa.RecordBatchReader.from_batches(reordered_schema, gen()) +def _field_extension_name(field: pa.Field) -> Optional[str]: + extension_name = getattr(field.type, "extension_name", None) + if extension_name is not None: + return extension_name + + metadata = field.metadata or {} + extension_name = metadata.get(b"ARROW:extension:name") or metadata.get( + "ARROW:extension:name" + ) + if isinstance(extension_name, bytes): + return extension_name.decode() + return extension_name + + def _align_field_types( fields: List[pa.Field], target_fields: List[pa.Field], @@ -439,6 +460,16 @@ def _align_field_types( target_field = next((f for f in target_fields if f.name == field.name), None) if target_field is None: raise ValueError(f"Field '{field.name}' not found in target schema") + # Preserve arrow.json input until it reaches Lance. LanceDB exposes stored + # JSON columns as lance.json (JSONB-backed LargeBinary), but casting the + # input to that storage type here merely relabels the raw JSON bytes as + # JSONB. Lance must see arrow.json so it can perform the JSONB encoding. + if ( + _field_extension_name(field) == "arrow.json" + and _field_extension_name(target_field) == "lance.json" + ): + new_fields.append(field) + continue if pa.types.is_struct(target_field.type): if pa.types.is_struct(field.type): new_type = pa.struct( @@ -864,12 +895,18 @@ class Table(ABC): """ raise NotImplementedError - def to_polars(self, **kwargs) -> "pl.DataFrame": - """Return the table as a polars.DataFrame. + def to_polars(self, **kwargs) -> "pl.LazyFrame": + """Return the table as a Polars LazyFrame. + + Note + ---- + The Polars streaming engine is not supported because it does not currently + implement Python PyArrow dataset scans. Use the default engine when collecting + this LazyFrame. Returns ------- - polars.DataFrame + polars.LazyFrame """ raise NotImplementedError @@ -1905,14 +1942,23 @@ class Table(ABC): @abstractmethod def add_columns( - self, transforms: Dict[str, str] | pa.Field | List[pa.Field] | pa.Schema + self, + transforms: Dict[str, str | FunctionApplication] + | FunctionApplication + | pa.Field + | List[pa.Field] + | pa.Schema + | None = None, + *, + computed: Dict[str, str] | None = None, ): """ Add new columns with defined values. Parameters ---------- - transforms: Dict[str, str], pa.Field, List[pa.Field], pa.Schema + transforms: Dict[str, str | FunctionApplication], FunctionApplication, + pa.Field, List[pa.Field], pa.Schema A map of column name to a SQL expression to use to calculate the value of the new column. These expressions will be evaluated for each row in the table, and can reference existing columns. @@ -1920,10 +1966,101 @@ class Table(ABC): new columns with the specified data types. The new columns will be initialized with null values. + A mapping with one ``FunctionApplication`` value keeps its scalar + or named-struct result in the named table column. A bare + named-struct application expands its ordered result fields as one + atomic sibling group; aliases come from ``rename(columns=...)``. + Function columns are supported only on LanceDB Cloud and + Enterprise. + computed: Dict[str, str], optional + A map of column name to a SQL expression defining the column. The + column's type and inputs are derived from the expression, so no + data type is supplied. + + Unlike ``transforms``, the expression is stored rather than + evaluated now: the column is committed with no values, and rows get + them from [`refresh_column`][lancedb.table.Table.refresh_column]. + Declaring one therefore costs the same on a large table as on an + empty one. + + A refresh does not revisit rows it has already filled, so mutating + an input leaves the value computed at fill time; recomputing means + dropping the column and declaring it again. While a declaration + reads a column, that column cannot be renamed, retyped or dropped. + + On LanceDB Cloud and Enterprise the expression is planned by the + server, and the refresh runs as a server job -- see + [`refresh_column_async`][lancedb.table.Table.refresh_column_async]. + Cannot be combined with ``transforms``. + Returns ------- AddColumnsResult version: the new version number of the table after adding columns. + + Examples + -------- + >>> import lancedb + >>> db = lancedb.connect("./.lancedb") + >>> table = db.create_table("computed_demo", [{"x": 1}, {"x": 2}]) + >>> table.add_columns(computed={"doubled": "x * 2"}) + AddColumnsResult(version=2) + >>> table.refresh_column("doubled") + RefreshColumnResult(rows_filled=2, version=3) + >>> table.to_arrow().sort_by("x").to_pandas() + x doubled + 0 1 2 + 1 2 4 + """ + + @abstractmethod + def refresh_column(self, column: str) -> "RefreshColumnResult": + """ + Fill the rows of a computed column that hold no value yet. + + Declared with ``add_columns(computed=...)``, a column starts empty and + gets its values here. Rows appended since the last refresh are filled + by the next one; rows already filled are left as they are, so the call + is idempotent and does not observe a mutated input. + + Local tables only: a remote refresh runs as a server job, through + [`refresh_column_async`][lancedb.table.Table.refresh_column_async]. + + Parameters + ---------- + column: str + The name of the computed column to fill. + + Returns + ------- + RefreshColumnResult + rows_filled: the number of rows given a value. + version: the new version number of the table. + """ + + @abstractmethod + def refresh_column_async(self, column: str) -> Job: + """ + Like :meth:`refresh_column`, but returns a handle to the refresh job + instead of blocking until it completes. + + The job may already be complete when returned; callers must not assume + the column is filled until :meth:`Job.wait` returns. Invalid input -- + an unknown column, or one that is not computed -- raises here rather + than failing the job. On local tables the job runs in-process; on + LanceDB Cloud and Enterprise it is the server's backfill job. + + Examples + -------- + >>> import lancedb + >>> db = lancedb.connect("./.lancedb") + >>> table = db.create_table("computed_job_demo", [{"x": 1}, {"x": 2}]) + >>> table.add_columns(computed={"doubled": "x * 2"}) + AddColumnsResult(version=2) + >>> job = table.refresh_column_async("doubled") + >>> job.wait() + >>> job.status() + 'finished' """ @abstractmethod @@ -2569,6 +2706,9 @@ class LanceTable(Table): 2. Currently we've disabled push-down of the filters from polars because polars pushdown into pyarrow uses pyarrow compute expressions rather than SQl strings (which LanceDB supports) + 3. The Polars streaming engine is not supported because it does not + currently implement Python PyArrow dataset scans. Use the default + engine when collecting this LazyFrame. Returns ------- @@ -2577,8 +2717,12 @@ class LanceTable(Table): from lancedb.integrations.pyarrow import PyarrowDatasetAdapter dataset = PyarrowDatasetAdapter(self) - return pl.scan_pyarrow_dataset( - dataset, allow_pyarrow_filter=False, batch_size=batch_size + # Polars 1.32's non-PyArrow callback path passes batch_size twice. Keep + # the compatible PyArrow path, but block predicates because this adapter + # cannot translate PyArrow expressions into LanceDB filters. + return pl.scan_pyarrow_dataset(dataset, batch_size=batch_size).map_batches( + _polars_predicate_pushdown_barrier, + predicate_pushdown=False, ) # New unified API overload @@ -3921,9 +4065,29 @@ class LanceTable(Table): return LOOP.run(self._table.index_stats(index_name)) def add_columns( - self, transforms: Dict[str, str] | pa.field | List[pa.field] | pa.Schema + self, + transforms: Dict[str, str | FunctionApplication] + | FunctionApplication + | pa.Field + | List[pa.Field] + | pa.Schema + | None = None, + *, + computed: Dict[str, str] | None = None, ) -> AddColumnsResult: - return LOOP.run(self._table.add_columns(transforms)) + return LOOP.run(self._table.add_columns(transforms, computed=computed)) + + def refresh_column(self, column: str) -> "RefreshColumnResult": + """Fill a computed column's unfilled rows. See + [`AsyncTable.refresh_column`][lancedb.AsyncTable.refresh_column].""" + return LOOP.run(self._table.refresh_column(column)) + + def refresh_column_async(self, column: str) -> Job: + """Fill a computed column's unfilled rows, returning a handle to the + refresh job. See + [`Table.refresh_column_async`][lancedb.table.Table.refresh_column_async]. + """ + return Job(LOOP.run(self._table.refresh_column_async(column))) def alter_columns( self, *alterations: Iterable[Dict[str, str]] @@ -3958,6 +4122,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].""" @@ -4636,6 +4822,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 @@ -4643,7 +4836,7 @@ class AsyncTable: Examples -------- - >>> from lancedb._lancedb import LsmWriteSpec + >>> from lancedb import LsmWriteSpec >>> # table.set_unenforced_primary_key("id") >>> # table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 16)) """ @@ -4662,12 +4855,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. + """ + 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. + """ + 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. + """ + 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. @@ -5748,37 +6002,154 @@ class AsyncTable: return await self._inner.update(updates_sql, where) async def add_columns( - self, transforms: dict[str, str] | pa.field | List[pa.field] | pa.Schema + self, + transforms: dict[str, str | FunctionApplication] + | FunctionApplication + | pa.Field + | List[pa.Field] + | pa.Schema + | None = None, + *, + computed: dict[str, str] | None = None, ) -> AddColumnsResult: """ Add new columns with defined values. Parameters ---------- - transforms: Dict[str, str] + transforms: Dict[str, str | FunctionApplication] or FunctionApplication A map of column name to a SQL expression to use to calculate the value of the new column. These expressions will be evaluated for each row in the table, and can reference existing columns. Alternatively, you can pass a pyarrow field or schema to add new columns with NULLs. + A mapping with one ``FunctionApplication`` value keeps its scalar + or named-struct result in the named table column. A bare + named-struct application expands its ordered result fields as one + atomic sibling group; aliases come from ``rename(columns=...)``. + Function columns are supported only on LanceDB Cloud and + Enterprise. + computed: Dict[str, str], optional + A map of column name to a SQL expression defining the column. The + column's type and inputs are derived from the expression. + + Unlike ``transforms``, the expression is stored rather than + evaluated now: the column is committed with no values, and rows get + them from + [`refresh_column`][lancedb.table.AsyncTable.refresh_column]. + + A refresh does not revisit rows it has already filled, so mutating + an input leaves the value computed at fill time. While a + declaration reads a column, that column cannot be renamed, retyped + or dropped. + + On LanceDB Cloud and Enterprise the expression is planned by + the server. Cannot be combined with ``transforms``. + Returns ------- AddColumnsResult version: the new version number of the table after adding columns. """ + function_application = None + function_output_name = None + if isinstance(transforms, FunctionApplication): + function_application = transforms + elif isinstance(transforms, dict) and any( + isinstance(value, FunctionApplication) for value in transforms.values() + ): + if len(transforms) != 1 or not all( + isinstance(value, FunctionApplication) for value in transforms.values() + ): + raise ValueError( + "one add_columns call declares exactly one Function sibling group" + ) + function_output_name, function_application = next(iter(transforms.items())) + + if function_application is not None: + if computed: + raise ValueError( + "add_columns cannot mix a Function application with SQL " + "computed columns" + ) + function_application._ensure_declarable() + return await self._inner.add_function_columns( + function_application.to_canonical_json(), function_output_name + ) + if isinstance(transforms, pa.Field): transforms = [transforms] if isinstance(transforms, list) and all( {isinstance(f, pa.Field) for f in transforms} ): transforms = pa.schema(transforms) + if computed: + if transforms: + raise ValueError( + "add_columns cannot take both transforms and computed columns" + ) + return await self._inner.add_computed_columns(list(computed.items())) + if transforms is None: + raise ValueError("add_columns requires transforms or computed columns") if isinstance(transforms, pa.Schema): return await self._inner.add_columns_with_schema(transforms) else: return await self._inner.add_columns(list(transforms.items())) + async def refresh_column(self, column: str) -> RefreshColumnResult: + """ + Fill the rows of a computed column that hold no value yet. + + Declared with ``add_columns(computed=...)``, a column starts empty and + gets its values here. Rows appended since the last refresh are filled + by the next one; rows already filled are left as they are, so the call + is idempotent and does not observe a mutated input. + + Local tables only: a remote refresh runs as a server job, through + [`refresh_column_async`][lancedb.table.Table.refresh_column_async]. + + Parameters + ---------- + column: str + The name of the computed column to fill. + + Returns + ------- + RefreshColumnResult + The number of rows filled and the new version of the table. + """ + return await self._inner.refresh_column(column) + + async def refresh_column_async(self, column: str) -> AsyncJob: + """ + Like :meth:`refresh_column`, but returns a handle to the refresh job + instead of blocking until it completes. + + The job may already be complete when returned; callers must not assume + the column is filled until :meth:`AsyncJob.wait` resolves. Invalid + input -- an unknown column, or one that is not computed -- raises here + rather than failing the job. On local tables the job runs + in-process; on LanceDB Cloud and Enterprise it is the server's + backfill job. + + Examples + -------- + >>> import asyncio + >>> import lancedb + >>> async def refresh_in_background(): + ... db = await lancedb.connect_async("./.lancedb") + ... table = await db.create_table("computed_job_async_demo", [{"x": 1}]) + ... await table.add_columns(computed={"doubled": "x * 2"}) + ... job = await table.refresh_column_async("doubled") + ... await job.wait() + ... return await job.status() + >>> asyncio.run(refresh_in_background()) + 'finished' + """ + return AsyncJob(await self._inner.refresh_column_async(column)) + async def alter_columns( self, *alterations: Iterable[dict[str, Any]] ) -> AlterColumnsResult: @@ -6233,7 +6604,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/lancedb/util.py b/python/python/lancedb/util.py index f582be7b4..dbc52bff6 100644 --- a/python/python/lancedb/util.py +++ b/python/python/lancedb/util.py @@ -395,6 +395,11 @@ def _(value: dict): ) +@value_to_sql.register(pa.Scalar) +def _(value: pa.Scalar): + return value_to_sql(value.as_py()) + + @value_to_sql.register(np.ndarray) def _(value: np.ndarray): return value_to_sql(value.tolist()) diff --git a/python/python/tests/test_db.py b/python/python/tests/test_db.py index 93b791650..38bbb53fb 100644 --- a/python/python/tests/test_db.py +++ b/python/python/tests/test_db.py @@ -2,9 +2,11 @@ # SPDX-FileCopyrightText: Copyright The LanceDB Authors +import inspect import re import sys from datetime import timedelta +from importlib import resources import os from types import SimpleNamespace @@ -17,6 +19,10 @@ from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError from lancedb.pydantic import LanceModel, Vector +def test_package_includes_pep_561_marker(): + assert resources.files(lancedb).joinpath("py.typed").is_file() + + def test_basic(tmp_path): db = lancedb.connect(tmp_path) @@ -62,17 +68,23 @@ def test_basic(tmp_path): assert db.open_table("test").name == db["test"].name -def test_sync_repr_does_not_use_background_loop(tmp_path, monkeypatch): +def test_sync_debugger_inspection_does_not_use_background_loop(tmp_path, monkeypatch): from lancedb.background_loop import LOOP db = lancedb.connect(tmp_path) table = db.create_table("test", data=[{"id": 1}]) def fail_run(*args, **kwargs): - raise AssertionError("repr should not use the Python background loop") + raise AssertionError("debugger inspection should not use the background loop") monkeypatch.setattr(LOOP, "run", fail_run) + # Debuggers enumerate and evaluate every exposed attribute when expanding a + # variable. This must remain safe while their breakpoint suspends LOOP's thread. + members = dict(inspect.getmembers(db)) + + assert members["uri"] == str(tmp_path) + assert members["read_consistency_interval"] is None assert repr(db) == f"LanceDBConnection(uri={str(tmp_path)!r})" assert repr(table) == f"LanceTable(name='test', _conn={db!r})" @@ -743,8 +755,7 @@ def test_delete_table(tmp_db: lancedb.DBConnection): assert tmp_db.table_names() == [] -@pytest.mark.asyncio -async def test_delete_table_async(tmp_db: lancedb.DBConnection): +def test_drop_table_async(tmp_db: lancedb.DBConnection): data = pd.DataFrame( { "vector": [[3.1, 4.1], [5.9, 26.5]], @@ -760,7 +771,10 @@ async def test_delete_table_async(tmp_db: lancedb.DBConnection): assert tmp_db.table_names() == ["test"] - tmp_db.drop_table("test") + job = tmp_db.drop_table_async("test") + assert job.id is None + assert job.status() == "finished" + job.wait() assert tmp_db.table_names() == [] tmp_db.create_table("test", data=data) @@ -769,6 +783,17 @@ async def test_delete_table_async(tmp_db: lancedb.DBConnection): tmp_db.drop_table("does_not_exist", ignore_missing=True) +@pytest.mark.asyncio +async def test_drop_table_async_connection(tmp_db_async: lancedb.AsyncConnection): + await tmp_db_async.create_table("test", data=pa.table({"id": [1, 2]})) + + job = await tmp_db_async.drop_table_async("test") + assert job.id is None + assert await job.status() == "finished" + await job.wait() + assert await tmp_db_async.table_names() == [] + + def test_drop_database(tmp_db: lancedb.DBConnection): data = pd.DataFrame( { 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 5efb7d98a..9850669eb 100644 --- a/python/python/tests/test_embeddings.py +++ b/python/python/tests/test_embeddings.py @@ -64,6 +64,23 @@ def test_embedding_function(tmp_path): assert np.allclose(actual, expected) +def test_instructor_ndims_uses_instruction(): + instructor = get_registry().get("instructor").create() + model = MagicMock() + model.encode.return_value = np.zeros((1, 384)) + + with patch.object(type(instructor), "get_model", return_value=model): + assert instructor.ndims() == 384 + + model.encode.assert_called_once_with( + [[instructor.source_instruction, "foo"]], + batch_size=instructor.batch_size, + show_progress_bar=instructor.show_progress_bar, + normalize_embeddings=instructor.normalize_embeddings, + device=instructor.device, + ) + + def test_embedding_function_variables(): @register("variable-testing") class VariableTestingFunction(TextEmbeddingFunction): @@ -115,34 +132,16 @@ def test_embedding_function_variables(): assert func.safe_model_dump()["secret_key"] == "$var:secret" -def test_parse_functions_with_variables(): - @register("variable-parsing-test") - class VariableParsingFunction(TextEmbeddingFunction): - api_key: str - base_url: Optional[str] = None - - @staticmethod - def sensitive_keys(): - return ["api_key"] - - def ndims(self): - return 10 - - def generate_embeddings(self, texts): - # Mock implementation that just returns random embeddings - # In real usage, this would use the api_key to call an API - return [np.random.rand(self.ndims()).tolist() for _ in texts] - +def test_openai_variables_survive_metadata_round_trip(): registry = EmbeddingFunctionRegistry.get_instance() registry.set_var("test_api_key", "sk-test-key-12345") - registry.set_var("test_base_url", "https://api.example.com") conf = EmbeddingFunctionConfig( source_column="text", vector_column="vector", - function=registry.get("variable-parsing-test").create( - api_key="$var:test_api_key", base_url="$var:test_base_url" + function=registry.get("openai").create( + api_key="$var:test_api_key", base_url="https://api.example.com" ), ) @@ -150,7 +149,10 @@ def test_parse_functions_with_variables(): # Create a mock arrow table with the metadata schema = pa.schema( - [pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))] + [ + pa.field("text", pa.string()), + pa.field("vector", pa.list_(pa.float32(), 1536)), + ] ) table = pa.table({"text": [], "vector": []}, schema=schema) table = table.replace_schema_metadata(metadata) @@ -164,13 +166,15 @@ def test_parse_functions_with_variables(): assert parsed_func.api_key == "sk-test-key-12345" assert parsed_func.base_url == "https://api.example.com" - - embeddings = parsed_func.generate_embeddings(["test text"]) - assert len(embeddings) == 1 - assert len(embeddings[0]) == 10 - assert parsed_func.safe_model_dump()["api_key"] == "$var:test_api_key" + with patch("lancedb.embeddings.openai.attempt_import_or_raise") as import_openai: + parsed_func._openai_client + + import_openai.return_value.OpenAI.assert_called_once_with( + api_key="sk-test-key-12345", base_url="https://api.example.com" + ) + def test_embedding_with_bad_results(tmp_path): @register("null-embedding") @@ -627,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_expr.py b/python/python/tests/test_expr.py index 6aa78943e..0eb6f8929 100644 --- a/python/python/tests/test_expr.py +++ b/python/python/tests/test_expr.py @@ -632,3 +632,101 @@ class TestExprBytesIntegration: .to_arrow() ) assert result.num_rows == 2 + + +# ── datetime / timezone integration for lit() (issue #3262) ────────────────── + + +class TestExprDatetimeTimezoneIntegration: + """Integration coverage for lit(datetime) against table timestamp columns. + + PyArrow stores naive timestamps as UTC wall-clock microseconds. Python's + datetime.timestamp() treats naive values as *local* time, which used to + shift lit(naive) by the host UTC offset and break equality filters on + non-UTC machines. These cases lock the expected semantics. + """ + + def test_both_naive_match(self, tmp_path): + """Table naive + lit naive with the same wall clock must match.""" + db = lancedb.connect(str(tmp_path / "naive")) + ts = datetime(2024, 7, 1, 10, 0, 0) + table = db.create_table( + "t", [{"id": 1, "ts": ts}, {"id": 2, "ts": datetime(2024, 7, 2, 10, 0, 0)}] + ) + result = table.search().where(col("ts") == lit(ts)).to_list() + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_both_same_timezone_match(self, tmp_path): + """Table UTC + lit UTC for the same instant must match.""" + db = lancedb.connect(str(tmp_path / "utc")) + ts = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + table = db.create_table( + "t", + pa.table( + { + "id": [1, 2], + "ts": pa.array( + [ts, datetime(2024, 7, 2, 10, 0, 0, tzinfo=timezone.utc)], + type=pa.timestamp("us", tz="UTC"), + ), + } + ), + ) + result = table.search().where(col("ts") == lit(ts)).to_list() + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_different_timezones_same_instant(self, tmp_path): + """UTC table row equals lit of the same instant in a different zone.""" + db = lancedb.connect(str(tmp_path / "diff_tz")) + ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + # Same instant as 06:00 in UTC-4 + ts_est = datetime(2024, 7, 1, 6, 0, 0, tzinfo=timezone(timedelta(hours=-4))) + table = db.create_table( + "t", + pa.table( + { + "id": [1], + "ts": pa.array([ts_utc], type=pa.timestamp("us", tz="UTC")), + } + ), + ) + result = table.search().where(col("ts") == lit(ts_est)).to_list() + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_table_tz_literal_naive(self, tmp_path): + """UTC table + naive lit uses wall-clock equality (10:00 == 10:00 UTC).""" + db = lancedb.connect(str(tmp_path / "tz_naive")) + ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + ts_naive = datetime(2024, 7, 1, 10, 0, 0) + table = db.create_table( + "t", + pa.table( + { + "id": [1], + "ts": pa.array([ts_utc], type=pa.timestamp("us", tz="UTC")), + } + ), + ) + result = table.search().where(col("ts") == lit(ts_naive)).to_list() + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_table_naive_literal_aware(self, tmp_path): + """Naive table + UTC lit with the same wall clock must match.""" + db = lancedb.connect(str(tmp_path / "naive_aware")) + ts_naive = datetime(2024, 7, 1, 10, 0, 0) + ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + table = db.create_table("t", [{"id": 1, "ts": ts_naive}]) + result = table.search().where(col("ts") == lit(ts_utc)).to_list() + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_naive_lit_sql_is_wall_clock_not_local_shifted(self): + """Regression: naive lit must not apply the host local UTC offset.""" + ts = datetime(2024, 7, 1, 10, 0, 0) + sql = lit(ts).to_sql() + # Must encode 10:00 wall clock, not 10:00+local_offset. + assert "2024-07-01 10:00:00" in sql diff --git a/python/python/tests/test_first_class_function_slice1.py b/python/python/tests/test_first_class_function_slice1.py new file mode 100644 index 000000000..fead28bc8 --- /dev/null +++ b/python/python/tests/test_first_class_function_slice1.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import json +from pathlib import Path + +import pytest + +import lancedb.functions as functions +from lancedb.functions import ( + FunctionApplication, + FunctionBinding, + FunctionVersion, + PythonRuntimeSpec, + RefreshColumnResult, +) +from lancedb.table import AsyncTable + + +FIXTURES = ( + Path(__file__).parents[3] + / "rust" + / "lancedb" + / "tests" + / "fixtures" + / "first_class_functions" + / "v1" +) + + +def fixture(name: str) -> str: + return (FIXTURES / name).read_text() + + +def job_result(name: str) -> dict: + return json.loads(fixture(name))["result"] + + +def assert_no_secret_values(value): + if isinstance(value, dict): + for key, child in value.items(): + assert key not in { + "secret_value", + "secret_values", + "resolved_secret", + "resolved_secrets", + } + assert_no_secret_values(child) + elif isinstance(value, list): + for child in value: + assert_no_secret_values(child) + + +def test_public_function_values_are_in_api_reference(): + docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md" + rendered = docs.read_text() + for name in functions.__all__: + assert f"::: lancedb.functions.{name}" in rendered + + +@pytest.mark.parametrize( + ("fixture_name", "canonical_name", "model", "nested_result"), + [ + ( + "remote_function_job.json", + "remote_function_version.canonical.json", + FunctionVersion, + True, + ), + ( + "remote_function_application.json", + "remote_function_application.canonical.json", + FunctionApplication, + False, + ), + ( + "remote_function_binding.json", + "remote_function_binding.canonical.json", + FunctionBinding, + False, + ), + ( + "remote_refresh_job.json", + "remote_refresh_result.canonical.json", + RefreshColumnResult, + True, + ), + ( + "remote_refresh_result_without_published_version.json", + "remote_refresh_result_without_published_version.canonical.json", + RefreshColumnResult, + False, + ), + ], +) +def test_python_and_rust_share_remote_canonical_goldens( + fixture_name, canonical_name, model, nested_result +): + value = json.loads(fixture(fixture_name)) + if nested_result: + value = value["result"] + decoded = model.from_json(json.dumps(value)) + assert decoded.to_canonical_json() == fixture(canonical_name).strip() + + +def test_function_version_identity_is_immutable_and_exact(): + value = job_result("remote_function_job.json") + version = FunctionVersion.from_json(json.dumps(value)) + assert version.name == "embed" + assert version.version == "fv_01K3EXACT" + assert version.required_secrets == ("HF_TOKEN",) + + with pytest.raises((TypeError, ValueError)): + version.version = "fv_changed" + with pytest.raises(TypeError, match="immutable"): + version.runtime.env["TOKENIZERS_PARALLELISM"] = "true" + + changed = dict(value) + changed["version"] = "fv_changed" + assert FunctionVersion(**changed) != version + + +def test_unknown_fields_and_discriminators_are_forward_decodable(): + value = job_result("remote_function_job.json") + value["future_version_metadata"] = {"retention_class": "catalog"} + value["runtime"] = {"kind": "wasm", "module_digest": "sha256:wasm"} + value["signature"]["output"]["kind"] = "future_output_shape" + + version = FunctionVersion.from_json(json.dumps(value)) + assert version.runtime.kind == "wasm" + assert version.runtime.python_version is None + assert version.runtime.environment is None + assert json.loads(version.to_canonical_json())["runtime"] == {"kind": "wasm"} + assert version.signature.output.kind == "future_output_shape" + + +def test_function_application_uses_rename_columns_only(): + application = FunctionApplication.from_json( + fixture("remote_function_application.json") + ) + renamed = application.rename(columns={"normalized_text": "body_normalized"}) + + assert application.columns["normalized_text"] == "search_text" + assert renamed.columns["normalized_text"] == "body_normalized" + assert renamed.function == application.function + assert renamed.group_id == application.group_id + assert not hasattr(application, "rename_outputs") + with pytest.raises(TypeError, match="immutable"): + renamed.columns["normalized_text"] = "changed" + with pytest.raises(TypeError, match="immutable"): + application.inputs[0].value["path"] = "changed" + + with pytest.raises(ValueError, match="unknown Function result fields"): + application.rename(columns={"missing": "search_text"}) + with pytest.raises(ValueError, match="destinations must be unique"): + application.rename(columns={"normalized_text": "same", "token_count": "same"}) + + bare_value = json.loads(fixture("remote_function_application.json")) + bare_value.pop("columns") + bare = FunctionApplication(**bare_value) + with pytest.raises(ValueError, match="destinations must be unique"): + bare.rename(columns={"normalized_text": "token_count"}) + + +def test_binding_and_refresh_result_keep_stable_remote_fields(): + binding = FunctionBinding.from_json(fixture("remote_function_binding.json")) + assert binding.revision == 3 + assert binding.function.version == "fv_01K3TEXT" + assert [output.output_ordinal for output in binding.outputs] == [0, 1] + assert binding.input_schema is not None + assert binding.output_schema is not None + + result = RefreshColumnResult.from_json( + json.dumps(job_result("remote_refresh_job.json")) + ) + assert result.rows_filled == result.rows_assigned + assert result.version == result.published_version + + result = RefreshColumnResult.from_json( + fixture("remote_refresh_result_without_published_version.json") + ) + assert result.published_version is None + assert RefreshColumnResult.from_json(result.to_canonical_json()) == result + + +def test_function_literal_numeric_domain_matches_rust(): + with pytest.raises(ValueError, match="floating-point Function literals"): + FunctionApplication.from_json(fixture("remote_function_application_float.json")) + + value = json.loads(fixture("remote_function_application_float.json")) + value["inputs"][0]["value"] = 2**64 + with pytest.raises(ValueError, match="outside the canonical JSON range"): + FunctionApplication.from_json(json.dumps(value)) + + +def test_empty_default_maps_have_stable_canonical_bytes(): + runtime = PythonRuntimeSpec( + kind="python", python_version="3.12", environment={"kind": "pip"} + ) + assert runtime.to_canonical_json() == ( + '{"environment":{"kind":"pip"},"kind":"python","python_version":"3.12"}' + ) + + value = json.loads(fixture("remote_function_application.json")) + value.pop("columns") + application = FunctionApplication.from_json(json.dumps(value)) + assert "columns" not in json.loads(application.to_canonical_json()) + + +@pytest.mark.parametrize("field", ["rows_assigned", "source_version"]) +def test_refresh_result_rejects_non_u64_values(field): + value = job_result("remote_refresh_job.json") + value[field] = -1 + with pytest.raises(ValueError): + RefreshColumnResult.from_json(json.dumps(value)) + + value[field] = "1" + with pytest.raises(ValueError): + RefreshColumnResult.from_json(json.dumps(value)) + + +def test_canonical_client_values_contain_secret_names_only(): + version = FunctionVersion.from_json( + json.dumps(job_result("remote_function_job.json")) + ) + canonical = json.loads(version.to_canonical_json()) + assert canonical["required_secrets"] == ["HF_TOKEN"] + assert_no_secret_values(canonical) + + +class _FunctionDeclarationInner: + def __init__(self): + self.calls = [] + + async def add_function_columns(self, application_json, output_name): + self.calls.append((json.loads(application_json), output_name)) + return "declared" + + +def known_application() -> FunctionApplication: + value = json.loads(fixture("remote_function_application.json")) + value.pop("future_application") + return FunctionApplication(**value) + + +@pytest.mark.asyncio +async def test_add_columns_routes_struct_as_one_and_grouped_expansion_atomically(): + inner = _FunctionDeclarationInner() + table = AsyncTable(inner) + application = known_application() + + result = await table.add_columns( + {"features": application._copy(update={"columns": {}})} + ) + assert result == "declared" + assert inner.calls[-1][1] == "features" + + bare = application._copy(update={"columns": {}}).rename( + columns={"normalized_text": "search_text"} + ) + result = await table.add_columns(bare) + assert result == "declared" + assert inner.calls[-1][1] is None + assert inner.calls[-1][0]["columns"] == {"normalized_text": "search_text"} + + +@pytest.mark.asyncio +async def test_add_columns_rejects_mixed_groups_and_unknown_newer_application(): + inner = _FunctionDeclarationInner() + table = AsyncTable(inner) + application = known_application() + + with pytest.raises(ValueError, match="exactly one Function sibling group"): + await table.add_columns({"a": application, "b": application}) + + future = json.loads(fixture("remote_function_application.json")) + application = FunctionApplication(**future) + with pytest.raises(ValueError, match="newer contract"): + await table.add_columns(application) + + future.pop("future_application") + future["output"]["assignment"] = "cell_flag" + application = FunctionApplication(**future) + assert "assignment" not in json.loads(application.to_canonical_json())["output"] + with pytest.raises(ValueError, match="output.assignment"): + await table.add_columns(application) + assert inner.calls == [] + + +def test_rename_requires_named_struct_and_keeps_partial_mapping_immutable(): + scalar = FunctionApplication.from_json( + json.dumps( + { + "function": {"name": "embed", "version": "fv_exact"}, + "inputs": [], + "output": { + "kind": "scalar", + "arrow_type": "list", + "nullable": False, + }, + "group_id": "fg_scalar", + } + ) + ) + with pytest.raises(ValueError, match="named-struct"): + scalar.rename(columns={"value": "embedding"}) + + application = known_application()._copy(update={"columns": {}}) + renamed = application.rename(columns={"normalized_text": "search_text"}) + assert dict(application.columns) == {} + assert dict(renamed.columns) == {"normalized_text": "search_text"} diff --git a/python/python/tests/test_hybrid_query.py b/python/python/tests/test_hybrid_query.py index 65a7890bf..5e9b45ecb 100644 --- a/python/python/tests/test_hybrid_query.py +++ b/python/python/tests/test_hybrid_query.py @@ -12,7 +12,7 @@ import pyarrow.compute as pc import pytest import pytest_asyncio -from lancedb.index import FTS +from lancedb.index import BTree, FTS, IvfPq from lancedb.table import AsyncTable, Table @@ -99,6 +99,86 @@ async def test_async_hybrid_query_filters(table: AsyncTable): assert result["text"].to_pylist() == ["cat", "b"] +@pytest.mark.asyncio +async def test_hybrid_query_with_stale_fixed_size_binary_prefilter( + tmpdir_factory, +): + tmp_path = str(tmpdir_factory.mktemp("stale_scalar_prefilter")) + db = await lancedb.connect_async(tmp_path) + + def fixed_size_binary(value: int) -> bytes: + return value.to_bytes(16, byteorder="big") + + num_rows = 1000 + data = pa.table( + { + "space_id": pa.array( + [fixed_size_binary(i) for i in range(num_rows)], + type=pa.binary(16), + ), + "text": ["book"] * num_rows, + "vector": pa.array( + [[float(i), float(i)] for i in range(num_rows)], + type=pa.list_(pa.float32(), 2), + ), + } + ) + table = await db.create_table("test", data) + await table.create_index( + "vector", config=IvfPq(num_partitions=4, num_sub_vectors=2) + ) + await table.create_index("space_id", config=BTree()) + await table.create_index("text", config=FTS(with_position=False)) + + # Advance the search indices without advancing the scalar index. This is the + # state that previously let hybrid search use an incomplete scalar prefilter. + await table.add(data) + lance_dataset = await table.to_lance() + lance_dataset.optimize.optimize_indices(index_names=["vector_idx", "text_idx"]) + await table.checkout_latest() + + scalar_stats = await table.index_stats("space_id_idx") + assert scalar_stats is not None + assert scalar_stats.num_indexed_rows == num_rows + assert scalar_stats.num_unindexed_rows == num_rows + + for index_name in ["vector_idx", "text_idx"]: + search_stats = await table.index_stats(index_name) + assert search_stats is not None + assert search_stats.num_indexed_rows == num_rows * 2 + assert search_stats.num_unindexed_rows == 0 + + matching_ids = [5, 10, 15, 20, 25, 30] + literals = [ + f"arrow_cast(0x{fixed_size_binary(i).hex()}, 'FixedSizeBinary(16)')" + for i in matching_ids + ] + predicate = f"space_id IN ({', '.join(literals)})" + expected_ids = sorted(fixed_size_binary(i) for i in matching_ids for _ in range(2)) + + vector_query = ( + table.query().where(predicate).nearest_to([5.0, 5.0]).limit(num_rows * 2) + ) + vector_results = await vector_query.to_arrow() + assert sorted(vector_results["space_id"].to_pylist()) == expected_ids + + fts_query = ( + table.query().where(predicate).nearest_to_text("book").limit(num_rows * 2) + ) + fts_results = await fts_query.to_arrow() + assert sorted(fts_results["space_id"].to_pylist()) == expected_ids + + hybrid_results = await ( + table.query() + .where(predicate) + .nearest_to([5.0, 5.0]) + .nearest_to_text("book") + .limit(num_rows * 2) + .to_arrow() + ) + assert sorted(hybrid_results["space_id"].to_pylist()) == expected_ids + + @pytest.mark.asyncio async def test_async_hybrid_query_default_limit(table: AsyncTable): # add 10 new rows @@ -123,6 +203,31 @@ async def test_async_hybrid_query_default_limit(table: AsyncTable): assert texts.count("a") == 1 +def test_hybrid_query_offset(sync_table: Table): + # The offset window of a hybrid query must be a suffix of the same query + # run without an offset -- it must not be silently ignored. + full = ( + sync_table.search(query_type="hybrid") + .vector([0.0, 0.4]) + .text("dog") + .limit(4) + .with_row_id(True) + .to_arrow() + ) + assert len(full) == 4 + + offset_result = ( + sync_table.search(query_type="hybrid") + .vector([0.0, 0.4]) + .text("dog") + .offset(2) + .limit(2) + .with_row_id(True) + .to_arrow() + ) + assert offset_result["_rowid"].to_pylist() == full["_rowid"].to_pylist()[2:] + + def test_hybrid_query_minimum_nprobes_zero_raises(sync_table: Table): # minimum_nprobes(0) must raise the same validation error a plain vector # query raises, not silently no-op because 0 is falsy. diff --git a/python/python/tests/test_import.py b/python/python/tests/test_import.py new file mode 100644 index 000000000..4b87a0ce8 --- /dev/null +++ b/python/python/tests/test_import.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import re +import shutil +import subprocess +import sys + +import lancedb._lancedb as _lancedb +import pytest + + +@pytest.mark.skipif(sys.platform != "linux", reason="ldd is Linux-specific") +def test_native_extension_does_not_link_openssl(): + """OpenSSL-linked wheels abort when imported on RHEL hosts in FIPS mode.""" + ldd = shutil.which("ldd") + if ldd is None: + pytest.skip("ldd is not installed") + + result = subprocess.run( + [ldd, _lancedb.__file__], + check=True, + capture_output=True, + text=True, + ) + openssl_libraries = re.findall( + r"^\s*(lib(?:crypto|ssl)\S*)\s+=>", result.stdout, flags=re.MULTILINE + ) + + assert not openssl_libraries, ( + "the LanceDB native extension must use rustls instead of linking OpenSSL: " + f"{openssl_libraries}" + ) diff --git a/python/python/tests/test_index.py b/python/python/tests/test_index.py index 1cf2c733c..94268a53e 100644 --- a/python/python/tests/test_index.py +++ b/python/python/tests/test_index.py @@ -372,6 +372,31 @@ async def test_create_vector_index(some_table: AsyncTable): assert stats.num_indices == 1 +@pytest.mark.asyncio +async def test_create_ivf_index_reports_unsplittable_partitions(db_async): + dim = 8 + num_partitions = 300 # More than 256 selects hierarchical k-means. + base_vectors = [[float(row == column) for column in range(dim)] for row in range(5)] + vectors = pa.array(base_vectors * 200, pa.list_(pa.float32(), dim)) + table = await db_async.create_table( + "unsplittable_partitions", + pa.table({"vector": vectors}), + ) + + error_pattern = ( + rf"Cannot create {num_partitions} IVF partitions: k-means could only form" + ) + with pytest.raises(RuntimeError, match=error_pattern): + await table.create_index( + "vector", + config=IvfFlat( + distance_type="dot", + num_partitions=num_partitions, + max_iterations=10, + ), + ) + + @pytest.mark.asyncio async def test_create_4bit_ivfpq_index(some_table: AsyncTable): # Can create diff --git a/python/python/tests/test_lsm_write_spec.py b/python/python/tests/test_lsm_write_spec.py index d38918f09..d43cb7532 100644 --- a/python/python/tests/test_lsm_write_spec.py +++ b/python/python/tests/test_lsm_write_spec.py @@ -21,6 +21,11 @@ SCHEMA = pa.schema( ) +def test_lsm_write_spec_module_metadata(): + assert lancedb.LsmWriteSpec is LsmWriteSpec + assert LsmWriteSpec.__module__ == "lancedb._lancedb" + + def _batch(ids, vs): return pa.RecordBatch.from_arrays( [pa.array(ids, type=pa.utf8()), pa.array(vs, type=pa.int32())], @@ -83,7 +88,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 +176,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_package_metadata.py b/python/python/tests/test_package_metadata.py new file mode 100644 index 000000000..5792f457b --- /dev/null +++ b/python/python/tests/test_package_metadata.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import importlib +import re +import sys +from pathlib import Path + +import pytest + + +def test_pyo3_abi_matches_minimum_supported_python(): + project_dir = Path(__file__).parents[2] + pyproject = (project_dir / "pyproject.toml").read_text() + cargo_manifest = (project_dir / "Cargo.toml").read_text() + + minimum_python = re.search( + r'^requires-python\s*=\s*">=(\d+)\.(\d+)"$', pyproject, re.MULTILINE + ) + assert minimum_python is not None + + major, minor = minimum_python.groups() + expected_abi = f"abi3-py{major}{minor}" + configured_abis = re.findall(r'"(abi3-py\d+)"', cargo_manifest) + + assert configured_abis == [expected_abi, expected_abi], ( + "the pyo3 runtime and build ABI features must both match requires-python" + ) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows wheel regression test") +def test_windows_wheel_tag_and_native_import(): + project_dir = Path(__file__).parents[2] + wheels = list((project_dir.parent / "target" / "wheels").glob("lancedb-*.whl")) + if not wheels: + pytest.skip("no wheel artifact is available in this development environment") + + assert len(wheels) == 1 + assert wheels[0].name.endswith("-cp310-abi3-win_amd64.whl") + + native_module = importlib.import_module("lancedb._lancedb") + assert Path(native_module.__file__).suffix == ".pyd" diff --git a/python/python/tests/test_pydantic.py b/python/python/tests/test_pydantic.py index e1d533784..ca03940f2 100644 --- a/python/python/tests/test_pydantic.py +++ b/python/python/tests/test_pydantic.py @@ -10,11 +10,10 @@ import pyarrow as pa import pydantic import pytest from lancedb.pydantic import ( - PYDANTIC_VERSION, LanceModel, + MultiVector, Vector, pydantic_to_schema, - MultiVector, ) from pydantic import BaseModel from pydantic import Field @@ -415,22 +414,27 @@ def test_nullable_vector(): assert schema == pa.schema([pa.field("vec", pa.list_(pa.float32(), 16), True)]) +def test_bare_vector_raises_clear_error(): + namespace = { + "__name__": "test_model_without_pyarrow", + "LanceModel": LanceModel, + "Vector": Vector, + } + + with pytest.raises(TypeError, match=r"Vector must be parameterized.*Vector\(128\)"): + exec("class TestModel(LanceModel):\n vector: Vector", namespace) + + def test_fixed_size_list_field(): class TestModel(pydantic.BaseModel): vec: Vector(16) li: List[int] data = TestModel(vec=list(range(16)), li=[1, 2, 3]) - if PYDANTIC_VERSION.major >= 2: - assert json.loads(data.model_dump_json()) == { - "vec": list(range(16)), - "li": [1, 2, 3], - } - else: - assert data.dict() == { - "vec": list(range(16)), - "li": [1, 2, 3], - } + assert json.loads(data.model_dump_json()) == { + "vec": list(range(16)), + "li": [1, 2, 3], + } schema = pydantic_to_schema(TestModel) assert schema == pa.schema( @@ -440,10 +444,7 @@ def test_fixed_size_list_field(): ] ) - if PYDANTIC_VERSION.major >= 2: - json_schema = TestModel.model_json_schema() - else: - json_schema = TestModel.schema() + json_schema = TestModel.model_json_schema() assert json_schema == { "properties": { diff --git a/python/python/tests/test_query.py b/python/python/tests/test_query.py index 6840be052..d2629d1a8 100644 --- a/python/python/tests/test_query.py +++ b/python/python/tests/test_query.py @@ -570,6 +570,15 @@ def test_query_builder(table): assert all(np.array(rs[0]["vector"]) == [1, 2]) +def test_query_multiple_vectors(table): + results = table.search([np.array([1, 2]), np.array([4, 5])]).limit(1).to_list() + + assert len(results) == 2 + results_by_query = {result["query_index"]: result for result in results} + assert results_by_query[0]["id"] == 1 + assert results_by_query[1]["id"] == 2 + + def test_with_row_id(table: lancedb.table.Table): rs = table.search().with_row_id(True).to_arrow() assert "_rowid" in rs.column_names diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index d5d3569d3..13ffc4415 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -35,6 +35,12 @@ def make_mock_http_handler(handler): return MockLanceDBHandler +@pytest.mark.parametrize("db_name", ["a" * 64, "invalid..database"]) +def test_connect_rejects_invalid_cloud_dns_hostname(db_name): + with pytest.raises(ValueError, match="DNS labels must contain 1 to 63 bytes"): + lancedb.connect(f"db://{db_name}", api_key="fake") + + @contextlib.contextmanager def mock_lancedb_connection(handler): with http.server.HTTPServer( @@ -1127,6 +1133,131 @@ def test_stats(): assert res == stats +@contextlib.contextmanager +def lsm_test_table(lsm_handler): + """A remote table whose LSM routes are served by ``lsm_handler``. + + ``lsm_handler(request, route)`` is called for ``/v1/table/test//`` + where route is one of flush_lsm, compact_lsm, get_lsm_stats, and is + responsible for writing the response. + """ + routes = ("flush_lsm", "compact_lsm", "get_lsm_stats") + + def handler(request): + match = re.fullmatch(r"/v1/table/test/(\w+)/", request.path) + route = match.group(1) if match else None + if route in routes: + lsm_handler(request, route) + elif route == "describe": + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write(b'{"version": 1, "schema": {"fields": []}}') + else: + request.send_response(404) + request.end_headers() + + with mock_lancedb_connection(handler) as db: + yield db.open_table("test") + + +def read_json_body(request): + content_len = int(request.headers.get("Content-Length")) + return json.loads(request.rfile.read(content_len)) + + +def send_json(request, payload, status=200): + request.send_response(status) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write(json.dumps(payload).encode()) + + +def test_get_lsm_stats_sync(): + """The sync wrapper round-trips the server payload into a dict.""" + bucket = { + "shard_id": "b0", + "status": "Active", + "writer_epoch": 3, + "manifest_version": 12, + "current_generation": 6, + "replay_after_wal_entry_position": 40, + "wal_entry_position_last_seen": 42, + "generations": [{"generation": 5, "bytes": 1024, "rows": 7}], + "compacting": False, + "memtables": [ + { + "generation": 6, + "rows": 2, + "bytes": 64, + "batches": 1, + "indexes": ["vec_idx"], + } + ], + } + seen_bodies = [] + + def lsm_handler(request, route): + assert route == "get_lsm_stats" + seen_bodies.append(read_json_body(request)) + send_json(request, {"lsm_stats": {"buckets": [bucket]}}) + + with lsm_test_table(lsm_handler) as table: + assert table.get_lsm_stats() == {"buckets": [bucket]} + # Off by default, and forwarded when asked for. + assert seen_bodies == [{"include_generation_rows": False}] + table.get_lsm_stats(include_generation_rows=True) + assert seen_bodies[-1] == {"include_generation_rows": True} + + +def test_get_lsm_stats_sync_returns_none_when_lsm_disabled(): + """A null envelope means the LSM write path is not enabled, not an error.""" + + def lsm_handler(request, route): + send_json(request, {"lsm_stats": None}) + + with lsm_test_table(lsm_handler) as table: + assert table.get_lsm_stats() is None + + +def test_flush_and_compact_lsm_sync(): + """Both are one-shot POSTs answered 202 with no body.""" + called = [] + + def lsm_handler(request, route): + called.append(route) + request.send_response(202) + request.end_headers() + + with lsm_test_table(lsm_handler) as table: + assert table.flush_lsm() is None + assert table.compact_lsm() is None + assert called == ["flush_lsm", "compact_lsm"] + + +def test_checkpoint_lsm_sync(): + """Seal, read the watermark, and return once L0 holds nothing. + + The convergence loop itself is covered in Rust; this pins the sync + binding to the endpoints it drives. + """ + called = [] + + def lsm_handler(request, route): + called.append(route) + if route == "get_lsm_stats": + # An empty L0 yields no target watermark, so the loop is done + # after the seal without ever polling compaction. + send_json(request, {"lsm_stats": {"buckets": []}}) + else: + request.send_response(202) + request.end_headers() + + with lsm_test_table(lsm_handler) as table: + assert table.checkpoint_lsm() is None + assert called == ["flush_lsm", "get_lsm_stats"] + + @contextlib.contextmanager def query_test_table(query_handler, *, server_version=Version("0.1.0")): def handler(request): diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index 069527b21..b28cd9d66 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -2,10 +2,13 @@ # SPDX-FileCopyrightText: Copyright The LanceDB Authors +import ctypes +import gc import os import sys import threading import warnings +import weakref from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime, timedelta from time import sleep @@ -99,6 +102,30 @@ def test_basic(mem_db: DBConnection): assert table.to_arrow() == expected_data +def test_search_preserves_nulls_from_sliced_arrow_table(mem_db: DBConnection): + data = pa.table( + { + "id": [0, 1, 2, 3, 4], + "score_cn": [None, 22, None, 5, 8], + "score_mt": [None, 42, None, 5, 8], + "vector": [ + [20, 19, -1, -1], + [41, 38, 22, 42], + [10, 10, -1, -1], + [5, 5, 5, 5], + [8, 8, 8, 8], + ], + } + ).slice(1) + + table = mem_db.create_table("sliced_nullable", data=data) + result = table.search([41, 38, 22, 42]).limit(1).to_arrow() + + assert result["id"].to_pylist() == [1] + assert result["score_cn"].to_pylist() == [22] + assert result["score_mt"].to_pylist() == [42] + + def test_table_to_pandas_default_matches_arrow(tmp_db: DBConnection): pd = pytest.importorskip("pandas") data = pa.table({"id": [1, 2], "text": ["one", "two"]}) @@ -435,6 +462,38 @@ def test_add(mem_db: DBConnection): _add(table, schema) +def test_add_releases_arrow_buffers_without_gc(mem_db: DBConnection): + """Regression test for https://github.com/lancedb/lancedb/issues/2512.""" + schema = pa.schema([pa.field("x", pa.int64())]) + table = mem_db.create_table("test_add_releases_arrow_buffers", schema=schema) + + class BufferOwner: + def __init__(self, size: int): + self.memory = ctypes.create_string_buffer(size) + + owner_refs = [] + gc_was_enabled = gc.isenabled() + gc.disable() + try: + for _ in range(3): + size = 8 * 1024 + owner = BufferOwner(size) + arrow_buffer = pa.foreign_buffer( + ctypes.addressof(owner.memory), size, owner + ) + array = pa.Array.from_buffers(pa.int64(), 1024, [None, arrow_buffer]) + batch = pa.RecordBatch.from_arrays([array], schema=schema) + owner_refs.append(weakref.ref(owner)) + + table.add(batch) + del batch, array, arrow_buffer, owner + + assert all(owner_ref() is None for owner_ref in owner_refs) + finally: + if gc_was_enabled: + gc.enable() + + def test_add_write_parallelism(mem_db: DBConnection): schema = pa.schema([pa.field("id", pa.int64())]) table = mem_db.create_table("test", schema=schema) @@ -870,6 +929,7 @@ def test_polars(mem_db: DBConnection): # enter table to polars dataframe result = table.to_polars() + assert isinstance(result, pl.LazyFrame) assert np.allclose(result.collect()["vector"].to_list(), data["vector"]) # make sure filtering isn't broken @@ -1786,6 +1846,27 @@ def test_add_with_empty_fixed_size_list_drops_bad_rows(mem_db: DBConnection): assert np.allclose(data["embedding"].to_pylist()[0], np.array([0.1] * 16)) +def test_add_nullable_fixed_size_list_with_none(mem_db: DBConnection): + """Regression test for issue #2340.""" + table = mem_db.create_table( + "test_nullable_fixed_size_list", + schema=pa.schema( + [ + pa.field("id", pa.string()), + pa.field("feature", pa.list_(pa.float32(), 256)), + pa.field("tags", pa.list_(pa.string())), + ] + ), + ) + + table.add([{"id": "1", "feature": None, "tags": ["tag1", "tag2"]}]) + + result = table.to_arrow() + assert result.to_pylist() == [ + {"id": "1", "feature": None, "tags": ["tag1", "tag2"]} + ] + + def test_add_nullable_struct_with_none(mem_db: DBConnection): """Regression test for issue #2654: a nullable struct column whose first batch contains only None values must not crash in @@ -1825,6 +1906,33 @@ def test_add_nullable_struct_with_none(mem_db: DBConnection): assert result.column("data").to_pylist() == [{"x": 1.0}, None] +def test_read_mostly_null_list_v2_2_page_boundary(tmp_path): + # Regression test for #3194. This row/value count crosses a v2.2 structural + # encoding page boundary where Lance 3.0.0 sliced repetition/definition + # levels by row offset and decoded child arrays at different lengths. + num_rows = 64_885 + num_values = 217 + list_type = pa.list_(pa.float32()) + source = pa.table( + { + "id": np.arange(num_rows, dtype=np.int64), + "coords": pa.array( + [[1.0, 2.0, 3.0, 4.0]] * num_values + [None] * (num_rows - num_values), + type=list_type, + ), + } + ) + db = lancedb.connect( + tmp_path, + storage_options={"new_table_data_storage_version": "2.2"}, + ) + table = db.create_table("test_sparse_nullable_list", data=source) + + result = table.search().select(["id", "coords"]).limit(num_rows).to_arrow() + + assert result.equals(source) + + def test_add_with_integer_embeddings_preserves_casting(mem_db: DBConnection): class Schema(LanceModel): text: str @@ -2110,6 +2218,45 @@ def test_merge(tmp_db: DBConnection, tmp_path): table.merge(other_dataset, left_on="id") +@pytest.mark.parametrize("storage_version", ["legacy", "stable"]) +def test_search_after_merge(tmp_path, storage_version): + pytest.importorskip("lance") + pd = pytest.importorskip("pandas") + + db = lancedb.connect( + tmp_path, + storage_options={"new_table_data_storage_version": storage_version}, + ) + rng = np.random.default_rng(42) + row_count = 512 + vectors = rng.standard_normal((row_count, 8)).astype(np.float32) + table = db.create_table( + "search_after_merge", + data=pd.DataFrame( + { + "id": [str(i) for i in range(row_count)], + "vector": list(vectors), + } + ), + ) + table.create_index("vector", config=IvfPq(num_partitions=1, num_sub_vectors=2)) + + links = pd.DataFrame( + { + "id": [str(i) for i in range(row_count // 2)], + "link": [f"https://example.com/{i}" for i in range(row_count // 2)], + } + ) + table.merge(links, left_on="id") + + query = table.search(vectors[-1]).refine_factor(50).limit(10) + assert "ANN" in query.explain_plan(verbose=True) + + result = query.to_arrow() + links_by_id = dict(zip(result["id"].to_pylist(), result["link"].to_pylist())) + assert links_by_id[str(row_count - 1)] is None + + def test_delete(mem_db: DBConnection): table = mem_db.create_table( "my_table", @@ -2196,6 +2343,20 @@ def test_update(mem_db: DBConnection): assert np.allclose(v, np.array([[1.2, 1.9], [1.1, 1.1]])) +def test_update_with_arrow_scalar(mem_db: DBConnection): + schema = pa.schema({"id": pa.int64(), "vector": pa.list_(pa.float32(), 4)}) + table = mem_db.create_table("my_table", schema=schema) + table.add([{"id": 1, "vector": [1.0, 2.0, 3.0, 4.0]}]) + + value = table.search().select(["vector"]).limit(1).to_arrow()["vector"][0] + assert isinstance(value, pa.FixedSizeListScalar) + + result = table.update(where="id == 1", values={"vector": value}) + + assert result.rows_updated == 1 + assert table.to_arrow()["vector"].to_pylist() == [[1.0, 2.0, 3.0, 4.0]] + + def test_update_types(mem_db: DBConnection): table = mem_db.create_table( "my_table", @@ -2363,6 +2524,55 @@ def test_merge_insert(mem_db: DBConnection): ) +def test_merge_insert_nullable_pandas_into_pydantic_schema(mem_db: DBConnection): + # Regression test for https://github.com/lancedb/lancedb/issues/2366 + pd = pytest.importorskip("pandas") + + class Document(LanceModel): + id: int + title: str + content: str + + table = mem_db.create_table("documents", schema=Document) + table.add( + pd.DataFrame( + { + "title": ["Old title", "Unchanged"], + "id": [2, 3], + "content": ["Old content", "Keep this"], + } + ) + ) + + # Pandas produces nullable Arrow fields, in an order that differs from the + # non-nullable Pydantic schema. This is valid as long as the data has no nulls. + new_data = pd.DataFrame( + { + "title": ["Inserted", "Updated"], + "id": [1, 2], + "content": ["New row", "New content"], + } + ) + result = ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(new_data) + ) + + assert result.num_inserted_rows == 1 + assert result.num_updated_rows == 1 + expected = pa.Table.from_pylist( + [ + {"id": 1, "title": "Inserted", "content": "New row"}, + {"id": 2, "title": "Updated", "content": "New content"}, + {"id": 3, "title": "Unchanged", "content": "Keep this"}, + ], + schema=Document.to_arrow_schema(), + ) + assert table.to_arrow().sort_by("id") == expected + + def test_merge_insert_by_source_delete_expr(mem_db: DBConnection): table = mem_db.create_table( "my_table", @@ -2463,6 +2673,36 @@ def test_merge_insert_subschema(mem_db: DBConnection, data_format): assert table.to_arrow().sort_by("id") == expected +def test_repeated_partial_merge_insert_with_scalar_index(mem_db: DBConnection): + def make_batch(start: int) -> pa.Table: + return pa.table( + { + "id": [f"id-{i:04}" for i in range(start, start + 100)], + "category": ["A"] * 100, + "value_a": [float(i) for i in range(start, start + 100)], + "value_b": [float(i) / 10 for i in range(100)], + } + ) + + table = mem_db.create_table("my_table", data=make_batch(0)) + table.add(make_batch(100)) + table.add(make_batch(200)) + table.create_index("id", config=BTree()) + + ids = [f"id-{i:04}" for i in range(100, 200)] + for value in (999.0, 888.0): + result = ( + table.merge_insert("id") + .when_matched_update_all() + .execute(pa.table({"id": ids, "value_a": [value] * 100})) + ) + assert result.num_updated_rows == 100 + + actual = table.to_arrow().sort_by("id") + assert actual.num_rows == 300 + assert actual["value_a"].to_pylist()[100:200] == [888.0] * 100 + + @pytest.mark.asyncio async def test_merge_insert_async(mem_db_async: AsyncConnection): data = pa.table({"a": [1, 2, 3], "b": ["a", "b", "c"]}) @@ -2532,6 +2772,56 @@ async def test_merge_insert_async(mem_db_async: AsyncConnection): assert (await table.to_arrow()).sort_by("a") == expected +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_merge_insert_encodes_json(mem_db_async: AsyncConnection): + json_type = pa.json_() + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", json_type)]) + + def json_table(rows): + json_values = pa.ExtensionArray.from_storage( + json_type, + pa.array([value for _, value in rows], type=json_type.storage_type), + ) + return pa.Table.from_arrays( + [pa.array([row_id for row_id, _ in rows]), json_values], schema=schema + ) + + table = await mem_db_async.create_table("json_merge", schema=schema) + await table.add(json_table([("a", '{"k": 1}'), ("b", '{"k": 9}')])) + + await ( + table.merge_insert("id") + .when_matched_update_all() + .execute(json_table([("a", '{"k": 2}')])) + ) + + rows = sorted(await table.query().to_list(), key=lambda row: row["id"]) + assert rows == [ + {"id": "a", "j": '{"k":2}'}, + {"id": "b", "j": '{"k":9}'}, + ] + filtered = await table.query().where("json_extract(j, '$.k') = '2'").to_list() + assert filtered == [{"id": "a", "j": '{"k":2}'}] + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_add_sanitization_encodes_json(mem_db_async: AsyncConnection): + json_type = pa.json_() + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", json_type)]) + json_values = pa.ExtensionArray.from_storage( + json_type, pa.array(['{"k": 3}'], type=json_type.storage_type) + ) + data = pa.Table.from_arrays([pa.array(["c"]), json_values], schema=schema) + + table = await mem_db_async.create_table("json_add", schema=schema) + await table.add(data, on_bad_vectors="fill") + + rows = await table.query().where("json_extract(j, '$.k') = '3'").to_list() + assert rows == [{"id": "c", "j": '{"k":3}'}] + + def test_create_with_embedding_function(mem_db: DBConnection): class MyTable(LanceModel): text: str @@ -2559,15 +2849,40 @@ def test_create_with_embedding_function(mem_db: DBConnection): assert actual == expected +def test_create_f16_table_from_arrow_data(mem_db: DBConnection): + dimension = 32 + num_rows = 512 + values = pa.array( + np.random.default_rng(42) + .standard_normal(num_rows * dimension) + .astype(np.float16) + ) + df = pa.table( + { + "text": [f"s-{i}" for i in range(num_rows)], + "vector": pa.FixedSizeListArray.from_arrays(values, dimension), + } + ) + table = mem_db.create_table("f16_tbl", data=df) + assert table.schema.field("vector").type == pa.list_(pa.float16(), dimension) + table.create_index(num_partitions=2, num_sub_vectors=2) + + query = df["vector"][2].as_py() + expected = table.search(query).limit(2).to_arrow() + + assert "s-2" in expected["text"].to_pylist() + + def test_create_f16_table(mem_db: DBConnection): class MyTable(LanceModel): text: str vector: Vector(32, value_type=pa.float16()) + rng = np.random.default_rng(42) df = pa.table( { "text": [f"s-{i}" for i in range(512)], - "vector": [np.random.randn(32).astype(np.float16) for _ in range(512)], + "vector": [rng.standard_normal(32).astype(np.float16) for _ in range(512)], } ) table = mem_db.create_table( @@ -3448,7 +3763,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": { @@ -3466,6 +3782,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 @@ -3489,8 +3812,8 @@ def test_create_table_empty_list_no_schema_error(mem_db: DBConnection): mem_db.create_table("test_empty_no_schema", data=[]) -def test_add_table_with_empty_embeddings(tmp_path): - """Test exact scenario from issue #1968 +def test_create_table_without_data_with_vector_schema(tmp_path): + """Test exact scenario from issue #1968. Regression test for issue #1968: https://github.com/lancedb/lancedb/issues/1968 @@ -3502,6 +3825,9 @@ def test_add_table_with_empty_embeddings(tmp_path): embedding: Vector(16) table = db.create_table("test", schema=MySchema) + assert table.count_rows() == 0 + assert table.schema == MySchema.to_arrow_schema() + table.add( [{"text": "bar", "embedding": [0.1] * 16}], on_bad_vectors="drop", @@ -3578,3 +3904,65 @@ async def test_async_search_runs_embedding_on_dedicated_executor( assert all(name.startswith("lancedb-embedding") for name in captured_threads), ( f"embedding ran off the dedicated executor: {captured_threads}" ) + + +def test_computed_column_declare_and_refresh(tmp_path): + db = lancedb.connect(tmp_path) + table = db.create_table("computed", [{"x": 1}, {"x": 2}]) + + table.add_columns(computed={"doubled": "x * 2"}) + assert table.to_arrow()["doubled"].to_pylist() == [None, None] + + result = table.refresh_column("doubled") + assert result.rows_filled == 2 + assert sorted(table.to_arrow()["doubled"].to_pylist()) == [2, 4] + + table.add([{"x": 5}]) + assert table.refresh_column("doubled").rows_filled == 1 + assert sorted(table.to_arrow()["doubled"].to_pylist()) == [2, 4, 10] + + +def test_computed_column_rejects_transforms_and_computed_together(tmp_path): + db = lancedb.connect(tmp_path) + table = db.create_table("computed_mixed", [{"x": 1}]) + with pytest.raises(ValueError): + table.add_columns({"a": "x + 1"}, computed={"b": "x * 2"}) + + +@pytest.mark.asyncio +async def test_computed_column_async(tmp_path): + db = await lancedb.connect_async(tmp_path) + table = await db.create_table("computed_async", [{"x": 3}]) + + await table.add_columns(computed={"tripled": "x * 3"}) + await table.refresh_column("tripled") + + assert (await table.to_arrow())["tripled"].to_pylist() == [9] + + +def test_refresh_column_async_returns_job(tmp_path): + db = lancedb.connect(tmp_path) + table = db.create_table("computed_job", [{"x": 1}, {"x": 2}]) + table.add_columns(computed={"doubled": "x * 2"}) + + job = table.refresh_column_async("doubled") + assert job.id is None # in-process jobs have no server id + job.wait() + assert job.status() == "finished" + assert sorted(table.to_arrow()["doubled"].to_pylist()) == [2, 4] + + # Bad input raises at the call, not through the job. + with pytest.raises(Exception, match="not a computed column"): + table.refresh_column_async("x") + + +@pytest.mark.asyncio +async def test_refresh_column_async_job_async_table(tmp_path): + db = await lancedb.connect_async(tmp_path) + table = await db.create_table("computed_job_async", [{"x": 3}]) + await table.add_columns(computed={"tripled": "x * 3"}) + + job = await table.refresh_column_async("tripled") + await job.wait() + assert await job.status() == "finished" + assert (await table.to_arrow())["tripled"].to_pylist() == [9] diff --git a/python/python/tests/test_voyageai_embeddings.py b/python/python/tests/test_voyageai_embeddings.py index ac1554cad..040cade1f 100644 --- a/python/python/tests/test_voyageai_embeddings.py +++ b/python/python/tests/test_voyageai_embeddings.py @@ -75,6 +75,22 @@ class TestVoyageAIModelRegistration: with pytest.raises(ValueError, match="not supported"): func.ndims() + def test_voyage3_source_embeddings_use_text_api(self, mock_voyageai_client): + """Regression test for text table data being sent to the multimodal API.""" + mock_voyageai_client.tokenize.return_value = [["hello", "world"]] + mock_voyageai_client.embed.return_value.embeddings = [[0.1] * 1024] + + registry = get_registry() + func = registry.get("voyageai").create(name="voyage-3") + + embeddings = func.compute_source_embeddings("hello world") + + assert embeddings == [[0.1] * 1024] + mock_voyageai_client.embed.assert_called_once_with( + texts=["hello world"], model="voyage-3", input_type="document" + ) + mock_voyageai_client.multimodal_embed.assert_not_called() + @pytest.mark.parametrize( "model_name", [ diff --git a/python/python/type_tests/connect.py b/python/python/type_tests/connect.py new file mode 100644 index 000000000..eb2cba37c --- /dev/null +++ b/python/python/type_tests/connect.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from typing import assert_type + +import lancedb +from lancedb import AsyncConnection, DBConnection + + +def check_connect_type() -> None: + assert_type(lancedb.connect("memory://"), DBConnection) + + +async def check_connect_async_type() -> None: + assert_type(await lancedb.connect_async("memory://"), AsyncConnection) diff --git a/python/src/connection.rs b/python/src/connection.rs index b97d48ad8..dbda29ba6 100644 --- a/python/src/connection.rs +++ b/python/src/connection.rs @@ -346,6 +346,23 @@ impl Connection { }) } + #[pyo3(signature = (name, namespace_path=None))] + pub fn drop_table_async( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let ns_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner + .drop_table_async(name, &ns_path) + .await + .infer_error() + .map(crate::job::Job::new) + }) + } + #[pyo3(signature = (namespace_path=None,))] pub fn drop_all_tables( self_: PyRef<'_, Self>, diff --git a/python/src/expr.rs b/python/src/expr.rs index 242e88b05..eae1d96ec 100644 --- a/python/src/expr.rs +++ b/python/src/expr.rs @@ -191,8 +191,27 @@ pub fn expr_lit(value: Bound<'_, PyAny>) -> PyResult { } // datetime.datetime is a subclass of datetime.date, so it must be checked first. + // + // Python's datetime.timestamp() treats *naive* datetimes as local wall time. + // PyArrow (and therefore Lance table storage) encodes naive timestamps as + // UTC wall-clock microseconds. Using .timestamp() for naive values therefore + // shifts the literal by the local UTC offset on non-UTC machines, so + // `col("ts") == lit(naive_dt)` fails against a table that holds the same + // naive value. Fix: treat naive datetimes as UTC wall clock (match Arrow); + // keep aware datetimes on the real .timestamp() path (correct epoch). if let Ok(dt) = value.cast::() { - let ts: f64 = dt.call_method0("timestamp")?.extract()?; + let ts: f64 = if dt.getattr("tzinfo")?.is_none() { + // Force UTC interpretation of the naive wall clock. + let utc = pyo3::types::PyModule::import(value.py(), "datetime")? + .getattr("timezone")? + .getattr("utc")?; + let kwargs = pyo3::types::PyDict::new(value.py()); + kwargs.set_item("tzinfo", utc)?; + let aware = dt.call_method("replace", (), Some(&kwargs))?; + aware.call_method0("timestamp")?.extract()? + } else { + dt.call_method0("timestamp")?.extract()? + }; let micros = (ts * 1_000_000.0).round() as i64; return Ok(PyExpr(df_lit(ScalarValue::TimestampMicrosecond( Some(micros), 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/lib.rs b/python/src/lib.rs index 6b0c0cf97..a19bf172d 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -16,7 +16,8 @@ use query::{FTSQuery, HybridQuery, Query, VectorQuery}; use session::Session; use table::{ AddColumnsResult, AddResult, AlterColumnsResult, DeleteResult, DropColumnsResult, FtsToken, - LsmWriteSpec, MergeResult, PyBlobFile, Table, UpdateFieldMetadataResult, UpdateResult, + LsmWriteSpec, MergeResult, PyBlobFile, RefreshColumnResult, Table, UpdateFieldMetadataResult, + UpdateResult, }; pub mod arrow; @@ -57,6 +58,7 @@ pub fn _lancedb(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; 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..cb4752cce 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,13 +246,23 @@ 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(...)`. -#[pyclass(from_py_object)] +/// `with_writer_config_defaults(...)`. A fresh spec maintains every index the +/// MemWAL supports, resolved on install. +#[pyclass(module = "lancedb._lancedb", from_py_object)] #[derive(Clone, Debug)] pub struct LsmWriteSpec { inner: lancedb::table::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. @@ -338,6 +415,32 @@ pub struct AddColumnsResult { pub version: u64, } +#[pyclass(get_all, from_py_object)] +#[derive(Clone, Debug)] +pub struct RefreshColumnResult { + pub rows_filled: u64, + pub version: u64, +} + +#[pymethods] +impl RefreshColumnResult { + pub fn __repr__(&self) -> String { + format!( + "RefreshColumnResult(rows_filled={}, version={})", + self.rows_filled, self.version + ) + } +} + +impl From for RefreshColumnResult { + fn from(result: lancedb::table::RefreshColumnResult) -> Self { + Self { + rows_filled: result.rows_filled, + version: result.version, + } + } +} + #[pymethods] impl AddColumnsResult { pub fn __repr__(&self) -> String { @@ -502,7 +605,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 +1442,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 { @@ -1388,6 +1536,58 @@ impl Table { }) } + pub fn add_computed_columns( + self_: PyRef<'_, Self>, + columns: Vec<(String, String)>, + ) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let mut builder = inner.add_columns(); + for (name, expression) in columns { + builder = builder.computed(name, expression); + } + let result = builder.execute().await.infer_error()?; + Ok(AddColumnsResult::from(result)) + }) + } + + pub fn add_function_columns( + self_: PyRef<'_, Self>, + application_json: String, + output_name: Option, + ) -> PyResult> { + let application = + lancedb::function::FunctionApplication::from_json(&application_json).infer_error()?; + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let builder = match output_name { + Some(name) => inner.add_columns().function_as(name, application), + None => inner.add_columns().function(application), + }; + let result = builder.execute().await.infer_error()?; + Ok(AddColumnsResult::from(result)) + }) + } + + pub fn refresh_column(self_: PyRef<'_, Self>, column: String) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let result = inner.refresh_column(column).await.infer_error()?; + Ok(RefreshColumnResult::from(result)) + }) + } + + pub fn refresh_column_async( + self_: PyRef<'_, Self>, + column: String, + ) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let job = inner.refresh_column_async(column).await.infer_error()?; + Ok(crate::job::Job::new(job)) + }) + } + pub fn add_columns_with_schema( self_: PyRef<'_, Self>, schema: PyArrowType, diff --git a/python/uv.lock b/python/uv.lock index 551dc3f68..c957a4c06 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1998,12 +1998,12 @@ requires-dist = [ { name = "pillow", marker = "extra == 'clip'", specifier = ">=12.1.1" }, { name = "pillow", marker = "extra == 'embeddings'", specifier = ">=12.1.1" }, { name = "pillow", marker = "extra == 'siglip'", specifier = ">=12.1.1" }, - { name = "polars", marker = "extra == 'tests'", specifier = ">=0.19,<=1.3.0" }, + { name = "polars", marker = "extra == 'tests'", specifier = ">=0.19,<=1.32.3" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "pyarrow", specifier = ">=16" }, { name = "pyarrow", marker = "extra == 'tests'", specifier = "<25" }, { name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" }, - { name = "pydantic", specifier = ">=1.10" }, + { name = "pydantic", specifier = ">=2.7.4,<3" }, { name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" }, { name = "pylance", marker = "extra == 'tests'", specifier = "==9.0.0rc1" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 66db3cf12..ac1c8754c 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.38.0-beta.2" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true @@ -49,24 +49,22 @@ lance-namespace = { workspace = true } lance-namespace-impls = { workspace = true } metrics = { workspace = true, optional = true } metrics-util = { workspace = true, optional = true } -# Pin the transitive GooseFS SDK until the 0.1.6 compile break is fixed upstream. -goosefs-sdk = { version = "=0.1.5", optional = true } moka = { workspace = true } pin-project = { workspace = true } -tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } +tokio = { workspace = true } log.workspace = true -async-trait = "0" -bytes = "1" +async-trait = { workspace = true } +bytes = { workspace = true } futures.workspace = true num-traits.workspace = true url.workspace = true rand.workspace = true regex.workspace = true -serde = { version = "^1" } -serde_json = { version = "1" } +serde = { workspace = true } +serde_json = { workspace = true } async-openai = { version = "0.20.0", optional = true } serde_with = { version = "3.8.1" } -tempfile = "3.5.0" +tempfile = { workspace = true } aws-sdk-bedrockruntime = { version = "1.27.0", optional = true } # For remote feature reqwest = { version = "0.12.0", default-features = false, features = [ @@ -75,11 +73,13 @@ reqwest = { version = "0.12.0", default-features = false, features = [ "http2", "json", "macos-system-configuration", + # Avoid linking OpenSSL into Python wheels, which breaks on FIPS hosts. + "rustls-tls-native-roots", "stream", ], optional = true } http = { version = "1", optional = true } # Matching what is in reqwest urlencoding = { version = "2", optional = true } -uuid = { version = "1.7.0", features = ["v4", "v5"] } +uuid = { workspace = true, features = ["v5"] } polars-arrow = { version = ">=0.37,<0.40.0", optional = true } polars = { version = ">=0.37,<0.40.0", optional = true } hf-hub = { version = "0.4.1", optional = true, default-features = false, features = [ @@ -96,10 +96,11 @@ semver = { workspace = true } [dev-dependencies] anyhow = "1" lance-testing = { workspace = true } -tempfile = "3.5.0" +tempfile = { workspace = true } random_word = { version = "0.4.3", features = ["en"] } -tokio = { version = "1.23", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } -uuid = { version = "1.7.0", features = ["v4"] } +roaring = "0.11.4" +tokio = { workspace = true, features = ["io-util", "macros", "net", "test-util"] } +uuid = { workspace = true } walkdir = "2" aws-sdk-dynamodb = { version = "1.55.0" } aws-sdk-s3 = { version = "1.55.0" } @@ -133,7 +134,6 @@ azure = [ ] cos = ["lance/tencent", "lance-io/tencent"] goosefs = [ - "dep:goosefs-sdk", "lance/goosefs", "lance-io/goosefs", "lance-namespace-impls/dir-goosefs", @@ -188,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..fbfddf86c --- /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 + +// 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 --profile release-no-lto -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/examples/bench_streaming_dataloader.rs b/rust/lancedb/examples/bench_streaming_dataloader.rs index 087268ff8..a46d924d1 100644 --- a/rust/lancedb/examples/bench_streaming_dataloader.rs +++ b/rust/lancedb/examples/bench_streaming_dataloader.rs @@ -5,10 +5,10 @@ //! streaming dataloader. //! //! Normal sweep: -//! cargo run --release --example bench_streaming_dataloader +//! cargo run --profile release-with-debug --example bench_streaming_dataloader //! //! Flamegraph (self-contained, no perf/dtrace needed): -//! BENCH_PROFILE=1 BENCH_CHUNK=64 cargo run --release \ +//! BENCH_PROFILE=1 BENCH_CHUNK=64 cargo run --profile release-with-debug \ //! --example bench_streaming_dataloader //! # writes flamegraph.svg in the current directory //! diff --git a/rust/lancedb/src/blob.rs b/rust/lancedb/src/blob.rs index 3448257ab..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_encoding::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 89e59e12e..12ca306b8 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -34,7 +34,7 @@ use crate::remote::{ db::{OPT_REMOTE_API_KEY, OPT_REMOTE_HOST_OVERRIDE, OPT_REMOTE_REGION}, }; use lance::io::ObjectStoreParams; -pub use lance_encoding::version::LanceFileVersion; +pub use lance_file::version::LanceFileVersion; #[cfg(feature = "remote")] use lance_io::object_store::StorageOptions; use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider}; @@ -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(), @@ -561,6 +565,21 @@ impl Connection { .await } + /// Start dropping a table and return a handle to the cleanup job. + /// + /// The table may become unavailable before its physical data is removed. + /// Call [`crate::job::Job::wait`] to wait for cleanup to finish. Local + /// backends may complete the drop before returning the handle. + pub async fn drop_table_async( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result { + self.internal + .drop_table_async(name.as_ref(), namespace_path) + .await + } + /// Drop the database /// /// This is the same as dropping all of the tables diff --git a/rust/lancedb/src/connection/create_table.rs b/rust/lancedb/src/connection/create_table.rs index 66f6dfa8d..39cc82ec0 100644 --- a/rust/lancedb/src/connection/create_table.rs +++ b/rust/lancedb/src/connection/create_table.rs @@ -202,6 +202,17 @@ mod tests { assert_eq!(table.count_rows(None).await.unwrap(), 0); } + #[tokio::test] + async fn create_table_in_named_memory_database() { + let db = connect("memory://foo").execute().await.unwrap(); + let batch = record_batch!(("id", Int64, [1, 2, 3])).unwrap(); + + let table = db.create_table("my_table", batch).execute().await.unwrap(); + + assert_eq!(table.uri().await.unwrap(), "memory://foo/my_table.lance"); + assert_eq!(table.count_rows(None).await.unwrap(), 3); + } + async fn test_create_table_with_data(data: T) where T: Scannable + 'static, @@ -427,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.rs b/rust/lancedb/src/database.rs index f99f6e12a..f52c02439 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -323,6 +323,18 @@ pub trait Database: ) -> Result<()>; /// Drop a table in the database async fn drop_table(&self, name: &str, namespace_path: &[String]) -> Result<()>; + /// Start dropping a table and return a handle to the cleanup job. + /// + /// Backends without asynchronous cleanup complete the drop before + /// returning an already-finished job. + async fn drop_table_async( + &self, + name: &str, + namespace_path: &[String], + ) -> Result { + self.drop_table(name, namespace_path).await?; + Ok(crate::job::Job::new_done()) + } /// Drop all tables in the database async fn drop_all_tables(&self, namespace_path: &[String]) -> Result<()>; fn as_any(&self) -> &dyn std::any::Any; diff --git a/rust/lancedb/src/database/listing.rs b/rust/lancedb/src/database/listing.rs index 454498d54..5ebbe6c5c 100644 --- a/rust/lancedb/src/database/listing.rs +++ b/rust/lancedb/src/database/listing.rs @@ -12,7 +12,7 @@ use lance::dataset::refs::Ref; use lance::dataset::{ReadParams, WriteMode, builder::DatasetBuilder}; use lance::io::{ObjectStore, ObjectStoreParams, WrappingObjectStore}; use lance_datafusion::utils::StreamingWriteSource; -use lance_encoding::version::LanceFileVersion; +use lance_file::version::LanceFileVersion; use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider}; use lance_table::io::commit::commit_handler_from_url; use object_store::local::LocalFileSystem; @@ -1032,6 +1032,7 @@ impl Database for ListingDatabase { }; Ok(ListTablesResponse { + context: None, tables: f, page_token: next_page_token, }) @@ -1291,14 +1292,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::table::WriteOptions; + use crate::query::QueryRequest; + use crate::table::{AnyQuery, WriteOptions}; use arrow_array::{Int32Array, RecordBatch, StringArray}; - use arrow_schema::{DataType, Field, Schema}; + use arrow_schema::{DataType, Field, Schema, SchemaRef}; + use futures::{TryStreamExt, 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(); @@ -1322,6 +1330,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_listing_database_root_ops_do_not_create_manifest() { let tempdir = tempdir().unwrap(); @@ -1376,6 +1492,156 @@ mod tests { assert!(!tempdir.path().join("__manifest").exists()); } + /// Regression test for https://github.com/lancedb/lancedb/issues/1600. + /// + /// Opening a table used to create a separate object-store client instead of + /// reusing the one that successfully connected to the database. Repeating + /// credential discovery made S3 table opens intermittent, especially in AWS + /// Lambda, and the failed open was reported as `TableNotFound`. + #[tokio::test] + async fn test_open_table_reuses_connection_object_store() { + let tempdir = tempdir().unwrap(); + let uri = tempdir.path().to_str().unwrap(); + let registry = Arc::new(lance_io::object_store::ObjectStoreRegistry::default()); + let session = Arc::new(lance::session::Session::new(16, 16, registry.clone())); + + let request = ConnectRequest { + uri: uri.to_string(), + #[cfg(feature = "remote")] + client_config: Default::default(), + options: Default::default(), + namespace_client_properties: Default::default(), + manifest_enabled: false, + read_consistency_interval: None, + session: Some(session), + }; + let db = ListingDatabase::connect_with_options(&request) + .await + .unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + db.create_table(CreateTableRequest { + name: "test".to_string(), + namespace_path: vec![], + data: Box::new(RecordBatch::new_empty(schema)) as Box, + mode: CreateTableMode::Create, + write_options: Default::default(), + location: None, + namespace_client: None, + }) + .await + .unwrap(); + + let before_open = registry.stats(); + for _ in 0..3 { + let table = db + .open_table(OpenTableRequest { + name: "test".to_string(), + namespace_path: vec![], + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, + }) + .await + .unwrap(); + assert_eq!(table.count_rows(None).await.unwrap(), 0); + } + + let after_open = registry.stats(); + assert_eq!(after_open.misses, before_open.misses); + assert!(after_open.hits >= before_open.hits + 3); + } + + /// Regression test for https://github.com/lancedb/lancedb/issues/3197. + #[cfg(unix)] + #[tokio::test] + async fn test_open_table_follows_hugging_face_symlinks() { + let (tempdir, db) = setup_database().await; + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + db.create_table(CreateTableRequest { + name: "test".to_string(), + namespace_path: vec![], + data: Box::new( + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]) + .unwrap(), + ) as Box, + mode: CreateTableMode::Create, + write_options: Default::default(), + location: None, + namespace_client: None, + }) + .await + .unwrap(); + + let table_dir = tempdir.path().join("test.lance"); + let versions_dir = table_dir.join("_versions"); + let manifest_path = std::fs::read_dir(&versions_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.extension().is_some_and(|ext| ext == "manifest")) + .unwrap(); + let data_path = std::fs::read_dir(table_dir.join("data")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.extension().is_some_and(|ext| ext == "lance")) + .unwrap(); + + // Hugging Face snapshots keep dataset objects in a separate blob directory and + // expose them through relative symlinks. + let blobs_dir = tempdir.path().join("blobs"); + std::fs::create_dir(&blobs_dir).unwrap(); + let manifest_blob = "9b603c63d0e692e05d58be25605f2f2064cc781e5ff94fe983a405059547b816"; + let data_blob = "be64f20e5723bd0a27cfdbdb41cf7d6fad94cd572a71973b717fb8340f4310c5"; + std::fs::rename(&manifest_path, blobs_dir.join(manifest_blob)).unwrap(); + std::fs::rename(&data_path, blobs_dir.join(data_blob)).unwrap(); + std::os::unix::fs::symlink(Path::new("../../blobs").join(manifest_blob), &manifest_path) + .unwrap(); + std::os::unix::fs::symlink(Path::new("../../blobs").join(data_blob), &data_path).unwrap(); + let symlink_len = std::fs::symlink_metadata(&manifest_path).unwrap().len(); + let target_len = std::fs::metadata(&manifest_path).unwrap().len(); + assert_ne!(symlink_len, target_len); + + drop(db); + let db = ListingDatabase::connect_with_options(&ConnectRequest { + uri: tempdir.path().to_str().unwrap().to_string(), + #[cfg(feature = "remote")] + client_config: Default::default(), + options: Default::default(), + namespace_client_properties: Default::default(), + manifest_enabled: false, + read_consistency_interval: None, + session: None, + }) + .await + .unwrap(); + + let table = db + .open_table(OpenTableRequest { + name: "test".to_string(), + namespace_path: vec![], + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, + }) + .await + .unwrap(); + let batches = table + .query( + &AnyQuery::Query(QueryRequest::default()), + Default::default(), + ) + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::(), 3); + } + #[tokio::test] async fn test_clone_table_basic() { let (_tempdir, db) = setup_database().await; @@ -2280,7 +2546,7 @@ mod tests { #[tokio::test] async fn test_table_uri() { - let (_tempdir, db) = setup_database().await; + let (_tempdir, mut db) = setup_database().await; let mut pb = PathBuf::new(); pb.push(db.uri.clone()); @@ -2289,6 +2555,18 @@ mod tests { let expected = pb.to_str().unwrap(); let uri = db.table_uri("test").ok().unwrap(); assert_eq!(uri, expected); + + // URI paths always use forward slashes, even on Windows. Using + // `Path::join` here used to produce `az://container/prefix\\test.lance`, + // which Azure treated as a different object from the table returned by + // `table_names` (https://github.com/lancedb/lancedb/issues/1072). + for base_uri in ["az://container/prefix", "az://container/prefix/"] { + db.uri = base_uri.to_string(); + assert_eq!( + db.table_uri("test").unwrap(), + "az://container/prefix/test.lance" + ); + } } /// Regression: connecting via a URL-style URI (which goes through diff --git a/rust/lancedb/src/database/namespace.rs b/rust/lancedb/src/database/namespace.rs index d18c78682..740e11645 100644 --- a/rust/lancedb/src/database/namespace.rs +++ b/rust/lancedb/src/database/namespace.rs @@ -201,7 +201,7 @@ impl LanceNamespaceDatabase { &self, request: &DbCreateTableRequest, ) -> Result<( - Option, + Option, Option, Option, )> { @@ -214,7 +214,7 @@ impl LanceNamespaceDatabase { let storage_version_override = storage_options .and_then(|opts| opts.get(OPT_NEW_TABLE_STORAGE_VERSION)) - .map(|s| s.parse::()) + .map(|s| s.parse::()) .transpose()?; let v2_manifest_override = storage_options diff --git a/rust/lancedb/src/error.rs b/rust/lancedb/src/error.rs index f6f596f3d..6bd1ffa2b 100644 --- a/rust/lancedb/src/error.rs +++ b/rust/lancedb/src/error.rs @@ -71,6 +71,14 @@ pub enum Error { IndexNotFound { name: String }, #[snafu(display("Embedding function '{name}' was not found. : {reason}"))] EmbeddingFunctionNotFound { name: String, reason: String }, + #[snafu(display("Column '{name}' was not found"))] + ColumnNotFound { name: String }, + #[snafu(display("Column '{name}' already exists"))] + ColumnAlreadyExists { name: String }, + #[snafu(display("Column '{name}' is not a computed column"))] + NotAComputedColumn { name: String }, + #[snafu(display("Invalid expression for column '{column}': {message}"))] + InvalidExpression { column: String, message: String }, #[snafu(display("Table '{name}' already exists"))] TableAlreadyExists { name: String }, @@ -169,6 +177,12 @@ impl From for Error { impl From for Error { fn from(source: lance::Error) -> Self { + if has_unsupported_local_filesystem_source(&source) { + return Self::NotSupported { + message: "the filesystem does not support an operation required for safe Lance commits (such as atomic rename). Object-storage mounts such as Mountpoint for Amazon S3 are not supported; use the native object-store URI (for example, s3://bucket/path) instead".to_string(), + }; + } + // Try to unwrap external errors that were wrapped by lance match source { lance::Error::Wrapped { error, .. } => Self::from_box_error(error), @@ -181,6 +195,27 @@ impl From for Error { } } +fn has_unsupported_local_filesystem_source(error: &(dyn std::error::Error + 'static)) -> bool { + let mut current = Some(error); + let mut is_local_filesystem = false; + let mut is_unsupported = false; + while let Some(error) = current { + is_local_filesystem |= error + .downcast_ref::() + .is_some_and(|error| { + matches!(error, object_store::Error::Generic { store, .. } if *store == "LocalFileSystem") + }); + is_unsupported |= error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::Unsupported); + if is_local_filesystem && is_unsupported { + return true; + } + current = error.source(); + } + false +} + impl Error { fn from_box_error(mut source: Box) -> Self { source = match source.downcast::() { @@ -270,3 +305,46 @@ impl From for Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_filesystem_operations_have_actionable_error() { + let object_store_error = object_store::Error::Generic { + store: "LocalFileSystem", + source: Box::new(std::io::Error::from(std::io::ErrorKind::Unsupported)), + }; + let lance_error = lance::Error::io_source(Box::new(object_store_error)); + + let error = Error::from(lance_error); + + assert!(matches!( + error, + Error::NotSupported { message } + if message.contains("Mountpoint for Amazon S3") + && message.contains("s3://bucket/path") + )); + } + + #[test] + fn other_io_errors_remain_lance_errors() { + let object_store_error = object_store::Error::Generic { + store: "LocalFileSystem", + source: Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + }; + let lance_error = lance::Error::io_source(Box::new(object_store_error)); + + assert!(matches!(Error::from(lance_error), Error::Lance { .. })); + } + + #[test] + fn unsupported_non_filesystem_errors_remain_lance_errors() { + let lance_error = lance::Error::io_source(Box::new(std::io::Error::from( + std::io::ErrorKind::Unsupported, + ))); + + assert!(matches!(Error::from(lance_error), Error::Lance { .. })); + } +} diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs new file mode 100644 index 000000000..fe91f1680 --- /dev/null +++ b/rust/lancedb/src/function.rs @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Canonical values exchanged with the Enterprise Function service. +//! +//! This module contains client/wire values only. Catalog persistence, +//! environment bake, secret resolution, and execution are owned by Sophon. + +use std::collections::BTreeMap; + +use serde::de::{self, DeserializeOwned}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use crate::{Error, Result}; + +fn invalid_json(error: impl std::fmt::Display) -> Error { + Error::InvalidInput { + message: format!("invalid remote Function JSON: {error}"), + } +} + +fn write_canonical_json(value: &Value, output: &mut String) -> serde_json::Result<()> { + match value { + Value::Object(map) => { + output.push('{'); + let mut entries = map.iter().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index != 0 { + output.push(','); + } + output.push_str(&serde_json::to_string(key)?); + output.push(':'); + write_canonical_json(value, output)?; + } + output.push('}'); + } + Value::Array(values) => { + output.push('['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + output.push(','); + } + write_canonical_json(value, output)?; + } + output.push(']'); + } + other => output.push_str(&serde_json::to_string(other)?), + } + Ok(()) +} + +fn canonical_json(value: &T) -> Result { + let value = serde_json::to_value(value).map_err(invalid_json)?; + let mut output = String::new(); + write_canonical_json(&value, &mut output).map_err(invalid_json)?; + Ok(output) +} + +fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(invalid_json) +} + +fn validate_literal(value: &Value) -> Result<()> { + match value { + Value::Number(number) if number.is_f64() => Err(Error::InvalidInput { + message: "floating-point Function literals are not part of the Slice 1 canonical wire contract" + .to_string(), + }), + Value::Array(values) => values.iter().try_for_each(validate_literal), + Value::Object(values) => values.values().try_for_each(validate_literal), + _ => Ok(()), + } +} + +fn has_unknown_keys(value: &Value, allowed: &[&str]) -> bool { + value + .as_object() + .is_some_and(|object| object.keys().any(|key| !allowed.contains(&key.as_str()))) +} + +fn application_has_unknown_nested_fields(value: &Value) -> bool { + let Some(application) = value.as_object() else { + return false; + }; + if application + .get("function") + .is_some_and(|value| has_unknown_keys(value, &["name", "version"])) + { + return true; + } + if application + .get("inputs") + .and_then(Value::as_array) + .is_some_and(|inputs| { + inputs + .iter() + .any(|input| has_unknown_keys(input, &["parameter", "kind", "value"])) + }) + { + return true; + } + application.get("output").is_some_and(|output| { + has_unknown_keys(output, &["kind", "arrow_type", "nullable", "fields"]) + || output + .get("fields") + .and_then(Value::as_array) + .is_some_and(|fields| { + fields + .iter() + .any(|field| has_unknown_keys(field, &["name", "arrow_type", "nullable"])) + }) + }) +} + +macro_rules! impl_json { + ($type:ty) => { + impl $type { + /// Decode a remote value. Unknown fields and discriminator values + /// are accepted so newer servers remain readable. + pub fn from_json(json: &str) -> Result { + from_json(json) + } + + /// Encode the known client contract with bytewise-sorted JSON keys. + pub fn to_canonical_json(&self) -> Result { + canonical_json(self) + } + } + }; +} + +/// Packaged Python artifact identity. Source bytes are never part of this value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionArtifact { + pub kind: String, + pub digest: String, + pub entrypoint: String, +} + +/// One ordered Arrow input parameter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionParameter { + pub name: String, + pub arrow_type: String, + pub nullable: bool, +} + +/// One field of an ordered named-struct result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionResultField { + pub name: String, + pub arrow_type: String, + pub nullable: bool, +} + +/// Scalar or named-struct Function output. +/// +/// `kind` remains a string so unknown future result shapes can be decoded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionOutput { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arrow_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nullable: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// Ordered language-neutral Function signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionSignature { + pub inputs: Vec, + pub output: FunctionOutput, +} + +/// One Python environment source. +/// +/// The selected source is interpreted by Sophon. `kind` is open for forward +/// compatible decoding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PythonEnvironmentSpec { + pub kind: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub packages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, +} + +/// Reproducible Python runtime definition understood by Sophon. +/// +/// `env` contains non-secret values. Secret values have no client model; +/// [`FunctionVersion::required_secrets`] contains names only. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum PythonRuntimeSpec { + /// The V1 Sophon-managed Python runtime. + Python { + python_version: String, + environment: PythonEnvironmentSpec, + env: BTreeMap, + }, + /// A runtime kind introduced by a newer server. + /// + /// Unknown payload fields are intentionally not retained because the + /// client does not proxy catalog values. + Unrecognized { kind: String }, +} + +impl PythonRuntimeSpec { + /// The wire discriminator reported by Sophon. + pub fn kind(&self) -> &str { + match self { + Self::Python { .. } => "python", + Self::Unrecognized { kind } => kind, + } + } + + /// The Python version for the V1 runtime, or `None` for an unknown kind. + pub fn python_version(&self) -> Option<&str> { + match self { + Self::Python { python_version, .. } => Some(python_version), + Self::Unrecognized { .. } => None, + } + } + + /// The Python environment for the V1 runtime, or `None` for an unknown kind. + pub fn environment(&self) -> Option<&PythonEnvironmentSpec> { + match self { + Self::Python { environment, .. } => Some(environment), + Self::Unrecognized { .. } => None, + } + } + + /// Non-secret environment variables, or `None` for an unknown kind. + pub fn env(&self) -> Option<&BTreeMap> { + match self { + Self::Python { env, .. } => Some(env), + Self::Unrecognized { .. } => None, + } + } +} + +#[derive(Deserialize)] +struct PythonRuntimeWire { + kind: String, + #[serde(default)] + python_version: Option, + #[serde(default)] + environment: Option, + #[serde(default)] + env: BTreeMap, +} + +impl<'de> Deserialize<'de> for PythonRuntimeSpec { + fn deserialize>(deserializer: D) -> std::result::Result { + let wire = PythonRuntimeWire::deserialize(deserializer)?; + if wire.kind == "python" { + Ok(Self::Python { + python_version: wire + .python_version + .ok_or_else(|| de::Error::missing_field("python_version"))?, + environment: wire + .environment + .ok_or_else(|| de::Error::missing_field("environment"))?, + env: wire.env, + }) + } else { + Ok(Self::Unrecognized { kind: wire.kind }) + } + } +} + +impl Serialize for PythonRuntimeSpec { + fn serialize(&self, serializer: S) -> std::result::Result { + #[derive(Serialize)] + struct PythonRuntimeRef<'a> { + kind: &'static str, + python_version: &'a str, + environment: &'a PythonEnvironmentSpec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + env: &'a BTreeMap, + } + + #[derive(Serialize)] + struct UnrecognizedRuntimeRef<'a> { + kind: &'a str, + } + + match self { + Self::Python { + python_version, + environment, + env, + } => PythonRuntimeRef { + kind: "python", + python_version, + environment, + env, + } + .serialize(serializer), + Self::Unrecognized { kind } => UnrecognizedRuntimeRef { kind }.serialize(serializer), + } + } +} + +/// Immutable Function version returned by the Enterprise catalog. +/// +/// Scheduling resources, priority, concurrency, and retry policy belong to +/// the submitting Job and are not part of this identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionVersion { + name: String, + version: String, + artifact: FunctionArtifact, + signature: FunctionSignature, + runtime: PythonRuntimeSpec, + runtime_digest: String, + environment_digest: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_secrets: Vec, + created_at: String, +} + +impl FunctionVersion { + pub fn name(&self) -> &str { + &self.name + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn artifact(&self) -> &FunctionArtifact { + &self.artifact + } + + pub fn signature(&self) -> &FunctionSignature { + &self.signature + } + + pub fn runtime(&self) -> &PythonRuntimeSpec { + &self.runtime + } + + pub fn runtime_digest(&self) -> &str { + &self.runtime_digest + } + + pub fn environment_digest(&self) -> &str { + &self.environment_digest + } + + /// Required secret names. Resolved values exist only inside Sophon. + pub fn required_secrets(&self) -> &[String] { + &self.required_secrets + } + + pub fn created_at(&self) -> &str { + &self.created_at + } +} + +impl_json!(FunctionVersion); + +/// Exact FunctionVersion reference embedded in applications and bindings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionVersionRef { + pub name: String, + pub version: String, +} + +/// Parameter binding in a FunctionApplication. +/// +/// `kind` remains open until Python authoring is added in Slice 2. Slice 1 +/// freezes JSON integers, strings, booleans, nulls, arrays, and objects as +/// canonical literal values. Floating-point literals are rejected until a +/// language-neutral numeric representation is defined. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApplicationInput { + pub parameter: String, + pub kind: String, + pub value: Value, +} + +/// Pre-declaration application of an exact FunctionVersion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FunctionApplication { + function: FunctionVersionRef, + inputs: Vec, + output: FunctionOutput, + group_id: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + columns: BTreeMap, + #[serde(default, flatten, skip_serializing)] + unknown_fields: BTreeMap, + #[serde(default, skip)] + unknown_nested_fields: bool, +} + +impl FunctionApplication { + pub fn function(&self) -> &FunctionVersionRef { + &self.function + } + + pub fn inputs(&self) -> &[ApplicationInput] { + &self.inputs + } + + pub fn output(&self) -> &FunctionOutput { + &self.output + } + + pub fn group_id(&self) -> &str { + &self.group_id + } + + pub fn columns(&self) -> &BTreeMap { + &self.columns + } + /// Whether a newer writer attached application fields this client cannot + /// validate. Such applications remain readable but must not be declared. + pub fn has_unknown_fields(&self) -> bool { + !self.unknown_fields.is_empty() || self.unknown_nested_fields + } + + /// Decode a remote application after validating the Slice 1 literal domain. + pub fn from_json(json: &str) -> Result { + let value: Value = from_json(json)?; + let has_unknown_nested_fields = application_has_unknown_nested_fields(&value); + let mut application: Self = serde_json::from_value(value).map_err(invalid_json)?; + application.unknown_nested_fields = has_unknown_nested_fields; + application + .inputs + .iter() + .try_for_each(|input| validate_literal(&input.value))?; + Ok(application) + } + + /// Encode the application with bytewise-sorted JSON keys. + pub fn to_canonical_json(&self) -> Result { + self.inputs + .iter() + .try_for_each(|input| validate_literal(&input.value))?; + canonical_json(self) + } +} + +/// Stable table input bound to a registered parameter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InputBinding { + pub parameter: String, + pub field_id: i32, + pub field_path: String, + pub arrow_type: String, + pub nullable: bool, +} + +/// Ordered result-field to table-field mapping for a grouped binding. +/// +/// Assignment state is not part of the Slice 1 client contract. During the +/// NULL transition there is no public Lance cell-flag identifier to persist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutputMapping { + pub result_field: String, + pub output_name: String, + pub output_field_id: i32, + pub output_ordinal: u32, + pub arrow_type: String, + pub nullable: bool, +} + +/// Immutable grouped binding persisted by the Enterprise table service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionBinding { + binding_id: String, + revision: u64, + function: FunctionVersionRef, + group_id: String, + inputs: Vec, + outputs: Vec, + /// Exact Arrow schema presented to the Function, encoded with the Lance + /// Namespace Arrow JSON representation. + #[serde(default, skip_serializing_if = "Option::is_none")] + input_schema: Option, + /// Exact physical Arrow schema of the grouped table outputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + output_schema: Option, +} + +impl FunctionBinding { + pub fn binding_id(&self) -> &str { + &self.binding_id + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn function(&self) -> &FunctionVersionRef { + &self.function + } + + pub fn group_id(&self) -> &str { + &self.group_id + } + + pub fn inputs(&self) -> &[InputBinding] { + &self.inputs + } + + pub fn outputs(&self) -> &[OutputMapping] { + &self.outputs + } + + pub fn input_schema(&self) -> Option<&Value> { + self.input_schema.as_ref() + } + + pub fn output_schema(&self) -> Option<&Value> { + self.output_schema.as_ref() + } +} + +impl_json!(FunctionBinding); + +/// Stable terminal result of a remote Function-column refresh Job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefreshColumnResult { + pub rows_assigned: u64, + pub rows_failed: u64, + pub rows_remaining: u64, + pub source_version: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_version: Option, +} + +impl RefreshColumnResult { + /// Deprecated compatibility alias for `rows_assigned`. + pub fn rows_filled(&self) -> u64 { + self.rows_assigned + } + + /// Deprecated compatibility alias for `published_version`. + pub fn version(&self) -> Option { + self.published_version + } +} + +impl_json!(RefreshColumnResult); diff --git a/rust/lancedb/src/io/object_store.rs b/rust/lancedb/src/io/object_store.rs index d27357b82..d594bd857 100644 --- a/rust/lancedb/src/io/object_store.rs +++ b/rust/lancedb/src/io/object_store.rs @@ -132,9 +132,14 @@ impl ObjectStore for MirroringObjectStore { if to.primary_only() { self.primary.copy_opts(from, to, options).await } else { - self.secondary.copy_opts(from, to, options.clone()).await?; - self.primary.copy_opts(from, to, options).await?; - Ok(()) + // The secondary store can be process-local and less durable than the + // primary, so a source written by another process may not exist here + // or may be evicted before the copy begins. + match self.secondary.copy_opts(from, to, options.clone()).await { + Ok(()) | Err(Error::NotFound { .. }) => {} + Err(err) => return Err(err), + } + self.primary.copy_opts(from, to, options).await } } } @@ -192,7 +197,8 @@ mod test { use futures::TryStreamExt; use lance::{dataset::WriteParams, io::ObjectStoreParams}; use lance_testing::datagen::{BatchGenerator, IncrementingInt32, RandomVector}; - use object_store::local::LocalFileSystem; + use object_store::{local::LocalFileSystem, memory::InMemory}; + use std::time::Duration; use tempfile; use crate::{ @@ -201,6 +207,139 @@ mod test { table::WriteOptions, }; + #[derive(Debug)] + struct EvictBeforeCopyStore { + inner: Arc, + } + + impl std::fmt::Display for EvictBeforeCopyStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "EvictBeforeCopyStore") + } + } + + #[async_trait] + impl ObjectStore for EvictBeforeCopyStore { + async fn put_opts( + &self, + location: &Path, + payload: PutPayload, + options: PutOptions, + ) -> Result { + self.inner.put_opts(location, payload, options).await + } + + async fn put_multipart_opts( + &self, + location: &Path, + options: PutMultipartOptions, + ) -> Result> { + self.inner.put_multipart_opts(location, options).await + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> Result { + self.inner.get_opts(location, options).await + } + + fn delete_stream( + &self, + locations: BoxStream<'static, Result>, + ) -> BoxStream<'static, Result> { + self.inner.delete_stream(locations) + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result> { + self.inner.list(prefix) + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result { + self.inner.list_with_delimiter(prefix).await + } + + async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> { + self.inner.delete(from).await?; + self.inner.copy_opts(from, to, options).await + } + } + + #[tokio::test] + async fn test_copy_when_source_is_missing_from_secondary() { + let primary_dir = tempfile::tempdir().unwrap(); + let secondary_dir = tempfile::tempdir().unwrap(); + let primary: Arc = + Arc::new(LocalFileSystem::new_with_prefix(primary_dir.path()).unwrap()); + let secondary: Arc = + Arc::new(LocalFileSystem::new_with_prefix(secondary_dir.path()).unwrap()); + let store = MirroringObjectStore { + primary: primary.clone(), + secondary: secondary.clone(), + }; + let staging = Path::from("_versions/1.manifest-staging"); + let finalized = Path::from("_versions/1.manifest"); + + primary + .put(&staging, "manifest contents".into()) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(5), store.copy(&staging, &finalized)) + .await + .expect("copy should not hang when the secondary source is missing") + .unwrap(); + + let copied = primary + .get(&finalized) + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert_eq!(copied, "manifest contents"); + assert!(matches!( + secondary.head(&finalized).await, + Err(Error::NotFound { .. }) + )); + } + + #[tokio::test] + async fn test_copy_when_secondary_source_disappears_after_head() { + let primary: Arc = Arc::new(InMemory::new()); + let secondary_inner: Arc = Arc::new(InMemory::new()); + let secondary: Arc = Arc::new(EvictBeforeCopyStore { + inner: secondary_inner.clone(), + }); + let store = MirroringObjectStore { + primary: primary.clone(), + secondary, + }; + let staging = Path::from("_versions/1.manifest-staging"); + let finalized = Path::from("_versions/1.manifest"); + + primary + .put(&staging, "manifest contents".into()) + .await + .unwrap(); + secondary_inner + .put(&staging, "manifest contents".into()) + .await + .unwrap(); + + store.copy(&staging, &finalized).await.unwrap(); + + let copied = primary + .get(&finalized) + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert_eq!(copied, "manifest contents"); + assert!(matches!( + secondary_inner.head(&finalized).await, + Err(Error::NotFound { .. }) + )); + } + // This test is ignored because lance 3.0 introduced LocalWriter optimization // that bypasses the object store wrapper for local writes. The mirroring feature // still works for remote/cloud storage, but can't be tested with local storage. diff --git a/rust/lancedb/src/job.rs b/rust/lancedb/src/job.rs index 789ce8312..0f880e398 100644 --- a/rust/lancedb/src/job.rs +++ b/rust/lancedb/src/job.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde_json::Value; use tokio::sync::watch; use tokio::task::{AbortHandle, JoinHandle}; @@ -19,43 +21,127 @@ pub(crate) trait JobHandle: Send + Sync { None } async fn status(&self) -> Result; - async fn wait(&self) -> Result<()>; + async fn wait(&self) -> Result; async fn cancel(&self) -> Result<()>; } +/// A backend-neutral successful terminal result. +/// +/// Local operations do not carry a value. Remote operations may carry JSON +/// that the public [`Job`] decodes according to its result type. +pub(crate) struct TerminalResult { + #[allow(dead_code)] // Typed remote submit endpoints consume this after Slice 1. + value: Option, + #[allow(dead_code)] // Preserved so typed decode errors retain request correlation. + request_id: Option, +} + +impl TerminalResult { + pub(crate) fn local() -> Self { + Self { + value: None, + request_id: None, + } + } + + pub(crate) fn remote(value: Option, request_id: String) -> Self { + Self { + value, + request_id: Some(request_id), + } + } + + #[allow(dead_code)] // Exercised by the remote typed-result fixtures in Slice 1. + fn decode(self) -> Result { + let request_id = self.request_id.unwrap_or_default(); + let value = self.value.ok_or_else(|| Error::Http { + source: "successful typed job response did not contain a result".into(), + request_id: request_id.clone(), + status_code: None, + })?; + serde_json::from_value(value).map_err(|error| Error::Http { + source: format!("failed to parse typed job result: {error}").into(), + request_id, + status_code: None, + }) + } +} + +type ResultDecoder = fn(TerminalResult) -> Result; + +enum JobInner { + Handle { + handle: Box, + decode: ResultDecoder, + }, + Completed(T), +} + /// A handle to an operation that may still be running. /// /// The operation may already be complete when the handle is created. -pub struct Job { - handle: Option>, +pub struct Job +where + T: Clone + Send + Sync + 'static, +{ + inner: JobInner, } -impl std::fmt::Debug for Job { +impl std::fmt::Debug for Job +where + T: Clone + Send + Sync + 'static, +{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Job") .field("id", &self.id()) - .field("done", &self.handle.is_none()) + .field("done", &matches!(self.inner, JobInner::Completed(_))) .finish() } } -impl Job { +impl Job<()> { /// A job whose operation finished before the handle was created. pub(crate) fn new_done() -> Self { - Self { handle: None } + Self { + inner: JobInner::Completed(()), + } } pub(crate) fn new(handle: Box) -> Self { Self { - handle: Some(handle), + inner: JobInner::Handle { + handle, + decode: |_| Ok(()), + }, } } - /// A job running as a task in this process. + /// A unit-result job running as a task in this process. pub(crate) fn spawned(task: JoinHandle>) -> Self { Self::new(Box::new(SpawnedJob::new(task))) } +} +impl Job +where + T: Clone + DeserializeOwned + Send + Sync + 'static, +{ + /// Construct a typed remote Job before result-specific submit APIs are added. + #[allow(dead_code)] + pub(crate) fn new_typed(handle: Box) -> Self { + Self { + inner: JobInner::Handle { + handle, + decode: TerminalResult::decode::, + }, + } + } +} + +impl Job +where + T: Clone + Send + Sync + 'static, +{ /// Identifies the operation on the server that is running it. /// /// Returned for correlating with server logs or the jobs API. Operations @@ -63,7 +149,10 @@ impl Job { /// value is opaque: parsing it or storing it to resume the job later is /// not supported. pub fn id(&self) -> Option<&str> { - self.handle.as_ref().and_then(|handle| handle.id()) + match &self.inner { + JobInner::Handle { handle, .. } => handle.id(), + JobInner::Completed(_) => None, + } } /// The operation's current lifecycle state: "running", "finished", @@ -73,9 +162,9 @@ impl Job { /// terminal failure state, or retry. States a newer server reports that /// this client version does not know pass through as-is. pub async fn status(&self) -> Result { - match &self.handle { - None => Ok("finished".to_string()), - Some(handle) => handle.status().await, + match &self.inner { + JobInner::Handle { handle, .. } => handle.status().await, + JobInner::Completed(_) => Ok("finished".to_string()), } } @@ -83,10 +172,10 @@ impl Job { /// /// Returns [`crate::Error::JobFailed`] if the operation failed and /// [`crate::Error::JobCancelled`] if it was cancelled. - pub async fn wait(&self) -> Result<()> { - match &self.handle { - None => Ok(()), - Some(handle) => handle.wait().await, + pub async fn wait(&self) -> Result { + match &self.inner { + JobInner::Handle { handle, decode } => decode(handle.wait().await?), + JobInner::Completed(result) => Ok(result.clone()), } } @@ -94,9 +183,9 @@ impl Job { /// /// Cancelling an operation that already finished is a no-op. pub async fn cancel(&self) -> Result<()> { - match &self.handle { - None => Ok(()), - Some(handle) => handle.cancel().await, + match &self.inner { + JobInner::Handle { handle, .. } => handle.cancel().await, + JobInner::Completed(_) => Ok(()), } } } @@ -141,7 +230,7 @@ impl SpawnedJob { Ok(Err(err)) => Outcome::Failed(Arc::new(err)), Err(err) if err.is_cancelled() => Outcome::Cancelled, Err(err) => Outcome::Failed(Arc::new(Error::Runtime { - message: format!("index job task failed: {err}"), + message: format!("job task failed: {err}"), })), }; let _ = tx.send(Some(outcome)); @@ -162,7 +251,7 @@ impl JobHandle for SpawnedJob { Ok(label.to_string()) } - async fn wait(&self) -> Result<()> { + async fn wait(&self) -> Result { let mut outcome = self.outcome.clone(); let settled = outcome .wait_for(|outcome| outcome.is_some()) @@ -172,7 +261,8 @@ impl JobHandle for SpawnedJob { })? .clone() .expect("wait_for returns once an outcome is set"); - settled.into_result() + settled.into_result()?; + Ok(TerminalResult::local()) } async fn cancel(&self) -> Result<()> { diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 70d023ccc..291dcaf65 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -181,6 +181,7 @@ pub mod dataloader; pub mod embeddings; pub mod error; pub mod expr; +pub mod function; pub mod index; pub mod io; pub mod ipc; @@ -205,6 +206,7 @@ use serde::{Deserialize, Serialize}; pub use blob::{BlobRangeRequest, blob, is_blob}; pub use connection::{ConnectNamespaceBuilder, Connection}; pub use error::{Error, JobFailure, Result}; +pub use function::FunctionVersion; pub use job::Job; use lance_index::vector::ApproxMode as LanceApproxMode; use lance_linalg::distance::DistanceType as LanceDistanceType; diff --git a/rust/lancedb/src/query.rs b/rust/lancedb/src/query.rs index 64f4e6a6a..2e653509a 100644 --- a/rust/lancedb/src/query.rs +++ b/rust/lancedb/src/query.rs @@ -1675,14 +1675,8 @@ mod tests { #[tokio::test] async fn test_setters_getters() { - // TODO: Switch back to memory://foo after https://github.com/lancedb/lancedb/issues/1051 - // is fixed - let tmp_dir = tempdir().unwrap(); - let dataset_path = tmp_dir.path().join("test.lance"); - let uri = dataset_path.to_str().unwrap(); - let batches = make_test_batches(); - let conn = connect(uri).execute().await.unwrap(); + let conn = connect("memory://foo").execute().await.unwrap(); let table = conn .create_table("my_table", batches) .execute() @@ -1777,14 +1771,8 @@ mod tests { #[tokio::test] async fn test_execute() { - // TODO: Switch back to memory://foo after https://github.com/lancedb/lancedb/issues/1051 - // is fixed - let tmp_dir = tempdir().unwrap(); - let dataset_path = tmp_dir.path().join("test.lance"); - let uri = dataset_path.to_str().unwrap(); - let batches = make_non_empty_batches(); - let conn = connect(uri).execute().await.unwrap(); + let conn = connect("memory://foo").execute().await.unwrap(); let table = conn .create_table("my_table", batches) .execute() @@ -2054,14 +2042,8 @@ mod tests { #[tokio::test] async fn test_select_with_transform() { - // TODO: Switch back to memory://foo after https://github.com/lancedb/lancedb/issues/1051 - // is fixed - let tmp_dir = tempdir().unwrap(); - let dataset_path = tmp_dir.path().join("test.lance"); - let uri = dataset_path.to_str().unwrap(); - let batches = make_non_empty_batches(); - let conn = connect(uri).execute().await.unwrap(); + let conn = connect("memory://foo").execute().await.unwrap(); let table = conn .create_table("my_table", batches) .execute() @@ -2158,15 +2140,9 @@ mod tests { #[tokio::test] async fn test_execute_no_vector() { - // TODO: Switch back to memory://foo after https://github.com/lancedb/lancedb/issues/1051 - // is fixed - let tmp_dir = tempdir().unwrap(); - let dataset_path = tmp_dir.path().join("test.lance"); - let uri = dataset_path.to_str().unwrap(); - // test that it's ok to not specify a query vector (just filter / limit) let batches = make_non_empty_batches(); - let conn = connect(uri).execute().await.unwrap(); + let conn = connect("memory://foo").execute().await.unwrap(); let table = conn .create_table("my_table", batches) .execute() diff --git a/rust/lancedb/src/remote.rs b/rust/lancedb/src/remote.rs index 4b5f8832f..be9d0eef6 100644 --- a/rust/lancedb/src/remote.rs +++ b/rust/lancedb/src/remote.rs @@ -19,6 +19,15 @@ const ARROW_FILE_CONTENT_TYPE: &str = "application/vnd.apache.arrow.file"; #[cfg(test)] const JSON_CONTENT_TYPE: &str = "application/json"; +fn extract_job_id(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .get("job_id")? + .as_str() + .filter(|job_id| !job_id.is_empty()) + .map(str::to_string) +} + pub use client::{ClientConfig, HeaderProvider, RetryConfig, TimeoutConfig, TlsConfig}; pub use db::{RemoteDatabaseOptions, RemoteDatabaseOptionsBuilder}; pub use oauth::{OAuthConfig, OAuthFlow, OAuthHeaderProvider}; diff --git a/rust/lancedb/src/remote/client.rs b/rust/lancedb/src/remote/client.rs index 9e34fca9f..57dd89890 100644 --- a/rust/lancedb/src/remote/client.rs +++ b/rust/lancedb/src/remote/client.rs @@ -373,6 +373,37 @@ pub fn parse_db_url(db_url: &str) -> Result { Ok(ParsedDbUrl { db_name, db_prefix }) } +fn validate_dns_hostname(hostname: &str) -> Result<()> { + let ascii_hostname = match url::Host::parse(hostname) { + Ok(url::Host::Domain(hostname)) => hostname, + Ok(_) => { + return Err(Error::InvalidInput { + message: "LanceDB Cloud database URI or region produced a non-DNS hostname" + .to_string(), + }); + } + Err(err) => { + return Err(Error::InvalidInput { + message: format!( + "LanceDB Cloud database URI or region produced an invalid hostname: {err}" + ), + }); + } + }; + + if ascii_hostname.len() > 253 + || ascii_hostname + .split('.') + .any(|label| label.is_empty() || label.len() > 63) + { + return Err(Error::InvalidInput { + message: "LanceDB Cloud database URI or region produced an invalid hostname: DNS labels must contain 1 to 63 bytes and the full hostname must not exceed 253 bytes".to_string(), + }); + } + + Ok(()) +} + impl RestfulLanceDbClient { fn get_timeout(passed: Option, env_var: &str) -> Result> { if let Some(passed) = passed { @@ -480,7 +511,11 @@ impl RestfulLanceDbClient { let host = match host_override { Some(host_override) => host_override, - None => format!("https://{}.{}.api.lancedb.com", parsed_url.db_name, region), + None => { + let hostname = format!("{}.{}.api.lancedb.com", parsed_url.db_name, region); + validate_dns_hostname(&hostname)?; + format!("https://{hostname}") + } }; debug!("Created client for host: {}", host); let retry_config = client_config.retry_config.clone().try_into()?; @@ -1157,6 +1192,29 @@ mod tests { assert_eq!(headers.get("x-api-key").unwrap(), "api-key"); } + #[test] + fn test_rejects_invalid_cloud_dns_hostname() { + let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()]; + + for db_name in invalid_database_names { + let parsed_url = parse_db_url(&format!("db://{db_name}")).unwrap(); + let error = RestfulLanceDbClient::::try_new( + &parsed_url, + "us-east-1", + None, + HeaderMap::new(), + ClientConfig::default(), + None, + ) + .unwrap_err(); + + assert!( + matches!(error, Error::InvalidInput { ref message } if message.contains("DNS labels must contain 1 to 63 bytes")), + "unexpected error: {error}" + ); + } + } + // Test implementation of HeaderProvider #[derive(Debug, Clone)] struct TestHeaderProvider { diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index 839cb3797..03a13cb4e 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -9,6 +9,7 @@ use http::StatusCode; use lance_io::object_store::StorageOptions; use lance_namespace_impls::{DynamicContextProvider, OperationInfo}; use moka::future::Cache; +use reqwest::Response; use reqwest::header::CONTENT_TYPE; use lance_namespace::models::{ @@ -23,15 +24,17 @@ use crate::database::{ JobDescription, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest, }; use crate::error::Result; +use crate::job::Job; +use crate::remote::job::{DescribeJobResponse, RemoteJob, job_state_to_client}; use crate::remote::util::stream_as_body; use crate::table::BaseTable; -use super::ARROW_STREAM_CONTENT_TYPE; use super::client::{ ClientConfig, HeaderProvider, HttpSend, RequestResultExt, RestfulLanceDbClient, Sender, }; use super::table::RemoteTable; use super::util::parse_server_version; +use super::{ARROW_STREAM_CONTENT_TYPE, extract_job_id}; // Request structure for the remote clone table API #[derive(serde::Serialize)] @@ -326,6 +329,22 @@ impl RemoteDatabase { } } +impl RemoteDatabase { + async fn submit_drop_table( + &self, + name: &str, + namespace_path: &[String], + ) -> Result<(String, Response)> { + let identifier = build_table_identifier(name, namespace_path, &self.client.id_delimiter); + let cache_key = build_cache_key(name, namespace_path); + let req = self.client.post(&format!("/v1/table/{}/drop/", identifier)); + let (request_id, resp) = self.client.send(req).await?; + let resp = self.client.check_response(&request_id, resp).await?; + self.table_cache.remove(&cache_key).await; + Ok((request_id, resp)) + } +} + #[cfg(all(test, feature = "remote"))] mod test_utils { use super::*; @@ -453,48 +472,6 @@ struct RemoteListJobsResponse { page_token: Option, } -/// The server's account of why a job failed. Absent from older servers, -/// which report only the terminal state. -#[derive(serde::Deserialize)] -struct RemoteReportedFailure { - #[serde(default)] - phase: Option, - #[serde(default)] - message: Option, - #[serde(default)] - retryable: Option, -} - -#[derive(serde::Deserialize)] -struct RemoteDescribeJobResponse { - job_id: String, - #[serde(default)] - job_type: String, - job_state: String, - #[serde(default)] - creation_ms: i64, - #[serde(default)] - spec: serde_json::Value, - #[serde(default)] - failure: Option, -} - -/// Server job states -> the client vocabulary ("running" / "finished" / -/// "failed" / "cancelled"). Covers both the describe enum (IN_PROGRESS / -/// DONE / FAILED / CANCELLED) and the registry's lowercase list-row states -/// (in_progress / succeeded / failed / canceled / timed_out). States this -/// client version does not know (e.g. created, queued) pass through as-is. -fn job_state_to_client(state: &str) -> String { - match state { - "IN_PROGRESS" | "in_progress" => "running", - "DONE" | "done" | "succeeded" => "finished", - "FAILED" | "failed" | "TIMED_OUT" | "timed_out" => "failed", - "CANCELLED" | "cancelled" | "canceled" => "cancelled", - other => other, - } - .to_string() -} - /// Bound on `list_jobs` page walking; a warning is logged when the listing /// is truncated at this many pages. const MAX_LIST_JOBS_PAGES: usize = 100; @@ -567,19 +544,14 @@ impl Database for RemoteDatabase { }) => return Ok(None), Err(err) => return Err(err), }; - let body: RemoteDescribeJobResponse = rsp.json().await.err_to_http(request_id)?; + let body: DescribeJobResponse = rsp.json().await.err_to_http(request_id)?; Ok(Some(JobDescription { job_id: body.job_id, job_type: body.job_type, state: job_state_to_client(&body.job_state), creation_ms: body.creation_ms, spec: body.spec, - failure: body.failure.map(|reported| crate::error::JobFailure { - phase: reported.phase, - message: reported.message, - retryable: reported.retryable, - source: None, - }), + failure: body.failure.map(|reported| reported.into_job_failure()), })) } @@ -894,13 +866,28 @@ impl Database for RemoteDatabase { } async fn drop_table(&self, name: &str, namespace_path: &[String]) -> Result<()> { - let identifier = build_table_identifier(name, namespace_path, &self.client.id_delimiter); - let cache_key = build_cache_key(name, namespace_path); - let req = self.client.post(&format!("/v1/table/{}/drop/", identifier)); - let (request_id, resp) = self.client.send(req).await?; - self.client.check_response(&request_id, resp).await?; - self.table_cache.remove(&cache_key).await; - Ok(()) + self.submit_drop_table(name, namespace_path) + .await + .map(|_| ()) + } + + async fn drop_table_async(&self, name: &str, namespace_path: &[String]) -> Result { + let (request_id, response) = self.submit_drop_table(name, namespace_path).await?; + let status = response.status(); + let body = response.text().await.err_to_http(request_id.clone())?; + let job_id = extract_job_id(&body); + Ok(match job_id { + Some(job_id) => Job::new(Box::new(RemoteJob::new(self.client.clone(), job_id))), + None if status == StatusCode::ACCEPTED => { + return Err(Error::Http { + source: "asynchronous drop-table response did not contain a valid job_id" + .into(), + request_id, + status_code: Some(status), + }); + } + None => Job::new_done(), + }) } async fn drop_all_tables(&self, namespace_path: &[String]) -> Result<()> { @@ -1492,6 +1479,67 @@ mod tests { // NOTE: the API will return 200 even if the table does not exist. So we shouldn't expect 404. } + #[tokio::test] + async fn test_drop_table_does_not_read_response_body() { + let conn = Connection::new_with_handler(|_| { + http::Response::builder() + .status(200) + .body(vec![0xff]) + .unwrap() + }); + + conn.drop_table("table1", &[]).await.unwrap(); + } + + #[tokio::test] + async fn test_drop_table_async_returns_job() { + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.method(), &reqwest::Method::POST); + assert_eq!(request.url().path(), "/v1/table/table1/drop/"); + http::Response::builder() + .status(202) + .body(r#"{"job_id":"drop-job-123"}"#) + .unwrap() + }); + + let job = conn.drop_table_async("table1", &[]).await.unwrap(); + assert_eq!(job.id(), Some("drop-job-123")); + } + + #[tokio::test] + async fn test_drop_table_async_old_server_returns_done_job() { + let conn = Connection::new_with_handler(|_| { + http::Response::builder().status(200).body("").unwrap() + }); + + let job = conn.drop_table_async("table1", &[]).await.unwrap(); + assert_eq!(job.id(), None); + assert_eq!(job.status().await.unwrap(), "finished"); + } + + #[tokio::test] + async fn test_drop_table_async_rejects_accepted_response_without_job_id() { + let conn = Connection::new_with_handler(|_| { + http::Response::builder().status(202).body("{}").unwrap() + }); + + let error = conn.drop_table_async("table1", &[]).await.err().unwrap(); + assert!(error.to_string().contains("valid job_id")); + } + + #[tokio::test] + async fn test_drop_table_async_rejects_empty_job_id() { + let conn = Connection::new_with_handler(|_| { + http::Response::builder() + .status(202) + .body(r#"{"job_id":""}"#) + .unwrap() + }); + + let error = conn.drop_table_async("table1", &[]).await.err().unwrap(); + assert!(error.to_string().contains("valid job_id")); + } + #[tokio::test] async fn test_rename_table() { let conn = Connection::new_with_handler(|request| { @@ -2412,7 +2460,7 @@ mod tests { http::Response::builder() .status(200) .body(format!( - r#"{{"job_id": "job-1", "job_type": "create_index", "job_state": "{}", "creation_ms": 1}}"#, + r#"{{"job_id": "job-1", "job_type": "create_function", "job_state": "{}", "creation_ms": 1, "result": {{"name": "embed", "version": "fv_1"}}}}"#, state )) .unwrap() diff --git a/rust/lancedb/src/remote/job.rs b/rust/lancedb/src/remote/job.rs index 2fc99da59..0d41dbb35 100644 --- a/rust/lancedb/src/remote/job.rs +++ b/rust/lancedb/src/remote/job.rs @@ -8,10 +8,10 @@ use std::time::Duration; use async_trait::async_trait; use tokio::time::sleep; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use crate::error::{Error, JobFailure, Result}; -use crate::job::JobHandle; +use crate::job::{JobHandle, TerminalResult}; use crate::remote::client::{HttpSend, RequestResultExt, RestfulLanceDbClient}; /// Delay before the second job-state poll; doubles up to [`MAX_POLL_INTERVAL`]. @@ -29,12 +29,6 @@ enum JobState { Other(String), } -impl<'de> Deserialize<'de> for JobState { - fn deserialize>(deserializer: D) -> std::result::Result { - Ok(Self::from(String::deserialize(deserializer)?.as_str())) - } -} - impl JobState { /// The client vocabulary label for this state. fn client_label(&self) -> String { @@ -51,22 +45,26 @@ impl JobState { impl From<&str> for JobState { fn from(state: &str) -> Self { match state { - "IN_PROGRESS" => Self::InProgress, - "CANCELLED" => Self::Cancelled, + "IN_PROGRESS" | "in_progress" => Self::InProgress, + "CANCELLED" | "cancelled" | "canceled" => Self::Cancelled, // The server reports a timed-out job as FAILED on describe; // accept the raw registry state too in case a future server // stops folding it. - "FAILED" | "TIMED_OUT" => Self::Failed, - "DONE" => Self::Done, + "FAILED" | "failed" | "TIMED_OUT" | "timed_out" => Self::Failed, + "DONE" | "done" | "succeeded" => Self::Done, other => Self::Other(other.to_string()), } } } +pub(super) fn job_state_to_client(state: &str) -> String { + JobState::from(state).client_label() +} + /// The server's account of why a job failed. Absent from older servers, which /// report only the terminal state. #[derive(Deserialize)] -struct ReportedFailure { +pub(super) struct ReportedFailure { #[serde(default)] phase: Option, #[serde(default)] @@ -75,11 +73,43 @@ struct ReportedFailure { retryable: Option, } +/// Forward-compatible `/v1/jobs/describe` wire envelope. #[derive(Deserialize)] -struct DescribeJobResponse { - job_state: JobState, +pub(super) struct DescribeJobResponse { #[serde(default)] - failure: Option, + pub(super) job_id: String, + #[serde(default)] + pub(super) job_type: String, + pub(super) job_state: String, + #[serde(default)] + pub(super) creation_ms: i64, + #[serde(default)] + pub(super) spec: serde_json::Value, + #[serde(default)] + result: Option, + #[serde(default)] + pub(super) failure: Option, +} + +impl ReportedFailure { + pub(super) fn into_job_failure(self) -> JobFailure { + JobFailure { + phase: self.phase, + message: self.message, + retryable: self.retryable, + source: None, + } + } +} + +impl DescribeJobResponse { + fn state(&self) -> JobState { + JobState::from(self.job_state.as_str()) + } + + fn into_terminal_result(self, request_id: String) -> TerminalResult { + TerminalResult::remote(self.result, request_id) + } } pub struct RemoteJob { @@ -93,7 +123,7 @@ impl RemoteJob { } /// One `/v1/jobs/describe` round trip. - async fn describe(&self) -> Result { + async fn describe(&self) -> Result<(String, DescribeJobResponse)> { let request = self .client .post("/v1/jobs/describe") @@ -104,10 +134,10 @@ impl RemoteJob { let description: DescribeJobResponse = serde_json::from_str(&body).map_err(|e| Error::Http { source: format!("failed to parse job description: {}", e).into(), - request_id, + request_id: request_id.clone(), status_code: None, })?; - Ok(description) + Ok((request_id, description)) } } @@ -118,26 +148,21 @@ impl JobHandle for RemoteJob { } async fn status(&self) -> Result { - Ok(self.describe().await?.job_state.client_label()) + Ok(self.describe().await?.1.state().client_label()) } - async fn wait(&self) -> Result<()> { + async fn wait(&self) -> Result { let mut interval = INITIAL_POLL_INTERVAL; loop { - let description = self.describe().await?; - match description.job_state { - JobState::Done => return Ok(()), + let (request_id, description) = self.describe().await?; + match description.state() { + JobState::Done => return Ok(description.into_terminal_result(request_id)), JobState::Failed => { return Err(Error::JobFailed { job_id: Some(self.job_id.clone()), failure: description .failure - .map(|reported| JobFailure { - phase: reported.phase, - message: reported.message, - retryable: reported.retryable, - source: None, - }) + .map(ReportedFailure::into_job_failure) .unwrap_or_default(), }); } @@ -168,3 +193,78 @@ impl JobHandle for RemoteJob { .map(|_| ()) } } + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + + use crate::Result; + use crate::function::{FunctionVersion, RefreshColumnResult}; + use crate::job::{Job, JobHandle, TerminalResult}; + + use super::DescribeJobResponse; + + const FUNCTION_JOB: &str = + include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_job.json"); + const REFRESH_JOB: &str = + include_str!("../../tests/fixtures/first_class_functions/v1/remote_refresh_job.json"); + const UNIT_JOB: &str = + include_str!("../../tests/fixtures/first_class_functions/v1/remote_unit_job.json"); + const MISSING_RESULT_JOB: &str = r#"{"job_state":"DONE"}"#; + + struct FixtureRemoteJob(&'static str); + + #[async_trait] + impl JobHandle for FixtureRemoteJob { + async fn status(&self) -> Result { + Ok("finished".to_string()) + } + + async fn wait(&self) -> Result { + let description: DescribeJobResponse = + serde_json::from_str(self.0).expect("remote job fixture"); + Ok(description.into_terminal_result("fixture-request".to_string())) + } + + async fn cancel(&self) -> Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn typed_remote_job_fixtures_decode_terminal_results() { + let function = Job::::new_typed(Box::new(FixtureRemoteJob(FUNCTION_JOB))); + let result = function.wait().await.expect("typed FunctionVersion result"); + assert_eq!(result.version(), "fv_01K3EXACT"); + + let refresh = + Job::::new_typed(Box::new(FixtureRemoteJob(REFRESH_JOB))); + let result = refresh.wait().await.expect("typed RefreshColumnResult"); + assert_eq!(result.rows_assigned, 999_998_800); + assert_eq!(result.rows_filled(), result.rows_assigned); + + let unit = Job::new(Box::new(FixtureRemoteJob(UNIT_JOB))); + unit.wait() + .await + .expect("unit result ignores additive remote payloads"); + } + + #[tokio::test] + async fn typed_remote_job_requires_a_terminal_result() { + let typed = + Job::::new_typed(Box::new(FixtureRemoteJob(MISSING_RESULT_JOB))); + let error = typed.wait().await.unwrap_err(); + assert!( + error + .to_string() + .contains("successful typed job response did not contain a result") + ); + } + + #[test] + fn remote_wire_unknown_fields_are_forward_decodable() { + let response: DescribeJobResponse = + serde_json::from_str(FUNCTION_JOB).expect("function job fixture"); + assert_eq!(response.job_state, "DONE"); + } +} diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 5628d5acf..7c4b8855d 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -8,7 +8,7 @@ use self::insert::{RemoteWriteExec, WriteOp}; use super::client::RequestResultExt; use super::client::{HttpSend, RestfulLanceDbClient, Sender}; use super::db::ServerVersion; -use super::{ARROW_FILE_CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE}; +use super::{ARROW_FILE_CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE, extract_job_id}; use crate::blob::BlobFile; use crate::data::scannable::{PeekedScannable, Scannable, estimate_write_partitions}; use crate::expr::expr_to_sql_string; @@ -23,15 +23,19 @@ 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; -use crate::table::{AlterColumnsResult, FieldMetadataUpdate, UpdateFieldMetadataResult}; +use crate::table::{ + AlterColumnsResult, FieldMetadataUpdate, RefreshColumnResult, UpdateFieldMetadataResult, +}; use crate::table::{AnyQuery, Filter, Predicate, PreprocessingOutput, TableStatistics}; use crate::utils::background_cache::BackgroundCache; use crate::utils::{ @@ -138,6 +142,40 @@ impl FreshnessHeaders { } } +/// A backfill job whose successful wait establishes a read-freshness +/// baseline on the submitting handle, so a later read cannot be served +/// from a cache older than the completed fill. A handle pinned by checkout +/// at completion keeps its time-travel view instead. +struct FreshnessJob { + inner: RemoteJob, + freshness: Arc>, + version: Arc>>, +} + +#[async_trait] +impl crate::job::JobHandle for FreshnessJob { + fn id(&self) -> Option<&str> { + crate::job::JobHandle::id(&self.inner) + } + + async fn status(&self) -> Result { + crate::job::JobHandle::status(&self.inner).await + } + + async fn wait(&self) -> Result { + let result = crate::job::JobHandle::wait(&self.inner).await?; + let version = self.version.read().await; + if version.is_none() { + self.freshness.lock().unwrap().checkout_baseline = Some(SystemTime::now()); + } + Ok(result) + } + + async fn cancel(&self) -> Result<()> { + crate::job::JobHandle::cancel(&self.inner).await + } +} + fn compute_min_timestamp( state: &FreshnessState, interval: Option, @@ -272,10 +310,10 @@ pub struct RemoteTable { identifier: String, server_version: ServerVersion, - version: RwLock>, + version: Arc>>, location: RwLock>, schema_cache: BackgroundCache, - freshness: Mutex, + freshness: Arc>, /// The branch this handle is scoped to, or `None` for the main branch. /// Stamped onto every branch-accepting request so reads and writes resolve /// on the branch's own version chain rather than main's. @@ -390,13 +428,7 @@ impl RemoteTable { .text() .await .ok() - .and_then(|body| serde_json::from_str::(&body).ok()) - .and_then(|value| { - value - .get("job_id") - .and_then(|id| id.as_str()) - .map(str::to_string) - }); + .and_then(|body| extract_job_id(&body)); if let Some(wait_timeout) = index.wait_timeout { let index_name = index.name.unwrap_or_else(|| format!("{}_idx", column)); @@ -419,10 +451,10 @@ impl RemoteTable { namespace, identifier, server_version, - version: RwLock::new(None), + version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), - freshness: Mutex::new(FreshnessState::default()), + freshness: Arc::new(Mutex::new(FreshnessState::default())), branch: None, } } @@ -451,10 +483,10 @@ impl RemoteTable { namespace: self.namespace.clone(), identifier: self.identifier.clone(), server_version: self.server_version.clone(), - version: RwLock::new(None), + version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), - freshness: Mutex::new(FreshnessState::default()), + freshness: Arc::new(Mutex::new(FreshnessState::default())), branch, } } @@ -991,6 +1023,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 { @@ -1261,10 +1305,10 @@ mod test_utils { namespace: vec![], identifier: name, server_version: version.map(ServerVersion).unwrap_or_default(), - version: RwLock::new(None), + version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), - freshness: Mutex::new(FreshnessState::default()), + freshness: Arc::new(Mutex::new(FreshnessState::default())), branch: None, } } @@ -1285,10 +1329,10 @@ mod test_utils { namespace: vec![], identifier: name, server_version: ServerVersion::default(), - version: RwLock::new(None), + version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), - freshness: Mutex::new(FreshnessState::default()), + freshness: Arc::new(Mutex::new(FreshnessState::default())), branch: None, } } @@ -1318,10 +1362,10 @@ mod test_utils { namespace: vec![], identifier: name, server_version: version.map(ServerVersion).unwrap_or_default(), - version: RwLock::new(None), + version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), - freshness: Mutex::new(FreshnessState::default()), + freshness: Arc::new(Mutex::new(FreshnessState::default())), branch: None, } } @@ -2104,6 +2148,7 @@ impl BaseTable for RemoteTable { self.check_mutable().await?; let table_schema = self.schema().await?; + crate::table::computed_columns::ensure_supported_function_metadata(table_schema.as_ref())?; let table_def = TableDefinition::try_from_rich_schema(table_schema.clone())?; let num_partitions = if self.server_version.support_multipart_write() { @@ -2471,13 +2516,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, @@ -2623,6 +2702,10 @@ impl BaseTable for RemoteTable { _read_columns: Option>, ) -> Result { self.check_mutable().await?; + crate::table::computed_columns::ensure_no_function_bindings_for_mutation( + self.schema().await?.as_ref(), + "schema evolution", + )?; match transforms { NewColumnTransform::SqlExpressions(expressions) => { let body = expressions @@ -2669,6 +2752,147 @@ impl BaseTable for RemoteTable { } } + async fn add_computed_columns(&self, columns: &[(String, String)]) -> Result { + self.check_mutable().await?; + crate::table::computed_columns::ensure_no_function_bindings_for_mutation( + self.schema().await?.as_ref(), + "schema evolution", + )?; + // The server plans the declaration: expression validation, type + // inference and the persisted binding all happen there. + let entries = columns + .iter() + .map( + |(name, expression)| lance_namespace::models::AddColumnsEntry { + name: name.clone(), + computed: Some(Some(expression.clone())), + ..Default::default() + }, + ) + .collect::>(); + let mut body = serde_json::json!({ "new_columns": entries }); + self.apply_branch_body(&mut body); + let request = self + .client + .post(&format!("/v1/table/{}/add_columns/", self.identifier)) + .json(&body); + let (request_id, response) = self.send(request, true).await?; + let response = self.check_table_response(&request_id, response).await?; + let body = response.text().await.err_to_http(request_id.clone())?; + + if body.trim().is_empty() { + // Backward compatible with old servers + return Ok(AddColumnsResult { version: 0 }); + } + + let result: AddColumnsResult = serde_json::from_str(&body).map_err(|e| Error::Http { + source: format!("Failed to parse add_columns response: {}", e).into(), + request_id, + status_code: None, + })?; + + self.invalidate_schema_cache(); + self.track_write_version(result.version); + + Ok(result) + } + + async fn add_function_columns( + &self, + application: &crate::function::FunctionApplication, + output_name: Option<&str>, + ) -> Result { + self.check_mutable().await?; + let schema = self.schema().await?; + let plan = crate::table::computed_columns::plan_function_application( + schema.as_ref(), + application, + output_name, + )?; + let new_columns = plan + .outputs + .iter() + .map(|output| { + serde_json::json!({ + "name": output.output_name, + "all_null": true, + }) + }) + .collect::>(); + let mut body = serde_json::json!({ + "new_columns": new_columns, + "function": { + "application": plan.application, + "binding_metadata_version": plan.binding_metadata_version, + "input_bindings": plan.input_bindings, + "input_schema": plan.input_schema, + "output_schema": plan.output_schema, + "outputs": plan.outputs, + }, + }); + self.apply_branch_body(&mut body); + let request = self + .client + .post(&format!("/v1/table/{}/add_columns/", self.identifier)) + .json(&body); + let (request_id, response) = self.send(request, true).await?; + let response = self.check_table_response(&request_id, response).await?; + let body = response.text().await.err_to_http(request_id.clone())?; + + if body.trim().is_empty() { + return Ok(AddColumnsResult { version: 0 }); + } + + let result: AddColumnsResult = serde_json::from_str(&body).map_err(|e| Error::Http { + source: format!("Failed to parse add Function columns response: {e}").into(), + request_id, + status_code: None, + })?; + + self.invalidate_schema_cache(); + self.track_write_version(result.version); + Ok(result) + } + + async fn refresh_column(&self, _column: &str) -> Result { + // The server runs a refresh as a job and does not report a fill + // count, so the blocking form has no honest result to return. + Err(Error::NotSupported { + message: "a remote refresh runs as a server job; use refresh_column_async and \ + wait on the returned handle" + .into(), + }) + } + + async fn refresh_column_async(&self, column: &str) -> Result { + self.check_mutable().await?; + let mut body = serde_json::json!({ "column": column }); + self.apply_branch_body(&mut body); + let request = self + .client + .post(&format!("/v1/table/{}/backfill_column", self.identifier)) + .json(&body); + let (request_id, response) = self.send(request, true).await?; + let response = self.check_table_response(&request_id, response).await?; + let body = response.text().await.err_to_http(request_id.clone())?; + + #[derive(serde::Deserialize)] + struct BackfillResponse { + job_id: String, + } + let response: BackfillResponse = serde_json::from_str(&body).map_err(|e| Error::Http { + source: format!("Failed to parse backfill_column response: {}", e).into(), + request_id, + status_code: None, + })?; + + Ok(Job::new(Box::new(FreshnessJob { + inner: RemoteJob::new(self.client.clone(), response.job_id), + freshness: self.freshness.clone(), + version: self.version.clone(), + }))) + } + async fn alter_columns(&self, alterations: &[ColumnAlteration]) -> Result { self.check_mutable().await?; let body = alterations @@ -2794,9 +3018,10 @@ impl BaseTable for RemoteTable { } async fn index_stats(&self, index_name: &str) -> Result> { + let encoded_name = urlencoding::encode(index_name); let mut request = self.post_read(&format!( - "/v1/table/{}/index/{}/stats/", - self.identifier, index_name + "/v1/table/{}/index/{encoded_name}/stats/", + self.identifier )); let version = self.current_version().await; let mut body = serde_json::json!({ "version": version }); @@ -2823,9 +3048,10 @@ impl BaseTable for RemoteTable { } async fn drop_index(&self, index_name: &str) -> Result<()> { + let encoded_name = urlencoding::encode(index_name); let request = self.apply_branch_query(self.client.post(&format!( - "/v1/table/{}/index/{}/drop/", - self.identifier, index_name + "/v1/table/{}/index/{encoded_name}/drop/", + self.identifier ))); let (request_id, response) = self.send(request, true).await?; if response.status() == StatusCode::NOT_FOUND { @@ -2838,9 +3064,10 @@ impl BaseTable for RemoteTable { } async fn prewarm_index(&self, index_name: &str) -> Result<()> { + let encoded_name = urlencoding::encode(index_name); let request = self.client.post(&format!( - "/v1/table/{}/index/{}/prewarm/", - self.identifier, index_name + "/v1/table/{}/index/{encoded_name}/prewarm/", + self.identifier )); let (request_id, response) = self.send(request, true).await?; if response.status() == StatusCode::NOT_FOUND { @@ -2942,7 +3169,7 @@ impl BaseTable for RemoteTable { } #[derive(Serialize, Clone, Debug)] -pub(crate) struct MergeInsertRequest { +pub struct MergeInsertRequest { on: String, when_matched_update_all: bool, when_matched_update_all_filt: Option, @@ -3654,11 +3881,14 @@ mod tests { assert_eq!(rename, "y"); if old_server { - http::Response::builder().status(200).body("{}").unwrap() + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() } else { http::Response::builder() .status(200) - .body(r#"{"version": 43}"#) + .body(r#"{"version": 43}"#.to_string()) .unwrap() } } else { @@ -3787,11 +4017,14 @@ mod tests { assert_eq!(predicate, "`ID` in (1, 2, 3)"); if old_server { - http::Response::builder().status(200).body("{}").unwrap() + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() } else { http::Response::builder() .status(200) - .body(r#"{"version": 43}"#) + .body(r#"{"version": 43}"#.to_string()) .unwrap() } } else { @@ -5916,16 +6149,18 @@ mod tests { .await .unwrap(); + // Positions are relative to the first retained token, so dropping the + // leading "hello" stop word does not shift the remaining tokens. assert_eq!( tokens, vec![ FtsToken { text: "こんにちは".to_string(), - position: 1, + position: 0, }, FtsToken { text: "世界".to_string(), - position: 2, + position: 1, }, ] ); @@ -6365,7 +6600,9 @@ mod tests { #[tokio::test] async fn test_add_columns(#[case] old_server: bool) { let table = Table::new_with_handler("my_table", move |request| { - if request.url().path() == "/v1/table/my_table/add_columns/" { + if request.url().path() == "/v1/table/my_table/describe/" { + simple_describe_response() + } else if request.url().path() == "/v1/table/my_table/add_columns/" { assert_eq!(request.method(), "POST"); assert_eq!( request.headers().get("Content-Type").unwrap(), @@ -6389,11 +6626,14 @@ mod tests { assert_eq!(expression, "cast(NULL as int32)"); if old_server { - http::Response::builder().status(200).body("{}").unwrap() + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() } else { http::Response::builder() .status(200) - .body(r#"{"version": 43}"#) + .body(r#"{"version": 43}"#.to_string()) .unwrap() } } else { @@ -6414,6 +6654,472 @@ mod tests { assert_eq!(result.version, if old_server { 0 } else { 43 }); } + /// A declaration is sent as `{name, computed}` entries for the server to + /// plan; the client never types the expression itself. + #[tokio::test] + async fn test_add_computed_columns_sends_the_expression() { + let table = Table::new_with_handler("my_table", |request| match request.url().path() { + "/v1/table/my_table/describe/" => simple_describe_response(), + "/v1/table/my_table/add_columns/" => { + assert_eq!(request.method(), "POST"); + let body = request.body().unwrap().as_bytes().unwrap(); + let value: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + value["new_columns"], + serde_json::json!([{"name": "doubled", "computed": "x * 2"}]) + ); + http::Response::builder() + .status(200) + .body(r#"{"version": 7}"#.to_string()) + .unwrap() + } + path => panic!("Unexpected path: {path}"), + }); + + let result = table + .add_columns() + .computed("doubled", "x * 2") + .execute() + .await + .unwrap(); + assert_eq!(result.version, 7); + } + + #[tokio::test] + async fn test_add_scalar_function_column_sends_atomic_null_declaration() { + let table = Table::new_with_handler("my_table", |request| { + match request.url().path() { + "/v1/table/my_table/describe/" => http::Response::builder() + .status(200) + .body( + r#"{"version":1,"schema":{"fields":[{"name":"description","nullable":true,"type":{"type":"string"}}]}}"#, + ) + .unwrap(), + "/v1/table/my_table/add_columns/" => { + let actual: serde_json::Value = serde_json::from_slice( + request.body().unwrap().as_bytes().unwrap(), + ) + .unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json" + )) + .unwrap(); + assert_eq!(actual, expected); + http::Response::builder() + .status(200) + .body(r#"{"version":8}"#) + .unwrap() + } + path => panic!("Unexpected path: {path}"), + } + }); + let application = crate::function::FunctionApplication::from_json( + r#"{ + "function":{"name":"embed","version":"fv_01K3EXACT"}, + "inputs":[{"parameter":"text","kind":"column","value":{"path":"description"}}], + "output":{"kind":"scalar","arrow_type":"list","nullable":false}, + "group_id":"fg_scalar" + }"#, + ) + .unwrap(); + + let result = table + .add_columns() + .function_as("embedding", application) + .execute() + .await + .unwrap(); + assert_eq!(result.version, 8); + } + + #[tokio::test] + async fn test_add_named_struct_function_expands_one_atomic_sibling_group() { + let table = Table::new_with_handler("my_table", |request| match request.url().path() { + "/v1/table/my_table/describe/" => http::Response::builder() + .status(200) + .body( + r#"{"version":1,"schema":{"fields":[ + {"name":"title","nullable":true,"type":{"type":"string"}}, + {"name":"body","nullable":true,"type":{"type":"string"}} + ]}}"#, + ) + .unwrap(), + "/v1/table/my_table/add_columns/" => { + let actual: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_grouped_declaration_request.json" + )) + .unwrap(); + assert_eq!(actual, expected); + http::Response::builder() + .status(200) + .body(r#"{"version":9}"#) + .unwrap() + } + path => panic!("Unexpected path: {path}"), + }); + let application = crate::function::FunctionApplication::from_json( + r#"{ + "function":{"name":"text_features","version":"fv_01K3TEXT"}, + "inputs":[ + {"parameter":"title","kind":"column","value":{"path":"title"}}, + {"parameter":"body","kind":"column","value":{"path":"body"}} + ], + "output":{"kind":"named_struct","fields":[ + {"name":"normalized_text","arrow_type":"utf8","nullable":false}, + {"name":"token_count","arrow_type":"int64","nullable":false} + ]}, + "group_id":"fg_01K3TEXT", + "columns":{"normalized_text":"search_text"} + }"#, + ) + .unwrap(); + + let result = table + .add_columns() + .function(application) + .execute() + .await + .unwrap(); + assert_eq!(result.version, 9); + } + + #[tokio::test] + async fn test_add_columns_fails_closed_on_newer_function_binding_metadata() { + let table = Table::new_with_handler("my_table", |request| { + match request.url().path() { + "/v1/table/my_table/describe/" => http::Response::builder() + .status(200) + .body( + r#"{"version":1,"schema":{"fields":[{"name":"x","nullable":true,"type":{"type":"int32"}}],"metadata":{"lancedb::function_bindings":"{\"version\":2,\"bindings\":[]}"}}}"#, + ) + .unwrap(), + path => panic!("mutation request must not be sent: {path}"), + } + }); + + let err = table + .add_columns() + .computed("doubled", "x * 2") + .execute() + .await + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + /// A remote refresh is a server job: the async form returns its handle, + /// and the blocking form refuses rather than invent a fill count. + #[tokio::test] + async fn test_refresh_column_async_submits_a_backfill_job() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/backfill_column"); + let body = request.body().unwrap().as_bytes().unwrap(); + let value: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(value["column"], "doubled"); + http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-42"}"#) + .unwrap() + }); + + let job = table.refresh_column_async("doubled").await.unwrap(); + assert_eq!(job.id(), Some("j-42")); + + let err = table.refresh_column("doubled").await.unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } + if message.contains("refresh_column_async")), + "{err:?}" + ); + } + + /// The gate's reproducer: after a successful wait, a same-handle read + /// must carry a freshness baseline so a stale server cache cannot serve + /// the pre-backfill snapshot. + #[tokio::test] + async fn test_backfill_wait_establishes_read_freshness() { + let saw_min_timestamp = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let saw = saw_min_timestamp.clone(); + let table = + Table::new_with_handler("my_table", move |request| match request.url().path() { + "/v1/table/my_table/backfill_column" => http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-7"}"#.to_string()) + .unwrap(), + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body(r#"{"job_id": "j-7", "job_state": "DONE"}"#.to_string()) + .unwrap(), + "/v1/table/my_table/count_rows/" => { + saw.store( + request.headers().contains_key("x-lancedb-min-timestamp"), + std::sync::atomic::Ordering::SeqCst, + ); + http::Response::builder() + .status(200) + .body("1".to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + }); + + let job = table.refresh_column_async("doubled").await.unwrap(); + job.wait().await.unwrap(); + table.count_rows(None).await.unwrap(); + assert!( + saw_min_timestamp.load(std::sync::atomic::Ordering::SeqCst), + "read after wait carried no freshness baseline" + ); + } + + /// A checkout after submission wins over the completion fence: the + /// pinned view must not regain a timestamp floor from the job. + #[tokio::test] + async fn test_checkout_after_submit_beats_the_completion_fence() { + let saw_min_timestamp = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let saw = saw_min_timestamp.clone(); + let table = + Table::new_with_handler("my_table", move |request| match request.url().path() { + "/v1/table/my_table/backfill_column" => http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-8"}"#.to_string()) + .unwrap(), + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body(r#"{"job_id": "j-8", "job_state": "DONE"}"#.to_string()) + .unwrap(), + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("x", DataType::Int32, true)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/count_rows/" => { + saw.store( + request.headers().contains_key("x-lancedb-min-timestamp"), + std::sync::atomic::Ordering::SeqCst, + ); + http::Response::builder() + .status(200) + .body("1".to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + }); + + let job = table.refresh_column_async("doubled").await.unwrap(); + table.checkout(3).await.unwrap(); + job.wait().await.unwrap(); + table.count_rows(None).await.unwrap(); + assert!( + !saw_min_timestamp.load(std::sync::atomic::Ordering::SeqCst), + "completion fence overrode an explicit checkout" + ); + } + + /// Tag checkout resets freshness state wholesale; the fence must not + /// survive it. + #[tokio::test] + async fn test_tag_checkout_after_submit_beats_the_completion_fence() { + let saw_min_timestamp = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let saw = saw_min_timestamp.clone(); + let table = + Table::new_with_handler("my_table", move |request| match request.url().path() { + "/v1/table/my_table/backfill_column" => http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-9"}"#.to_string()) + .unwrap(), + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body(r#"{"job_id": "j-9", "job_state": "DONE"}"#.to_string()) + .unwrap(), + "/v1/table/my_table/tags/version/" => http::Response::builder() + .status(200) + .body(r#"{"version": 5}"#.to_string()) + .unwrap(), + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("x", DataType::Int32, true)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/count_rows/" => { + saw.store( + request.headers().contains_key("x-lancedb-min-timestamp"), + std::sync::atomic::Ordering::SeqCst, + ); + http::Response::builder() + .status(200) + .body("1".to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + }); + + let job = table.refresh_column_async("doubled").await.unwrap(); + table.checkout_tag("v1").await.unwrap(); + job.wait().await.unwrap(); + table.count_rows(None).await.unwrap(); + assert!( + !saw_min_timestamp.load(std::sync::atomic::Ordering::SeqCst), + "completion fence overrode a tag checkout" + ); + } + + /// A checkout landing while the submission request is in flight advances + /// the epoch past the token captured at submit. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_checkout_during_submission_beats_the_completion_fence() { + let saw_min_timestamp = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let saw = saw_min_timestamp.clone(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let release_rx = Arc::new(std::sync::Mutex::new(release_rx)); + let (arrived_tx, arrived_rx) = std::sync::mpsc::channel::<()>(); + let arrived_tx = Arc::new(std::sync::Mutex::new(arrived_tx)); + let table = Table::new_with_handler("my_table", move |request| { + match request.url().path() { + "/v1/table/my_table/backfill_column" => { + // Signal arrival, then hold the response until the + // test's checkout completes. + arrived_tx.lock().unwrap().send(()).unwrap(); + release_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-10"}"#.to_string()) + .unwrap() + } + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body(r#"{"job_id": "j-10", "job_state": "DONE"}"#.to_string()) + .unwrap(), + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("x", DataType::Int32, true)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/count_rows/" => { + saw.store( + request.headers().contains_key("x-lancedb-min-timestamp"), + std::sync::atomic::Ordering::SeqCst, + ); + http::Response::builder() + .status(200) + .body("1".to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + } + }); + + let submit = tokio::spawn({ + let table = table.clone(); + async move { table.refresh_column_async("doubled").await } + }); + tokio::task::spawn_blocking(move || { + arrived_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap() + }) + .await + .unwrap(); + table.checkout(7).await.unwrap(); + release_tx.send(()).unwrap(); + + let job = submit.await.unwrap().unwrap(); + job.wait().await.unwrap(); + table.count_rows(None).await.unwrap(); + assert!( + !saw_min_timestamp.load(std::sync::atomic::Ordering::SeqCst), + "completion fence overrode a checkout that landed mid-submission" + ); + } + + /// checkout_latest keeps the handle on latest, so a completed backfill + /// must still establish its post-fill baseline -- strictly later than the + /// checkout's own, or a pre-fill cache could still serve. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_checkout_latest_during_submission_keeps_the_fence() { + let seen_min_timestamp = Arc::new(std::sync::Mutex::new(None::)); + let saw = seen_min_timestamp.clone(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let release_rx = Arc::new(std::sync::Mutex::new(release_rx)); + let (arrived_tx, arrived_rx) = std::sync::mpsc::channel::<()>(); + let arrived_tx = Arc::new(std::sync::Mutex::new(arrived_tx)); + let table = + Table::new_with_handler("my_table", move |request| match request.url().path() { + "/v1/table/my_table/backfill_column" => { + arrived_tx.lock().unwrap().send(()).unwrap(); + release_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + http::Response::builder() + .status(202) + .body(r#"{"job_id": "j-11"}"#.to_string()) + .unwrap() + } + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body(r#"{"job_id": "j-11", "job_state": "DONE"}"#.to_string()) + .unwrap(), + "/v1/table/my_table/count_rows/" => { + *saw.lock().unwrap() = request + .headers() + .get("x-lancedb-min-timestamp") + .map(|v| v.to_str().unwrap().to_string()); + http::Response::builder() + .status(200) + .body("1".to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + }); + + let submit = tokio::spawn({ + let table = table.clone(); + async move { table.refresh_column_async("doubled").await } + }); + tokio::task::spawn_blocking(move || { + arrived_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap() + }) + .await + .unwrap(); + table.checkout_latest().await.unwrap(); + let after_checkout = SystemTime::now(); + // Real separation between the checkout baseline and completion. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + release_tx.send(()).unwrap(); + + let job = submit.await.unwrap().unwrap(); + job.wait().await.unwrap(); + table.count_rows(None).await.unwrap(); + let header = seen_min_timestamp + .lock() + .unwrap() + .clone() + .expect("no baseline"); + let sent: SystemTime = chrono::DateTime::parse_from_rfc3339(&header) + .unwrap() + .into(); + assert!( + sent > after_checkout, + "baseline {header} did not advance past the checkout" + ); + } + #[tokio::test] async fn test_prewarm_index() { let table = Table::new_with_handler("my_table", |request| { @@ -6501,6 +7207,41 @@ mod tests { assert!(matches!(e, Error::IndexNotFound { .. })); } + /// Index names are unvalidated, so reserved characters must be + /// percent-encoded or they restructure the request path. + #[tokio::test] + async fn test_per_index_paths_encode_reserved_characters() { + const NAME: &str = "my/index?a#b c"; + const PREFIX: &str = "/v1/table/my_table/index/my%2Findex%3Fa%23b%20c"; + + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.url().path(), format!("{PREFIX}/stats/")); + let body = serde_json::json!({ + "num_indexed_rows": 1, + "num_unindexed_rows": 0, + "index_type": "IVF_PQ", + "distance_type": "l2" + }); + http::Response::builder() + .status(200) + .body(serde_json::to_string(&body).unwrap()) + .unwrap() + }); + assert!(table.index_stats(NAME).await.unwrap().is_some()); + + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.url().path(), format!("{PREFIX}/drop/")); + http::Response::builder().status(200).body("{}").unwrap() + }); + table.drop_index(NAME).await.unwrap(); + + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.url().path(), format!("{PREFIX}/prewarm/")); + http::Response::builder().status(200).body("{}").unwrap() + }); + table.prewarm_index(NAME).await.unwrap(); + } + #[tokio::test] async fn test_set_lsm_write_spec_unsharded() { let table = Table::new_with_handler("my_table", |request| { @@ -6523,7 +7264,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(); } @@ -6542,7 +7283,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 @@ -6551,6 +7293,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| { @@ -6625,7 +7384,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") @@ -6654,6 +7413,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); @@ -9874,6 +11126,7 @@ mod tests { .status(200) .body("{}".to_string()) .unwrap(), + "/v1/table/my_table/describe/" => simple_describe_response(), "/v1/table/my_table/add_columns/" | "/v1/table/my_table/alter_columns/" | "/v1/table/my_table/drop_columns/" => { diff --git a/rust/lancedb/src/remote/table/blobs.rs b/rust/lancedb/src/remote/table/blobs.rs index bb8a5d103..387d3c6dc 100644 --- a/rust/lancedb/src/remote/table/blobs.rs +++ b/rust/lancedb/src/remote/table/blobs.rs @@ -90,7 +90,7 @@ struct RemoteBlobState { /// Seekable Cloud blob handle over HTTP Range. #[derive(Debug)] -pub(crate) struct RemoteBlobFile { +pub struct RemoteBlobFile { requester: Arc, state: Mutex, closed: AtomicBool, diff --git a/rust/lancedb/src/remote/table/insert.rs b/rust/lancedb/src/remote/table/insert.rs index 67ea7765d..a4a28a9c6 100644 --- a/rust/lancedb/src/remote/table/insert.rs +++ b/rust/lancedb/src/remote/table/insert.rs @@ -33,7 +33,7 @@ use crate::table::{AddResult, MergeResult}; /// same Arrow-IPC streaming body and error side-channel; only the target /// endpoint, query parameters, and parsed result type differ. #[derive(Debug, Clone)] -pub(crate) enum WriteOp { +pub enum WriteOp { /// `add`: stream to `/v1/table/{id}/insert/`, optionally overwriting. Insert { overwrite: bool }, /// `merge_insert`: stream to `/v1/table/{id}/merge_insert/` with the merge @@ -49,7 +49,7 @@ pub(crate) enum WriteOp { /// The parsed server response for a completed write, discriminated by the /// operation that produced it. #[derive(Debug, Clone)] -pub(crate) enum WriteResult { +pub enum WriteResult { Add(AddResult), Merge(MergeResult), } diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index e4d895a46..3cecd0685 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,14 +67,18 @@ use self::merge::MergeInsertBuilder; pub mod add_columns; mod add_data; pub mod branch_merge; +pub mod checkpoint; +pub mod computed_columns; 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; pub mod query; +pub mod refresh; pub mod schema_evolution; pub mod update; pub mod write_progress; @@ -89,13 +92,17 @@ pub use branch_merge::{ MergeBranchResult, MergeBranchStatus, MergePreview, RowCountSummary, }; pub use chrono::Duration; +pub use computed_columns::{ + ComputedColumn, ComputedColumnKind, computed_column_from_field, computed_columns, +}; 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 refresh::RefreshColumnResult; pub use schema_evolution::{ AddColumnsResult, AlterColumnsResult, DropColumnsResult, FieldMetadataUpdate, UpdateFieldMetadataResult, @@ -150,55 +157,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 +326,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 +342,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 +357,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 +403,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 +443,7 @@ impl LsmWriteSpec { } | Self::Unsharded { maintained_indexes, .. - } => *maintained_indexes = v, + } => *maintained_indexes = indexes, } self } @@ -501,8 +479,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 +491,7 @@ impl LsmWriteSpec { } | Self::Unsharded { maintained_indexes, .. - } => maintained_indexes, + } => maintained_indexes.as_deref(), } } @@ -664,6 +643,15 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { message: "set_lsm_write_spec is not supported on this table type".into(), }) } + /// Switch this table to required index catch-up, one way. + /// + /// The default implementation returns `NotSupported`. Implementations + /// that support the MemWAL LSM write path must override this. + async fn require_mem_wal_index_catchup(&self) -> Result<()> { + Err(Error::NotSupported { + message: "require_mem_wal_index_catchup is not supported on this table type".into(), + }) + } /// Remove the [`LsmWriteSpec`] from this table. /// /// This is a no-op if no spec is currently set. @@ -685,6 +673,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 @@ -734,6 +747,44 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { transforms: NewColumnTransform, read_columns: Option>, ) -> Result; + /// Declare computed columns, each defined by a SQL expression. + /// + /// Where the declaration is planned depends on the backend: a local table + /// validates and types the expression itself, a remote one sends the text + /// for the server to plan. + async fn add_computed_columns( + &self, + _columns: &[(String, String)], + ) -> Result { + Err(Error::NotSupported { + message: "computed columns are not supported on this table type".into(), + }) + } + /// Declare one immutable registered-Function output group. + async fn add_function_columns( + &self, + _application: &crate::function::FunctionApplication, + _output_name: Option<&str>, + ) -> Result { + Err(Error::NotSupported { + message: "Function columns are supported only on LanceDB Cloud and Enterprise".into(), + }) + } + /// Fill a computed column's unfilled rows. + /// + /// The default returns `NotSupported`; Lance-backed tables override it. + async fn refresh_column(&self, _column: &str) -> Result { + Err(Error::NotSupported { + message: "computed columns are supported only on local tables".into(), + }) + } + /// Fill a computed column's unfilled rows, returning a [`Job`] tracking + /// the operation. + async fn refresh_column_async(&self, _column: &str) -> Result { + Err(Error::NotSupported { + message: "computed columns are supported only on local tables".into(), + }) + } /// Alter columns in the table. async fn alter_columns(&self, alterations: &[ColumnAlteration]) -> Result; /// Drop columns from the table. @@ -1635,6 +1686,53 @@ impl Table { AddColumnsBuilder::new(self.inner.clone()) } + /// Fill the fragments of a computed column that hold no values yet. + /// + /// Declared with + /// [`AddColumnsBuilder::computed`](add_columns::AddColumnsBuilder::computed), + /// a column starts empty and gets its values here. Fragments appended + /// since the last refresh are filled by the next one; fragments already + /// filled are left as they are, so the call is idempotent and does not + /// observe a mutated input. + /// + /// Local tables only: a remote refresh runs as a server job, through + /// [`Table::refresh_column_async`]. + /// + /// ``` + /// # use lancedb::Table; + /// # async fn refresh(table: &Table) -> Result<(), Box> { + /// let result = table.refresh_column("doubled").await?; + /// println!("filled {} rows at version {}", result.rows_filled, result.version); + /// # Ok(()) + /// # } + /// ``` + pub async fn refresh_column(&self, column: impl AsRef) -> Result { + self.inner.refresh_column(column.as_ref()).await + } + + /// Like [`Table::refresh_column`], but returns a [`Job`] tracking the + /// operation instead of blocking until it completes. + /// + /// The job may already be complete when returned, and callers must not + /// assume the column is filled until [`Job::wait`] returns. Invalid input + /// -- an unknown column, or one that is not computed -- is reported by + /// this call rather than by the job. On local tables the job runs as an + /// in-process task; on LanceDB Cloud and Enterprise it is the server's + /// backfill job. + /// + /// ``` + /// # use lancedb::Table; + /// # async fn refresh_in_background(table: &Table) -> Result<(), Box> { + /// let job = table.refresh_column_async("doubled").await?; + /// println!("refresh running: {:?}", job.status().await?); + /// job.wait().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn refresh_column_async(&self, column: impl AsRef) -> Result { + self.inner.refresh_column_async(column.as_ref()).await + } + /// Change a column's name or nullability. pub async fn alter_columns( &self, @@ -1694,7 +1792,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(()) @@ -1704,6 +1802,20 @@ impl Table { self.inner.set_lsm_write_spec(spec).await } + /// Switch this table to required index catch-up, one way. + /// + /// Separate from [`Self::set_lsm_write_spec`] on purpose: a table carrying + /// the bit retains its SSTables until an index records that it holds the + /// compacted rows, so turn it on only once something can repair coverage. + /// A writer that already holds the dataset can call the equivalent on + /// `DatasetMemWalExt` instead; this is the table-level entry point. + /// + /// Errors if no spec is set, or if the table already records SSTable + /// compaction progress from before this protocol. + pub async fn require_mem_wal_index_catchup(&self) -> Result<()> { + self.inner.require_mem_wal_index_catchup().await + } + /// Remove the [`LsmWriteSpec`] from this table, reverting to the standard /// `merge_insert` write path. /// @@ -1716,9 +1828,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 /// @@ -1735,6 +1848,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 @@ -2301,8 +2493,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 @@ -2321,7 +2511,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()), }; @@ -2533,6 +2728,7 @@ impl NativeTable { namespace_client: Option>, pushdown_operations: HashSet, ) -> Result { + computed_columns::ensure_no_foreign_declarations(batches.arrow_schema().fields())?; // Default params uses format v1. let params = params.unwrap_or(WriteParams { ..Default::default() @@ -2984,6 +3180,14 @@ impl BaseTable for NativeTable { let ds = self.dataset.get().await?; let table_schema = Schema::from(&ds.schema().clone()); + computed_columns::ensure_supported_function_metadata(&table_schema)?; + computed_columns::ensure_not_written( + &table_schema, + add.data.schema().fields().iter().map(|f| f.name().as_str()), + )?; + if matches!(add.mode, AddDataMode::Overwrite) { + computed_columns::ensure_no_foreign_declarations(add.data.schema().fields())?; + } let num_partitions = if let Some(parallelism) = add.write_parallelism { parallelism @@ -3144,6 +3348,11 @@ impl BaseTable for NativeTable { params: MergeInsertBuilder, new_data: Box, ) -> Result { + let source_schema = arrow_array::RecordBatchReader::schema(&new_data); + computed_columns::ensure_not_written( + &Schema::from(self.dataset.get().await?.schema()), + source_schema.fields().iter().map(|f| f.name().as_str()), + )?; let result = merge::execute_merge_insert(self, params, new_data).await?; self.bump_freshness(); Ok(result) @@ -3157,6 +3366,10 @@ impl BaseTable for NativeTable { merge::lsm::set_lsm_write_spec(self, spec).await } + async fn require_mem_wal_index_catchup(&self) -> Result<()> { + merge::lsm::require_mem_wal_index_catchup(self).await + } + async fn unset_lsm_write_spec(&self) -> Result<()> { merge::lsm::unset_lsm_write_spec(self).await } @@ -3225,6 +3438,22 @@ impl BaseTable for NativeTable { Ok(result) } + async fn add_computed_columns(&self, columns: &[(String, String)]) -> Result { + let result = schema_evolution::execute_declare(self, columns).await?; + self.bump_freshness(); + Ok(result) + } + + async fn refresh_column(&self, column: &str) -> Result { + let result = refresh::execute_refresh_column(self, column).await?; + self.bump_freshness(); + Ok(result) + } + + async fn refresh_column_async(&self, column: &str) -> Result { + refresh::execute_refresh_column_async(self, column).await + } + async fn alter_columns(&self, alterations: &[ColumnAlteration]) -> Result { let result = schema_evolution::execute_alter_columns(self, alterations).await?; self.bump_freshness(); @@ -3453,9 +3682,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( @@ -3527,7 +3771,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 @@ -3572,7 +3821,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::{ @@ -3588,6 +3837,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; @@ -3653,73 +3903,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(), @@ -3727,12 +3954,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" ); } @@ -4970,7 +5362,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)); @@ -4980,15 +5372,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] @@ -5036,12 +5538,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, @@ -5081,4 +5587,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/add_columns.rs b/rust/lancedb/src/table/add_columns.rs index 0d410cd04..3b91c30e4 100644 --- a/rust/lancedb/src/table/add_columns.rs +++ b/rust/lancedb/src/table/add_columns.rs @@ -9,12 +9,15 @@ use lance::dataset::NewColumnTransform; use super::BaseTable; use super::schema_evolution::AddColumnsResult; +use crate::function::FunctionApplication; use crate::{Error, Result}; /// Adds columns to a table. See [`Table::add_columns`](super::Table::add_columns). pub struct AddColumnsBuilder { parent: Arc, transform: Option, + computed: Vec<(String, String)>, + function: Option<(FunctionApplication, Option)>, read_columns: Option>, } @@ -23,6 +26,8 @@ impl std::fmt::Debug for AddColumnsBuilder { f.debug_struct("AddColumnsBuilder") .field("parent", &self.parent) .field("has_transform", &self.transform.is_some()) + .field("computed", &self.computed) + .field("has_function", &self.function.is_some()) .field("read_columns", &self.read_columns) .finish() } @@ -33,19 +38,101 @@ impl AddColumnsBuilder { Self { parent, transform: None, + computed: Vec::new(), + function: None, read_columns: None, } } - /// Set how the new columns' values are produced. Required. + /// Set how the new columns' values are produced. pub fn transform(mut self, transform: NewColumnTransform) -> Self { self.transform = Some(transform); self } + /// Add a column defined by `expression`, evaluated by a later refresh + /// rather than by this commit. Its type and inputs are derived from the + /// expression. + /// + /// The column is committed with no values, so declaring one costs the same + /// on an empty table as on a large one. Rows get values from + /// [`Table::refresh_column`](super::Table::refresh_column), which fills + /// every fragment that has none -- including fragments appended since the + /// last refresh. + /// + /// Refresh does not revisit a fragment it has filled, so mutating an input + /// leaves the value computed at fill time; recomputing means dropping the + /// column and declaring it again. An input cannot be renamed, retyped or + /// dropped while a declaration reads it, since the expression names it. + /// + /// On LanceDB Cloud and Enterprise the expression is planned by the + /// server, and the refresh runs as a server job -- see + /// [`Table::refresh_column_async`](super::Table::refresh_column_async). + /// + /// ``` + /// # use lancedb::Table; + /// # async fn declare(table: &Table) -> Result<(), Box> { + /// table + /// .add_columns() + /// .computed("doubled", "x * 2") + /// .execute() + /// .await?; + /// let filled = table.refresh_column("doubled").await?; + /// println!("filled {} rows", filled.rows_filled); + /// # Ok(()) + /// # } + /// ``` + pub fn computed(mut self, name: impl Into, expression: impl Into) -> Self { + self.computed.push((name.into(), expression.into())); + self + } + + /// Declare every field of a named-struct Function result as one atomic + /// sibling group. Result-field aliases come from + /// [`FunctionApplication::columns`](crate::function::FunctionApplication::columns). + /// + /// ``` + /// # use lancedb::Table; + /// # use lancedb::function::FunctionApplication; + /// # async fn declare(table: &Table, application: FunctionApplication) -> lancedb::Result<()> { + /// table.add_columns().function(application).execute().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn function(mut self, application: FunctionApplication) -> Self { + self.function = Some((application, None)); + self + } + + /// Declare a scalar or entire named-struct Function result as one table + /// column. The physical column starts all-null and is materialized by the + /// remote Function refresh path. + /// + /// ``` + /// # use lancedb::Table; + /// # use lancedb::function::FunctionApplication; + /// # async fn declare(table: &Table, application: FunctionApplication) -> lancedb::Result<()> { + /// table + /// .add_columns() + /// .function_as("embedding", application) + /// .execute() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn function_as( + mut self, + name: impl Into, + application: FunctionApplication, + ) -> Self { + self.function = Some((application, Some(name.into()))); + self + } + /// Limit which existing columns a [`NewColumnTransform::BatchUDF`] mapper - /// receives. Every other transform determines what it reads, so setting - /// this alongside one is an error rather than a silent no-op. + /// receives. Every other transform, and a computed column, determines what + /// it reads, so setting this alongside one is an error rather than a silent + /// no-op. pub fn read_columns(mut self, columns: impl IntoIterator>) -> Self { self.read_columns = Some(columns.into_iter().map(Into::into).collect()); self @@ -56,24 +143,56 @@ impl AddColumnsBuilder { let Self { parent, transform, + computed, + function, read_columns, } = self; - let Some(transform) = transform else { + let declaration_count = usize::from(!computed.is_empty()) + usize::from(function.is_some()); + if transform.is_some() && declaration_count != 0 || declaration_count > 1 { return Err(Error::InvalidInput { - message: "add_columns requires a transform".into(), - }); - }; - - if read_columns.is_some() && !matches!(transform, NewColumnTransform::BatchUDF(_)) { - return Err(Error::InvalidInput { - message: "read_columns applies only to a BatchUDF transform; \ - every other transform determines what it reads" + message: "add_columns cannot mix transforms, SQL computed columns, and a Function application; they cannot be added atomically in one call" .into(), }); } - parent.add_columns(transform, read_columns).await + match (transform, computed.is_empty(), function) { + (None, true, None) => Err(Error::InvalidInput { + message: "add_columns requires a transform or a computed column".into(), + }), + (Some(transform), true, None) => { + if read_columns.is_some() && !matches!(transform, NewColumnTransform::BatchUDF(_)) { + return Err(Error::InvalidInput { + message: "read_columns applies only to a BatchUDF transform; \ + every other transform determines what it reads" + .into(), + }); + } + parent.add_columns(transform, read_columns).await + } + (None, false, None) => { + if read_columns.is_some() { + return Err(Error::InvalidInput { + message: "read_columns applies only to a BatchUDF transform; \ + a computed column's inputs come from its expression" + .into(), + }); + } + parent.add_computed_columns(&computed).await + } + (None, true, Some((application, output_name))) => { + if read_columns.is_some() { + return Err(Error::InvalidInput { + message: "read_columns does not apply to a Function application; its inputs are already bound" + .into(), + }); + } + parent + .add_function_columns(&application, output_name.as_deref()) + .await + } + _ => unreachable!("mixed add_columns modes were rejected above"), + } } } @@ -85,8 +204,8 @@ mod tests { use arrow_schema::{DataType, Field, Schema}; use lance::dataset::{BatchUDF, NewColumnTransform}; - use crate::Table; use crate::connect; + use crate::{Error, Table}; async fn table_with_two_columns(name: &str) -> Table { let conn = connect("memory://").execute().await.unwrap(); @@ -98,10 +217,7 @@ mod tests { async fn test_requires_a_transform() { let table = table_with_two_columns("no_transform").await; let err = table.add_columns().execute().await.unwrap_err(); - assert!( - err.to_string().contains("requires a transform"), - "got: {err}" - ); + assert!(matches!(err, Error::InvalidInput { .. })); } #[tokio::test] @@ -117,7 +233,7 @@ mod tests { .execute() .await .unwrap_err(); - assert!(err.to_string().contains("BatchUDF"), "got: {err}"); + assert!(matches!(err, Error::InvalidInput { .. })); let schema = table.schema().await.unwrap(); assert!( @@ -126,6 +242,47 @@ mod tests { ); } + #[tokio::test] + async fn test_mixing_transform_and_computed_is_rejected() { + let table = table_with_two_columns("mixed_add").await; + let err = table + .add_columns() + .transform(NewColumnTransform::SqlExpressions(vec![( + "eager".into(), + "x * 2".into(), + )])) + .computed("lazy", "x * 3") + .execute() + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + + let schema = table.schema().await.unwrap(); + assert!(schema.field_with_name("eager").is_err()); + assert!(schema.field_with_name("lazy").is_err()); + } + + #[tokio::test] + async fn test_read_columns_with_computed_is_rejected() { + let table = table_with_two_columns("read_cols_computed").await; + let err = table + .add_columns() + .computed("doubled", "x * 2") + .read_columns(["x"]) + .execute() + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + assert!( + table + .schema() + .await + .unwrap() + .field_with_name("doubled") + .is_err() + ); + } + #[tokio::test] async fn test_read_columns_limits_what_a_batch_udf_sees() { let table = table_with_two_columns("read_cols_udf").await; 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/computed_columns.rs b/rust/lancedb/src/table/computed_columns.rs new file mode 100644 index 000000000..841b13856 --- /dev/null +++ b/rust/lancedb/src/table/computed_columns.rs @@ -0,0 +1,2424 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Computed columns. +//! +//! A computed column is defined by a rule rather than by values supplied at +//! write time. Declaring one commits the column carrying that rule in field +//! metadata but no data, so the cost does not scale with the table; a later +//! refresh fills the rows. +//! +//! The rule is tagged by kind ([`ComputedColumnKind`]) because kinds differ in +//! where the column's type and inputs come from. A SQL expression is +//! self-describing -- both are derived from the expression, so a caller writes +//! neither -- while a kind resolved through a registry cannot be typed without +//! consulting it. Registered Functions use an exact remote version plus a +//! schema-level grouped binding; unknown newer kinds remain readable and fail +//! closed before mutation. +//! +//! [`computed_columns`] and [`computed_column_from_field`] read declarations +//! back off a schema. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef}; +use datafusion_common::tree_node::TreeNode; +use datafusion_physical_plan::PhysicalExpr; +use lance::dataset::NewColumnTransform; +use lance_datafusion::planner::Planner; +use lance_namespace::models::{JsonArrowDataType, JsonArrowField, JsonArrowSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::function::{FunctionApplication, FunctionBinding}; +use crate::{Error, Result}; + +/// Field metadata key marking a column as computed. The value is `"true"`. +pub const COMPUTED_COLUMN_META_KEY: &str = "computed_column"; + +/// Field metadata key naming the kind of rule that defines the column. +pub const KIND_META_KEY: &str = "computed_column.kind"; + +/// Field metadata key holding the SQL expression that defines the column. +pub const EXPRESSION_META_KEY: &str = "computed_column.expression"; + +/// Field metadata key holding the column's inputs, as a JSON array of names. +pub const INPUTS_META_KEY: &str = "computed_column.inputs"; + +/// Field metadata key holding the grouped Function binding identity. +pub const FUNCTION_BINDING_ID_META_KEY: &str = "computed_column.function.binding_id"; + +/// Field metadata key holding this sibling's ordered Function output ordinal. +pub const FUNCTION_OUTPUT_ORDINAL_META_KEY: &str = "computed_column.function.output_ordinal"; + +/// Schema metadata key holding all immutable grouped Function bindings. +pub const FUNCTION_BINDINGS_META_KEY: &str = "lancedb::function_bindings"; + +/// Version of the schema-level grouped Function binding envelope. +pub const FUNCTION_BINDINGS_VERSION: u32 = 1; + +/// Value of [`KIND_META_KEY`] for a column defined by a SQL expression. +pub const SQL_KIND: &str = "sql"; + +/// Value of [`KIND_META_KEY`] for a registered Function binding. +pub const FUNCTION_KIND: &str = "function"; + +/// Synthetic result identity used when the entire Function result maps to one +/// table column (scalar or struct-as-one-column). +pub const WHOLE_RESULT_FIELD: &str = "$value"; + +/// The rule that defines a computed column's values. +/// +/// Non-exhaustive: a kind added later is an additive change, and a caller that +/// only handles the kinds it knows keeps compiling. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ComputedColumnKind { + /// A SQL expression evaluated by DataFusion. It is the whole definition: + /// the column's type and its inputs are both derived from it. + Sql { + /// The expression. + expression: String, + }, + /// One physical output in an immutable grouped registered-Function + /// binding. The full binding lives in schema metadata. + Function { + /// Shared immutable binding identity. + binding_id: String, + /// Position of this field in the binding's ordered sibling outputs. + output_ordinal: u32, + }, + /// A kind this version does not understand, written by a newer one. + /// + /// Reported rather than hidden so a caller can tell a column it cannot + /// refresh apart from one that was never computed. Nothing produces this. + Unrecognized { + /// The kind as it was found in the metadata. + kind: String, + }, +} + +/// A computed column's declaration, as read back from field metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComputedColumn { + /// Name of the computed column. + pub name: String, + /// The rule that defines it. + pub kind: ComputedColumnKind, + /// Columns the rule reads, recorded at declaration time. + /// + /// Outside the kind because every kind has inputs and the consumers that + /// use them -- refresh planning, dependency ordering -- do not care which + /// kind produced them. Where they come from does differ, and that is + /// settled at declaration: derived from a SQL expression, supplied by the + /// caller for a kind that cannot be parsed. + pub inputs: Vec, +} + +/// Build the field metadata recording a SQL binding. +fn computed_column_metadata(expression: &str, inputs: &[String]) -> HashMap { + HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), expression.to_string()), + ( + INPUTS_META_KEY.to_string(), + serde_json::to_string(inputs).unwrap_or_else(|_| "[]".to_string()), + ), + ]) +} + +/// Build field metadata for one physical sibling of a Function binding. +pub fn function_computed_column_metadata( + binding_id: &str, + output_ordinal: u32, + inputs: &[String], +) -> HashMap { + HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), FUNCTION_KIND.to_string()), + ( + FUNCTION_BINDING_ID_META_KEY.to_string(), + binding_id.to_string(), + ), + ( + FUNCTION_OUTPUT_ORDINAL_META_KEY.to_string(), + output_ordinal.to_string(), + ), + ( + INPUTS_META_KEY.to_string(), + serde_json::to_string(inputs).unwrap_or_else(|_| "[]".to_string()), + ), + ]) +} + +#[derive(Debug, Serialize, Deserialize)] +struct FunctionBindingEnvelope { + version: u32, + bindings: Vec, +} + +/// Encode immutable grouped bindings for schema-level persistence. +pub fn function_bindings_metadata(bindings: &[FunctionBinding]) -> Result { + let bindings = bindings + .iter() + .map(serde_json::to_value) + .collect::, _>>() + .map_err(|e| Error::InvalidInput { + message: format!("invalid Function binding metadata: {e}"), + })?; + serde_json::to_string(&FunctionBindingEnvelope { + version: FUNCTION_BINDINGS_VERSION, + bindings, + }) + .map_err(|e| Error::InvalidInput { + message: format!("invalid Function binding metadata: {e}"), + }) +} + +/// Decode known grouped Function bindings without rewriting their raw schema +/// metadata. Unknown envelope versions fail closed. +pub fn function_bindings(schema: &ArrowSchema) -> Result> { + let Some(envelope) = function_binding_envelope(schema)? else { + return Ok(Vec::new()); + }; + envelope + .bindings + .into_iter() + .map(|binding| { + serde_json::from_value(binding).map_err(|e| Error::InvalidInput { + message: format!("invalid Function binding metadata: {e}"), + }) + }) + .collect() +} + +fn function_binding_envelope(schema: &ArrowSchema) -> Result> { + let Some(raw) = schema.metadata().get(FUNCTION_BINDINGS_META_KEY) else { + return Ok(None); + }; + let envelope: FunctionBindingEnvelope = + serde_json::from_str(raw).map_err(|e| Error::InvalidInput { + message: format!("invalid Function binding metadata: {e}"), + })?; + if envelope.version != FUNCTION_BINDINGS_VERSION { + return Err(Error::NotSupported { + message: format!( + "Function binding metadata version {} is not supported by this client", + envelope.version + ), + }); + } + Ok(Some(envelope)) +} + +/// Validate metadata before a schema mutation. Read-only access remains +/// possible for older datasets, while incomplete or newer contracts cannot be +/// silently rewritten by this client. +pub(crate) fn ensure_supported_function_metadata(schema: &ArrowSchema) -> Result<()> { + let raw_bindings = function_binding_envelope(schema)? + .map(|envelope| envelope.bindings) + .unwrap_or_default(); + for value in &raw_bindings { + ensure_known_binding_shape(value)?; + } + let bindings = raw_bindings + .into_iter() + .map(|binding| { + serde_json::from_value(binding).map_err(|e| Error::InvalidInput { + message: format!("invalid Function binding metadata: {e}"), + }) + }) + .collect::>>()?; + let mut binding_ids = BTreeSet::new(); + for binding in &bindings { + if !binding_ids.insert(binding.binding_id().to_string()) { + return Err(Error::InvalidInput { + message: format!("duplicate Function binding '{}'", binding.binding_id()), + }); + } + if binding.revision() == 0 || binding.outputs().is_empty() { + return Err(Error::InvalidInput { + message: format!( + "Function binding '{}' has no immutable revision or outputs", + binding.binding_id() + ), + }); + } + if binding.function().name.is_empty() + || binding.function().version.is_empty() + || binding.group_id().is_empty() + { + return Err(Error::InvalidInput { + message: format!( + "Function binding '{}' has no exact version or group identity", + binding.binding_id() + ), + }); + } + if binding.input_schema().is_none() || binding.output_schema().is_none() { + return Err(Error::NotSupported { + message: format!( + "Function binding '{}' does not contain exact Arrow schemas", + binding.binding_id() + ), + }); + } + for (ordinal, output) in binding.outputs().iter().enumerate() { + if output.output_ordinal != ordinal as u32 { + return Err(Error::InvalidInput { + message: format!( + "Function binding '{}' has non-canonical output ordinals", + binding.binding_id() + ), + }); + } + } + ensure_binding_matches_schema(schema, binding)?; + } + + let bindings_by_id = bindings + .iter() + .map(|binding| (binding.binding_id(), binding)) + .collect::>(); + for field in schema.fields() { + if field + .metadata() + .get(COMPUTED_COLUMN_META_KEY) + .map(String::as_str) + != Some("true") + { + continue; + } + match computed_column_from_field(field) { + Some(ComputedColumn { + kind: + ComputedColumnKind::Function { + binding_id, + output_ordinal, + }, + .. + }) => { + let binding = + bindings_by_id + .get(binding_id.as_str()) + .ok_or_else(|| Error::InvalidInput { + message: format!( + "Function output '{}' references missing binding '{}'", + field.name(), + binding_id + ), + })?; + let output = binding + .outputs() + .get(output_ordinal as usize) + .ok_or_else(|| Error::InvalidInput { + message: format!( + "Function output '{}' has invalid ordinal {}", + field.name(), + output_ordinal + ), + })?; + if output.output_name != field.name().as_str() { + return Err(Error::InvalidInput { + message: format!( + "Function output '{}' does not match binding destination '{}'", + field.name(), + output.output_name + ), + }); + } + } + Some(ComputedColumn { + kind: ComputedColumnKind::Sql { .. }, + .. + }) => {} + Some(ComputedColumn { + kind: ComputedColumnKind::Unrecognized { kind }, + .. + }) => { + return Err(Error::NotSupported { + message: format!( + "computed column '{}' uses unsupported kind '{}'", + field.name(), + kind + ), + }); + } + None => { + return Err(Error::InvalidInput { + message: format!( + "computed column '{}' has incomplete declaration metadata", + field.name() + ), + }); + } + } + } + Ok(()) +} + +pub(crate) fn ensure_no_function_bindings_for_mutation( + schema: &ArrowSchema, + operation: &str, +) -> Result<()> { + ensure_supported_function_metadata(schema)?; + if !function_bindings(schema)?.is_empty() { + return Err(Error::NotSupported { + message: format!( + "{operation} is not supported on a table with registered Function bindings" + ), + }); + } + Ok(()) +} + +/// Read a field's computed-column declaration, if it carries one. +/// +/// A field flagged computed but carrying no kind, or a SQL one missing its +/// expression, is not a computed column here: without the rule there is +/// nothing to refresh from, so it is reported as absent rather than as a +/// half-formed declaration. An unrecognized kind is different -- the rule is +/// there and intact, this version just cannot act on it -- and comes back as +/// [`ComputedColumnKind::Unrecognized`]. +pub fn computed_column_from_field(field: &ArrowField) -> Option { + let metadata = field.metadata(); + if metadata.get(COMPUTED_COLUMN_META_KEY).map(String::as_str) != Some("true") { + return None; + } + let kind = match metadata.get(KIND_META_KEY)?.as_str() { + SQL_KIND => ComputedColumnKind::Sql { + expression: metadata.get(EXPRESSION_META_KEY)?.clone(), + }, + FUNCTION_KIND => match ( + metadata.get(FUNCTION_BINDING_ID_META_KEY), + metadata + .get(FUNCTION_OUTPUT_ORDINAL_META_KEY) + .and_then(|value| value.parse::().ok()), + ) { + (Some(binding_id), Some(output_ordinal)) if !binding_id.is_empty() => { + ComputedColumnKind::Function { + binding_id: binding_id.clone(), + output_ordinal, + } + } + _ => ComputedColumnKind::Unrecognized { + kind: FUNCTION_KIND.to_string(), + }, + }, + other => ComputedColumnKind::Unrecognized { + kind: other.to_string(), + }, + }; + let inputs = metadata + .get(INPUTS_META_KEY) + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .unwrap_or_default(); + Some(ComputedColumn { + name: field.name().clone(), + kind, + inputs, + }) +} + +/// Read every computed-column declaration carried by `schema`, in field order. +/// +/// Introspection is a pure read of the schema the caller already holds, the +/// way a SQL catalog reports a generation expression as another column of +/// `information_schema.columns`. +pub fn computed_columns(schema: &ArrowSchema) -> Vec { + schema + .fields() + .iter() + .filter_map(|field| computed_column_from_field(field)) + .collect() +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct FunctionOutputTarget { + pub result_field: String, + pub output_name: String, + pub output_ordinal: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct FunctionInputTarget { + pub parameter: String, + pub field_path: String, + pub arrow_type: String, + pub nullable: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct FunctionDeclarationPlan { + pub application: FunctionApplication, + pub binding_metadata_version: u32, + pub input_bindings: Vec, + pub input_schema: JsonArrowSchema, + pub output_schema: JsonArrowSchema, + pub outputs: Vec, +} + +fn invalid_function(message: impl Into) -> Error { + Error::InvalidInput { + message: message.into(), + } +} + +fn reject_unknown_object_fields(value: &Value, allowed: &[&str], context: &str) -> Result<()> { + let object = value.as_object().ok_or_else(|| { + invalid_function(format!( + "invalid Function binding metadata: {context} must be an object" + )) + })?; + let unknown = object + .keys() + .filter(|key| !allowed.contains(&key.as_str())) + .cloned() + .collect::>(); + if unknown.is_empty() { + Ok(()) + } else { + Err(Error::NotSupported { + message: format!( + "Function binding metadata contains newer {context} fields: {unknown:?}" + ), + }) + } +} + +fn ensure_known_binding_shape(value: &Value) -> Result<()> { + reject_unknown_object_fields( + value, + &[ + "binding_id", + "revision", + "function", + "group_id", + "inputs", + "outputs", + "input_schema", + "output_schema", + ], + "binding", + )?; + let object = value.as_object().unwrap(); + reject_unknown_object_fields( + object + .get("function") + .ok_or_else(|| invalid_function("Function binding is missing its exact version"))?, + &["name", "version"], + "version reference", + )?; + for input in object + .get("inputs") + .and_then(Value::as_array) + .ok_or_else(|| invalid_function("Function binding inputs must be an array"))? + { + reject_unknown_object_fields( + input, + &[ + "parameter", + "field_id", + "field_path", + "arrow_type", + "nullable", + ], + "input binding", + )?; + } + for output in object + .get("outputs") + .and_then(Value::as_array) + .ok_or_else(|| invalid_function("Function binding outputs must be an array"))? + { + reject_unknown_object_fields( + output, + &[ + "result_field", + "output_name", + "output_field_id", + "output_ordinal", + "arrow_type", + "nullable", + ], + "output mapping", + )?; + } + Ok(()) +} + +fn resolve_field_path<'a>(schema: &'a ArrowSchema, path: &str) -> Result<&'a ArrowField> { + let parts = lance_core::datatypes::parse_field_path(path).map_err(|e| { + invalid_function(format!("invalid Function input field path '{path}': {e}")) + })?; + let Some((root, children)) = parts.split_first() else { + return Err(invalid_function( + "Function input field path cannot be empty", + )); + }; + let mut field = schema + .field_with_name(root) + .map_err(|_| invalid_function(format!("unknown Function input column '{path}'")))?; + for child in children { + let DataType::Struct(fields) = field.data_type() else { + return Err(invalid_function(format!( + "Function input field path '{path}' traverses a non-struct field" + ))); + }; + field = fields + .iter() + .find(|field| field.name() == child) + .map(AsRef::as_ref) + .ok_or_else(|| invalid_function(format!("unknown Function input column '{path}'")))?; + } + Ok(field) +} + +fn canonical_input_arrow_type(field: &JsonArrowField) -> Result { + if field.r#type.fields.is_none() && field.r#type.length.is_none() { + Ok(field.r#type.r#type.clone()) + } else { + serde_json::to_string(field.r#type.as_ref()).map_err(|e| { + invalid_function(format!("could not encode exact Function input type: {e}")) + }) + } +} + +fn parse_output_arrow_type(raw: &str) -> Result { + fn parse(raw: &str) -> Result { + let raw = raw.trim(); + if raw.starts_with('{') { + return serde_json::from_str(raw).map_err(|e| { + invalid_function(format!("invalid Function Arrow type '{raw}': {e}")) + }); + } + if let Some(inner) = raw + .strip_prefix("list<") + .and_then(|value| value.strip_suffix('>')) + { + let mut data_type = JsonArrowDataType::new("list".to_string()); + data_type.fields = Some(vec![JsonArrowField::new( + "item".to_string(), + false, + parse(inner)?, + )]); + return Ok(data_type); + } + if let Some(inner) = raw + .strip_prefix("large_list<") + .and_then(|value| value.strip_suffix('>')) + { + let mut data_type = JsonArrowDataType::new("large_list".to_string()); + data_type.fields = Some(vec![JsonArrowField::new( + "item".to_string(), + false, + parse(inner)?, + )]); + return Ok(data_type); + } + let normalized = match raw { + "boolean" => "bool", + "string" => "utf8", + "large_string" => "large_utf8", + "halffloat" => "float16", + "float" => "float32", + "double" => "float64", + other => other, + }; + Ok(JsonArrowDataType::new(normalized.to_string())) + } + + let data_type = parse(raw)?; + lance_namespace::schema::convert_json_arrow_type(&data_type) + .map_err(|e| invalid_function(format!("unsupported Function Arrow type '{raw}': {e}")))?; + Ok(data_type) +} + +fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding) -> Result<()> { + let mut input_fields = Vec::with_capacity(binding.inputs().len()); + for input in binding.inputs() { + let field = resolve_field_path(schema, &input.field_path)?; + if field + .metadata() + .get(COMPUTED_COLUMN_META_KEY) + .map(String::as_str) + == Some("true") + { + return Err(invalid_function(format!( + "Function input '{}' is computed", + input.field_path + ))); + } + if field.is_nullable() != input.nullable { + return Err(invalid_function(format!( + "Function input '{}' no longer matches binding '{}'", + input.field_path, + binding.binding_id() + ))); + } + let parameter_field = ArrowField::new( + input.parameter.clone(), + field.data_type().clone(), + field.is_nullable(), + ) + .with_metadata(field.metadata().clone()); + let json = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + parameter_field.clone(), + ])) + .map_err(|e| invalid_function(format!("invalid Function input schema: {e}")))?; + let json_field = json.fields.into_iter().next().unwrap(); + if canonical_input_arrow_type(&json_field)? != input.arrow_type { + return Err(invalid_function(format!( + "Function input '{}' type no longer matches binding '{}'", + input.field_path, + binding.binding_id() + ))); + } + input_fields.push(parameter_field); + } + let input_schema = + lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(input_fields)) + .map_err(|e| invalid_function(format!("invalid Function input schema: {e}")))?; + let input_schema = serde_json::to_value(input_schema).map_err(|e| { + invalid_function(format!("could not encode exact Function input schema: {e}")) + })?; + if binding.input_schema() != Some(&input_schema) { + return Err(invalid_function(format!( + "Function binding '{}' input schema does not match its inputs", + binding.binding_id() + ))); + } + + let mut output_fields = Vec::with_capacity(binding.outputs().len()); + for output in binding.outputs() { + let field = schema.field_with_name(&output.output_name).map_err(|_| { + invalid_function(format!( + "Function binding '{}' output '{}' is missing", + binding.binding_id(), + output.output_name + )) + })?; + if field.name() != &output.output_name || !field.is_nullable() || output.nullable { + return Err(invalid_function(format!( + "Function output '{}' no longer matches binding '{}'", + output.output_name, + binding.binding_id() + ))); + } + let expected_type = parse_output_arrow_type(&output.arrow_type)?; + let expected_type = lance_namespace::schema::convert_json_arrow_type(&expected_type) + .map_err(|e| invalid_function(format!("invalid Function output type: {e}")))?; + if field.data_type() != &expected_type { + return Err(invalid_function(format!( + "Function output '{}' type no longer matches binding '{}'", + output.output_name, + binding.binding_id() + ))); + } + output_fields.push(ArrowField::new( + field.name().clone(), + field.data_type().clone(), + true, + )); + } + let output_schema = + lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(output_fields)) + .map_err(|e| invalid_function(format!("invalid Function output schema: {e}")))?; + let output_schema = serde_json::to_value(output_schema).map_err(|e| { + invalid_function(format!( + "could not encode exact Function output schema: {e}" + )) + })?; + if binding.output_schema() != Some(&output_schema) { + return Err(invalid_function(format!( + "Function binding '{}' output schema does not match physical siblings", + binding.binding_id() + ))); + } + Ok(()) +} + +/// Resolve a Function application against a table schema before any request is +/// serialized. Input paths and the complete sibling output schema are fixed in +/// one plan. +pub(crate) fn plan_function_application( + schema: &ArrowSchema, + application: &FunctionApplication, + output_name: Option<&str>, +) -> Result { + ensure_no_function_bindings_for_mutation(schema, "Function binding declaration")?; + if application.has_unknown_fields() { + return Err(Error::NotSupported { + message: "Function application contains fields from a newer contract".into(), + }); + } + if application.function().name.is_empty() + || application.function().version.is_empty() + || application.group_id().is_empty() + { + return Err(invalid_function( + "Function application requires an exact version and group identity", + )); + } + + let mut parameters = BTreeSet::new(); + let mut input_bindings = Vec::with_capacity(application.inputs().len()); + let mut input_fields = Vec::with_capacity(application.inputs().len()); + for input in application.inputs() { + if !parameters.insert(input.parameter.as_str()) { + return Err(invalid_function(format!( + "duplicate Function parameter '{}'", + input.parameter + ))); + } + if input.kind != "column" { + return Err(Error::NotSupported { + message: format!( + "Function input kind '{}' is not supported for column declaration", + input.kind + ), + }); + } + let source = input.value.as_object().ok_or_else(|| { + invalid_function(format!( + "Function parameter '{}' has an invalid column source", + input.parameter + )) + })?; + if source.len() != 1 { + return Err(Error::NotSupported { + message: format!( + "Function parameter '{}' uses a newer column source contract", + input.parameter + ), + }); + } + let path = source.get("path").and_then(Value::as_str).ok_or_else(|| { + invalid_function(format!( + "Function parameter '{}' requires a column path", + input.parameter + )) + })?; + let field = resolve_field_path(schema, path)?; + if field + .metadata() + .get(COMPUTED_COLUMN_META_KEY) + .map(String::as_str) + == Some("true") + { + return Err(invalid_function(format!( + "Function input '{path}' is computed; computed-on-computed bindings are not supported" + ))); + } + let parameter_field = ArrowField::new( + input.parameter.clone(), + field.data_type().clone(), + field.is_nullable(), + ) + .with_metadata(field.metadata().clone()); + let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + parameter_field.clone(), + ])) + .map_err(|e| invalid_function(format!("invalid Function input schema: {e}")))?; + let json_field = input_schema.fields.into_iter().next().unwrap(); + input_bindings.push(FunctionInputTarget { + parameter: input.parameter.clone(), + field_path: path.to_string(), + arrow_type: canonical_input_arrow_type(&json_field)?, + nullable: field.is_nullable(), + }); + input_fields.push(parameter_field); + } + let input_schema = + lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(input_fields)) + .map_err(|e| invalid_function(format!("invalid Function input schema: {e}")))?; + + let output = application.output(); + let mut outputs = Vec::new(); + let mut output_fields = Vec::new(); + match output.kind.as_str() { + "scalar" => { + if !application.columns().is_empty() { + return Err(invalid_function( + "scalar Function applications cannot rename result fields", + )); + } + let name = output_name.ok_or_else(|| { + invalid_function( + "a scalar Function application must be mapped to one output column", + ) + })?; + if output.nullable != Some(false) { + return Err(invalid_function( + "Function logical outputs must be non-nullable during NULL assignment", + )); + } + let data_type = + parse_output_arrow_type(output.arrow_type.as_deref().ok_or_else(|| { + invalid_function("scalar Function output is missing its Arrow type") + })?)?; + outputs.push(FunctionOutputTarget { + result_field: WHOLE_RESULT_FIELD.to_string(), + output_name: name.to_string(), + output_ordinal: 0, + }); + output_fields.push(JsonArrowField::new(name.to_string(), true, data_type)); + } + "named_struct" => { + if output.fields.is_empty() { + return Err(invalid_function( + "named-struct Function output requires at least one field", + )); + } + let result_names = output + .fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(); + if result_names.len() != output.fields.len() { + return Err(invalid_function( + "named-struct Function result field names must be unique", + )); + } + if output.fields.iter().any(|field| field.nullable) { + return Err(invalid_function( + "Function logical outputs must be non-nullable during NULL assignment", + )); + } + let unknown = application + .columns() + .keys() + .filter(|name| !result_names.contains(name.as_str())) + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(invalid_function(format!( + "unknown Function result fields: {unknown:?}" + ))); + } + + if let Some(name) = output_name { + if !application.columns().is_empty() { + return Err(invalid_function( + "a named-struct mapped to one column cannot also rename expanded fields", + )); + } + let fields = output + .fields + .iter() + .map(|field| { + Ok(JsonArrowField::new( + field.name.clone(), + false, + parse_output_arrow_type(&field.arrow_type)?, + )) + }) + .collect::>>()?; + let mut data_type = JsonArrowDataType::new("struct".to_string()); + data_type.fields = Some(fields); + outputs.push(FunctionOutputTarget { + result_field: WHOLE_RESULT_FIELD.to_string(), + output_name: name.to_string(), + output_ordinal: 0, + }); + output_fields.push(JsonArrowField::new(name.to_string(), true, data_type)); + } else { + let mut destinations = BTreeSet::new(); + for (ordinal, field) in output.fields.iter().enumerate() { + let name = application + .columns() + .get(&field.name) + .unwrap_or(&field.name); + if !destinations.insert(name.as_str()) { + return Err(invalid_function( + "Function output destinations must be unique", + )); + } + outputs.push(FunctionOutputTarget { + result_field: field.name.clone(), + output_name: name.clone(), + output_ordinal: ordinal as u32, + }); + output_fields.push(JsonArrowField::new( + name.clone(), + true, + parse_output_arrow_type(&field.arrow_type)?, + )); + } + } + } + kind => { + return Err(Error::NotSupported { + message: format!( + "Function output kind '{kind}' is not supported for column declaration" + ), + }); + } + } + + for output in &outputs { + if output.output_name.is_empty() { + return Err(invalid_function( + "Function output column name cannot be empty", + )); + } + if schema.field_with_name(&output.output_name).is_ok() { + return Err(Error::ColumnAlreadyExists { + name: output.output_name.clone(), + }); + } + } + + Ok(FunctionDeclarationPlan { + application: application.clone(), + binding_metadata_version: FUNCTION_BINDINGS_VERSION, + input_bindings, + input_schema, + output_schema: JsonArrowSchema::new(output_fields), + outputs, + }) +} + +/// Reject a schema change to a column some declaration reads. +/// +/// A binding is SQL text naming its inputs, so renaming, retyping or dropping +/// one leaves an expression that no longer resolves. Refusing the change keeps +/// a declaration that survived [`plan`] evaluable for as long as it exists. +/// +/// Paths are compared at their root: a declaration reading `metadata` is +/// invalidated by a change to `metadata.age` just as surely. +pub(crate) fn ensure_not_an_input(schema: &SchemaRef, paths: &[&str]) -> Result<()> { + for declaration in computed_columns(schema) { + // The expression, not stored inputs, is the source of truth; an + // expression that no longer parses proves nothing, so refuse. + let inputs = match &declaration.kind { + ComputedColumnKind::Sql { expression } => Planner::new(schema.clone()) + .parse_expr(expression) + .map(|parsed| Planner::column_names_in_expr(&parsed)) + .map_err(|e| Error::InvalidInput { + message: format!( + "computed column '{}' has an unevaluable expression ({e}); drop it \ + before changing the schema", + declaration.name + ), + })?, + _ => declaration.inputs.clone(), + }; + for path in paths { + // Exact target only: the binding travels with the whole column, + // not with a nested field the expression still shapes. + if declaration.name == *path { + continue; + } + if declaration.name == root(path) { + return Err(Error::InvalidInput { + message: format!( + "'{}' is part of computed column '{}'; drop the column and declare \ + it again", + path, declaration.name + ), + }); + } + if inputs.iter().any(|input| root(input) == root(path)) { + return Err(Error::InvalidInput { + message: format!( + "column '{}' is read by computed column '{}'; drop that column first", + path, declaration.name + ), + }); + } + } + } + Ok(()) +} + +/// Reject a write that supplies values for a computed column directly: +/// only refresh materializes one, and refresh never revisits a filled row. +pub(crate) fn ensure_not_written<'a>( + schema: &ArrowSchema, + written: impl IntoIterator, +) -> Result<()> { + let declared: Vec = computed_columns(schema) + .into_iter() + .map(|declaration| declaration.name) + .collect(); + for name in written { + if declared.iter().any(|declared| declared == root(name)) { + return Err(Error::InvalidInput { + message: format!( + "column '{}' is computed; its values come from refresh and cannot be \ + written directly", + root(name) + ), + }); + } + } + Ok(()) +} + +/// Reject a batch holding values for a computed column. Null slots are the +/// declared state, so planner-padded placeholders pass. +pub(crate) fn ensure_batch_writes_no_computed_values( + declared: &[String], + batch: &arrow_array::RecordBatch, +) -> Result<()> { + for name in declared { + if let Some(column) = batch.column_by_name(name) + && column.null_count() != column.len() + { + return Err(Error::InvalidInput { + message: format!( + "column '{name}' is computed; its values come from refresh and cannot \ + be written directly" + ), + }); + } + } + Ok(()) +} + +/// Reject fields carrying declaration metadata that did not come through +/// [`plan`]. One authority for creation, overwrite and raw transforms. +pub(crate) fn ensure_no_foreign_declarations<'a>( + fields: impl IntoIterator>, +) -> Result<()> { + for field in fields { + if field.metadata().keys().any(|k| is_declaration_key(k)) { + return Err(Error::InvalidInput { + message: format!( + "field '{}' carries computed-column metadata; declare computed columns \ + with add_columns().computed()", + field.name() + ), + }); + } + } + Ok(()) +} + +/// True for field-metadata keys that belong to a computed-column declaration. +/// +/// A declaration is immutable through metadata edits: it is validated as a +/// whole at declare time, and rewriting any piece of it -- the flag, the +/// kind, the expression, the inputs -- would bypass that validation or move +/// a binding out from under a refresh. Drop the column and declare it again. +pub(crate) fn is_declaration_key(key: &str) -> bool { + key == COMPUTED_COLUMN_META_KEY || key.starts_with("computed_column.") +} + +/// Reject retyping a computed column itself. +/// +/// A cast keeps the stored expression while changing the type it must yield +/// -- and lance's cast rewrites the field without its metadata, so the +/// declaration silently stops being one. Dropping and redeclaring is the +/// coherent way to change a computed column's type. +pub(crate) fn ensure_not_retyped(schema: &ArrowSchema, paths: &[&str]) -> Result<()> { + for declaration in computed_columns(schema) { + for path in paths { + if declaration.name == root(path) { + return Err(Error::InvalidInput { + message: format!( + "column '{}' is computed; drop it and declare it again to change \ + its type", + declaration.name + ), + }); + } + } + } + Ok(()) +} + +/// The top-level column a possibly nested input path reads. +pub(crate) fn root(path: &str) -> &str { + path.split('.').next().unwrap_or(path) +} + +/// A declaration's expression bound to a schema, ready to evaluate. +pub(crate) struct BoundExpression { + /// The columns the expression names, as written; nested inputs keep + /// their dotted path. + pub inputs: Vec, + /// The top-level columns evaluation reads, in [`Self::read_schema`] + /// order. A nested input appears through its root. + pub roots: Vec, + /// The projected schema evaluation runs against. + pub read_schema: SchemaRef, + /// The compiled expression. + pub physical: Arc, + /// The type the expression yields. + pub data_type: DataType, +} + +/// Parse, resolve and compile `expression` against `schema`. +/// +/// Inputs come from the expression as written, before optimization: the +/// simplifier can fold a referenced column out entirely (`true OR x > 0`), +/// and the guard protecting the stored SQL has to see every column the text +/// names, not just the ones the simplified form still reads. +pub(crate) fn bind(schema: SchemaRef, column: &str, expression: &str) -> Result { + let invalid = |message: String| Error::InvalidExpression { + column: column.to_string(), + message, + }; + + let planner = Planner::new(schema.clone()); + let parsed = planner + .parse_expr(expression) + .map_err(|e| invalid(e.to_string()))?; + + // A declaration is evaluated more than once -- staging and writing are + // separate passes, and a refresh years later replays the same text -- so + // a function that can answer differently each time has no coherent value + // to declare. + let mut volatile = None; + parsed + .apply(|expr| { + use datafusion_common::tree_node::TreeNodeRecursion; + if let datafusion_expr::Expr::ScalarFunction(function) = expr + && function.func.signature().volatility != datafusion_expr::Volatility::Immutable + { + volatile = Some(function.func.name().to_string()); + return Ok(TreeNodeRecursion::Stop); + } + Ok(TreeNodeRecursion::Continue) + }) + .map_err(|e| invalid(e.to_string()))?; + if let Some(function) = volatile { + return Err(invalid(format!( + "'{function}' is not deterministic; a computed column's expression must \ + yield the same value every time it is evaluated" + ))); + } + + let mut inputs = Planner::column_names_in_expr(&parsed); + inputs.sort(); + inputs.dedup(); + + // A nested input is recorded by its path but read through its root + // column; Schema::index_of resolves top-level names only. Resolved here + // rather than left to the planner so an unknown column names itself in + // the error instead of surfacing as a plan failure. + let mut indices = Vec::with_capacity(inputs.len()); + for input in &inputs { + let index = schema + .index_of(root(input)) + .map_err(|_| invalid(format!("unknown column '{input}'")))?; + if !indices.contains(&index) { + indices.push(index); + } + } + indices.sort_unstable(); + + // Physical expressions address columns by position, so the planner that + // compiles the expression has to be built on the projected schema + // evaluation will actually read. + let read_schema = Arc::new( + schema + .project(&indices) + .map_err(|e| invalid(e.to_string()))?, + ); + let roots = read_schema + .fields() + .iter() + .map(|field| field.name().clone()) + .collect(); + + let optimized = planner + .optimize_expr(parsed) + .map_err(|e| invalid(e.to_string()))?; + let physical = Planner::new(read_schema.clone()) + .create_physical_expr(&optimized) + .map_err(|e| invalid(e.to_string()))?; + let data_type = physical + .data_type(read_schema.as_ref()) + .map_err(|e| invalid(e.to_string()))?; + + Ok(BoundExpression { + inputs, + roots, + read_schema, + physical, + data_type, + }) +} + +/// Resolve `(name, expression)` pairs against `schema` into fields carrying +/// their bindings. +/// +/// Everything that can be known statically is checked here rather than at +/// refresh time: that the expression parses, that every column it reads +/// exists, and that the target name is free. A declaration that survives this +/// is one a refresh can always act on. +pub(crate) fn plan(schema: SchemaRef, columns: &[(String, String)]) -> Result> { + if columns.is_empty() { + return Err(Error::InvalidInput { + message: "at least one computed column is required".into(), + }); + } + + let mut fields = Vec::with_capacity(columns.len()); + let mut declared: Vec<&str> = Vec::with_capacity(columns.len()); + + for (name, expression) in columns { + if schema.field_with_name(name).is_ok() || declared.contains(&name.as_str()) { + return Err(Error::ColumnAlreadyExists { name: name.clone() }); + } + + let bound = bind(schema.clone(), name, expression)?; + + // Declared columns start entirely null, so nullability is a property + // of the declaration rather than of what the expression yields. + fields.push( + ArrowField::new(name, bound.data_type, true) + .with_metadata(computed_column_metadata(expression, &bound.inputs)), + ); + declared.push(name); + } + + Ok(fields) +} + +/// Build the transform that declares `columns` against `schema`. +/// +/// An all-null column is how a binding with no values yet is carried into a +/// commit; that it is spelled `AllNulls` is a detail of the commit, not of the +/// column, which is why this is internal and +/// [`AddColumnsBuilder::computed`](super::AddColumnsBuilder::computed) is the +/// public way in. +pub(crate) fn declare( + schema: SchemaRef, + columns: &[(String, String)], +) -> Result { + let fields = plan(schema, columns)?; + Ok(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new( + fields, + )))) +} + +/// Commit a declaration of a kind this version does not produce, the way a +/// newer lancedb would leave one behind. Bypasses admission, which exists to +/// stop exactly this through the public API. +#[cfg(test)] +pub(super) async fn add_foreign_kind(table: &crate::Table, name: &str, kind: &str) { + let field = ArrowField::new(name, DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), kind.to_string()), + (INPUTS_META_KEY.to_string(), r#"["x"]"#.to_string()), + ])); + super::schema_evolution::commit_add_columns( + table.as_native().unwrap(), + NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new(vec![field]))), + None, + ) + .await + .unwrap(); +} + +#[cfg(test)] +mod tests { + use arrow_array::record_batch; + use arrow_schema::DataType; + use futures::TryStreamExt; + use lance::dataset::ColumnAlteration; + + use super::*; + use crate::connect; + use crate::query::{ExecutableQuery, QueryBase, Select}; + use crate::{Error, Table}; + + async fn table_with_ints(name: &str) -> Table { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("x", Int32, [1, 2, 3])).unwrap(); + conn.create_table(name, batch).execute().await.unwrap() + } + + /// Declare `columns` the way a caller would: plan the expressions, then + /// add them through the ordinary column API. + async fn add_computed(table: &Table, columns: &[(String, String)]) -> Result { + let mut builder = table.add_columns(); + for (name, expression) in columns { + builder = builder.computed(name, expression); + } + Ok(builder.execute().await?.version) + } + + async fn declared(table: &Table) -> Vec { + computed_columns(table.schema().await.unwrap().as_ref()) + } + + #[tokio::test] + async fn test_declare_infers_type_and_inputs() { + let table = table_with_ints("declare_infers").await; + let initial = table.version().await.unwrap(); + + let version = add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + assert!(version > initial); + + let schema = table.schema().await.unwrap(); + let field = schema.field_with_name("doubled").unwrap(); + assert_eq!(field.data_type(), &DataType::Int32); + assert!(field.is_nullable()); + + assert_eq!( + declared(&table).await, + vec![ComputedColumn { + name: "doubled".into(), + kind: ComputedColumnKind::Sql { + expression: "x * 2".into() + }, + inputs: vec!["x".into()], + }] + ); + } + + /// The binding reaches the schema only if `AllNulls` carries per-field + /// metadata through the commit. The whole representation rests on it. + #[tokio::test] + async fn test_all_nulls_preserves_field_metadata() { + let table = table_with_ints("metadata_survives").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let schema = table.schema().await.unwrap(); + let metadata = schema.field_with_name("doubled").unwrap().metadata(); + assert_eq!( + metadata.get(COMPUTED_COLUMN_META_KEY).map(String::as_str), + Some("true") + ); + assert_eq!(metadata.get(KIND_META_KEY).map(String::as_str), Some("sql")); + assert_eq!( + metadata.get(EXPRESSION_META_KEY).map(String::as_str), + Some("x * 2") + ); + assert_eq!( + metadata.get(INPUTS_META_KEY).map(String::as_str), + Some(r#"["x"]"#) + ); + } + + #[tokio::test] + async fn test_declared_column_is_all_null() { + let table = table_with_ints("declare_is_null").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let batches = table + .query() + .select(Select::columns(&["doubled"])) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + let total: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total, 3); + for batch in &batches { + assert_eq!(batch["doubled"].null_count(), batch.num_rows()); + } + } + + #[tokio::test] + async fn test_unknown_column_fails_at_declare_time() { + let table = table_with_ints("unknown_input").await; + let err = add_computed(&table, &[("bad".into(), "missing + 1".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidExpression { column, .. } if column == "bad")); + + let schema = table.schema().await.unwrap(); + assert!(schema.field_with_name("bad").is_err()); + } + + #[tokio::test] + async fn test_unparsable_expression_fails_at_declare_time() { + let table = table_with_ints("bad_syntax").await; + let err = add_computed(&table, &[("bad".into(), "x *".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidExpression { column, .. } if column == "bad")); + assert!( + table + .schema() + .await + .unwrap() + .field_with_name("bad") + .is_err() + ); + } + + /// A user-defined function is an expression like any other; only its + /// resolution is missing. When a registry-aware planner exists this + /// becomes a supported declaration rather than a new API. + #[tokio::test] + async fn test_unregistered_function_is_rejected_for_now() { + let table = table_with_ints("udf_not_yet").await; + let err = add_computed(&table, &[("vec".into(), "embed(x)".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidExpression { column, .. } if column == "vec")); + assert!( + table + .schema() + .await + .unwrap() + .field_with_name("vec") + .is_err() + ); + } + + #[tokio::test] + async fn test_existing_column_name_is_rejected() { + let table = table_with_ints("name_taken").await; + let err = add_computed(&table, &[("x".into(), "x * 2".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::ColumnAlreadyExists { name } if name == "x")); + assert!(declared(&table).await.is_empty()); + } + + #[tokio::test] + async fn test_constant_expression_needs_no_inputs() { + let table = table_with_ints("constant").await; + add_computed(&table, &[("answer".into(), "42".into())]) + .await + .unwrap(); + + let declared = declared(&table).await; + assert_eq!(declared.len(), 1); + assert!(declared[0].inputs.is_empty()); + } + + #[tokio::test] + async fn test_multiple_columns_in_one_commit() { + let table = table_with_ints("multi").await; + let initial = table.version().await.unwrap(); + + add_computed( + &table, + &[ + ("plus".into(), "x + 1".into()), + ("squared".into(), "x * x".into()), + ], + ) + .await + .unwrap(); + + assert_eq!(table.version().await.unwrap(), initial + 1); + let declared = declared(&table).await; + assert_eq!(declared.len(), 2); + assert_eq!(declared[0].name, "plus"); + assert_eq!(declared[1].name, "squared"); + } + + #[tokio::test] + async fn test_duplicate_declaration_in_one_call_is_rejected() { + let table = table_with_ints("dupe").await; + let err = add_computed( + &table, + &[ + ("dup".into(), "x + 1".into()), + ("dup".into(), "x + 2".into()), + ], + ) + .await + .unwrap_err(); + assert!(matches!(err, Error::ColumnAlreadyExists { name } if name == "dup")); + assert!(declared(&table).await.is_empty()); + } + + /// A column added by an ordinary transform is materialized, not bound, so + /// it carries no declaration to report. + #[tokio::test] + async fn test_ordinary_columns_are_not_reported_as_computed() { + let table = table_with_ints("plain").await; + assert!(declared(&table).await.is_empty()); + + table + .add_columns() + .transform(NewColumnTransform::SqlExpressions(vec![( + "eager".into(), + "x * 2".into(), + )])) + .execute() + .await + .unwrap(); + assert!(declared(&table).await.is_empty()); + } + + /// Built-in functions type the column the same way an operator does. + #[tokio::test] + async fn test_builtin_function_inference() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("name", Utf8, ["ada", "grace"]), ("n", Int32, [-1, 2])).unwrap(); + let table = conn + .create_table("builtins", batch) + .execute() + .await + .unwrap(); + + add_computed( + &table, + &[ + ("shout".into(), "upper(name)".into()), + ("width".into(), "length(name)".into()), + ("magnitude".into(), "abs(n)".into()), + ], + ) + .await + .unwrap(); + + let schema = table.schema().await.unwrap(); + assert_eq!( + schema.field_with_name("shout").unwrap().data_type(), + &DataType::Utf8 + ); + assert_eq!( + schema.field_with_name("magnitude").unwrap().data_type(), + &DataType::Int32 + ); + // length() returns a width-dependent integer type; assert it is one + // rather than pinning which. + assert!( + schema + .field_with_name("width") + .unwrap() + .data_type() + .is_integer() + ); + + let declared = declared(&table).await; + assert_eq!(declared.len(), 3); + assert_eq!(declared[0].inputs, vec!["name".to_string()]); + assert_eq!(declared[2].inputs, vec!["n".to_string()]); + } + + /// The reason the kind is tagged: a declaration written by a newer version + /// has to read back as a computed column this one cannot evaluate, not as + /// an ordinary column. Reported as absent it would be refreshable by + /// nothing and redeclarable over, silently. + #[tokio::test] + async fn test_unrecognized_kind_is_reported_rather_than_hidden() { + let table = table_with_ints("foreign_kind").await; + super::add_foreign_kind(&table, "embedding", "udf").await; + + assert_eq!( + declared(&table).await, + vec![ComputedColumn { + name: "embedding".into(), + kind: ComputedColumnKind::Unrecognized { kind: "udf".into() }, + inputs: vec!["x".into()], + }] + ); + + let err = add_computed(&table, &[("embedding".into(), "x * 2".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + /// A kind is what makes a declaration readable at all, so the flag alone + /// is half-formed in the same way a missing expression is. + #[test] + fn test_flag_without_a_kind_is_not_a_declaration() { + let field = + ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([( + COMPUTED_COLUMN_META_KEY.to_string(), + "true".to_string(), + )])); + assert_eq!(computed_column_from_field(&field), None); + } + + /// A SQL declaration is its expression; without one there is nothing to + /// refresh from. + #[test] + fn test_sql_kind_without_an_expression_is_not_a_declaration() { + let field = ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + ])); + assert_eq!(computed_column_from_field(&field), None); + } + + #[tokio::test] + async fn test_inputs_are_deduplicated_and_sorted() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("b", Int32, [1, 2]), ("a", Int32, [3, 4])).unwrap(); + let table = conn.create_table("dedupe", batch).execute().await.unwrap(); + + add_computed(&table, &[("total".into(), "b + a + b".into())]) + .await + .unwrap(); + + assert_eq!( + declared(&table).await[0].inputs, + vec!["a".to_string(), "b".to_string()] + ); + } + + #[tokio::test] + async fn test_dropping_an_input_is_refused() { + let table = table_with_ints("drop_input").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let err = table.drop_columns(&["x"]).await.unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("doubled")), + "{err:?}" + ); + } + + #[tokio::test] + async fn test_renaming_an_input_is_refused() { + let table = table_with_ints("rename_input").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let err = table + .alter_columns(&[ColumnAlteration::new("x".into()).rename("y".into())]) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("doubled")), + "{err:?}" + ); + } + + /// Nothing resolves against nullability, so it is not a rebinding. + #[tokio::test] + async fn test_altering_an_input_nullability_is_allowed() { + let table = table_with_ints("nullable_input").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + table + .alter_columns(&[ColumnAlteration::new("x".into()).set_nullable(true)]) + .await + .unwrap(); + } + + /// The gate's reproducer: a volatile function evaluates differently in + /// the counting and writing passes, so the declared value is incoherent. + /// Refused at declare time. + #[tokio::test] + async fn test_a_volatile_expression_is_refused() { + let table = table_with_ints("volatile_expr").await; + let err = add_computed(&table, &[("maybe".into(), "random() < 0.5".into())]) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidExpression { message, .. } + if message.contains("random") && message.contains("deterministic")), + "{err:?}" + ); + } + + /// The gate's reproducer: the simplifier folds `true OR x > 0` to a + /// constant, but the stored SQL still names `x`, so the recorded inputs + /// must too -- otherwise dropping `x` is allowed and refresh breaks. + #[tokio::test] + async fn test_inputs_survive_expression_optimization() { + let table = table_with_ints("optimized_inputs").await; + add_computed(&table, &[("flag".into(), "true OR x > 0".into())]) + .await + .unwrap(); + + assert_eq!(declared(&table).await[0].inputs, vec!["x".to_string()]); + let err = table.drop_columns(&["x"]).await.unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("flag")), + "{err:?}" + ); + } + + /// The gate's reproducer: casting a computed column rewrites the field + /// without its metadata, silently destroying the declaration. + #[tokio::test] + async fn test_retyping_the_computed_column_is_refused() { + use arrow_schema::DataType as ArrowDataType; + + let table = table_with_ints("retype_computed").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let err = table + .alter_columns(&[ColumnAlteration::new("doubled".into()).cast_to(ArrowDataType::Int64)]) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed")), + "{err:?}" + ); + + // The declaration survives the refused change. + table.refresh_column("doubled").await.unwrap(); + } + + /// A declaration cannot be edited, fabricated or erased through field + /// metadata: it is validated as a whole at declare time. + #[tokio::test] + async fn test_declaration_metadata_is_immutable() { + use crate::table::FieldMetadataUpdate; + + let table = table_with_ints("metadata_tamper").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + // Moving the binding. + let err = table + .update_field_metadata(&[ + FieldMetadataUpdate::new("doubled").set(EXPRESSION_META_KEY, "x * 3") + ]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. }), "{err:?}"); + + // Fabricating a declaration on a plain column. + let err = table + .update_field_metadata(&[FieldMetadataUpdate::new("x") + .set(COMPUTED_COLUMN_META_KEY, "true") + .set(KIND_META_KEY, SQL_KIND) + .set(EXPRESSION_META_KEY, "x")]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. }), "{err:?}"); + + // Erasing the declaration wholesale. + let err = table + .update_field_metadata(&[FieldMetadataUpdate::new("doubled") + .set("note", "hi") + .replace()]) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. }), "{err:?}"); + + // Ordinary metadata on a computed column still merges. + table + .update_field_metadata(&[FieldMetadataUpdate::new("doubled").set("note", "hi")]) + .await + .unwrap(); + table.refresh_column("doubled").await.unwrap(); + } + + /// The gate's reproducer: only refresh materializes a declared column; + /// a direct write would store an arbitrary durable value. + #[tokio::test] + async fn test_a_computed_column_cannot_be_written_directly() { + let table = table_with_ints("direct_write").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let batch = record_batch!(("x", Int32, [4]), ("doubled", Int32, [999])).unwrap(); + let err = table.add(batch.clone()).execute().await.unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("refresh")), + "{err:?}" + ); + + let err = table + .update() + .column("doubled", "999") + .execute() + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + + let mut merge = table.merge_insert(&["x"]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all(); + let err = merge + .execute(Box::new(arrow_array::RecordBatchIterator::new( + vec![Ok(batch.clone())], + batch.schema(), + ))) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + + // The append that omits the column still works. + let plain = record_batch!(("x", Int32, [4])).unwrap(); + table.add(plain).execute().await.unwrap(); + } + + /// The gate's reproducer: the reciprocal of the declare-under-spec check. + #[tokio::test] + async fn test_installing_an_lsm_spec_over_computed_columns_is_refused() { + use crate::table::LsmWriteSpec; + + let tmp_dir = tempfile::tempdir().unwrap(); + let conn = connect(tmp_dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( + "x", + DataType::Int32, + false, + )])); + let batch = arrow_array::RecordBatch::try_new( + schema, + vec![Arc::new(arrow_array::Int32Array::from(vec![1, 2])) as _], + ) + .unwrap(); + let table = conn + .create_table("lsm_after", batch) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["x"]).await.unwrap(); + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let err = table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } if message.contains("computed")), + "{err:?}" + ); + assert!(table.get_lsm_write_spec().await.unwrap().is_none()); + } + + /// The gate's reproducer: declaration metadata is admitted only through + /// the validated declare path, never smuggled through a raw transform. + #[tokio::test] + async fn test_forged_declaration_metadata_is_rejected() { + let table = table_with_ints("forged_metadata").await; + let field = + ArrowField::new("doubled", DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), "x * 2".to_string()), + (INPUTS_META_KEY.to_string(), "[]".to_string()), + ])); + let err = table + .add_columns() + .transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new( + vec![field], + )))) + .execute() + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed()")), + "{err:?}" + ); + assert!(declared(&table).await.is_empty()); + } + + /// The gate's reproducer: SQL INSERT is a write path too. + #[tokio::test] + async fn test_sql_insert_cannot_write_a_computed_column() { + use datafusion::prelude::SessionContext; + + let table = table_with_ints("sql_insert").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let ctx = SessionContext::new(); + let provider = + crate::table::datafusion::BaseTableAdapter::try_new(table.base_table().clone()) + .await + .unwrap(); + ctx.register_table("t", Arc::new(provider)).unwrap(); + + let result = async { + ctx.sql("INSERT INTO t (x, doubled) VALUES (4, 999)") + .await? + .collect() + .await + } + .await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("refresh"), "{err}"); + } + + /// The gate's reproducer: an overwrite must not smuggle in a filled + /// declaration. + #[tokio::test] + async fn test_overwrite_cannot_inject_a_declaration() { + use crate::table::AddDataMode; + + let table = table_with_ints("overwrite_inject").await; + let field = + ArrowField::new("doubled", DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), "x * 2".to_string()), + ])); + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, true), + field, + ])); + let batch = arrow_array::RecordBatch::try_new( + schema, + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])) as _, + Arc::new(arrow_array::Int32Array::from(vec![999])) as _, + ], + ) + .unwrap(); + + let err = table + .add(batch) + .mode(AddDataMode::Overwrite) + .execute() + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("declare")), + "{err:?}" + ); + } + + #[tokio::test] + async fn test_create_table_cannot_inject_a_declaration() { + let conn = connect("memory://").execute().await.unwrap(); + let field = + ArrowField::new("doubled", DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), "x * 2".to_string()), + ])); + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, true), + field, + ])); + let batch = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])) as _, + Arc::new(arrow_array::Int32Array::from(vec![999])) as _, + ], + ) + .unwrap(); + let err = conn + .create_table("forged_create", batch) + .execute() + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed()")), + "{err:?}" + ); + } + + #[tokio::test] + async fn test_sql_insert_omitting_computed_is_allowed() { + use datafusion::prelude::SessionContext; + + let table = table_with_ints("sql_insert_omitted").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + let ctx = SessionContext::new(); + let provider = + crate::table::datafusion::BaseTableAdapter::try_new(table.base_table().clone()) + .await + .unwrap(); + ctx.register_table("t", Arc::new(provider)).unwrap(); + ctx.sql("INSERT INTO t (x) VALUES (4)") + .await + .unwrap() + .collect() + .await + .unwrap(); + + table.checkout_latest().await.unwrap(); + assert_eq!(table.count_rows(None).await.unwrap(), 4); + } + + #[tokio::test] + async fn test_a_nested_computed_field_cannot_be_renamed() { + let table = table_with_ints("computed_struct_rename").await; + add_computed(&table, &[("payload".into(), "named_struct('a', x)".into())]) + .await + .unwrap(); + + let err = table + .alter_columns(&[ColumnAlteration::new("payload.a".into()).rename("b".into())]) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("payload")), + "{err:?}" + ); + } + + /// Stale handles must not commit the computed/LSM state in either order. + #[tokio::test] + async fn test_stale_handles_cannot_mix_computed_and_lsm() { + use crate::table::LsmWriteSpec; + + let tmp_dir = tempfile::tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "x", + DataType::Int32, + false, + )])); + let batch = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![Arc::new(arrow_array::Int32Array::from(vec![1])) as _], + ) + .unwrap(); + let conn = connect(uri).execute().await.unwrap(); + let table = conn.create_table("mix", batch).execute().await.unwrap(); + table.set_unenforced_primary_key(["x"]).await.unwrap(); + let stale = conn.open_table("mix").execute().await.unwrap(); + + // Declare on one handle; the stale handle must not install a spec. + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + let err = stale + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. }), "install won"); + + // Reverse order on fresh tables. + let batch = arrow_array::RecordBatch::try_new( + schema, + vec![Arc::new(arrow_array::Int32Array::from(vec![1])) as _], + ) + .unwrap(); + let table = conn.create_table("mix2", batch).execute().await.unwrap(); + table.set_unenforced_primary_key(["x"]).await.unwrap(); + let stale = conn.open_table("mix2").execute().await.unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + let err = add_computed(&stale, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. }), "declare won"); + } + + /// The gate's reproducer: after catch-up activation, an LSM write, and + /// unset, retained SSTable rows survive without a live spec. The catch-up + /// flag is the durable marker; declaration refuses on it. + #[tokio::test] + async fn test_unset_with_retained_lsm_rows_cannot_admit_a_declaration() { + use crate::table::LsmWriteSpec; + use arrow_array::{Int64Array, RecordBatchIterator}; + + let tmp_dir = tempfile::tempdir().unwrap(); + let conn = connect(tmp_dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int64, false), + ArrowField::new("value", DataType::Int64, false), + ])); + let batch = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2])) as _, + Arc::new(Int64Array::from(vec![10, 20])) as _, + ], + ) + .unwrap(); + let table = conn + .create_table("t", batch.clone()) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["id"]).await.unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + table.require_mem_wal_index_catchup().await.unwrap(); + + let mut merge = table.merge_insert(&["id"]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all() + .use_lsm(true); + merge + .execute(Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema))) + .await + .unwrap(); + table.unset_lsm_write_spec().await.unwrap(); + + let err = add_computed(&table, &[("doubled".into(), "value * 2".into())]) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } if message.contains("LSM")), + "{err:?}" + ); + } + + /// A declaration does not read itself, so it travels with its binding. + #[tokio::test] + async fn test_dropping_the_computed_column_is_allowed() { + let table = table_with_ints("drop_computed").await; + add_computed(&table, &[("doubled".into(), "x * 2".into())]) + .await + .unwrap(); + + table.drop_columns(&["doubled"]).await.unwrap(); + assert!(declared(&table).await.is_empty()); + } + + fn function_input_schema() -> ArrowSchema { + ArrowSchema::new(vec![ + ArrowField::new("title", DataType::Utf8, true), + ArrowField::new("body", DataType::Utf8, true), + ]) + } + + fn named_struct_application(columns: &str) -> FunctionApplication { + FunctionApplication::from_json(&format!( + r#"{{ + "function":{{"name":"text_features","version":"fv_exact"}}, + "inputs":[ + {{"parameter":"title","kind":"column","value":{{"path":"title"}}}}, + {{"parameter":"body","kind":"column","value":{{"path":"body"}}}} + ], + "output":{{"kind":"named_struct","fields":[ + {{"name":"normalized_text","arrow_type":"utf8","nullable":false}}, + {{"name":"token_count","arrow_type":"int64","nullable":false}} + ]}}, + "group_id":"fg_exact", + "columns":{columns} + }}"# + )) + .unwrap() + } + + #[test] + fn test_function_binding_metadata_survives_schema_round_trip() { + let binding = FunctionBinding::from_json(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + let raw = function_bindings_metadata(std::slice::from_ref(&binding)).unwrap(); + let mut fields = vec![ + ArrowField::new("title", DataType::Utf8, true), + ArrowField::new("body", DataType::Utf8, true), + ]; + fields.extend( + binding + .outputs() + .iter() + .map(|output| { + let data_type = match output.arrow_type.as_str() { + "utf8" => DataType::Utf8, + "int64" => DataType::Int64, + other => panic!("unexpected fixture output type {other}"), + }; + let metadata = function_computed_column_metadata( + binding.binding_id(), + output.output_ordinal, + &["title".into(), "body".into()], + ); + ArrowField::new(&output.output_name, data_type, true).with_metadata(metadata) + }) + .collect::>(), + ); + let schema = ArrowSchema::new_with_metadata( + fields, + HashMap::from([(FUNCTION_BINDINGS_META_KEY.to_string(), raw)]), + ); + + let reopened = + ArrowSchema::new_with_metadata(schema.fields().to_vec(), schema.metadata().clone()); + let bindings = function_bindings(&reopened).unwrap(); + assert_eq!(bindings, vec![binding.clone()]); + assert!(bindings[0].input_schema().is_some()); + assert!(bindings[0].output_schema().is_some()); + assert!(matches!( + computed_column_from_field(reopened.field(3)).unwrap().kind, + ComputedColumnKind::Function { + ref binding_id, + output_ordinal: 1, + } if binding_id == "fb_01K3TEXT" + )); + let err = plan_function_application(&reopened, &named_struct_application("{}"), None) + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + #[test] + fn test_newer_binding_fields_remain_readable_but_fail_closed_on_mutation() { + let raw_binding: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + let binding: FunctionBinding = serde_json::from_value(raw_binding.clone()).unwrap(); + assert_eq!(binding.binding_id(), "fb_01K3TEXT"); + + let schema = ArrowSchema::new_with_metadata( + Vec::::new(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + serde_json::json!({ + "version": FUNCTION_BINDINGS_VERSION, + "bindings": [raw_binding], + }) + .to_string(), + )]), + ); + let err = ensure_supported_function_metadata(&schema).unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + #[test] + fn test_named_struct_can_be_kept_as_one_nullable_physical_column() { + let application = named_struct_application("{}"); + let plan = + plan_function_application(&function_input_schema(), &application, Some("features")) + .unwrap(); + + assert_eq!(plan.outputs.len(), 1); + assert_eq!(plan.outputs[0].result_field, WHOLE_RESULT_FIELD); + assert_eq!(plan.output_schema.fields.len(), 1); + assert!(plan.output_schema.fields[0].nullable); + assert_eq!(plan.output_schema.fields[0].r#type.r#type, "struct"); + assert_eq!( + plan.output_schema.fields[0] + .r#type + .fields + .as_ref() + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn test_function_mapping_and_sibling_collisions_fail_before_request() { + let unknown = named_struct_application(r#"{"missing":"renamed"}"#); + let err = plan_function_application(&function_input_schema(), &unknown, None).unwrap_err(); + assert!(matches!(&err, Error::InvalidInput { message } if message.contains("unknown"))); + + let duplicate = + named_struct_application(r#"{"normalized_text":"same","token_count":"same"}"#); + let err = + plan_function_application(&function_input_schema(), &duplicate, None).unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("destinations")) + ); + + let mut fields = function_input_schema().fields().to_vec(); + fields.push(Arc::new(ArrowField::new( + "token_count", + DataType::Int64, + true, + ))); + let collision_schema = ArrowSchema::new(fields); + let err = + plan_function_application(&collision_schema, &named_struct_application("{}"), None) + .unwrap_err(); + assert!(matches!(err, Error::ColumnAlreadyExists { name } if name == "token_count")); + } + + #[test] + fn test_unknown_and_mixed_version_function_contracts_fail_closed() { + let application = FunctionApplication::from_json( + r#"{ + "function":{"name":"f","version":"fv"}, + "inputs":[{"parameter":"title","kind":"future_source","value":{"path":"title"}}], + "output":{"kind":"scalar","arrow_type":"int64","nullable":false}, + "group_id":"fg" + }"#, + ) + .unwrap(); + let err = plan_function_application(&function_input_schema(), &application, Some("out")) + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + + let future_application = FunctionApplication::from_json( + r#"{ + "function":{"name":"f","version":"fv"}, + "inputs":[], + "output":{"kind":"scalar","arrow_type":"int64","nullable":false}, + "group_id":"fg", + "future_declaration":{"mode":"managed"} + }"#, + ) + .unwrap(); + let err = + plan_function_application(&function_input_schema(), &future_application, Some("out")) + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + + let nested_future_application = FunctionApplication::from_json( + r#"{ + "function":{"name":"f","version":"fv"}, + "inputs":[], + "output":{"kind":"scalar","arrow_type":"int64","nullable":false,"assignment":"cell_flag"}, + "group_id":"fg" + }"#, + ) + .unwrap(); + let err = plan_function_application( + &function_input_schema(), + &nested_future_application, + Some("out"), + ) + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + + let mixed_schema = ArrowSchema::new_with_metadata( + function_input_schema().fields().to_vec(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + r#"{"version":2,"bindings":[]}"#.to_string(), + )]), + ); + let err = plan_function_application(&mixed_schema, &named_struct_application("{}"), None) + .unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + #[test] + fn test_function_inputs_use_paths_and_cannot_be_computed() { + let mut schema = function_input_schema(); + let plan = + plan_function_application(&schema, &named_struct_application("{}"), None).unwrap(); + assert_eq!(plan.input_bindings[0].field_path, "title"); + assert_eq!(plan.input_bindings[1].field_path, "body"); + + let title = schema + .field(0) + .as_ref() + .clone() + .with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), "title".to_string()), + ])); + schema = ArrowSchema::new(vec![title, schema.field(1).as_ref().clone()]); + let err = + plan_function_application(&schema, &named_struct_application("{}"), None).unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) + ); + } +} diff --git a/rust/lancedb/src/table/datafusion/insert.rs b/rust/lancedb/src/table/datafusion/insert.rs index e176c228b..b9bd2396e 100644 --- a/rust/lancedb/src/table/datafusion/insert.rs +++ b/rust/lancedb/src/table/datafusion/insert.rs @@ -17,7 +17,7 @@ use datafusion_physical_plan::stream::RecordBatchStreamAdapter; use datafusion_physical_plan::{ DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties, }; -use futures::TryStreamExt; +use futures::StreamExt; use lance::Dataset; use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::{CommitBuilder, InsertBuilder, WriteParams, WriteProgressFn}; @@ -194,12 +194,23 @@ impl ExecutionPlan for InsertExec { let output_bytes = MetricBuilder::new(&self.metrics).output_bytes(partition); let input_schema = input_stream.schema(); + let declared: Vec = crate::table::computed_columns::computed_columns( + &arrow_schema::Schema::from(self.dataset.schema()), + ) + .into_iter() + .map(|declaration| declaration.name) + .collect(); let input_stream: SendableRecordBatchStream = Box::pin(InstrumentedRecordBatchStreamAdapter::new( input_schema, - input_stream.map_ok(move |batch| { + input_stream.map(move |batch| { + let batch = batch?; + crate::table::computed_columns::ensure_batch_writes_no_computed_values( + &declared, &batch, + ) + .map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?; output_bytes.add(batch.get_array_memory_size()); - batch + Ok(batch) }), partition, &self.metrics, 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 df378c999..14c8acfa7 100644 --- a/rust/lancedb/src/table/merge.rs +++ b/rust/lancedb/src/table/merge.rs @@ -257,6 +257,10 @@ pub(crate) async fn execute_merge_insert( new_data: Box, ) -> Result { params.canonicalize_filters()?; + super::computed_columns::ensure_no_function_bindings_for_mutation( + table.schema().await?.as_ref(), + "merge_insert", + )?; match lsm::lsm_dispatch_decision(table, ¶ms).await? { lsm::LsmDispatch::Lsm(plan) => { let future = @@ -339,7 +343,10 @@ pub(crate) async fn execute_merge_insert( #[cfg(test)] mod tests { - use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, RecordBatchReader}; + use arrow_array::builder::FixedSizeBinaryBuilder; + use arrow_array::{ + Int32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, StringArray, UInt64Array, + }; use arrow_schema::{DataType, Field, Schema}; use std::sync::Arc; @@ -361,6 +368,42 @@ mod tests { Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema)) } + fn fixed_size_binary_merge_batch( + id_range: std::ops::Range, + price: u64, + ) -> Box { + let ids = id_range.collect::>(); + let mut id_builder = FixedSizeBinaryBuilder::new(16); + for id in &ids { + let mut bytes = [0; 16]; + bytes[..8].copy_from_slice(&id.to_le_bytes()); + id_builder.append_value(bytes).unwrap(); + } + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::FixedSizeBinary(16), false), + Field::new("id_as_int", DataType::UInt64, false), + Field::new("name", DataType::Utf8, false), + Field::new("market", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(id_builder.finish()), + Arc::new(UInt64Array::from_iter_values(ids.iter().copied())), + Arc::new(StringArray::from_iter_values( + ids.iter().map(|id| format!("name{id}")), + )), + Arc::new(StringArray::from_iter_values(std::iter::repeat_n( + format!("market_{price}"), + ids.len(), + ))), + ], + ) + .unwrap(); + Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema)) + } + #[tokio::test] async fn test_merge_insert() { let conn = connect("memory://").execute().await.unwrap(); @@ -412,6 +455,36 @@ mod tests { ); } + #[tokio::test] + async fn test_merge_insert_fixed_size_binary_non_nullable() { + // Regression test for #2869: an unrelated FixedSizeBinary column used to corrupt the + // outer join that implements when_not_matched_by_source_delete. + let conn = connect("memory://").execute().await.unwrap(); + let table = conn + .create_table( + "fixed_size_binary_merge", + fixed_size_binary_merge_batch(0..256, 100), + ) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id_as_int"]); + merge_insert + .when_matched_update_all(None) + .when_not_matched_insert_all() + .when_not_matched_by_source_delete(None); + let result = merge_insert + .execute(fixed_size_binary_merge_batch(100..356, 200)) + .await + .unwrap(); + + assert_eq!(result.num_updated_rows, 156); + assert_eq!(result.num_inserted_rows, 100); + assert_eq!(result.num_deleted_rows, 100); + assert_eq!(table.count_rows(None).await.unwrap(), 256); + } + #[tokio::test] async fn test_merge_insert_use_index() { let conn = connect("memory://").execute().await.unwrap(); @@ -1116,7 +1189,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(); @@ -1209,7 +1282,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..5751cd916 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,53 @@ 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? + }; + + table.checkout_latest().await?; let mut dataset = (*table.dataset.get().await?).clone(); + let schema = arrow_schema::Schema::from(dataset.schema()); + if !crate::table::computed_columns::computed_columns(&schema).is_empty() { + return Err(Error::NotSupported { + message: "an LSM write spec cannot be installed on a table with computed \ + columns: rows in un-compacted tiers are invisible to refresh" + .into(), + }); + } 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 +140,88 @@ 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(", ")) +} + +// ============================================================================= +// require_mem_wal_index_catchup +// ============================================================================= + +/// Switch this table to required index catch-up, one way. +/// +/// Deliberately **not** part of installing the write spec. Until something can +/// actually repair coverage, a table carrying the bit reports every index as +/// not known to hold the compacted rows, so its SSTables are retained +/// indefinitely -- and the WAL pod trims on the legacy rule meanwhile, leaving +/// readers pointed at files that are gone. Turn this on only once remote +/// maintenance owns the merge and the repair for the table. +/// +/// Lance refuses the activation if the table already records SSTable +/// compaction progress: those numbers predate this protocol and cannot be +/// validated, so such a table must be drained rather than activated. +#[allow(clippy::redundant_pub_crate)] +pub(crate) async fn require_mem_wal_index_catchup(table: &NativeTable) -> Result<()> { + table.dataset.ensure_mutable()?; + let mut dataset = (*table.dataset.get().await?).clone(); + if dataset.mem_wal_index_details().await?.is_none() { + return Err(Error::InvalidInput { + message: "require_mem_wal_index_catchup: no LSM write spec is set on this table".into(), + }); + } + dataset.require_mem_wal_index_catchup().await?; + table.dataset.update(dataset); + Ok(()) +} + // ============================================================================= // unset_lsm_write_spec // ============================================================================= diff --git a/rust/lancedb/src/table/optimize.rs b/rust/lancedb/src/table/optimize.rs index e29445b2d..4ad58cffb 100644 --- a/rust/lancedb/src/table/optimize.rs +++ b/rust/lancedb/src/table/optimize.rs @@ -214,12 +214,17 @@ pub(crate) async fn execute_optimize( #[cfg(test)] mod tests { - use arrow_array::{Int32Array, RecordBatch, StringArray}; + use arrow_array::{ + Array, FixedSizeListArray, Float32Array, Int32Array, RecordBatch, StringArray, + }; use arrow_schema::{DataType, Field, Schema}; + use lance_arrow::FixedSizeListArrayExt; use rstest::rstest; use std::sync::Arc; use crate::connect; + use crate::database::listing::OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS; + use crate::index::vector::IvfRqIndexBuilder; use crate::index::{Index, scalar::BTreeIndexBuilder}; use crate::query::ExecutableQuery; use crate::table::{CompactionOptions, OptimizeAction, OptimizeStats}; @@ -304,6 +309,96 @@ mod tests { assert_eq!(all_values, expected); } + #[tokio::test] + async fn test_compact_with_concurrent_add() { + const NUM_FRAGMENTS: usize = 5; + const ROWS_PER_FRAGMENT: i32 = 300; + + let tmpdir = tempfile::tempdir().unwrap(); + let conn = connect(tmpdir.path().to_str().unwrap()) + .execute() + .await + .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_iter_values(0..ROWS_PER_FRAGMENT))], + ) + .unwrap(); + + let table = conn + .create_table("test_concurrent_compact", batch.clone()) + .execute() + .await + .unwrap(); + table + .create_index(&["id"], Index::BTree(BTreeIndexBuilder::default())) + .execute() + .await + .unwrap(); + for _ in 0..NUM_FRAGMENTS { + table.add(batch.clone()).execute().await.unwrap(); + } + + // Use separate handles so the two writes actually overlap, as they can + // when different Node connections operate on the same S3 table. + let compact_table = conn + .open_table("test_concurrent_compact") + .execute() + .await + .unwrap(); + let append_table = conn + .open_table("test_concurrent_compact") + .execute() + .await + .unwrap(); + let compact_task = tokio::spawn(async move { + compact_table + .optimize(OptimizeAction::Compact { + options: CompactionOptions { + target_rows_per_fragment: 1_000, + ..Default::default() + }, + remap_options: None, + }) + .await + }); + tokio::task::yield_now().await; + for _ in 0..NUM_FRAGMENTS { + append_table.add(batch.clone()).execute().await.unwrap(); + } + compact_task.await.unwrap().unwrap(); + + let table = conn + .open_table("test_concurrent_compact") + .execute() + .await + .unwrap(); + let dataset = table.dataset().unwrap().get().await.unwrap(); + let fragment_ids = dataset + .get_fragments() + .iter() + .map(|fragment| fragment.id()) + .collect::>(); + assert!(fragment_ids.windows(2).all(|ids| ids[0] < ids[1])); + + // A second compaction exposed the original out-of-order row-id bug. + table + .optimize(OptimizeAction::Compact { + options: CompactionOptions { + target_rows_per_fragment: 1_000, + ..Default::default() + }, + remap_options: None, + }) + .await + .unwrap(); + assert_eq!( + table.count_rows(None).await.unwrap(), + ROWS_PER_FRAGMENT as usize * (NUM_FRAGMENTS * 2 + 1) + ); + } + #[tokio::test] async fn test_optimize_prune_versions() { let conn = connect("memory://").execute().await.unwrap(); @@ -442,6 +537,58 @@ mod tests { assert_eq!(final_row_count, 200); } + #[tokio::test] + async fn test_optimize_vector_index_after_delete_with_stable_row_ids() { + const NUM_ROWS: i32 = 400; + const DIMENSION: i32 = 32; + + let conn = connect("memory://").execute().await.unwrap(); + let vectors = FixedSizeListArray::try_new_from_values( + Float32Array::from_iter_values((0..NUM_ROWS).flat_map(|id| { + (0..DIMENSION).map(move |offset| ((id as f32 * 0.1) + (offset as f32 * 0.3)).sin()) + })), + DIMENSION, + ) + .unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("vector", vectors.data_type().clone(), false), + ])); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(Int32Array::from_iter_values(0..NUM_ROWS)), + Arc::new(vectors), + ], + ) + .unwrap(); + let table = conn + .create_table("test_vector_index_optimize_after_delete", batch) + .storage_option(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true") + .execute() + .await + .unwrap(); + + table + .create_index( + &["vector"], + Index::IvfRq(IvfRqIndexBuilder::default().num_partitions(4)), + ) + .execute() + .await + .unwrap(); + table.delete("id % 3 = 0").await.unwrap(); + + // Regression test for #3330: deleted stable row IDs used to become + // misaligned with row addresses while joining small IVF partitions. + table + .optimize(OptimizeAction::Index(Default::default())) + .await + .unwrap(); + + assert_eq!(table.count_rows(None).await.unwrap(), 266); + } + #[tokio::test] async fn test_optimize_all() { let conn = connect("memory://").execute().await.unwrap(); diff --git a/rust/lancedb/src/table/query/lsm.rs b/rust/lancedb/src/table/query/lsm.rs index 074d13476..6155ec095 100644 --- a/rust/lancedb/src/table/query/lsm.rs +++ b/rust/lancedb/src/table/query/lsm.rs @@ -36,6 +36,7 @@ use lance::dataset::mem_wal::{ DatasetMemWalExt, LsmScanner, ShardManifestStore, ShardSnapshot, ShardWriterConfig, }; use lance_index::mem_wal::{MemWalIndexDetails, ShardManifest}; +use lance_table::feature_flags::FLAG_MEM_WAL_INDEX_CATCHUP; use uuid::Uuid; use super::NativeTable; @@ -84,9 +85,8 @@ pub(super) async fn create_lsm_plan( let pk_columns = pk_columns(&ds_ref)?; // The base index an indexed arm relies on may lag compaction; resolve it so the // snapshot retains SSTables the index has not yet caught up to. - let arm_index = arm_maintained_index_name(&ds_ref, &query, &details).await?; - let (snapshots, in_memory) = - build_read_context(table, &ds_ref, &details, arm_index.as_deref()).await?; + let arm_indexes = arm_maintained_index_names(&ds_ref, &query, &details).await?; + let (snapshots, in_memory) = build_read_context(table, &ds_ref, &details, &arm_indexes).await?; let limit = query.base.limit; let offset = query.base.offset; @@ -232,28 +232,44 @@ fn pk_columns(dataset: &Dataset) -> Result> { Ok(pk) } -/// Per-shard SSTable exclusion watermark: the generation at or below which SSTables -/// are safe to drop for this arm. A generation is droppable only once it is -/// compacted into the base table AND covered by `index_name`'s catch-up (for an -/// indexed arm); a plain scan (`index_name == None`) uses the compaction watermark -/// alone. Capping at the index catch-up keeps rows the base index has not yet -/// indexed visible through their SSTable. First occurrence per shard mirrors Lance's -/// `compacted_generation_for_shard`. +/// Per-shard SSTable exclusion watermark: the generation at or below which +/// SSTables are safe to drop for this query. +/// +/// A generation is droppable only once it is compacted into the base table AND +/// covered by the catch-up of every index the query relies on, so the watermark +/// is the minimum across `index_names`. Gating on fewer than all of them would +/// drop SSTables holding rows an uncounted index has not yet indexed, and that +/// arm would silently return fewer rows. +/// +/// See [`arm_maintained_index_names`] for which indexes are collected today: a +/// vector search with a scalar prefilter is not yet among them. +/// +/// An empty `index_names` (a plain scan) uses the compaction watermark alone. +/// First occurrence per shard mirrors Lance's `compacted_generation_for_shard`. fn exclusion_watermarks( details: &MemWalIndexDetails, - index_name: Option<&str>, + index_names: &[String], + catchup_required: bool, ) -> HashMap { let mut exclude: HashMap = HashMap::new(); for entry in &details.compacted_sstables { let mut watermark = entry.generation; - if let Some(name) = index_name - && let Some(caught_up) = details + for name in index_names { + match details .index_catchup .iter() - .find(|icp| icp.index_name == name) + .find(|icp| icp.index_name == *name) .and_then(|icp| icp.caught_up_generation_for_shard(&entry.shard_id)) - { - watermark = watermark.min(caught_up); + { + Some(caught_up) => watermark = watermark.min(caught_up), + // No entry. On a table that requires catch-up this means the + // index is *not* known to hold these rows, and the base arm is + // index-only -- so every generation stays readable from its + // SSTable. Without the bit the field is not maintained at all, + // and absence carries no information. + None if catchup_required => watermark = 0, + None => {} + } } exclude.entry(entry.shard_id).or_insert(watermark); } @@ -267,13 +283,26 @@ fn exclusion_watermarks( /// with a live cached `ShardWriter` (this session's in-flight writes) the /// writer's authoritative in-memory manifest and memtables override the /// on-disk view so a read sees data not yet flushed. +/// Whether this table reads a missing `index_catchup` entry as "not caught up". +/// +/// Both words must be set. A reader honouring the bit while a writer does not +/// would retain SSTables the writer had already trimmed, and the reverse would +/// serve rows from files the writer still expects to be excluded -- so a +/// half-set manifest is treated as legacy, which is the conservative side. +fn requires_index_catchup(dataset: &Dataset) -> bool { + let manifest = dataset.manifest(); + manifest.reader_feature_flags & FLAG_MEM_WAL_INDEX_CATCHUP != 0 + && manifest.writer_feature_flags & FLAG_MEM_WAL_INDEX_CATCHUP != 0 +} + async fn build_read_context( table: &NativeTable, dataset: &Dataset, details: &MemWalIndexDetails, - index_name: Option<&str>, + index_names: &[String], ) -> Result<(Vec, HashMap)> { - let exclude = exclusion_watermarks(details, index_name); + let catchup_required = requires_index_catchup(dataset); + let exclude = exclusion_watermarks(details, index_names, catchup_required); let shard_ids = dataset.list_mem_wal_latest_shard_ids().await?; // Use the dataset's own object store (not `ObjectStore::from_uri`, which @@ -487,19 +516,33 @@ async fn index_maintained( })) } -/// The maintained base index the query's arm relies on (vector index for ANN, FTS -/// index for full-text), used to gate SSTable compaction exclusion by index catch-up. -/// `None` for a plain scan or when no maintained index covers the searched column. -async fn arm_maintained_index_name( +/// Every maintained base index this query relies on, used to gate SSTable +/// exclusion by index catch-up. +/// +/// Returns a list because the watermark must be the lowest across every index a +/// query relies on. Today it never holds more than one: `reject_unsupported` +/// refuses hybrid search, so the vector and full-text arms are mutually +/// exclusive. +/// +/// The case that is genuinely multi-index -- a vector search with a scalar or +/// bitmap prefilter -- is **not collected yet**. Identifying those needs the +/// planner's chosen indexes, not the columns the filter names, and no Lance API +/// exposes them. Until it does, such a query is gated on its vector index alone. +/// +/// Empty for a plain scan, or when no maintained index covers the searched +/// column. +async fn arm_maintained_index_names( dataset: &Dataset, query: &VectorQueryRequest, details: &MemWalIndexDetails, -) -> Result> { +) -> Result> { use lance::index::DatasetIndexExt; - // Resolve the arm's searched column, the index-detail type it relies on, and a + + // Each arm's searched column, the index-detail type it relies on, and a // label for diagnostics — catch-up is taken from the vector/FTS index // specifically, not a BTree on the same column. - let (column, type_url_suffix, arm) = if !query.query_vector.is_empty() { + let mut arms: Vec<(String, &str, &str)> = Vec::new(); + if !query.query_vector.is_empty() { let arrow_schema = ArrowSchema::from(dataset.schema()); let column = match &query.column { Some(column) => column.clone(), @@ -508,31 +551,43 @@ async fn arm_maintained_index_name( default_vector_column(&arrow_schema, dim)? } }; - (column, "VectorIndexDetails", "vector") - } else if let Some(fts) = &query.base.full_text_search { - match fts.columns().into_iter().next() { - Some(column) => (column, "InvertedIndexDetails", "full-text"), - None => return Ok(None), - } - } else { - return Ok(None); - }; - let Some(field) = dataset.schema().field(&column) else { - return Ok(None); - }; + arms.push((column, "VectorIndexDetails", "vector")); + } + if let Some(fts) = &query.base.full_text_search + && let Some(column) = fts.columns().into_iter().next() + { + arms.push((column, "InvertedIndexDetails", "full-text")); + } + if arms.is_empty() { + return Ok(Vec::new()); + } + let indices = dataset.load_indices().await?; - let segment_names: Vec = indices - .iter() - .filter(|idx| { - idx.fields.contains(&field.id) - && idx - .index_details - .as_ref() - .is_some_and(|d| d.type_url.ends_with(type_url_suffix)) - }) - .map(|idx| idx.name.clone()) - .collect(); - resolve_single_index(segment_names, &details.maintained_indexes, arm, &column) + let mut names = Vec::with_capacity(arms.len()); + for (column, type_url_suffix, arm) in arms { + let Some(field) = dataset.schema().field(&column) else { + continue; + }; + let segment_names: Vec = indices + .iter() + .filter(|idx| { + idx.fields.contains(&field.id) + && idx + .index_details + .as_ref() + .is_some_and(|d| d.type_url.ends_with(type_url_suffix)) + }) + .map(|idx| idx.name.clone()) + .collect(); + if let Some(name) = + resolve_single_index(segment_names, &details.maintained_indexes, arm, &column)? + { + names.push(name); + } + } + names.sort(); + names.dedup(); + Ok(names) } /// Resolve the single logical index from the names of its matching physical @@ -734,22 +789,117 @@ mod tests { }; // Plain scan: drop every compacted generation (through 5). - assert_eq!(exclusion_watermarks(&details, None).get(&shard), Some(&5)); + assert_eq!( + exclusion_watermarks(&details, &[], false).get(&shard), + Some(&5) + ); // FTS arm with a lagging index: exclusion is capped at the index catch-up // (2), so SSTable generations 3..=5 are retained until the index covers // them — otherwise those documents would silently vanish from FTS results. assert_eq!( - exclusion_watermarks(&details, Some("fts_idx")).get(&shard), + exclusion_watermarks(&details, &["fts_idx".to_string()], false).get(&shard), Some(&2) ); // A caught-up index — or one untracked in index_catchup — falls back to the // compaction watermark. assert_eq!( - exclusion_watermarks(&details, Some("caught_up_idx")).get(&shard), + exclusion_watermarks(&details, &["caught_up_idx".to_string()], false).get(&shard), Some(&5) ); + + // The same missing entry, once the table requires catch-up: absence now + // means "not known to hold these rows", so nothing may be excluded and + // every generation stays readable from its SSTable. This is the whole + // point of the protocol -- an indexed query against a table whose index + // has not caught up must not silently lose rows. + assert_eq!( + exclusion_watermarks(&details, &["untracked_idx".to_string()], true).get(&shard), + Some(&0) + ); + + // A tracked index is unaffected by the mode: the recorded position is + // information either way, and it still caps the exclusion. + assert_eq!( + exclusion_watermarks(&details, &["fts_idx".to_string()], true).get(&shard), + Some(&2) + ); + + // One missing entry is enough to hold everything back, even alongside an + // index that has caught up. + let mixed = vec!["fts_idx".to_string(), "untracked_idx".to_string()]; + assert_eq!( + exclusion_watermarks(&details, &mixed, true).get(&shard), + Some(&0) + ); + } + + /// A hybrid search reads a vector and a full-text index, and either may lag. + /// Retaining to the lower of the two is what keeps both arms complete; + /// gating on one alone would drop SSTables the other has not indexed. + #[test] + fn exclusion_watermark_takes_the_minimum_across_every_index_used() { + let shard = Uuid::from_u128(1); + let details = MemWalIndexDetails { + compacted_sstables: vec![CompactedSsTable::new(shard, 9)], + index_catchup: vec![ + IndexCatchupProgress::new( + "vec_idx".to_string(), + vec![CompactedSsTable::new(shard, 7)], + ), + IndexCatchupProgress::new( + "fts_idx".to_string(), + vec![CompactedSsTable::new(shard, 4)], + ), + ], + maintained_indexes: vec!["vec_idx".to_string(), "fts_idx".to_string()], + ..Default::default() + }; + + // Each index alone stops at its own catch-up. + assert_eq!( + exclusion_watermarks(&details, &["vec_idx".to_string()], false).get(&shard), + Some(&7) + ); + assert_eq!( + exclusion_watermarks(&details, &["fts_idx".to_string()], false).get(&shard), + Some(&4) + ); + + // Used together, the lower one governs regardless of order. + let both = ["vec_idx".to_string(), "fts_idx".to_string()]; + assert_eq!( + exclusion_watermarks(&details, &both, false).get(&shard), + Some(&4) + ); + let reversed = ["fts_idx".to_string(), "vec_idx".to_string()]; + assert_eq!( + exclusion_watermarks(&details, &reversed, false).get(&shard), + Some(&4) + ); + } + + /// An index with no catch-up entry contributes no cap today, so a lagging + /// sibling must still govern rather than being widened by the untracked one. + #[test] + fn an_untracked_index_does_not_widen_a_lagging_sibling() { + let shard = Uuid::from_u128(1); + let details = MemWalIndexDetails { + compacted_sstables: vec![CompactedSsTable::new(shard, 9)], + index_catchup: vec![IndexCatchupProgress::new( + "fts_idx".to_string(), + vec![CompactedSsTable::new(shard, 4)], + )], + maintained_indexes: vec!["fts_idx".to_string(), "untracked_idx".to_string()], + ..Default::default() + }; + + let both = ["fts_idx".to_string(), "untracked_idx".to_string()]; + assert_eq!( + exclusion_watermarks(&details, &both, false).get(&shard), + Some(&4) + ); } #[test] diff --git a/rust/lancedb/src/table/refresh.rs b/rust/lancedb/src/table/refresh.rs new file mode 100644 index 000000000..e94f20f2b --- /dev/null +++ b/rust/lancedb/src/table/refresh.rs @@ -0,0 +1,957 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Filling computed columns. +//! +//! A row without a value gets one; a row that has one keeps it. Refresh is +//! therefore idempotent and does not observe input mutation -- once a row is +//! filled, changing what the expression reads leaves the stored result alone. +//! +//! Two passes per fragment. The first scans only the unfilled live rows and +//! evaluates the expression over them, which yields the exact fill count and +//! decides whether the fragment is staged at all -- a fragment where nothing +//! would change stages nothing, which is what lets an expression yielding +//! null settle instead of restaging forever. The second streams the +//! fragment's physical rows into `write_columns` a batch at a time, so peak +//! memory is bounded by a scan batch. The expression is evaluated by this +//! module, never through a projection alias, and only over rows being +//! filled: every other row -- deleted, or already holding a value -- has its +//! inputs masked to null first, so a poison value in a row nobody is filling +//! cannot fail the refresh. + +use std::sync::Arc; + +use arrow_array::{ArrayRef, BooleanArray, RecordBatch, RecordBatchOptions}; +use arrow_schema::Schema as ArrowSchema; +use datafusion_expr::ColumnarValue; +use futures::{Stream, StreamExt, TryStreamExt}; +use lance::Dataset; +use lance::dataset::WriteDestination; +use lance::dataset::fragment::FileFragment; +use lance::dataset::transaction::Operation; +use lance_core::ROW_ID; +use lance_core::datatypes::Schema as LanceSchema; +use serde::{Deserialize, Serialize}; + +use super::computed_columns::{BoundExpression, ComputedColumnKind, computed_column_from_field}; +use super::{BaseTable, NativeTable}; +use crate::job::Job; +use crate::{Error, Result}; + +/// The result of refreshing a computed column. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RefreshColumnResult { + /// Rows that had a value computed. + #[serde(default)] + pub rows_filled: u64, + /// The commit version associated with the operation. + #[serde(default)] + pub version: u64, +} + +/// Internal implementation of the refresh logic. +pub(crate) async fn execute_refresh_column( + table: &NativeTable, + column: &str, +) -> Result { + table.dataset.ensure_mutable()?; + ensure_no_lsm_write_spec(table).await?; + let dataset = table.dataset.get().await?; + + let expression = declared_expression(&dataset, column)?; + let schema = Arc::new(ArrowSchema::from(dataset.schema())); + let bound = Arc::new(super::computed_columns::bind(schema, column, &expression)?); + let field = dataset + .schema() + .field(column) + .ok_or_else(|| Error::ColumnNotFound { + name: column.to_string(), + })?; + // The dataset's own field, so the identity write_columns checks against the + // manifest holds by construction. + let column_schema = LanceSchema { + fields: vec![field.clone()], + metadata: Default::default(), + }; + + let mut rows_filled = 0u64; + let mut replacements = Vec::new(); + for fragment in dataset.get_fragments() { + let gained = count_fragment_gains(&dataset, &fragment, &bound, column).await?; + if gained == 0 { + continue; + } + rows_filled += gained; + let values = fill_stream(&dataset, &fragment, bound.clone(), column).await?; + replacements.push(fragment.write_columns(values, &column_schema).await?); + } + + if replacements.is_empty() { + return Ok(RefreshColumnResult { + rows_filled: 0, + version: dataset.version().version, + }); + } + + let read_version = dataset.version().version; + // The dataset's own session, so registrations and caches survive the + // commit being installed on the handle. + let session = dataset.session(); + let new_dataset = Dataset::commit( + WriteDestination::Dataset(dataset.clone()), + Operation::DataReplacement { replacements }, + Some(read_version), + None, + None, + session, + false, + ) + .await?; + + let version = new_dataset.version().version; + table.dataset.update(new_dataset); + Ok(RefreshColumnResult { + rows_filled, + version, + }) +} + +/// Run the refresh as a [`Job`] in this process. +pub(crate) async fn execute_refresh_column_async(table: &NativeTable, column: &str) -> Result { + // Validate before spawning so bad input is reported by this call rather + // than only by the job. + table.dataset.ensure_mutable()?; + ensure_no_lsm_write_spec(table).await?; + let dataset = table.dataset.get().await?; + declared_expression(&dataset, column)?; + drop(dataset); + + let table = table.clone(); + let column = column.to_string(); + Ok(Job::spawned(tokio::spawn(async move { + execute_refresh_column(&table, &column).await?; + table.bump_freshness(); + Ok(()) + }))) +} + +/// Refuse to refresh under an LSM write spec. +/// +/// Refresh enumerates base fragments, and a write spec keeps visible rows in +/// un-compacted MemWAL tiers it cannot reach -- success would silently omit +/// readable rows. +async fn ensure_no_lsm_write_spec(table: &NativeTable) -> Result<()> { + // The catch-up flag outlives unset and marks retained SSTable rows. + let catchup = table.dataset.get().await?.manifest().reader_feature_flags + & lance_table::feature_flags::FLAG_MEM_WAL_INDEX_CATCHUP + != 0; + if catchup || table.get_lsm_write_spec().await?.is_some() { + return Err(Error::NotSupported { + message: "refresh_column is not supported on a table with an LSM write \ + spec: rows in un-compacted tiers are invisible to refresh" + .into(), + }); + } + Ok(()) +} + +/// The SQL expression `column` is declared with. +fn declared_expression(dataset: &Dataset, column: &str) -> Result { + let schema = ArrowSchema::from(dataset.schema()); + let field = schema + .field_with_name(column) + .map_err(|_| Error::ColumnNotFound { + name: column.to_string(), + })?; + let declaration = + computed_column_from_field(field).ok_or_else(|| Error::NotAComputedColumn { + name: column.to_string(), + })?; + match declaration.kind { + ComputedColumnKind::Sql { expression } => Ok(expression), + ComputedColumnKind::Function { .. } => Err(Error::NotSupported { + message: "registered Function columns are refreshed only by a remote server Job".into(), + }), + ComputedColumnKind::Unrecognized { kind } => Err(Error::NotSupported { + message: format!( + "computed column '{column}' is defined by '{kind}', which this version of \ + lancedb cannot evaluate" + ), + }), + } +} + +/// Quote `name` as a lance SQL identifier. +/// +/// Lance's dialect delimits with backticks, so a double-quoted name would +/// parse as a string literal rather than a column. +fn quote_identifier(name: &str) -> String { + format!("`{}`", name.replace('`', "``")) +} + +/// Assemble the batch evaluation runs against: the bound roots, in read-schema +/// order. Built by name so scan-side column order never matters. +fn evaluation_batch( + batch: &RecordBatch, + bound: &BoundExpression, + mask_out: Option<&BooleanArray>, +) -> lance_core::Result { + let mut columns = Vec::with_capacity(bound.roots.len()); + for name in &bound.roots { + let column = batch.column_by_name(name).ok_or_else(|| { + lance_core::Error::invalid_input(format!( + "refreshing a computed column read no {name} column" + )) + })?; + // Rows outside the mask must not reach the expression: a value in a + // deleted or already-filled row can be one it would choke on. + columns.push(match mask_out { + Some(mask) => arrow::compute::nullif(column, mask)?, + None => column.clone(), + }); + } + Ok(RecordBatch::try_new_with_options( + bound.read_schema.clone(), + columns, + &RecordBatchOptions::new().with_row_count(Some(batch.num_rows())), + )?) +} + +/// Evaluate the expression over `batch`, materializing a constant result to +/// the batch's length. +fn evaluate(bound: &BoundExpression, batch: &RecordBatch) -> lance_core::Result { + let value = bound + .physical + .evaluate(batch) + .map_err(lance_core::Error::from)?; + match value { + ColumnarValue::Array(array) => Ok(array), + scalar => scalar + .into_array(batch.num_rows()) + .map_err(lance_core::Error::from), + } +} + +/// How many rows of one fragment would gain a value. +/// +/// Scans only the unfilled live rows -- deleted rows never reach the +/// expression here, the filter having already excluded them -- and counts the +/// non-null results. Exact, so it is both the staging decision and the +/// fragment's contribution to `rows_filled`. +async fn count_fragment_gains( + dataset: &Dataset, + fragment: &FileFragment, + bound: &BoundExpression, + column: &str, +) -> Result { + let mut scanner = dataset.scan(); + scanner + .with_fragments(vec![fragment.metadata().clone()]) + .with_row_id() + .filter(&format!("{} IS NULL", quote_identifier(column)))? + .project(&bound.roots)?; + + let mut gained = 0u64; + let mut batches = scanner.try_into_stream().await?; + while let Some(batch) = batches.try_next().await? { + let evaluated = evaluate(bound, &evaluation_batch(&batch, bound, None)?)?; + gained += (batch.num_rows() - evaluated.null_count()) as u64; + } + Ok(gained) +} + +/// Stream one fragment's column in physical order, filling the unfilled live +/// rows and keeping every other value. +/// +/// Deleted rows are carried through so the values line up positionally with +/// the fragment's data files; they are never read back, but the column file +/// has to cover them. +async fn fill_stream( + dataset: &Dataset, + fragment: &FileFragment, + bound: Arc, + column: &str, +) -> Result> + Send + use<>> { + let mut projection: Vec = bound.roots.clone(); + projection.push(column.to_string()); + let mut scanner = dataset.scan(); + scanner + .with_fragments(vec![fragment.metadata().clone()]) + .with_row_id() + .include_deleted_rows() + .project(&projection)?; + + let projected = Arc::new(ArrowSchema::new(vec![ + ArrowSchema::from(dataset.schema()) + .field_with_name(column) + .map_err(|_| Error::ColumnNotFound { + name: column.to_string(), + })? + .clone(), + ])); + + let column = column.to_string(); + let batches = scanner.try_into_stream().await?; + Ok(batches.map(move |batch| { + let batch = batch?; + let missing = |name: &str| { + lance_core::Error::invalid_input(format!( + "refreshing a computed column read no {name} column" + )) + }; + let existing = batch + .column_by_name(&column) + .ok_or_else(|| missing(&column))?; + let row_ids = batch + .column_by_name(ROW_ID) + .ok_or_else(|| missing(ROW_ID))?; + + // Only an unfilled live row gains a value; a deleted row has a null + // row id and keeps its (null) slot. + let unfilled = arrow::compute::is_null(existing.as_ref())?; + let live = arrow::compute::is_not_null(row_ids.as_ref())?; + let fill = arrow::compute::and(&unfilled, &live)?; + let keep = arrow::compute::not(&fill)?; + + let computed = evaluate(&bound, &evaluation_batch(&batch, &bound, Some(&keep))?)?; + let merged = arrow_select::zip::zip(&fill, &computed, existing)?; + Ok(RecordBatch::try_new(projected.clone(), vec![merged])?) + })) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arrow_array::{Int32Array, record_batch}; + use futures::TryStreamExt; + + use crate::connect; + use crate::query::{ExecutableQuery, QueryBase, Select}; + use crate::{Error, Result, Table}; + + async fn table_with(name: &str, values: Vec) -> Table { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("x", Int32, values)).unwrap(); + conn.create_table(name, batch).execute().await.unwrap() + } + + async fn declare_doubled(table: &Table) -> Result { + Ok(table + .add_columns() + .computed("doubled", "x * 2") + .execute() + .await? + .version) + } + + async fn read(table: &Table, column: &str) -> Vec> { + let batches = table + .query() + .select(Select::columns(&[column])) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let mut values: Vec> = batches + .iter() + .flat_map(|batch| { + batch[column] + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .collect::>() + }) + .collect(); + values.sort(); + values + } + + async fn append(table: &Table, values: Vec) { + let batch = record_batch!(("x", Int32, values)).unwrap(); + table.add(batch).execute().await.unwrap(); + } + + #[tokio::test] + async fn test_refresh_fills_a_declared_column() { + let table = table_with("refresh_fills", vec![1, 2, 3]).await; + let declared = declare_doubled(&table).await.unwrap(); + assert_eq!(read(&table, "doubled").await, vec![None, None, None]); + + let result = table.refresh_column("doubled").await.unwrap(); + assert!(result.version > declared); + assert_eq!(result.rows_filled, 3); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(6)] + ); + } + + /// Values written after the last refresh must be reachable by another one. + #[tokio::test] + async fn test_refresh_fills_rows_appended_since_the_last_refresh() { + let table = table_with("refresh_appended", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + append(&table, vec![5, 6]).await; + assert_eq!( + read(&table, "doubled").await, + vec![None, None, Some(2), Some(4)] + ); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 2); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(10), Some(12)] + ); + } + + #[tokio::test] + async fn test_refresh_with_nothing_to_fill() { + let table = table_with("refresh_noop", vec![1, 2, 3]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + let again = table.refresh_column("doubled").await.unwrap(); + assert_eq!(again.rows_filled, 0); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(6)] + ); + } + + /// A row is filled only by gaining a value, so an expression yielding null + /// settles at once instead of re-selecting the same rows forever. Nothing + /// is staged, so the version does not move either. + #[tokio::test] + async fn test_refresh_converges_on_a_null_result() { + let table = table_with("refresh_null_result", vec![1, 2, 3]).await; + let declared = table + .add_columns() + .computed("maybe", "nullif(x, x)") + .execute() + .await + .unwrap() + .version; + + let first = table.refresh_column("maybe").await.unwrap(); + assert_eq!(first.rows_filled, 0); + assert_eq!(first.version, declared); + assert_eq!(read(&table, "maybe").await, vec![None, None, None]); + + let again = table.refresh_column("maybe").await.unwrap(); + assert_eq!(again.rows_filled, 0); + assert_eq!(again.version, declared); + } + + /// The contract's boundary: a filled fragment is not revisited, so + /// mutating an input leaves the value computed at fill time. + #[tokio::test] + async fn test_refresh_does_not_observe_input_mutation() { + let table = table_with("refresh_mutation", vec![1]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + assert_eq!(read(&table, "doubled").await, vec![Some(2)]); + + table.update().column("x", "3").execute().await.unwrap(); + + let again = table.refresh_column("doubled").await.unwrap(); + assert_eq!(again.rows_filled, 0); + assert_eq!(read(&table, "doubled").await, vec![Some(2)]); + } + + /// A row rewrite before the first refresh materializes the declared + /// column as null behind a covering data file. Those rows are still + /// unfilled and a later refresh has to reach them. + #[tokio::test] + async fn test_update_before_the_first_refresh() { + let table = table_with("refresh_update_first", vec![1]).await; + declare_doubled(&table).await.unwrap(); + + table.update().column("x", "3").execute().await.unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 1); + assert_eq!(read(&table, "doubled").await, vec![Some(6)]); + } + + /// The contract holds row by row, not fragment by fragment: revisiting a + /// fragment to fill one row must not recompute a filled row sitting beside + /// it, even where the input behind it has since changed. + #[tokio::test] + async fn test_refresh_does_not_recompute_a_filled_row_beside_an_unfilled_one() { + let table = table_with("refresh_mixed", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + append(&table, vec![5]).await; + table + .update() + .column("x", "100") + .only_if("x = 1") + .execute() + .await + .unwrap(); + table + .optimize(crate::table::OptimizeAction::Compact { + options: crate::table::CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 1); + // 2 is the mutated row keeping the value it was filled with, not 200. + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(10)] + ); + } + + /// Filling a fragment must not disturb the values it already holds, which + /// is what makes a compaction-mixed fragment safe to revisit. + #[tokio::test] + async fn test_refresh_preserves_already_filled_rows() { + let table = table_with("refresh_preserves", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + append(&table, vec![5]).await; + table + .optimize(crate::table::OptimizeAction::Compact { + options: crate::table::CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 1); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(10)] + ); + } + + #[tokio::test] + async fn test_refresh_leaves_deleted_rows_alone() { + let table = table_with("refresh_deleted", vec![1, 2, 3, 4]).await; + declare_doubled(&table).await.unwrap(); + table.delete("x = 2").await.unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 3); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(6), Some(8)] + ); + } + + #[tokio::test] + async fn test_refresh_a_constant_expression() { + let table = table_with("refresh_constant", vec![1, 2, 3]).await; + table + .add_columns() + .computed("answer", "42") + .execute() + .await + .unwrap(); + + let result = table.refresh_column("answer").await.unwrap(); + assert_eq!(result.rows_filled, 3); + } + + /// A name needing quotes reaches the evaluator intact: it is carried as a + /// projection alias, never spliced into SQL text. + #[tokio::test] + async fn test_refresh_a_column_whose_name_needs_quoting() { + let table = table_with("refresh_quoted", vec![1, 2, 3]).await; + table + .add_columns() + .computed("double value", "x * 2") + .execute() + .await + .unwrap(); + + let result = table.refresh_column("double value").await.unwrap(); + assert_eq!(result.rows_filled, 3); + assert_eq!( + read(&table, "double value").await, + vec![Some(2), Some(4), Some(6)] + ); + } + + /// A fragment spanning several scan batches exercises the streamed fill: + /// the probe buffers only until the first gained value and the rest flows + /// through write_column a batch at a time. + #[tokio::test] + async fn test_refresh_streams_a_multi_batch_fragment() { + let values: Vec = (0..20_000).collect(); + let table = table_with("refresh_multi_batch", values.clone()).await; + declare_doubled(&table).await.unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 20_000); + + let read_back = read(&table, "doubled").await; + assert_eq!(read_back.len(), 20_000); + let mut expected: Vec> = values.iter().map(|v| Some(v * 2)).collect(); + expected.sort(); + assert_eq!(read_back, expected); + } + + /// The gate's reproducer: the commit must reuse the configured session, + /// or registrations and caches vanish from the handle after a refresh. + #[tokio::test] + async fn test_refresh_preserves_the_configured_session() { + let session = Arc::new(lance::session::Session::default()); + let conn = crate::connect("memory://") + .session(session.clone()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, [1, 2])).unwrap(); + let table = conn + .create_table("session_kept", batch) + .execute() + .await + .unwrap(); + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + let dataset = table.as_native().unwrap().dataset.get().await.unwrap(); + assert!(Arc::ptr_eq(&dataset.session(), &session)); + } + + /// The async form's job settles with the fill visible, like + /// create_index's execute_async. + #[tokio::test] + async fn test_refresh_async_job_waits_for_the_fill() { + let table = table_with("refresh_async", vec![1, 2, 3]).await; + declare_doubled(&table).await.unwrap(); + + let job = table.refresh_column_async("doubled").await.unwrap(); + assert!(job.id().is_none(), "in-process jobs have no server id"); + job.wait().await.unwrap(); + assert_eq!(job.status().await.unwrap(), "finished"); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(6)] + ); + } + + /// Bad input is reported by the call, not by the job. + #[tokio::test] + async fn test_refresh_async_rejects_bad_input_before_spawning() { + let table = table_with("refresh_async_bad", vec![1, 2, 3]).await; + + let err = table.refresh_column_async("x").await.unwrap_err(); + assert!(matches!(err, Error::NotAComputedColumn { name } if name == "x")); + + let err = table.refresh_column_async("nope").await.unwrap_err(); + assert!(matches!(err, Error::ColumnNotFound { name } if name == "nope")); + } + + #[tokio::test] + async fn test_refresh_async_job_reports_success_to_every_waiter() { + let table = table_with("refresh_async_waiters", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + + let job = table.refresh_column_async("doubled").await.unwrap(); + job.wait().await.unwrap(); + // A second wait after completion observes the same outcome. + job.wait().await.unwrap(); + assert_eq!(job.status().await.unwrap(), "finished"); + } + + #[tokio::test] + async fn test_refresh_rejects_a_plain_column() { + let table = table_with("refresh_plain", vec![1, 2, 3]).await; + let err = table.refresh_column("x").await.unwrap_err(); + assert!(matches!(err, Error::NotAComputedColumn { name } if name == "x")); + } + + #[tokio::test] + async fn test_refresh_rejects_an_unknown_column() { + let table = table_with("refresh_missing", vec![1, 2, 3]).await; + let err = table.refresh_column("nope").await.unwrap_err(); + assert!(matches!(err, Error::ColumnNotFound { name } if name == "nope")); + } + + /// The gate's reproducer: a poison value in a deleted row must not + /// abort filling the live rows, since nobody can read it. + #[tokio::test] + async fn test_a_deleted_rows_value_is_never_evaluated() { + let table = table_with("refresh_deleted_poison", vec![1, 0]).await; + table + .add_columns() + .computed("quotient", "10 / x") + .execute() + .await + .unwrap(); + table.delete("x = 0").await.unwrap(); + + let result = table.refresh_column("quotient").await.unwrap(); + assert_eq!(result.rows_filled, 1); + assert_eq!(read(&table, "quotient").await, vec![Some(10)]); + } + + /// The gate's reproducer: an already-filled row's value must not be + /// re-evaluated either -- its input may have mutated into one the + /// expression chokes on. + #[tokio::test] + async fn test_a_filled_rows_value_is_never_evaluated() { + let table = table_with("refresh_filled_poison", vec![1, 2]).await; + table + .add_columns() + .computed("quotient", "10 / x") + .execute() + .await + .unwrap(); + table.refresh_column("quotient").await.unwrap(); + + table + .update() + .column("x", "0") + .only_if("x = 1") + .execute() + .await + .unwrap(); + append(&table, vec![5]).await; + + let result = table.refresh_column("quotient").await.unwrap(); + assert_eq!(result.rows_filled, 1); + assert_eq!( + read(&table, "quotient").await, + vec![Some(2), Some(5), Some(10)] + ); + } + + /// The gate's reproducer: the old internal projection alias is an + /// ordinary column name; a computed column may use it. + #[tokio::test] + async fn test_refresh_a_column_named_like_the_old_alias() { + let table = table_with("refresh_alias_name", vec![1, 2]).await; + table + .add_columns() + .computed("__lancedb_computed", "x * 2") + .execute() + .await + .unwrap(); + + let result = table.refresh_column("__lancedb_computed").await.unwrap(); + assert_eq!(result.rows_filled, 2); + assert_eq!( + read(&table, "__lancedb_computed").await, + vec![Some(2), Some(4)] + ); + } + + /// The gate's reproducer: a late-gain fragment (filled, then one null row + /// compacted onto the end) fills without the old probe's buffering, which + /// this pins behaviorally; the memory bound is structural -- the fill + /// stream retains no batches at all. + #[tokio::test] + async fn test_refresh_fills_a_late_gain_fragment() { + let values: Vec = (0..20_000).collect(); + let table = table_with("refresh_late_gain", values).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + append(&table, vec![2_000_000]).await; + table + .optimize(crate::table::OptimizeAction::Compact { + options: crate::table::CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 1); + let read_back = read(&table, "doubled").await; + assert_eq!(read_back.len(), 20_001); + assert_eq!(read_back.last().unwrap(), &Some(4_000_000)); + } + + /// The gate's reproducer: a nested input declares, refreshes, and guards + /// its root against invalidating schema changes. + #[tokio::test] + async fn test_a_nested_input_declares_and_refreshes() { + use arrow_array::{Int32Array, StructArray}; + use arrow_schema::{DataType, Field, Fields}; + + let conn = connect("memory://").execute().await.unwrap(); + let age = Arc::new(Int32Array::from(vec![30, 40])); + let fields = Fields::from(vec![Field::new("age", DataType::Int32, true)]); + let metadata = StructArray::new(fields.clone(), vec![age as _], None); + let schema = Arc::new(arrow_schema::Schema::new(vec![Field::new( + "metadata", + DataType::Struct(fields), + true, + )])); + let batch = + arrow_array::RecordBatch::try_new(schema, vec![Arc::new(metadata) as _]).unwrap(); + let table = conn + .create_table("refresh_nested", batch) + .execute() + .await + .unwrap(); + + table + .add_columns() + .computed("next_age", "metadata.age + 1") + .execute() + .await + .unwrap(); + let declaration = + &crate::table::computed_columns(table.schema().await.unwrap().as_ref())[0]; + assert_eq!(declaration.inputs, vec!["metadata.age".to_string()]); + + let result = table.refresh_column("next_age").await.unwrap(); + assert_eq!(result.rows_filled, 2); + assert_eq!(read(&table, "next_age").await, vec![Some(31), Some(41)]); + + // The dotted input guards its root. + let err = table.drop_columns(&["metadata"]).await.unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("next_age")), + "{err:?}" + ); + + // Masking a struct input for a deleted row goes through the same + // nullif path as a primitive; a nested input plus deletions must not + // be the combination that breaks it. + table.delete("next_age = 31").await.unwrap(); + append_struct_row(&table, 50).await; + let result = table.refresh_column("next_age").await.unwrap(); + assert_eq!(result.rows_filled, 1); + assert_eq!(read(&table, "next_age").await, vec![Some(41), Some(51)]); + } + + /// Append one `metadata: {age}` row to the nested-input table. + async fn append_struct_row(table: &Table, age: i32) { + use arrow_array::{Int32Array, StructArray}; + use arrow_schema::{DataType, Field, Fields}; + + let ages = Arc::new(Int32Array::from(vec![age])); + let fields = Fields::from(vec![Field::new("age", DataType::Int32, true)]); + let metadata = StructArray::new(fields.clone(), vec![ages as _], None); + let schema = Arc::new(arrow_schema::Schema::new(vec![Field::new( + "metadata", + DataType::Struct(fields), + true, + )])); + let batch = + arrow_array::RecordBatch::try_new(schema, vec![Arc::new(metadata) as _]).unwrap(); + table.add(batch).execute().await.unwrap(); + } + + /// Both orders of declare+spec are refused at the source (see the + /// schema_evolution tests); refresh's own check covers a dataset another + /// writer left in that state. + #[tokio::test] + async fn test_refresh_refuses_a_foreign_lsm_state() { + use crate::table::LsmWriteSpec; + + let tmp_dir = tempfile::tempdir().unwrap(); + let conn = connect(tmp_dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( + "x", + arrow_schema::DataType::Int32, + false, + )])); + let batch = + arrow_array::RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1]))]) + .unwrap(); + let table = conn.create_table("lsm", batch).execute().await.unwrap(); + table.set_unenforced_primary_key(["x"]).await.unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + super::super::computed_columns::add_foreign_kind(&table, "doubled", "sql").await; + + let err = table.refresh_column("doubled").await.unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } if message.contains("LSM")), + "{err:?}" + ); + let err = table.refresh_column_async("doubled").await.unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. })); + } + + /// After catch-up activation and unset, no spec remains but the catch-up + /// flag still marks retained SSTable rows; refresh refuses on the flag. + #[tokio::test] + async fn test_refresh_refuses_retained_catchup_state() { + use crate::table::LsmWriteSpec; + + let tmp_dir = tempfile::tempdir().unwrap(); + let conn = connect(tmp_dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( + "x", + arrow_schema::DataType::Int32, + false, + )])); + let batch = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1]))], + ) + .unwrap(); + let table = conn + .create_table("catchup", batch.clone()) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["x"]).await.unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + table.require_mem_wal_index_catchup().await.unwrap(); + let mut merge = table.merge_insert(&["x"]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all() + .use_lsm(true); + merge + .execute(Box::new(arrow_array::RecordBatchIterator::new( + vec![Ok(batch)], + schema, + ))) + .await + .unwrap(); + table.unset_lsm_write_spec().await.unwrap(); + super::super::computed_columns::add_foreign_kind(&table, "doubled", "sql").await; + + let err = table.refresh_column("doubled").await.unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } if message.contains("LSM")), + "{err:?}" + ); + } + + /// A declaration of a kind this version cannot evaluate is refused by + /// name, rather than mistaken for a plain column or fed to the SQL path. + #[tokio::test] + async fn test_refresh_rejects_a_kind_it_cannot_evaluate() { + let table = table_with("refresh_foreign", vec![1, 2, 3]).await; + super::super::computed_columns::add_foreign_kind(&table, "embedding", "udf").await; + + let err = table.refresh_column("embedding").await.unwrap_err(); + assert!(matches!(err, Error::NotSupported { message } if message.contains("udf"))); + } +} diff --git a/rust/lancedb/src/table/schema_evolution.rs b/rust/lancedb/src/table/schema_evolution.rs index ce208111a..4e41f0e85 100644 --- a/rust/lancedb/src/table/schema_evolution.rs +++ b/rust/lancedb/src/table/schema_evolution.rs @@ -8,12 +8,14 @@ //! - [`alter_columns`](execute_alter_columns): Rename columns, change types, or modify nullability //! - [`drop_columns`](execute_drop_columns): Remove columns from the table +use arrow_schema::Schema as ArrowSchema; use lance::dataset::{ColumnAlteration, NewColumnTransform}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::NativeTable; -use crate::Result; +use super::computed_columns; +use super::{BaseTable, NativeTable}; +use crate::{Error, Result}; /// The result of an add columns operation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -98,6 +100,56 @@ pub(crate) async fn execute_add_columns( table: &NativeTable, transforms: NewColumnTransform, read_columns: Option>, +) -> Result { + computed_columns::ensure_no_function_bindings_for_mutation( + table.schema().await?.as_ref(), + "schema evolution", + )?; + // Declarations are admitted only through [`execute_declare`]. + match &transforms { + NewColumnTransform::AllNulls(schema) => { + computed_columns::ensure_no_foreign_declarations(schema.fields())? + } + NewColumnTransform::BatchUDF(udf) => { + computed_columns::ensure_no_foreign_declarations(udf.output_schema.fields())? + } + _ => {} + } + commit_add_columns(table, transforms, read_columns).await +} + +/// Declare validated computed columns. The only admission path for +/// declaration metadata. +pub(crate) async fn execute_declare( + table: &NativeTable, + columns: &[(String, String)], +) -> Result { + // An LSM write spec keeps visible rows in tiers refresh cannot reach; + // checked against latest committed state, not this handle's snapshot. + // The catch-up flag outlives unset and marks retained SSTable rows. + table.checkout_latest().await?; + computed_columns::ensure_no_function_bindings_for_mutation( + table.schema().await?.as_ref(), + "schema evolution", + )?; + let catchup = table.dataset.get().await?.manifest().reader_feature_flags + & lance_table::feature_flags::FLAG_MEM_WAL_INDEX_CATCHUP + != 0; + if catchup || table.get_lsm_write_spec().await?.is_some() { + return Err(Error::NotSupported { + message: "computed columns are not supported on a table with an LSM write \ + spec: rows in un-compacted tiers are invisible to refresh" + .into(), + }); + } + let transform = computed_columns::declare(table.schema().await?, columns)?; + commit_add_columns(table, transform, None).await +} + +pub(crate) async fn commit_add_columns( + table: &NativeTable, + transforms: NewColumnTransform, + read_columns: Option>, ) -> Result { table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); @@ -116,6 +168,25 @@ pub(crate) async fn execute_alter_columns( ) -> Result { table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); + // Nullability is not part of what an expression resolves against, so only + // a rename or a retype can invalidate a binding. + let schema = std::sync::Arc::new(ArrowSchema::from(dataset.schema())); + computed_columns::ensure_no_function_bindings_for_mutation( + schema.as_ref(), + "schema evolution", + )?; + let rebinding = alterations + .iter() + .filter(|alteration| alteration.rename.is_some() || alteration.data_type.is_some()) + .map(|alteration| alteration.path.as_str()) + .collect::>(); + computed_columns::ensure_not_an_input(&schema, &rebinding)?; + let retyped = alterations + .iter() + .filter(|alteration| alteration.data_type.is_some()) + .map(|alteration| alteration.path.as_str()) + .collect::>(); + computed_columns::ensure_not_retyped(schema.as_ref(), &retyped)?; dataset.alter_columns(alterations).await?; let version = dataset.version().version; table.dataset.update(dataset); @@ -131,6 +202,14 @@ pub(crate) async fn execute_drop_columns( ) -> Result { table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); + computed_columns::ensure_no_function_bindings_for_mutation( + &ArrowSchema::from(dataset.schema()), + "schema evolution", + )?; + computed_columns::ensure_not_an_input( + &std::sync::Arc::new(ArrowSchema::from(dataset.schema())), + columns, + )?; dataset.drop_columns(columns).await?; let version = dataset.version().version; table.dataset.update(dataset); @@ -147,6 +226,45 @@ pub(crate) async fn execute_update_field_metadata( table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); + // A declaration is validated as a whole at declare time; editing its keys + // here would bypass that, fabricate one on a plain column, or move a + // binding out from under a refresh. A replace on a declared column would + // silently erase it. + let schema = ArrowSchema::from(dataset.schema()); + computed_columns::ensure_no_function_bindings_for_mutation(&schema, "schema evolution")?; + let declared: Vec = computed_columns::computed_columns(&schema) + .into_iter() + .map(|declaration| declaration.name) + .collect(); + for update in updates { + if update + .metadata + .keys() + .any(|key| computed_columns::is_declaration_key(key)) + { + return Err(Error::InvalidInput { + message: format!( + "metadata keys of a computed-column declaration cannot be edited \ + (path '{}'); drop the column and declare it again", + update.path + ), + }); + } + if update.replace + && declared + .iter() + .any(|name| name == computed_columns::root(&update.path)) + { + return Err(Error::InvalidInput { + message: format!( + "replacing all metadata of computed column '{}' would erase its \ + declaration; drop the column and declare it again", + update.path + ), + }); + } + } + let mut builder = dataset.update_field_metadata(); for update in updates { let entries = update.metadata.iter().map(|(k, v)| (k.clone(), v.clone())); diff --git a/rust/lancedb/src/table/update.rs b/rust/lancedb/src/table/update.rs index 9da040a1a..f10594f23 100644 --- a/rust/lancedb/src/table/update.rs +++ b/rust/lancedb/src/table/update.rs @@ -93,6 +93,14 @@ pub(crate) async fn execute_update( // 1. Snapshot the current dataset let dataset = table.dataset.get().await?; + super::computed_columns::ensure_no_function_bindings_for_mutation( + &arrow_schema::Schema::from(dataset.schema()), + "update", + )?; + super::computed_columns::ensure_not_written( + &arrow_schema::Schema::from(dataset.schema()), + update.columns.iter().map(|(name, _)| name.as_str()), + )?; // 2. Initialize the Lance Core builder let mut builder = LanceUpdateBuilder::new(dataset); diff --git a/rust/lancedb/tests/blob_integration.rs b/rust/lancedb/tests/blob_integration.rs index 3d327766e..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_encoding::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" ); diff --git a/rust/lancedb/tests/first_class_function_slice1.rs b/rust/lancedb/tests/first_class_function_slice1.rs new file mode 100644 index 000000000..dc565d309 --- /dev/null +++ b/rust/lancedb/tests/first_class_function_slice1.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::fs; +use std::path::PathBuf; + +use lancedb::function::{ + FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, +}; +use serde_json::Value; + +fn fixture(name: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/first_class_functions/v1") + .join(name); + fs::read_to_string(path).expect("fixture must be readable") +} + +fn job_result(name: &str) -> Value { + serde_json::from_str::(&fixture(name)).expect("remote Job fixture")["result"].clone() +} + +fn assert_no_secret_values(value: &Value) { + match value { + Value::Object(values) => { + for (key, value) in values { + assert!( + !matches!( + key.as_str(), + "secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets" + ), + "client canonical value must not model resolved secret material" + ); + assert_no_secret_values(value); + } + } + Value::Array(values) => values.iter().for_each(assert_no_secret_values), + _ => {} + } +} + +#[test] +fn function_version_job_result_matches_shared_canonical_golden() { + let result = job_result("remote_function_job.json"); + let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); + + assert_eq!(version.name(), "embed"); + assert_eq!(version.version(), "fv_01K3EXACT"); + assert_eq!(version.runtime_digest(), "sha256:runtime"); + assert_eq!(version.required_secrets(), &["HF_TOKEN"]); + assert_eq!( + version.to_canonical_json().expect("canonical JSON"), + fixture("remote_function_version.canonical.json").trim() + ); +} + +#[test] +fn version_identity_is_immutable_and_exact() { + let original = job_result("remote_function_job.json"); + let version = + FunctionVersion::from_json(&original.to_string()).expect("FunctionVersion result"); + let reopened = version.clone(); + assert_eq!(reopened, version); + assert_eq!(reopened.name(), version.name()); + assert_eq!(reopened.version(), version.version()); + + let mut changed = original; + changed["version"] = Value::String("fv_01K3DIFFERENT".to_string()); + let changed = FunctionVersion::from_json(&changed.to_string()).expect("changed version"); + assert_ne!(changed, version); +} + +#[test] +fn application_and_binding_match_shared_remote_goldens() { + let application = FunctionApplication::from_json(&fixture("remote_function_application.json")) + .expect("application fixture"); + assert_eq!(application.function().version, "fv_01K3TEXT"); + assert_eq!(application.output().kind, "named_struct"); + assert_eq!(application.inputs().len(), 2); + assert_eq!( + application.to_canonical_json().expect("canonical JSON"), + fixture("remote_function_application.canonical.json").trim() + ); + + let binding = FunctionBinding::from_json(&fixture("remote_function_binding.json")) + .expect("binding fixture"); + assert_eq!(binding.revision(), 3); + assert_eq!(binding.function().version, "fv_01K3TEXT"); + assert_eq!(binding.outputs()[0].output_ordinal, 0); + assert_eq!(binding.outputs()[1].output_ordinal, 1); + assert!(binding.input_schema().is_some()); + assert!(binding.output_schema().is_some()); + assert_eq!( + binding.to_canonical_json().expect("canonical JSON"), + fixture("remote_function_binding.canonical.json").trim() + ); +} + +#[test] +fn refresh_job_result_matches_shared_canonical_golden() { + let result = job_result("remote_refresh_job.json"); + let result = RefreshColumnResult::from_json(&result.to_string()).expect("refresh result"); + assert_eq!(result.rows_assigned, 999_998_800); + assert_eq!(result.rows_filled(), result.rows_assigned); + assert_eq!(result.version(), result.published_version); + assert_eq!( + result.to_canonical_json().expect("canonical JSON"), + fixture("remote_refresh_result.canonical.json").trim() + ); + + let result = RefreshColumnResult::from_json(&fixture( + "remote_refresh_result_without_published_version.json", + )) + .expect("optional version"); + assert_eq!(result.published_version, None); + assert_eq!( + result + .to_canonical_json() + .expect("canonical result without version"), + fixture("remote_refresh_result_without_published_version.canonical.json").trim() + ); + assert_eq!( + RefreshColumnResult::from_json( + &result + .to_canonical_json() + .expect("canonical result without version") + ) + .expect("round-trip result without version"), + result + ); +} + +#[test] +fn unknown_fields_and_discriminators_are_forward_decodable() { + let mut result = job_result("remote_function_job.json"); + result["future_version_metadata"] = serde_json::json!({"retention_class": "catalog"}); + result["runtime"] = serde_json::json!({ + "kind": "wasm", + "module_digest": "sha256:wasm" + }); + result["signature"]["output"]["kind"] = Value::String("future_output_shape".to_string()); + + let version = FunctionVersion::from_json(&result.to_string()).expect("future remote value"); + assert_eq!(version.runtime().kind(), "wasm"); + assert_eq!(version.runtime().python_version(), None); + assert_eq!(version.signature().output.kind, "future_output_shape"); + assert_eq!( + serde_json::from_str::( + &version.to_canonical_json().expect("canonical future value") + ) + .expect("canonical JSON")["runtime"], + serde_json::json!({"kind": "wasm"}) + ); +} + +#[test] +fn floating_point_application_literals_are_rejected_consistently() { + let error = FunctionApplication::from_json(&fixture("remote_function_application_float.json")) + .unwrap_err(); + assert!( + error + .to_string() + .contains("floating-point Function literals") + ); +} + +#[test] +fn canonical_client_values_contain_secret_names_only() { + let result = job_result("remote_function_job.json"); + let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); + let canonical: Value = serde_json::from_str( + &version + .to_canonical_json() + .expect("canonical FunctionVersion"), + ) + .expect("canonical JSON"); + + assert_eq!( + canonical["required_secrets"], + serde_json::json!(["HF_TOKEN"]) + ); + assert_no_secret_values(&canonical); +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json new file mode 100644 index 000000000..05da9fe39 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json @@ -0,0 +1 @@ +{"columns":{"normalized_text":"search_text","token_count":"search_token_count"},"function":{"name":"text_features","version":"fv_01K3TEXT"},"group_id":"fg_01K3TEXT","inputs":[{"kind":"column","parameter":"title","value":{"path":"title"}},{"kind":"column","parameter":"body","value":{"path":"body"}}],"output":{"fields":[{"arrow_type":"utf8","name":"normalized_text","nullable":false},{"arrow_type":"int64","name":"token_count","nullable":false}],"kind":"named_struct"}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json new file mode 100644 index 000000000..44aeff460 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json @@ -0,0 +1,20 @@ +{ + "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "inputs": [ + {"parameter": "title", "kind": "column", "value": {"path": "title"}}, + {"parameter": "body", "kind": "column", "value": {"path": "body"}} + ], + "output": { + "kind": "named_struct", + "fields": [ + {"name": "normalized_text", "arrow_type": "utf8", "nullable": false}, + {"name": "token_count", "arrow_type": "int64", "nullable": false} + ] + }, + "group_id": "fg_01K3TEXT", + "columns": { + "normalized_text": "search_text", + "token_count": "search_token_count" + }, + "future_application": {"declaration_mode": "managed"} +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json new file mode 100644 index 000000000..47724eee0 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json @@ -0,0 +1,8 @@ +{ + "function": {"name": "score", "version": "fv_01K3FLOAT"}, + "inputs": [ + {"parameter": "threshold", "kind": "literal", "value": 1e-7} + ], + "output": {"kind": "scalar", "arrow_type": "bool", "nullable": false}, + "group_id": "fg_01K3FLOAT" +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json new file mode 100644 index 000000000..7bf93b8a8 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json @@ -0,0 +1 @@ +{"binding_id":"fb_01K3TEXT","function":{"name":"text_features","version":"fv_01K3TEXT"},"group_id":"fg_01K3TEXT","input_schema":{"fields":[{"name":"title","nullable":true,"type":{"type":"utf8"}},{"name":"body","nullable":true,"type":{"type":"utf8"}}]},"inputs":[{"arrow_type":"utf8","field_id":11,"field_path":"title","nullable":true,"parameter":"title"},{"arrow_type":"utf8","field_id":12,"field_path":"body","nullable":true,"parameter":"body"}],"output_schema":{"fields":[{"name":"search_text","nullable":true,"type":{"type":"utf8"}},{"name":"search_token_count","nullable":true,"type":{"type":"int64"}}]},"outputs":[{"arrow_type":"utf8","nullable":false,"output_field_id":21,"output_name":"search_text","output_ordinal":0,"result_field":"normalized_text"},{"arrow_type":"int64","nullable":false,"output_field_id":22,"output_name":"search_token_count","output_ordinal":1,"result_field":"token_count"}],"revision":3} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json new file mode 100644 index 000000000..1a2053e42 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json @@ -0,0 +1,27 @@ +{ + "binding_id": "fb_01K3TEXT", + "revision": 3, + "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "group_id": "fg_01K3TEXT", + "inputs": [ + {"parameter": "title", "field_id": 11, "field_path": "title", "arrow_type": "utf8", "nullable": true}, + {"parameter": "body", "field_id": 12, "field_path": "body", "arrow_type": "utf8", "nullable": true} + ], + "outputs": [ + {"result_field": "normalized_text", "output_name": "search_text", "output_field_id": 21, "output_ordinal": 0, "arrow_type": "utf8", "nullable": false}, + {"result_field": "token_count", "output_name": "search_token_count", "output_field_id": 22, "output_ordinal": 1, "arrow_type": "int64", "nullable": false} + ], + "input_schema": { + "fields": [ + {"name": "title", "nullable": true, "type": {"type": "utf8"}}, + {"name": "body", "nullable": true, "type": {"type": "utf8"}} + ] + }, + "output_schema": { + "fields": [ + {"name": "search_text", "nullable": true, "type": {"type": "utf8"}}, + {"name": "search_token_count", "nullable": true, "type": {"type": "int64"}} + ] + }, + "future_binding": {"metadata_revision": 1} +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json new file mode 100644 index 000000000..6ba4eb226 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json @@ -0,0 +1,31 @@ +{ + "job_id": "job_function_01K3", + "job_type": "create_function", + "job_state": "DONE", + "creation_ms": 1787270400000, + "spec": {"name": "embed"}, + "result": { + "name": "embed", + "version": "fv_01K3EXACT", + "artifact": { + "kind": "python_callable", + "digest": "sha256:code", + "entrypoint": "embed" + }, + "signature": { + "inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}], + "output": {"kind": "scalar", "arrow_type": "list", "nullable": false} + }, + "runtime": { + "kind": "python", + "python_version": "3.12", + "environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]}, + "env": {"TOKENIZERS_PARALLELISM": "false"} + }, + "runtime_digest": "sha256:runtime", + "environment_digest": "sha256:environment", + "required_secrets": ["HF_TOKEN"], + "created_at": "2026-08-21T00:00:00Z" + }, + "future_job": {"trace_id": "trace-1"} +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json new file mode 100644 index 000000000..7ab632a98 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json @@ -0,0 +1 @@ +{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","required_secrets":["HF_TOKEN"],"runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_grouped_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_grouped_declaration_request.json new file mode 100644 index 000000000..d0b42cc99 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_grouped_declaration_request.json @@ -0,0 +1,45 @@ +{ + "new_columns": [ + {"name": "search_text", "all_null": true}, + {"name": "token_count", "all_null": true} + ], + "function": { + "application": { + "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "inputs": [ + {"parameter": "title", "kind": "column", "value": {"path": "title"}}, + {"parameter": "body", "kind": "column", "value": {"path": "body"}} + ], + "output": { + "kind": "named_struct", + "fields": [ + {"name": "normalized_text", "arrow_type": "utf8", "nullable": false}, + {"name": "token_count", "arrow_type": "int64", "nullable": false} + ] + }, + "group_id": "fg_01K3TEXT", + "columns": {"normalized_text": "search_text"} + }, + "binding_metadata_version": 1, + "input_bindings": [ + {"parameter": "title", "field_path": "title", "arrow_type": "utf8", "nullable": true}, + {"parameter": "body", "field_path": "body", "arrow_type": "utf8", "nullable": true} + ], + "input_schema": { + "fields": [ + {"name": "title", "nullable": true, "type": {"type": "utf8"}}, + {"name": "body", "nullable": true, "type": {"type": "utf8"}} + ] + }, + "output_schema": { + "fields": [ + {"name": "search_text", "nullable": true, "type": {"type": "utf8"}}, + {"name": "token_count", "nullable": true, "type": {"type": "int64"}} + ] + }, + "outputs": [ + {"result_field": "normalized_text", "output_name": "search_text", "output_ordinal": 0}, + {"result_field": "token_count", "output_name": "token_count", "output_ordinal": 1} + ] + } +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_job.json new file mode 100644 index 000000000..bb2490bc8 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_job.json @@ -0,0 +1,15 @@ +{ + "job_id": "job_refresh_01K3", + "job_type": "refresh_function_columns", + "job_state": "DONE", + "creation_ms": 1787270400001, + "spec": {"table": "documents", "binding_revision": 3}, + "result": { + "rows_assigned": 999998800, + "rows_failed": 0, + "rows_remaining": 0, + "source_version": 812, + "published_version": 919, + "future_result": {"committed_fragment_groups": 100} + } +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result.canonical.json new file mode 100644 index 000000000..cda9287a3 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result.canonical.json @@ -0,0 +1 @@ +{"published_version":919,"rows_assigned":999998800,"rows_failed":0,"rows_remaining":0,"source_version":812} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.canonical.json new file mode 100644 index 000000000..69c9c96c1 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.canonical.json @@ -0,0 +1 @@ +{"rows_assigned":120,"rows_failed":0,"rows_remaining":0,"source_version":812} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.json new file mode 100644 index 000000000..27231411e --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_refresh_result_without_published_version.json @@ -0,0 +1,6 @@ +{ + "rows_assigned": 120, + "rows_failed": 0, + "rows_remaining": 0, + "source_version": 812 +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json new file mode 100644 index 000000000..0aaa0cf72 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json @@ -0,0 +1,41 @@ +{ + "new_columns": [ + {"name": "embedding", "all_null": true} + ], + "function": { + "application": { + "function": {"name": "embed", "version": "fv_01K3EXACT"}, + "inputs": [ + {"parameter": "text", "kind": "column", "value": {"path": "description"}} + ], + "output": {"kind": "scalar", "arrow_type": "list", "nullable": false}, + "group_id": "fg_scalar" + }, + "binding_metadata_version": 1, + "input_bindings": [ + {"parameter": "text", "field_path": "description", "arrow_type": "utf8", "nullable": true} + ], + "input_schema": { + "fields": [ + {"name": "text", "nullable": true, "type": {"type": "utf8"}} + ] + }, + "output_schema": { + "fields": [ + { + "name": "embedding", + "nullable": true, + "type": { + "type": "list", + "fields": [ + {"name": "item", "nullable": false, "type": {"type": "float32"}} + ] + } + } + ] + }, + "outputs": [ + {"result_field": "$value", "output_name": "embedding", "output_ordinal": 0} + ] + } +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_unit_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_unit_job.json new file mode 100644 index 000000000..7f2b4c6de --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_unit_job.json @@ -0,0 +1,9 @@ +{ + "job_id": "job_index_01K3", + "job_type": "create_index", + "job_state": "DONE", + "creation_ms": 1787270400002, + "spec": {"column": "vector"}, + "result": {"future_information": "ignored by Job<()>"}, + "future_job": {"trace_id": "trace-2"} +}