diff --git a/.bumpversion.toml b/.bumpversion.toml index 454e0d1ca..57bcad5da 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.32.0-beta.2" +current_version = "0.37.0-beta.0" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. @@ -75,6 +75,13 @@ filename = "nodejs/Cargo.toml" replace = "\nversion = \"{new_version}\"" search = "\nversion = \"{current_version}\"" +# The Python package takes its version from here (pyproject.toml declares +# `dynamic = ["version"]`, so maturin reads it out of the crate manifest). +[[tool.bumpversion.files]] +filename = "python/Cargo.toml" +replace = "\nversion = \"{new_version}\"" +search = "\nversion = \"{current_version}\"" + # Java documentation [[tool.bumpversion.files]] filename = "docs/src/java/java.md" diff --git a/.github/workflows/cargo-publish.yml b/.github/workflows/cargo-publish.yml index 08f01d9a0..052fb1876 100644 --- a/.github/workflows/cargo-publish.yml +++ b/.github/workflows/cargo-publish.yml @@ -6,7 +6,6 @@ on: # We don't publish pre-releases for Rust. Crates.io is just a source # distribution, so we don't need to publish pre-releases. - "v*-beta*" - - "*-v*" # for example, python-vX.Y.Z env: # This env var is used by Swatinem/rust-cache@v2 for the cache diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml new file mode 100644 index 000000000..683d55d71 --- /dev/null +++ b/.github/workflows/gh-release.yml @@ -0,0 +1,85 @@ +name: GitHub Release + +# All SDKs share one version, so a single `vX.Y.Z` tag produces a single GitHub +# release covering all of them. The per-package publish workflows (PyPI, NPM, +# Cargo, Maven) trigger off the same tag independently. + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + gh-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + lfs: true + - name: Extract version + id: extract_version + env: + GITHUB_REF: ${{ github.ref }} + run: | + set -e + echo "Extracting tag and version from $GITHUB_REF" + if [[ $GITHUB_REF =~ refs/tags/v(.*) ]]; then + VERSION=${BASH_REMATCH[1]} + TAG=v$VERSION + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + else + echo "Failed to extract version from $GITHUB_REF" + exit 1 + fi + echo "Extracted version $VERSION from $GITHUB_REF" + if [[ $VERSION =~ beta ]]; then + echo "This is a beta release" + echo "prerelease=true" >> $GITHUB_OUTPUT + + # Get last release (that is not this one) + FROM_TAG=$(git tag --sort='version:refname' \ + | grep ^v \ + | grep -vF "$TAG" \ + | python ci/semver_sort.py v \ + | tail -n 1) + else + echo "This is a stable release" + echo "prerelease=false" >> $GITHUB_OUTPUT + # Get last stable tag (ignore betas) + FROM_TAG=$(git tag --sort='version:refname' \ + | grep ^v \ + | grep -vF "$TAG" \ + | grep -v beta \ + | python ci/semver_sort.py v \ + | tail -n 1) + fi + echo "Found from tag $FROM_TAG" + echo "from_tag=$FROM_TAG" >> $GITHUB_OUTPUT + - name: Create Release Notes + id: release_notes + uses: mikepenz/release-changelog-builder-action@v4 + with: + configuration: .github/release_notes.json + toTag: ${{ steps.extract_version.outputs.tag }} + fromTag: ${{ steps.extract_version.outputs.from_tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create GH release + uses: softprops/action-gh-release@v2 + with: + # Marking betas as pre-releases keeps them from taking the "Latest" + # badge on the releases page. + prerelease: ${{ steps.extract_version.outputs.prerelease }} + make_latest: ${{ steps.extract_version.outputs.prerelease == 'false' }} + tag_name: ${{ steps.extract_version.outputs.tag }} + token: ${{ secrets.GITHUB_TOKEN }} + generate_release_notes: false + name: LanceDB v${{ steps.extract_version.outputs.version }} + body: ${{ steps.release_notes.outputs.changelog }} diff --git a/.github/workflows/make-release-commit.yml b/.github/workflows/make-release-commit.yml index 538c7f486..2ab69ee32 100644 --- a/.github/workflows/make-release-commit.yml +++ b/.github/workflows/make-release-commit.yml @@ -1,13 +1,14 @@ name: Create release commit -# This workflow increments versions, tags the version, and pushes it. +# This workflow increments the version, tags it, and pushes it. All SDKs share +# a single version, so one tag releases all of them. # When a tag is pushed, another workflow is triggered that creates a GH release # and uploads the binaries. This workflow is only for creating the tag. # This script will enforce that a minor version is incremented if there are any -# breaking changes since the last minor increment. However, it isn't able to -# differentiate between breaking changes in Node versus Python. If you wish to -# bypass this check, you can manually increment the version and push the tag. +# breaking changes since the last minor increment. A breaking change in any SDK +# bumps the minor version for all of them. If you wish to bypass this check, you +# can manually increment the version and push the tag. on: workflow_dispatch: inputs: @@ -24,16 +25,6 @@ on: options: - preview - stable - python: - description: 'Make a Python release' - required: true - default: true - type: boolean - other: - description: 'Make a Node/Rust/Java release' - required: true - default: true - type: boolean bump-minor: description: 'Bump minor version' required: true @@ -65,25 +56,12 @@ jobs: run: | git config user.name 'Lance Release' git config user.email 'lance-dev@lancedb.com' - - name: Bump Python version - if: ${{ inputs.python }} - working-directory: python - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Need to get the commit before bumping the version, so we can - # determine if there are breaking changes in the next step as well. - echo "COMMIT_BEFORE_BUMP=$(git rev-parse HEAD)" >> $GITHUB_ENV - - pip install bump-my-version PyGithub packaging - bash ../ci/bump_version.sh ${{ inputs.type }} ${{ inputs.bump-minor }} python-v - - name: Bump Node/Rust version - if: ${{ inputs.other }} + - name: Bump version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pip install bump-my-version PyGithub packaging - bash ci/bump_version.sh ${{ inputs.type }} ${{ inputs.bump-minor }} v $COMMIT_BEFORE_BUMP + bash ci/bump_version.sh ${{ inputs.type }} ${{ inputs.bump-minor }} bash ci/update_lockfiles.sh --amend - name: Push new version tag if: ${{ !inputs.dry_run }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5707b085a..8a049ee0f 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -26,73 +26,6 @@ concurrency: cancel-in-progress: true jobs: - gh-release: - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - lfs: true - - name: Extract version - id: extract_version - env: - GITHUB_REF: ${{ github.ref }} - run: | - set -e - echo "Extracting tag and version from $GITHUB_REF" - if [[ $GITHUB_REF =~ refs/tags/v(.*) ]]; then - VERSION=${BASH_REMATCH[1]} - TAG=v$VERSION - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT - else - echo "Failed to extract version from $GITHUB_REF" - exit 1 - fi - echo "Extracted version $VERSION from $GITHUB_REF" - if [[ $VERSION =~ beta ]]; then - echo "This is a beta release" - - # Get last release (that is not this one) - FROM_TAG=$(git tag --sort='version:refname' \ - | grep ^v \ - | grep -vF "$TAG" \ - | python ci/semver_sort.py v \ - | tail -n 1) - else - echo "This is a stable release" - # Get last stable tag (ignore betas) - FROM_TAG=$(git tag --sort='version:refname' \ - | grep ^v \ - | grep -vF "$TAG" \ - | grep -v beta \ - | python ci/semver_sort.py v \ - | tail -n 1) - fi - echo "Found from tag $FROM_TAG" - echo "from_tag=$FROM_TAG" >> $GITHUB_OUTPUT - - name: Create Release Notes - id: release_notes - uses: mikepenz/release-changelog-builder-action@v4 - with: - configuration: .github/release_notes.json - toTag: ${{ steps.extract_version.outputs.tag }} - fromTag: ${{ steps.extract_version.outputs.from_tag }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create GH release - uses: softprops/action-gh-release@v2 - with: - prerelease: ${{ contains('beta', github.ref) }} - tag_name: ${{ steps.extract_version.outputs.tag }} - token: ${{ secrets.GITHUB_TOKEN }} - generate_release_notes: false - name: Node/Rust LanceDB v${{ steps.extract_version.outputs.version }} - body: ${{ steps.release_notes.outputs.changelog }} - build-lancedb: strategy: fail-fast: false diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 720079292..8825e7fb0 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -3,7 +3,7 @@ name: PyPI Publish on: push: tags: - - 'python-v*' + - 'v*' pull_request: # This should trigger a dry run (we skip the final publish step) paths: @@ -72,7 +72,7 @@ jobs: package-name: ${{ matrix.config.package_name }} rustflags: ${{ matrix.config.rustflags }} - uses: actions/upload-artifact@v7 - if: startsWith(github.ref, 'refs/tags/python-v') + if: startsWith(github.ref, 'refs/tags/v') with: name: wheels-linux-${{ matrix.config.package_name }}-${{ matrix.config.platform }}-${{ matrix.config.manylinux }} path: target/wheels/*.whl @@ -101,7 +101,7 @@ jobs: python-minor-version: 10 args: "--release --strip --target ${{ matrix.config.target }} --features fp16kernels" - uses: actions/upload-artifact@v7 - if: startsWith(github.ref, 'refs/tags/python-v') + if: startsWith(github.ref, 'refs/tags/v') with: name: wheels-mac-${{ matrix.config.target }} path: target/wheels/lancedb-*.whl @@ -127,14 +127,14 @@ jobs: python-minor-version: 10 args: "--release --strip" - uses: actions/upload-artifact@v7 - if: startsWith(github.ref, 'refs/tags/python-v') + if: startsWith(github.ref, 'refs/tags/v') with: name: wheels-windows path: target/wheels/lancedb-*.whl if-no-files-found: error publish: name: Publish wheels - if: startsWith(github.ref, 'refs/tags/python-v') + if: startsWith(github.ref, 'refs/tags/v') needs: [linux, mac, windows] runs-on: ubuntu-latest permissions: @@ -183,72 +183,6 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: target/wheels/ - gh-release: - if: startsWith(github.ref, 'refs/tags/python-v') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - lfs: true - - name: Extract version - id: extract_version - env: - GITHUB_REF: ${{ github.ref }} - run: | - set -e - echo "Extracting tag and version from $GITHUB_REF" - if [[ $GITHUB_REF =~ refs/tags/python-v(.*) ]]; then - VERSION=${BASH_REMATCH[1]} - TAG=python-v$VERSION - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT - else - echo "Failed to extract version from $GITHUB_REF" - exit 1 - fi - echo "Extracted version $VERSION from $GITHUB_REF" - if [[ $VERSION =~ beta ]]; then - echo "This is a beta release" - - # Get last release (that is not this one) - FROM_TAG=$(git tag --sort='version:refname' \ - | grep ^python-v \ - | grep -vF "$TAG" \ - | python ci/semver_sort.py python-v \ - | tail -n 1) - else - echo "This is a stable release" - # Get last stable tag (ignore betas) - FROM_TAG=$(git tag --sort='version:refname' \ - | grep ^python-v \ - | grep -vF "$TAG" \ - | grep -v beta \ - | python ci/semver_sort.py python-v \ - | tail -n 1) - fi - echo "Found from tag $FROM_TAG" - echo "from_tag=$FROM_TAG" >> $GITHUB_OUTPUT - - name: Create Python Release Notes - id: python_release_notes - uses: mikepenz/release-changelog-builder-action@v4 - with: - configuration: .github/release_notes.json - toTag: ${{ steps.extract_version.outputs.tag }} - fromTag: ${{ steps.extract_version.outputs.from_tag }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create Python GH release - uses: softprops/action-gh-release@v2 - with: - prerelease: ${{ contains('beta', github.ref) }} - tag_name: ${{ steps.extract_version.outputs.tag }} - token: ${{ secrets.GITHUB_TOKEN }} - generate_release_notes: false - name: Python LanceDB v${{ steps.extract_version.outputs.version }} - body: ${{ steps.python_release_notes.outputs.changelog }} report-failure: name: Report Workflow Failure runs-on: ubuntu-latest @@ -256,7 +190,7 @@ jobs: permissions: contents: read issues: write - if: always() && failure() && startsWith(github.ref, 'refs/tags/python-v') + if: always() && failure() && startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v6 - uses: ./.github/actions/create-failure-issue diff --git a/Cargo.lock b/Cargo.lock index e18f3d364..e80165a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,9 +157,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -535,13 +535,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1750,7 +1750,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2288,9 +2288,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997a31e15872606a49478e670c58302094c97cb96abb0a7d60720f8e92170040" +checksum = "754ef4e8f073922a26f5b23133b9db4829342362b09be0bc94309cf261c2f098" dependencies = [ "arrow", "arrow-schema", @@ -2335,9 +2335,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7dd61161508f8f5fa1107774ea687bd753c22d83a32eebf963549f89de14139" +checksum = "06afd1e38dd27bbb1258685a1fc6524df6aff4e07b25b393a47de59635178d99" dependencies = [ "arrow", "async-trait", @@ -2360,9 +2360,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897c70f871277f9ce99aa38347be0d679bbe3e617156c4d2a8378cec8a2a0891" +checksum = "f0668fb32c12065ec242be0e5b4bc62bd7a06a0be3ecd83791ef877e4be67e02" dependencies = [ "arrow", "async-trait", @@ -2383,9 +2383,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c9ded5d87d9172319e006f2afdb9928d72dbacd6a90a458d8acb1e3b43a65" +checksum = "ca43b263cdff57042cfa8fb817fb3469f4878933380dccff25f5e793580abbf9" dependencies = [ "arrow", "arrow-ipc", @@ -2407,9 +2407,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "981b9dae74f78ee3d9f714fb49b01919eab975461b56149510c3ba9ea11287d1" +checksum = "05f0ba2b864792bdca4d76c59a1de0ab6e1b61946596b9936888dbd6360035f2" dependencies = [ "futures", "log", @@ -2418,9 +2418,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd7d295b2ec7c00d8a56562f41ed41062cf0af75549ed891c12a0a09eddfefe" +checksum = "b840a8bce0bcbf5afad02946d438591e7c373f7afccaf3d874c04485772514dd" dependencies = [ "arrow", "async-trait", @@ -2448,9 +2448,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552b0b3f342f7ec41b3fbd70f6339dc82a30cfd0349e7f280e7852528085349f" +checksum = "a24cc0b9cf6e367f27f27406eff13abf48a11b72446aaa40b3105c0ded5c17d9" dependencies = [ "arrow", "arrow-ipc", @@ -2472,9 +2472,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68850aa426b897e879c8b87e512ea8124f1d0a2869a4e51808ddaaddf1bc0ada" +checksum = "e1abe56b2a7a2d1d6de5117dd1a203181e267f28529faa5da546947621b697d7" dependencies = [ "arrow", "async-trait", @@ -2495,9 +2495,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402f93242ae08ef99139ee2c528a49d087efe88d5c7b2c3ff5480855a40ce54f" +checksum = "9c3e467f0611ad7bdd5aad17c63c9bb6182d04e5282e5496d897ea2b49c024ba" dependencies = [ "arrow", "async-trait", @@ -2518,15 +2518,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb9e7e5d11130c48c8bd4e80c79a9772dd28ce6dc330baca9246205d245b9e2e" +checksum = "d69bb69d8769e34f76839c960dbde24c1ac0c885a79b6c3c2287bdc56ec67891" [[package]] name = "datafusion-execution" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a8643ab852eb68864e1b72ae789e8066282dce48eea6347ffb0aee33d1ccc0" +checksum = "d8eac0a09bc8d263f52025cad9e001da4d8138d633fa288edda4d06b1772eae6" dependencies = [ "arrow", "arrow-buffer", @@ -2546,9 +2546,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6932f4d71eed9c8d9341476a2b845aadfabde5495d08dbcd8fc23881f49fa7a0" +checksum = "eeb14d374767ee0fc62dc79a5ba8bcf8a63c14e993c7d992d0e63adfa23d77d3" dependencies = [ "arrow", "arrow-schema", @@ -2568,9 +2568,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0225491839a31b1f7d2cb8092c2d50792e2fe1c1724e4e6d08e011f5feaf4ed2" +checksum = "b7b19a8c95522bee8cbb313d74263b85e355d2b52f42e67ef5694bf5de9e9356" dependencies = [ "arrow", "datafusion-common", @@ -2580,9 +2580,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14872c47bfc3d21e53ec82f57074e6987a15941c1e2f43cde4ac6ae2746634e3" +checksum = "5f64c983bbbdcb729d921a2b2ac3375598719b5cc0c30345ad664936f3176fc7" dependencies = [ "arrow", "arrow-buffer", @@ -2612,9 +2612,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a2ca14e1b609be21e657e2d3130b2f446456b08393b377bb721a33952d2e09" +checksum = "89bc17041e424a47ed062f43df24d84aab8b57c4c3221e5c1a5eef46d6c5718b" dependencies = [ "arrow", "datafusion-common", @@ -2633,9 +2633,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ece74ba09092d2ef9c9b54a38445450aea292a1f8b04faf531936b723a24b3c" +checksum = "97dd2a9e865c6108059f5b37b77934f84b50bfb108f837bd0e5c9536e03f0545" dependencies = [ "arrow", "datafusion-common", @@ -2645,9 +2645,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3e3f9ee8ca59bf70518802107de6f1b88a9509efdc629fadc5de9d6b2d5ef5" +checksum = "75f0bdfeef16d96417b9632ef855645376b242e9006a126dfd0bedfc54a93f5f" dependencies = [ "arrow", "arrow-ord", @@ -2670,9 +2670,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89161dffc22cf2b50f9f4b1bee83b5221d3b4ed7c2e37fd7aa2b22a5297b3a26" +checksum = "f4e4941673c917819616877e9993da4503e4f4739812be0bc32c5356184c6383" dependencies = [ "arrow", "async-trait", @@ -2686,9 +2686,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7339345b226b3874037708bf5023ba1c2de705128f8457a095aae5ae9cb9c78" +checksum = "12dd2e16c12b84b6f6b41b19f55b366dd1c46876bb35b86896c6349067379e8d" dependencies = [ "arrow", "datafusion-common", @@ -2703,9 +2703,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa84836dc2392df6f43d6a29d37fb56a8ebdc8b3f4e10ae8dc15861fd20278fb" +checksum = "4cdc5e4b6f8b6ef823cc1c761f85088ad4c884fe8df64df3cbcc6b2b84698441" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2713,9 +2713,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "587164e03ad68732aa9e7bfe5686e3f25970d4c64fd4bd80790749840892dae5" +checksum = "1a3614234dd93578c92428cb4f408e020874f0d2b7e6c90c928d9d28b5df2ceb" dependencies = [ "datafusion-doc", "quote", @@ -2724,9 +2724,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f20e8cf9e8654d92f4c16b24c487353ee5bf153ffc12d5772cd399ab8cd281" +checksum = "a0635620b050b81bb92764e99250868f654e2cd5ad1bece413283b3f73c83179" dependencies = [ "arrow", "chrono", @@ -2743,9 +2743,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f015a4a82f6f7ff7e1d8d4bf3870a936752fa38b17705dfcc14adef95aa8922c" +checksum = "8cabf7a86eb70b816729e33c81bf7767c936ee1226f607a114f5dac2decac8d0" dependencies = [ "arrow", "datafusion-common", @@ -2764,9 +2764,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e6ffff8acdfe54e0ea15ccf38115c4a9184433b0439f42907637928d00a235" +checksum = "de222e04f7e6744501555a54ab0abe26bfdfebee380af79a9bdc175704246859" dependencies = [ "arrow", "datafusion-common", @@ -2779,9 +2779,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7967a3e171c6a4bf09474b3f7a14f1a3db13ed1714ba12156f33fcce2bba54e8" +checksum = "72d0d0057fc5a502d45c870cb6d47c66eb7bdd5edb1bd71ad6f3f724975ac2a8" dependencies = [ "arrow", "chrono", @@ -2796,9 +2796,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ff803e2a96054cb6d83f35f9e60fd4f42eac515e1932bd1b2dbc91d5fcbf36" +checksum = "86046eed10950c5f9aaed9acfd148e9bd2e1dfdfe4f9aef607d1447b271e4183" dependencies = [ "arrow", "datafusion-common", @@ -2814,9 +2814,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776ee54d47d15bdb126452f9ca17b03761e3b004682914beaedd3f86eb507fbc" +checksum = "9bc84da934c903407ba297971ebcc020c4c1a38aafd765d6c144c76eff3fa6a1" dependencies = [ "arrow", "arrow-data", @@ -2847,9 +2847,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fb9e5774660aa69c3ba93c610f175f75b65cb8c3776edb3626de8f3a4f4ee3" +checksum = "eb63eeac6de19be40f487b65dd84e546195f783c5a9928618e0c4f2a3569b0d7" dependencies = [ "arrow", "datafusion-common", @@ -2863,9 +2863,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15ce715fa2a61f4623cc234bcc14a3ef6a91f189128d5b14b468a6a17cdfc417" +checksum = "5f961d209177f91bd014db5cbb2c33b7d28a2597b9003e77f17aeb712964315a" dependencies = [ "async-trait", "datafusion-common", @@ -2877,9 +2877,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "54.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6094ad36a3ed6d7ac87b20b479b2d0b118250f66cf997603829fdc65b44a7099" +checksum = "b1d71cb454da682b2af7488e1fc1ddd72ee1b28f19297b8ccad73f0a21ee9a69" dependencies = [ "arrow", "bigdecimal", @@ -3006,7 +3006,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3229,7 +3229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4218,7 +4218,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -4517,7 +4517,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4611,7 +4611,7 @@ dependencies = [ "portable-atomic-util", "serde_core", "wasm-bindgen", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.32.0-beta.2" +version = "0.37.0-beta.0" dependencies = [ "ahash", "anyhow", @@ -5467,7 +5467,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.32.0-beta.2" +version = "0.37.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -5492,7 +5492,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.35.0-beta.2" +version = "0.37.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -5592,9 +5592,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -6065,9 +6065,9 @@ dependencies = [ [[package]] name = "napi" -version = "3.10.5" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6826e5ddc15589b2d68c8ad5321c18e85d40488e93e32962f362e572669bccf6" +checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" dependencies = [ "bitflags 2.11.1", "chrono", @@ -6090,9 +6090,9 @@ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "3.5.10" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fe526e81c105d3640516fcde83909dd1afe757c0d7a15af58830b5bc0fb9a1" +checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" dependencies = [ "convert_case", "ctor 1.0.5", @@ -6104,9 +6104,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.1.2" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514281397bcddd9ea9a876c7a21a57bff2374237a000ca9a64ea0211ec1993e2" +checksum = "4747005fa3e2c9989ac45a723a514c5db2411238b72981a3cda4c701a9dfea17" dependencies = [ "convert_case", "proc-macro2", @@ -6117,9 +6117,9 @@ dependencies = [ [[package]] name = "napi-sys" -version = "3.2.3" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e43cf2eb0bd1bf95a43c07c076ebd2da5d1e015a71c3d201faeffffcc0ecac" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" dependencies = [ "libloading", ] @@ -6208,7 +6208,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7587,8 +7587,8 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", - "itertools 0.11.0", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -7607,7 +7607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7828,7 +7828,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -7866,9 +7866,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8174,9 +8174,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -8186,9 +8186,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -8642,7 +8642,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8713,7 +8713,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8918,9 +8918,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -8928,29 +8928,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -9281,7 +9281,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", @@ -9293,7 +9293,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", @@ -9609,6 +9609,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -9726,7 +9737,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9929,9 +9940,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -10416,9 +10427,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.5" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -10703,7 +10714,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/ci/bump_version.sh b/ci/bump_version.sh index 1b3311c88..559e8ffdc 100644 --- a/ci/bump_version.sh +++ b/ci/bump_version.sh @@ -2,9 +2,9 @@ set -e RELEASE_TYPE=${1:-"stable"} BUMP_MINOR=${2:-false} -TAG_PREFIX=${3:-"v"} # Such as "python-v" -HEAD_SHA=${4:-$(git rev-parse HEAD)} +HEAD_SHA=$(git rev-parse HEAD) +readonly TAG_PREFIX="v" readonly SELF_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) PREV_TAG=$(git tag --sort='version:refname' | grep ^$TAG_PREFIX | python $SELF_DIR/semver_sort.py $TAG_PREFIX | tail -n 1) @@ -12,7 +12,7 @@ echo "Found previous tag $PREV_TAG" # Initially, we don't want to tag if we are doing stable, because we will bump # again later. See comment at end for why. -if [[ "$RELEASE_TYPE" == 'stable' ]]; then +if [[ "$RELEASE_TYPE" == 'stable' ]]; then BUMP_ARGS="--no-tag" fi diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 9265c3a56..891cc1674 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.32.0-beta.2 + 0.37.0-beta.0 ``` @@ -249,6 +249,57 @@ try (BufferAllocator allocator = new RootAllocator(); } ``` +### Creating an Empty Table + +To create an empty table, send an Arrow IPC stream that contains the table schema and no record batches. +The schema in the IPC stream becomes the table schema, and rows can be inserted later. + +```java +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; + +import java.io.ByteArrayOutputStream; +import java.nio.channels.Channels; +import java.util.Arrays; + +Schema schema = new Schema(Arrays.asList( + new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null), + new Field("name", FieldType.nullable(new ArrowType.Utf8()), null), + new Field("embedding", + FieldType.nullable(new ArrowType.FixedSizeList(128)), + Arrays.asList(new Field("item", + FieldType.nullable(new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)), + null))) +)); + +byte[] emptyTableData; +try (BufferAllocator allocator = new RootAllocator(); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + root.setRowCount(0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, Channels.newChannel(out))) { + writer.start(); + writer.end(); + } + emptyTableData = out.toByteArray(); +} + +CreateTableRequest request = new CreateTableRequest(); +request.setId(Arrays.asList("my_namespace", "empty_table")); + +CreateTableResponse response = namespaceClient.createTable(request, emptyTableData); +``` + ### Insert ```java @@ -431,9 +482,88 @@ query.setVector(vector); byte[] result = namespaceClient.queryTable(query); ``` -### Reading Query Results +## Indexing -Query results are returned in Apache Arrow IPC file format. Here's how to read them: +The Java SDK exposes the REST namespace index operations through the same `LanceNamespace` client. +Index creation runs asynchronously, so use `listTableIndices` or `describeTableIndexStats` to check progress. + +### Creating a Vector Index + +```java +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; + +CreateTableIndexRequest request = new CreateTableIndexRequest(); +request.setId(Arrays.asList("my_namespace", "my_table")); +request.setColumn("embedding"); +request.setIndexType("IVF_PQ"); +request.setDistanceType("cosine"); +request.setName("embedding_idx"); + +CreateTableIndexResponse response = namespaceClient.createTableIndex(request); +System.out.println("Index transaction: " + response.getTransactionId()); +``` + +### Creating a Scalar Index + +```java +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableScalarIndexResponse; + +CreateTableIndexRequest request = new CreateTableIndexRequest(); +request.setId(Arrays.asList("my_namespace", "my_table")); +request.setColumn("category"); +request.setIndexType("BTREE"); +request.setName("category_idx"); + +CreateTableScalarIndexResponse response = namespaceClient.createTableScalarIndex(request); +System.out.println("Index transaction: " + response.getTransactionId()); +``` + +### Creating a Full Text Search Index + +```java +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableScalarIndexResponse; + +CreateTableIndexRequest request = new CreateTableIndexRequest(); +request.setId(Arrays.asList("my_namespace", "my_table")); +request.setColumn("text_column"); +request.setIndexType("FTS"); +request.setName("text_idx"); +request.setBaseTokenizer("simple"); +request.setLowerCase(true); +request.setWithPosition(true); + +CreateTableScalarIndexResponse response = namespaceClient.createTableScalarIndex(request); +System.out.println("Index transaction: " + response.getTransactionId()); +``` + +### Listing Indexes + +```java +import org.lance.namespace.model.IndexContent; +import org.lance.namespace.model.ListTableIndicesRequest; +import org.lance.namespace.model.ListTableIndicesResponse; + +ListTableIndicesRequest request = new ListTableIndicesRequest(); +request.setId(Arrays.asList("my_namespace", "my_table")); + +ListTableIndicesResponse response = namespaceClient.listTableIndices(request); +for (IndexContent index : response.getIndexes()) { + System.out.println(index.getIndexName() + ": " + index.getStatus()); +} +``` + +!!! note + The current Java namespace API exposes index type, index name, distance type, and full text search tokenizer options. + IVF training parameters such as `num_partitions` are not exposed by `CreateTableIndexRequest` yet. + To make those configurable from Java, the namespace API must add those fields first. + +## Reading Query Results + +Query results are returned as bytes in Apache Arrow IPC file format. Put the byte-channel +adapter behind a small helper so query code can work with `ArrowFileReader` directly: ```java import org.apache.arrow.vector.ipc.ArrowFileReader; @@ -441,45 +571,50 @@ import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; +import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; -// Helper class to read Arrow data from byte array -class ByteArraySeekableByteChannel implements SeekableByteChannel { - private final byte[] data; - private long position = 0; - private boolean isOpen = true; - - public ByteArraySeekableByteChannel(byte[] data) { - this.data = data; +final class ArrowIpc { + static ArrowFileReader openFileReader(byte[] data, BufferAllocator allocator) throws IOException { + return new ArrowFileReader(new ByteArraySeekableByteChannel(data), allocator); } - @Override - public int read(ByteBuffer dst) { - int remaining = dst.remaining(); - int available = (int) (data.length - position); - if (available <= 0) return -1; - int toRead = Math.min(remaining, available); - dst.put(data, (int) position, toRead); - position += toRead; - return toRead; - } + private static final class ByteArraySeekableByteChannel implements SeekableByteChannel { + private final byte[] data; + private long position = 0; + private boolean isOpen = true; - @Override public long position() { return position; } - @Override public SeekableByteChannel position(long newPosition) { position = newPosition; return this; } - @Override public long size() { return data.length; } - @Override public boolean isOpen() { return isOpen; } - @Override public void close() { isOpen = false; } - @Override public int write(ByteBuffer src) { throw new UnsupportedOperationException(); } - @Override public SeekableByteChannel truncate(long size) { throw new UnsupportedOperationException(); } + private ByteArraySeekableByteChannel(byte[] data) { + this.data = data; + } + + @Override + public int read(ByteBuffer dst) { + int remaining = dst.remaining(); + int available = (int) (data.length - position); + if (available <= 0) return -1; + int toRead = Math.min(remaining, available); + dst.put(data, (int) position, toRead); + position += toRead; + return toRead; + } + + @Override public long position() { return position; } + @Override public SeekableByteChannel position(long newPosition) { position = newPosition; return this; } + @Override public long size() { return data.length; } + @Override public boolean isOpen() { return isOpen; } + @Override public void close() { isOpen = false; } + @Override public int write(ByteBuffer src) { throw new UnsupportedOperationException(); } + @Override public SeekableByteChannel truncate(long size) { throw new UnsupportedOperationException(); } + } } // Read query results byte[] queryResult = namespaceClient.queryTable(query); try (BufferAllocator allocator = new RootAllocator(); - ArrowFileReader reader = new ArrowFileReader( - new ByteArraySeekableByteChannel(queryResult), allocator)) { + ArrowFileReader reader = ArrowIpc.openFileReader(queryResult, allocator)) { for (int i = 0; i < reader.getRecordBlocks().size(); i++) { reader.loadRecordBatch(reader.getRecordBlocks().get(i)); diff --git a/docs/src/js/classes/MergeInsertBuilder.md b/docs/src/js/classes/MergeInsertBuilder.md index ac0493bad..beb6cdfce 100644 --- a/docs/src/js/classes/MergeInsertBuilder.md +++ b/docs/src/js/classes/MergeInsertBuilder.md @@ -76,24 +76,23 @@ the query optimizer chooses a suboptimal path. *** -### useLsmWrite() +### useLsm() ```ts -useLsmWrite(useLsmWrite): MergeInsertBuilder +useLsm(enable): MergeInsertBuilder ``` -Controls whether the merge uses the MemWAL LSM write path. +Control MemWAL routing for this merge. By default (unset), a `mergeInsert` on a table with an LSM write spec is -routed through Lance's MemWAL shard writer, and a table without one uses -the standard path. Pass `false` to force the standard path even when a -spec is set. Pass `true` to require a spec — `mergeInsert` rejects if none -is installed. +routed through Lance's MemWAL shard writer, and a table without one uses the +standard path. #### Parameters -* **useLsmWrite**: `boolean` - Whether to use the LSM write path. +* **enable**: `boolean` + `true` forces MemWAL routing and errors if the table has no + LSM write spec. `false` forces the standard write path even when a spec is set. #### Returns diff --git a/docs/src/js/classes/Query.md b/docs/src/js/classes/Query.md index c4bc3ff23..6ebaebd75 100644 --- a/docs/src/js/classes/Query.md +++ b/docs/src/js/classes/Query.md @@ -497,6 +497,42 @@ ArrowTable. *** +### useLsm() + +```ts +useLsm(enable): this +``` + +Control MemWAL read routing for this query. + +By default (unset), when the table carries a MemWAL write spec (see +[Table#setLsmWriteSpec](Table.md#setlsmwritespec)), reads are routed through the LSM scanner so +they also return data written via the `mergeInsert` LSM path that has not yet +been compacted into the base table (the active/frozen in-memory memtables and +the flushed generations), deduplicated by primary key; a table without a spec +reads the base table. + +#### Parameters + +* **enable**: `boolean` + `true` forces the LSM scanner and errors if the table has no + MemWAL write spec. `false` bypasses the MemWAL and reads the base table only, + even when a spec is present. + Note: the LSM scanner does not support every query shape (e.g. reranking, + hybrid search, `orderBy`). On a MemWAL table those shapes error unless + `useLsm(false)` is set, because a base-only read would silently exclude + un-compacted MemWAL data. + +#### Returns + +`this` + +#### Inherited from + +`StandardQueryBase.useLsm` + +*** + ### where() ```ts diff --git a/docs/src/js/classes/TakeQuery.md b/docs/src/js/classes/TakeQuery.md index 0d658b32c..6ae2f9c8c 100644 --- a/docs/src/js/classes/TakeQuery.md +++ b/docs/src/js/classes/TakeQuery.md @@ -273,6 +273,29 @@ ArrowTable. *** +### useLsm() + +```ts +useLsm(enable): this +``` + +Control MemWAL read routing for this take query. + +`false` bypasses the MemWAL and reads the base table only — the escape hatch, +since take-by-row-id/offset is not supported on the LSM scanner and, on a +MemWAL table, auto-routes to it and errors otherwise. + +#### Parameters + +* **enable**: `boolean` + `false` reads the base table only. + +#### Returns + +`this` + +*** + ### withRowId() ```ts diff --git a/docs/src/js/classes/VectorQuery.md b/docs/src/js/classes/VectorQuery.md index af3317029..f9412c76d 100644 --- a/docs/src/js/classes/VectorQuery.md +++ b/docs/src/js/classes/VectorQuery.md @@ -746,6 +746,42 @@ ArrowTable. *** +### useLsm() + +```ts +useLsm(enable): this +``` + +Control MemWAL read routing for this query. + +By default (unset), when the table carries a MemWAL write spec (see +[Table#setLsmWriteSpec](Table.md#setlsmwritespec)), reads are routed through the LSM scanner so +they also return data written via the `mergeInsert` LSM path that has not yet +been compacted into the base table (the active/frozen in-memory memtables and +the flushed generations), deduplicated by primary key; a table without a spec +reads the base table. + +#### Parameters + +* **enable**: `boolean` + `true` forces the LSM scanner and errors if the table has no + MemWAL write spec. `false` bypasses the MemWAL and reads the base table only, + even when a spec is present. + Note: the LSM scanner does not support every query shape (e.g. reranking, + hybrid search, `orderBy`). On a MemWAL table those shapes error unless + `useLsm(false)` is set, because a base-only read would silently exclude + un-compacted MemWAL data. + +#### Returns + +`this` + +#### Inherited from + +`StandardQueryBase.useLsm` + +*** + ### where() ```ts diff --git a/docs/src/js/interfaces/FtsOptions.md b/docs/src/js/interfaces/FtsOptions.md index 12ab8fc84..b8b264fed 100644 --- a/docs/src/js/interfaces/FtsOptions.md +++ b/docs/src/js/interfaces/FtsOptions.md @@ -43,6 +43,19 @@ The following tokenizers are available: *** +### blockSize? + +```ts +optional blockSize: 128 | 256; +``` + +Number of documents per compressed posting block. + +The default is 128. Supported values are 128 and 256. A value of 256 uses +the experimental FTS V3 format and may introduce breaking changes. + +*** + ### language? ```ts diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 7ef3e1267..eb47e25f3 100644 --- a/java/lancedb-core/pom.xml +++ b/java/lancedb-core/pom.xml @@ -8,7 +8,7 @@ com.lancedb lancedb-parent - 0.32.0-beta.2 + 0.37.0-beta.0 ../pom.xml diff --git a/java/pom.xml b/java/pom.xml index 60ea6b33c..ab8ec2cc8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.32.0-beta.2 + 0.37.0-beta.0 pom ${project.artifactId} LanceDB Java SDK Parent POM diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index b42228ff9..aef5fd54d 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "lancedb-nodejs" edition.workspace = true -version = "0.32.0-beta.2" +version = "0.37.0-beta.0" publish = false license.workspace = true description.workspace = true diff --git a/nodejs/__test__/remote.test.ts b/nodejs/__test__/remote.test.ts index a59dd4e71..22562b0bc 100644 --- a/nodejs/__test__/remote.test.ts +++ b/nodejs/__test__/remote.test.ts @@ -15,6 +15,7 @@ import { OAuthHeaderProvider, StaticHeaderProvider, } from "../lancedb/header"; +import { Index } from "../lancedb/indices"; // Test-only header providers class CustomProvider extends HeaderProvider { @@ -225,6 +226,54 @@ describe("remote connection", () => { ); }); + it("sends the FTS posting block size to remote tables", async () => { + let createIndexBody: Record | undefined; + + await withMockDatabase( + (req, res) => { + const path = req.url ?? ""; + if (path.endsWith("/describe/")) { + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ + name: "t", + version: 1, + schema: { + fields: [ + { name: "text", type: { type: "string" }, nullable: false }, + ], + }, + }), + ); + return; + } + + if (path.endsWith("/create_index/")) { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + createIndexBody = JSON.parse(raw); + res.writeHead(200).end(); + }); + return; + } + + res.writeHead(404).end(); + }, + async (db) => { + const table = await db.openTable("t"); + await table.createIndex("text", { + config: Index.fts({ blockSize: 256 }), + }); + }, + ); + + expect(createIndexBody?.["column"]).toBe("text"); + expect(createIndexBody?.["index_type"]).toBe("FTS"); + expect(createIndexBody?.["block_size"]).toBe(256); + }); + it("diffs and merges remote branches", async () => { const sampleDiff = { fromBranch: "exp", diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 39345996a..15248f5cf 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -527,6 +527,14 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( ); }); + it("should expose useLsm on takeRowIds as the base-only escape hatch", async () => { + await table.add([{ id: 1 }, { id: 2 }, { id: 3 }]); + // useLsm(false) is reachable on TakeQuery (the escape hatch for MemWAL tables, + // where take-by-row-id auto-routes to the LSM scanner and is rejected). + const res = await table.takeRowIds([0, 2]).useLsm(false).toArray(); + expect(res.map((r) => r.id)).toEqual([1, 3]); + }); + it("should throw for negative number in takeRowIds", () => { expect(() => table.takeRowIds([-1])).toThrow("Row id cannot be negative"); expect(() => table.takeRowIds([0, -5, 2])).toThrow( @@ -2527,6 +2535,35 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( expect(results3.length).toBe(1); }); + test("full text search with custom posting block size", async () => { + const db = await connect(tmpDir.name); + const data = [ + { text: "hello world", vector: [0.1, 0.2, 0.3] }, + { text: "goodbye world", vector: [0.4, 0.5, 0.6] }, + ]; + const table = await db.createTable("test", data); + await table.createIndex("text", { + config: Index.fts({ blockSize: 256 }), + }); + + const index = (await table.listIndices()).find( + (index) => index.indexType === "FTS", + ); + expect(index?.indexVersion).toBe(3); + expect( + (index?.indexDetails as Record)["block_size"], + ).toBe(256); + + const results = await table.search("hello").toArray(); + expect(results[0].text).toBe(data[0].text); + }); + + test("rejects invalid full text posting block size", () => { + expect(() => Index.fts({ blockSize: 129 as 128 | 256 })).toThrow( + "128 or 256", + ); + }); + test("full text search without lowercase", async () => { const db = await connect(tmpDir.name); const data = [ @@ -3170,14 +3207,14 @@ describe("LSM merge insert", () => { await table.closeLsmWriters(); }); - it("falls back to the standard path with useLsmWrite(false)", async () => { + it("falls back to the standard path with useLsm(false)", async () => { const conn = await connect(tmpDir.name); const table = await bucketTable(conn); const res = await table .mergeInsert("id") .whenNotMatchedInsertAll() - .useLsmWrite(false) + .useLsm(false) .execute([ { id: "b", value: 9 }, { id: "e", value: 5 }, @@ -3211,4 +3248,36 @@ describe("LSM merge insert", () => { .execute([{ id: "g", value: 7 }]), ).rejects.toThrow(); }); + + it("auto-routes reads through the MemWAL scanner", async () => { + const conn = await connect(tmpDir.name); + const table = await bucketTable(conn); // base ids "a", "b" + + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute([{ id: "c", value: 3 }]); + + // Default read auto-routes and includes the active memtable row. + const lsm = await table.query().toArray(); + expect(lsm.map((r) => r.id).sort()).toEqual(["a", "b", "c"]); + + // useLsm(false) bypasses the MemWAL and reads the base table only. + const baseOnly = await table.query().useLsm(false).toArray(); + expect(baseOnly.map((r) => r.id).sort()).toEqual(["a", "b"]); + }); + + it("reads the base table when no LSM spec is installed", async () => { + const conn = await connect(tmpDir.name); + const table = await conn.createEmptyTable( + "plain", + new arrow.Schema([new arrow.Field("id", new arrow.Utf8(), false)]), + ); + // No spec: default read and useLsm(false) both succeed against the base table. + await expect(table.query().toArray()).resolves.toBeDefined(); + await expect(table.query().useLsm(false).toArray()).resolves.toBeDefined(); + // useLsm(true) demands MemWAL routing; without a spec it errors. + await expect(table.query().useLsm(true).toArray()).rejects.toThrow(); + }); }); diff --git a/nodejs/lancedb/indices.ts b/nodejs/lancedb/indices.ts index e45556e5f..9b438ef0f 100644 --- a/nodejs/lancedb/indices.ts +++ b/nodejs/lancedb/indices.ts @@ -572,6 +572,14 @@ export interface FtsOptions { * whether to only index the prefix of the token for ngram tokenizer */ prefixOnly?: boolean; + + /** + * Number of documents per compressed posting block. + * + * The default is 128. Supported values are 128 and 256. A value of 256 uses + * the experimental FTS V3 format and may introduce breaking changes. + */ + blockSize?: 128 | 256; } export class Index { @@ -751,6 +759,7 @@ export class Index { options?.ngramMinLength, options?.ngramMaxLength, options?.prefixOnly, + options?.blockSize, ), ); } diff --git a/nodejs/lancedb/merge.ts b/nodejs/lancedb/merge.ts index 08321427f..30bde7281 100644 --- a/nodejs/lancedb/merge.ts +++ b/nodejs/lancedb/merge.ts @@ -88,21 +88,17 @@ export class MergeInsertBuilder { ); } /** - * Controls whether the merge uses the MemWAL LSM write path. + * Control MemWAL routing for this merge. * * By default (unset), a `mergeInsert` on a table with an LSM write spec is - * routed through Lance's MemWAL shard writer, and a table without one uses - * the standard path. Pass `false` to force the standard path even when a - * spec is set. Pass `true` to require a spec — `mergeInsert` rejects if none - * is installed. + * routed through Lance's MemWAL shard writer, and a table without one uses the + * standard path. * - * @param useLsmWrite - Whether to use the LSM write path. + * @param enable - `true` forces MemWAL routing and errors if the table has no + * LSM write spec. `false` forces the standard write path even when a spec is set. */ - useLsmWrite(useLsmWrite: boolean): MergeInsertBuilder { - return new MergeInsertBuilder( - this.#native.useLsmWrite(useLsmWrite), - this.#schema, - ); + useLsm(enable: boolean): MergeInsertBuilder { + return new MergeInsertBuilder(this.#native.useLsm(enable), this.#schema); } /** * Controls how an LSM merge checks that its input targets a single shard. diff --git a/nodejs/lancedb/query.ts b/nodejs/lancedb/query.ts index 7c9d96750..843a1276f 100644 --- a/nodejs/lancedb/query.ts +++ b/nodejs/lancedb/query.ts @@ -460,6 +460,30 @@ export class StandardQueryBase< this.doCall((inner: NativeQueryType) => inner.fastSearch()); return this; } + + /** + * Control MemWAL read routing for this query. + * + * By default (unset), when the table carries a MemWAL write spec (see + * {@link Table#setLsmWriteSpec}), reads are routed through the LSM scanner so + * they also return data written via the `mergeInsert` LSM path that has not yet + * been compacted into the base table (the active/frozen in-memory memtables and + * the flushed generations), deduplicated by primary key; a table without a spec + * reads the base table. + * + * @param enable - `true` forces the LSM scanner and errors if the table has no + * MemWAL write spec. `false` bypasses the MemWAL and reads the base table only, + * even when a spec is present. + * + * Note: the LSM scanner does not support every query shape (e.g. reranking, + * hybrid search, `orderBy`). On a MemWAL table those shapes error unless + * `useLsm(false)` is set, because a base-only read would silently exclude + * un-compacted MemWAL data. + */ + useLsm(enable: boolean): this { + this.doCall((inner: NativeQueryType) => inner.useLsm(enable)); + return this; + } } /** @@ -748,6 +772,20 @@ export class TakeQuery extends QueryBase { constructor(inner: NativeTakeQuery) { super(inner); } + + /** + * Control MemWAL read routing for this take query. + * + * `false` bypasses the MemWAL and reads the base table only — the escape hatch, + * since take-by-row-id/offset is not supported on the LSM scanner and, on a + * MemWAL table, auto-routes to it and errors otherwise. + * + * @param enable - `false` reads the base table only. + */ + useLsm(enable: boolean): this { + this.doCall((inner: NativeTakeQuery) => inner.useLsm(enable)); + return this; + } } /** A builder for LanceDB queries. diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index 2021be22e..618b02547 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.32.0-beta.2", + "version": "0.37.0-beta.0", "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 6d2224649..3eb4eeaba 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.32.0-beta.2", + "version": "0.37.0-beta.0", "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 e27c2cd9c..7dbed5ae4 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.32.0-beta.2", + "version": "0.37.0-beta.0", "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 35d4c037a..733aef750 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.32.0-beta.2", + "version": "0.37.0-beta.0", "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 ce11db616..041a49cb4 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.32.0-beta.2", + "version": "0.37.0-beta.0", "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 a334401ef..2604d021e 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.32.0-beta.2", + "version": "0.37.0-beta.0", "os": [ "win32" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index b18e34f1c..2ee52df53 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.32.0-beta.2", + "version": "0.37.0-beta.0", "os": ["win32"], "cpu": ["x64"], "main": "lancedb.win32-x64-msvc.node", diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e7b5322bd..2317c89a4 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lancedb/lancedb", - "version": "0.32.0-beta.2", + "version": "0.37.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lancedb/lancedb", - "version": "0.32.0-beta.2", + "version": "0.37.0-beta.0", "cpu": [ "x64", "arm64" diff --git a/nodejs/package.json b/nodejs/package.json index 17c432b8c..e9b861ad1 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.32.0-beta.2", + "version": "0.37.0-beta.0", "main": "dist/index.js", "exports": { ".": "./dist/index.js", diff --git a/nodejs/src/index.rs b/nodejs/src/index.rs index db84710ba..66dee7913 100644 --- a/nodejs/src/index.rs +++ b/nodejs/src/index.rs @@ -226,7 +226,8 @@ impl Index { ngram_min_length: Option, ngram_max_length: Option, prefix_only: Option, - ) -> Self { + block_size: Option, + ) -> napi::Result { let mut opts = FtsIndexBuilder::default(); if let Some(with_position) = with_position { opts = opts.with_position(with_position); @@ -261,10 +262,15 @@ impl Index { if let Some(prefix_only) = prefix_only { opts = opts.ngram_prefix_only(prefix_only); } - - Self { - inner: Mutex::new(Some(LanceDbIndex::FTS(opts))), + if let Some(block_size) = block_size { + opts = opts + .block_size(block_size as usize) + .map_err(|err| napi::Error::from_reason(err.to_string()))?; } + + Ok(Self { + inner: Mutex::new(Some(LanceDbIndex::FTS(opts))), + }) } #[napi(factory)] diff --git a/nodejs/src/merge.rs b/nodejs/src/merge.rs index 1f9609160..ac8649419 100644 --- a/nodejs/src/merge.rs +++ b/nodejs/src/merge.rs @@ -51,9 +51,9 @@ impl NativeMergeInsertBuilder { } #[napi] - pub fn use_lsm_write(&self, use_lsm_write: bool) -> Self { + pub fn use_lsm(&self, enable: bool) -> Self { let mut this = self.clone(); - this.inner.use_lsm_write(use_lsm_write); + this.inner.use_lsm(enable); this } diff --git a/nodejs/src/query.rs b/nodejs/src/query.rs index 7266e05fc..3828023a9 100644 --- a/nodejs/src/query.rs +++ b/nodejs/src/query.rs @@ -168,6 +168,11 @@ impl Query { self.inner = self.inner.clone().with_row_id(); } + #[napi] + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + #[napi] pub fn order_by(&mut self, ordering: Option>) -> napi::Result<()> { let ordering = ordering.map(|ordering| { @@ -374,6 +379,11 @@ impl VectorQuery { self.inner = self.inner.clone().with_row_id(); } + #[napi] + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + #[napi] pub fn rerank( &mut self, @@ -479,6 +489,11 @@ impl TakeQuery { self.inner = self.inner.clone().with_row_id(); } + #[napi] + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + #[napi(catch_unwind)] pub async fn output_schema(&self) -> napi::Result { let schema = self.inner.output_schema().await.default_error()?; diff --git a/plugins/lancedb/skills/lancedb/references/branch_ops.md b/plugins/lancedb/skills/lancedb/references/branch_ops.md index bc05671e8..e94c7e6db 100644 --- a/plugins/lancedb/skills/lancedb/references/branch_ops.md +++ b/plugins/lancedb/skills/lancedb/references/branch_ops.md @@ -2,7 +2,7 @@ Manage branches on a LanceDB table: list what exists, create new ones, delete stale ones, and direct read/write operations at a specific branch without touching main. Use for branch lifecycle tasks, experimental/isolated table versions, targeting an operation at a non-main branch, or confirming a mutation did not affect main. -Works on local/OSS and remote Enterprise/Cloud tables. +Works on local/OSS and remote Enterprise/Cloud tables, except merging a branch into main, which is Enterprise-only. ## The branch model (important) @@ -101,6 +101,69 @@ assert b"lancedb:description" not in (table.schema.field("category").metadata or Two handles on the same branch see each other's writes (e.g. `table.branches.create("exp")` and `db.open_table(name, branch="exp")`); main stays isolated. +## Merging a branch into main (Enterprise only) + +Merge is available through the SDKs (`table.branches.merge(...)`) on **Enterprise tables only** — it is not supported on Cloud or local/OSS tables, which raise `NotSupported`. + +`merge` takes the branch to merge **from** and a `dry_run` flag. Both the SDK method and the underlying REST endpoint **actually merge by default** (`dry_run=False`); pass `dry_run=True` to only preview. A rejected merge is **not an exception** — it returns a result with `status="rejected"` rather than raising, so inspect the return value. Use `branches.diff(from_branch)` to inspect a branch's pending diff without attempting a merge. + +```python +exp = "experiment-reindex" + +# preview only — returns status="ready" if it would merge cleanly +preview = table.branches.merge(exp, dry_run=True) + +# actually merge (default) +result = table.branches.merge(exp) +if result["status"] == "merged": + print("landed at", result["mainVersionAfter"]) +elif result["status"] == "rejected": + print(result["diff"]["mergeBlockers"]) # why it was refused + +# inspect a branch's pending diff without merging +diff = table.branches.diff(exp) +``` + +Async: `await table.branches.merge(exp)`, `await table.branches.diff(exp)`. + +```typescript +const branches = await table.branches(); +const exp = "experiment-reindex"; + +// preview only (second arg is dryRun) +const preview = await branches.merge(exp, true); + +// actually merge (default) +const result = await branches.merge(exp); +if (result.status === "merged") { + console.log("landed at", result.mainVersionAfter); +} else if (result.status === "rejected") { + console.log(result.diff.mergeBlockers); +} + +const diff = await branches.diff(exp); +``` + +The result is the wire JSON, containing `status` (`ready` on a passing dry run, `merged` on success, `rejected` when refused — also `notImplemented`/`unknown`), the branch `diff` (including `mergeBlockers` explaining any rejection), a `preview` of the columns that would be promoted, and — after a real merge — `mainVersionAfter`. + +### Merge preconditions + +Merge only **promotes newly added columns** onto main; it does not replay arbitrary commits. Practically, a branch is mergeable only if it has **exactly one commit since it was created, and that commit added a column**. The merge is rejected (`status: "rejected"`, with `mergeBlockers` set) if: + +- the branch was forked from another branch rather than directly from main +- main has advanced since the branch was forked +- the branch's rows changed since the fork (row counts must match main exactly) +- the branch removed columns or changed a column's type/nullability +- the branch added no columns (index-only changes are not merged) + +### Adding a column in a single commit + +Because the branch must contain just one column-adding commit, add the column with its values in one operation rather than add-then-backfill: + +1. **SQL transformation** — `add_columns` with a SQL expression computed from existing columns, so the column lands populated in one commit. +2. **Precompute the values** — compute the column's values externally, then add the fully-populated column in a single operation (e.g. via `merge_insert`/`add_columns` with the data ready). +3. **Lance-format-level data evolution (pylance)** — use Lance's data evolution with backfill, documented at . + ## Quick reference | Goal | Python | TypeScript | @@ -113,5 +176,7 @@ Two handles on the same branch see each other's writes (e.g. `table.branches.cre | Delete branch | `table.branches.delete(name)` | `await branches.delete(name)` | | Which branch is this handle on? | `table.current_branch()` (`None` = main) | `table.currentBranch()` (`null` = main) | | Target main | use the original (non-branch) handle | use the original (non-branch) handle | +| Merge branch into main (Enterprise only) | `table.branches.merge(from_branch, dry_run=False)` | `await branches.merge(fromBranch, dryRun)` | +| Preview a branch's pending diff (Enterprise only) | `table.branches.diff(from_branch)` | `await branches.diff(fromBranch)` | Branch names must be non-empty; empty names raise a validation error. diff --git a/plugins/lancedb/skills/lancedb/references/python/api_reference.md b/plugins/lancedb/skills/lancedb/references/python/api_reference.md index 323800670..bbb209630 100644 --- a/plugins/lancedb/skills/lancedb/references/python/api_reference.md +++ b/plugins/lancedb/skills/lancedb/references/python/api_reference.md @@ -4,12 +4,19 @@ Quick method reference for Python LanceDB code. Cross-check source for non-trivi ## Connect +If you're connecting to a remote database, use this: ```python import lancedb -db = lancedb.connect("./camelot-db") # local/OSS -db = lancedb.connect("db://my-db", api_key=api_key, region=region) # remote +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. diff --git a/python/.bumpversion.toml b/python/.bumpversion.toml deleted file mode 100644 index b1fcd48e0..000000000 --- a/python/.bumpversion.toml +++ /dev/null @@ -1,49 +0,0 @@ -[tool.bumpversion] -current_version = "0.35.0-beta.2" -parse = """(?x) - (?P0|[1-9]\\d*)\\. - (?P0|[1-9]\\d*)\\. - (?P0|[1-9]\\d*) - (?:-(?P[a-zA-Z-]+)\\.(?P0|[1-9]\\d*))? -""" -serialize = [ - "{major}.{minor}.{patch}-{pre_l}.{pre_n}", - "{major}.{minor}.{patch}", -] -search = "{current_version}" -replace = "{new_version}" -regex = false -ignore_missing_version = false -ignore_missing_files = false -tag = true -sign_tags = false -tag_name = "python-v{new_version}" -tag_message = "Bump version: {current_version} → {new_version}" -allow_dirty = true -commit = true -message = "Bump version: {current_version} → {new_version}" -commit_args = "" -# bump-my-version >=1.4.0 rejects pre_commit_hooks containing shell syntax unless opted in. -allow_shell_hooks = true - -# Update Cargo.lock after version bump -pre_commit_hooks = [ - """ - cd python && cargo update -p lancedb-python - if git diff --quiet ../Cargo.lock; then - echo "Cargo.lock unchanged" - else - git add ../Cargo.lock - echo "Updated and staged Cargo.lock" - fi - """, -] - -[tool.bumpversion.parts.pre_l] -values = ["beta", "final"] -optional_value = "final" - -[[tool.bumpversion.files]] -filename = "Cargo.toml" -search = "\nversion = \"{current_version}\"" -replace = "\nversion = \"{new_version}\"" diff --git a/python/Cargo.toml b/python/Cargo.toml index 3850ab249..a0e9394ec 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb-python" -version = "0.35.0-beta.2" +version = "0.37.0-beta.0" publish = false edition.workspace = true description = "Python bindings for LanceDB" diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 229eeaafc..448947a29 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -396,6 +396,7 @@ class Query: def fast_search(self): ... def with_row_id(self): ... def postfilter(self): ... + def use_lsm(self, enable: bool): ... def nearest_to(self, query_vec: pa.Array) -> VectorQuery: ... def nearest_to_text(self, query: dict) -> FTSQuery: ... def order_by(self, ordering: Optional[List[ColumnOrdering]]): ... @@ -412,6 +413,7 @@ class Query: class TakeQuery: def select(self, columns: List[str]): ... def with_row_id(self): ... + def use_lsm(self, enable: bool): ... async def output_schema(self) -> pa.Schema: ... async def execute(self) -> RecordBatchStream: ... async def explain_plan(self, verbose: Optional[bool]) -> str: ... @@ -430,6 +432,7 @@ class FTSQuery: def fast_search(self): ... def with_row_id(self): ... def postfilter(self): ... + def use_lsm(self, enable: bool): ... def get_query(self) -> str: ... def add_query_vector(self, query_vec: pa.Array) -> None: ... def nearest_to(self, query_vec: pa.Array) -> HybridQuery: ... @@ -457,6 +460,7 @@ class VectorQuery: def column(self, column: str): ... def distance_type(self, distance_type: str): ... def postfilter(self): ... + def use_lsm(self, enable: bool): ... def refine_factor(self, refine_factor: int): ... def nprobes(self, nprobes: int): ... def minimum_nprobes(self, minimum_nprobes: int): ... @@ -480,6 +484,7 @@ class HybridQuery: def fast_search(self): ... def with_row_id(self): ... def postfilter(self): ... + def use_lsm(self, enable: bool): ... def distance_type(self, distance_type: str): ... def refine_factor(self, refine_factor: int): ... def nprobes(self, nprobes: int): ... @@ -504,6 +509,7 @@ class PyQueryRequest: select: Optional[Union[str, List[str]]] fast_search: Optional[bool] with_row_id: Optional[bool] + use_lsm: Optional[bool] column: Optional[str] query_vector: Optional[List[pa.Array]] minimum_nprobes: Optional[int] diff --git a/python/python/lancedb/index.py b/python/python/lancedb/index.py index 76daaed11..c06bb9903 100644 --- a/python/python/lancedb/index.py +++ b/python/python/lancedb/index.py @@ -115,6 +115,12 @@ class FTS: For example, it works with `title`, `description`, `content`, etc. + Examples + -------- + Create an index configuration that uses 256-document posting blocks: + + >>> config = FTS(block_size=256) + Attributes ---------- with_position : bool, default False @@ -148,6 +154,10 @@ class FTS: ascii_folding : bool, default True Whether to fold ASCII characters. This converts accented characters to their ASCII equivalent. For example, "café" would be converted to "cafe". + block_size : int, default 128 + The number of documents per compressed posting block. Supported values + are 128 and 256. A value of 256 uses the experimental FTS V3 format + and may introduce breaking changes. Notes ----- @@ -168,6 +178,7 @@ class FTS: ngram_min_length: int = 3 ngram_max_length: int = 3 prefix_only: bool = False + block_size: int = 128 @dataclass diff --git a/python/python/lancedb/merge.py b/python/python/lancedb/merge.py index 386c592cd..3debed97b 100644 --- a/python/python/lancedb/merge.py +++ b/python/python/lancedb/merge.py @@ -37,7 +37,7 @@ class LanceMergeInsertBuilder(object): self._when_not_matched_by_source_condition_expr = None self._timeout = None self._use_index = True - self._use_lsm_write = None + self._use_lsm = None self._validate_single_shard = None def when_matched_update_all( @@ -113,22 +113,22 @@ class LanceMergeInsertBuilder(object): self._use_index = use_index return self - def use_lsm_write(self, use_lsm_write: bool) -> LanceMergeInsertBuilder: + def use_lsm(self, enable: bool) -> LanceMergeInsertBuilder: """ - Controls whether the merge uses the MemWAL LSM write path. + Control MemWAL routing for this merge. - By default (unset), a `merge_insert` on a table with an LSM write spec - is routed through Lance's MemWAL shard writer, and a table without one - uses the standard path. Pass `False` to force the standard path even - when a spec is set. Pass `True` to require a spec — `merge_insert` - raises an error if none is installed. + By default (unset), a `merge_insert` on a table with an LSM write spec is + routed through Lance's MemWAL shard writer, and a table without one uses + the standard path. Parameters ---------- - use_lsm_write: bool - Whether to use the LSM write path. + enable: bool + ``True`` forces MemWAL routing and errors if the table has no LSM + write spec. ``False`` forces the standard write path even when a spec + is set. """ - self._use_lsm_write = use_lsm_write + self._use_lsm = enable return self def validate_single_shard( diff --git a/python/python/lancedb/permutation.py b/python/python/lancedb/permutation.py index bd0dbdd75..8aa764c4b 100644 --- a/python/python/lancedb/permutation.py +++ b/python/python/lancedb/permutation.py @@ -885,7 +885,7 @@ class Permutation: This method refines the current selection, potentially removing columns. It will not add back columns that were previously removed. - If any of the columns do not exist then an error will be raised + If any of the columns do not exist then an error will be raised. This does not introduce a post-processing step. It simply reduces the amount of data we read. @@ -898,9 +898,14 @@ class Permutation: for name in columns: value = self.selection.get(name, None) if value is None: - raise ValueError( - f"Cannot select column `{name}` because it does not exist" - ) + if name == "_rowid": + # _rowid is a system column not in the default schema + # but can be explicitly selected + value = "_rowid" + else: + raise ValueError( + f"Cannot select column `{name}` because it does not exist" + ) new_selection[name] = value return self._with_selection(new_selection) diff --git a/python/python/lancedb/query.py b/python/python/lancedb/query.py index 9a730e35f..7063f08cc 100644 --- a/python/python/lancedb/query.py +++ b/python/python/lancedb/query.py @@ -778,6 +778,11 @@ class Query(pydantic.BaseModel): # if true, will only search the indexed data fast_search: Optional[bool] = None + # MemWAL LSM read routing: None auto-routes when the table carries a write + # spec, True forces the LSM scanner (errors without a spec), False reads the + # base table only + use_lsm: Optional[bool] = None + # size of the nearest neighbor list maintained during HNSW search ef: Optional[int] = None @@ -795,6 +800,9 @@ class Query(pydantic.BaseModel): query.full_text_query = req.full_text_search query.columns = req.select query.with_row_id = req.with_row_id + # use_lsm is a genuine tri-state (None / True / False); preserve it as-is + # so a round-tripped query keeps an explicit False. + query.use_lsm = req.use_lsm query.vector_column = req.column query.vector = req.query_vector query.distance_type = req.distance_type @@ -967,6 +975,7 @@ class LanceQueryBuilder(ABC): self._with_row_address = None self._fragments = None self._fragment_ids = None + self._use_lsm = None self._vector = None self._text = None self._ef = None @@ -1326,6 +1335,30 @@ class LanceQueryBuilder(ABC): self._fragment_ids = fragment_ids return self + def use_lsm(self, enable: bool) -> Self: + """Control MemWAL LSM read routing for this query. + + By default (unset), a query against a table with an LSM write spec is + routed through the LSM scanner so it also returns data written via the + ``merge_insert`` LSM path that has not yet been compacted into the base + table (active/frozen memtables + flushed generations); a table without a + spec reads the base table. + + Parameters + ---------- + enable : bool + ``True`` forces the LSM scanner and errors if the table has no LSM + write spec. ``False`` bypasses the MemWAL and reads the base table + only, even when a spec is present. + + Returns + ------- + LanceQueryBuilder + The LanceQueryBuilder object. + """ + self._use_lsm = enable + return self + def explain_plan(self, verbose: Optional[bool] = False) -> str: """Return the execution plan for this query. @@ -1788,6 +1821,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder): with_row_address=self._with_row_address, fragments=self._fragments, fragment_ids=self._fragment_ids, + use_lsm=self._use_lsm, offset=self._offset, fast_search=self._fast_search, ef=self._ef, @@ -2012,6 +2046,7 @@ class LanceFtsQueryBuilder(LanceQueryBuilder): with_row_address=self._with_row_address, fragments=self._fragments, fragment_ids=self._fragment_ids, + use_lsm=self._use_lsm, full_text_query=FullTextSearchQuery( query=self._query_with_phrase_semantics(), columns=self._fts_columns ), @@ -2078,6 +2113,7 @@ class LanceEmptyQueryBuilder(LanceQueryBuilder): with_row_address=self._with_row_address, fragments=self._fragments, fragment_ids=self._fragment_ids, + use_lsm=self._use_lsm, offset=self._offset, order_by=self._order_by, ) @@ -2655,6 +2691,9 @@ class LanceHybridQueryBuilder(LanceQueryBuilder): if self._with_row_id: self._vector_query.with_row_id(True) self._fts_query.with_row_id(True) + if self._use_lsm is not None: + self._vector_query.use_lsm(self._use_lsm) + self._fts_query.use_lsm(self._use_lsm) if self._phrase_query: self._fts_query.phrase_query(True) if self._distance_type: @@ -3231,6 +3270,27 @@ class AsyncStandardQuery(AsyncQueryBase): self._inner.fast_search() return self + def use_lsm(self, enable: bool) -> Self: + """ + Control MemWAL LSM read routing for this query. + + By default (unset), a query against a table with an LSM write spec (see + [AsyncTable.set_lsm_write_spec][lancedb.table.AsyncTable.set_lsm_write_spec]) + is routed through the LSM scanner so it also returns data written via the + ``merge_insert`` LSM path that has not yet been compacted into the base + table (the active/frozen in-memory memtables and the flushed generations), + deduplicated by primary key; a table without a spec reads the base table. + + Parameters + ---------- + enable : bool + ``True`` forces the LSM scanner and errors if the table has no LSM + write spec. ``False`` bypasses the MemWAL and reads the base table + only, even when a spec is present. + """ + self._inner.use_lsm(enable) + return self + def postfilter(self) -> Self: """ If this is called then filtering will happen after the search instead of @@ -3944,6 +4004,15 @@ class AsyncTakeQuery(AsyncQueryBase): def __init__(self, inner: LanceTakeQuery, table: Optional["AsyncTable"] = None): super().__init__(inner, table) + def use_lsm(self, enable: bool) -> "AsyncTakeQuery": + """Control MemWAL LSM read routing for this take query. + + ``False`` bypasses the MemWAL and reads the base table only — the escape + hatch, since take-by-row-id/offset is not supported on the LSM scanner. + """ + self._inner.use_lsm(enable) + return self + async def _plain_scan_to_pandas( self, blob_mode: BlobMode, @@ -4002,6 +4071,16 @@ class BaseQueryBuilder(object): self._inner.with_row_id() return self + def use_lsm(self, enable: bool) -> Self: + """ + Control MemWAL LSM read routing for this query. + + ``False`` bypasses the MemWAL and reads the base table only, the escape + hatch for shapes the LSM scanner cannot honor (e.g. take-by-row-id). + """ + self._inner.use_lsm(enable) + return self + def with_row_address(self, with_row_address: bool = True) -> Self: """ Include the _rowaddr column in scanner-backed plain query results. diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index d01224909..682b8533c 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -344,6 +344,7 @@ class RemoteTable(Table): ngram_min_length: int = 3, ngram_max_length: int = 3, prefix_only: bool = False, + block_size: int = 128, name: Optional[str] = None, ): """Create a full-text search index on a column. @@ -364,6 +365,7 @@ class RemoteTable(Table): ngram_min_length=ngram_min_length, ngram_max_length=ngram_max_length, prefix_only=prefix_only, + block_size=block_size, ) LOOP.run( self._table.create_index( diff --git a/python/python/lancedb/rerankers/answerdotai.py b/python/python/lancedb/rerankers/answerdotai.py index 0b44569e3..0e2d9c6cb 100644 --- a/python/python/lancedb/rerankers/answerdotai.py +++ b/python/python/lancedb/rerankers/answerdotai.py @@ -23,7 +23,7 @@ class AnswerdotaiRerankers(Reranker): column : str, default "text" The name of the column to use as input to the cross encoder model. return_score : str, default "relevance" - options are "relevance" or "all". Only "relevance" is supported for now. + options are "relevance" or "all". **kwargs Additional keyword arguments to pass to the model. For example, 'device'. See AnswerDotAI/rerankers for more information. @@ -77,12 +77,13 @@ class AnswerdotaiRerankers(Reranker): vector_results: pa.Table, fts_results: pa.Table, ): - combined_results = self.merge_results(vector_results, fts_results) + if self.score == "all": + combined_results = self._merge_and_keep_scores(vector_results, fts_results) + else: + combined_results = self.merge_results(vector_results, fts_results) combined_results = self._rerank(combined_results, query) if self.score == "relevance": combined_results = self._keep_relevance_score(combined_results) - elif self.score == "all": - combined_results = self._merge_and_keep_scores(vector_results, fts_results) combined_results = combined_results.sort_by( [("_relevance_score", "descending")] ) diff --git a/python/python/lancedb/rerankers/colbert.py b/python/python/lancedb/rerankers/colbert.py index a7a98b361..ff3bc8c4a 100644 --- a/python/python/lancedb/rerankers/colbert.py +++ b/python/python/lancedb/rerankers/colbert.py @@ -16,7 +16,7 @@ class ColbertReranker(AnswerdotaiRerankers): column : str, default "text" The name of the column to use as input to the cross encoder model. return_score : str, default "relevance" - options are "relevance" or "all". Only "relevance" is supported for now. + options are "relevance" or "all". **kwargs Additional keyword arguments to pass to the model, for example, 'device'. See AnswerDotAI/rerankers for more information. diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index a2c533892..6c3854734 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -1107,6 +1107,7 @@ class Table(ABC): ngram_min_length: int = 3, ngram_max_length: int = 3, prefix_only: bool = False, + block_size: int = 128, wait_timeout: Optional[timedelta] = None, name: Optional[str] = None, ): @@ -1178,6 +1179,10 @@ class Table(ABC): The maximum length of an n-gram. prefix_only: bool, default False Whether to only index the prefix of the token for ngram tokenizer. + block_size: int, default 128 + The number of documents per compressed posting block. Must be 128 + or 256. A value of 256 uses the experimental FTS V3 format and + may introduce breaking changes. wait_timeout: timedelta, optional The timeout to wait if indexing is asynchronous. name: str, optional @@ -3054,6 +3059,7 @@ class LanceTable(Table): ngram_min_length: int = 3, ngram_max_length: int = 3, prefix_only: bool = False, + block_size: int = 128, name: Optional[str] = None, ): """Create a full-text search index on a column. @@ -3103,9 +3109,7 @@ class LanceTable(Table): else: tokenizer_configs = self.infer_tokenizer_configs(tokenizer_name) - config = FTS( - **tokenizer_configs, - ) + config = FTS(block_size=block_size, **tokenizer_configs) try: LOOP.run( @@ -5387,6 +5391,8 @@ class AsyncTable: async_query = async_query.where(query.filter) if query.fast_search: async_query = async_query.fast_search() + if query.use_lsm is not None: + async_query = async_query.use_lsm(query.use_lsm) if query.with_row_id: async_query = async_query.with_row_id() if query.order_by: @@ -5507,7 +5513,7 @@ class AsyncTable: when_not_matched_by_source_condition_expr=merge._when_not_matched_by_source_condition_expr, timeout=merge._timeout, use_index=merge._use_index, - use_lsm_write=merge._use_lsm_write, + use_lsm=merge._use_lsm, validate_single_shard=merge._validate_single_shard, ), ) diff --git a/python/python/tests/test_fts.py b/python/python/tests/test_fts.py index f822d1692..127510bc2 100644 --- a/python/python/tests/test_fts.py +++ b/python/python/tests/test_fts.py @@ -226,6 +226,23 @@ def test_create_inverted_index(table, with_position): assert any(i.name == "custom_fts_index" for i in fts_indices) +@pytest.mark.parametrize("block_size", [128, 256]) +def test_create_inverted_index_block_size(table, block_size): + table.create_index("text", config=FTS(block_size=block_size)) + + index = next(index for index in table.list_indices() if index.index_type == "FTS") + assert index.index_details["block_size"] == block_size + assert index.index_version == (2 if block_size == 128 else 3) + + results = table.search("puppy").limit(5).to_list() + assert len(results) == 5 + + +def test_create_inverted_index_rejects_invalid_block_size(table): + with pytest.raises(ValueError, match="128 or 256"): + table.create_index("text", config=FTS(block_size=129)) + + def test_search_fts(table): table.create_fts_index("text") results = table.search("puppy").select(["id", "text"]).limit(5).to_list() diff --git a/python/python/tests/test_merge_insert_lsm.py b/python/python/tests/test_merge_insert_lsm.py index abdfb306d..5674a05ab 100644 --- a/python/python/tests/test_merge_insert_lsm.py +++ b/python/python/tests/test_merge_insert_lsm.py @@ -9,6 +9,7 @@ import lancedb import pyarrow as pa import pytest from lancedb._lancedb import LsmWriteSpec +from lancedb.index import FTS, IvfPq SCHEMA = pa.schema( [ @@ -102,19 +103,35 @@ def test_lsm_merge_insert_identity(tmp_path): assert result.num_rows == 2 -def test_lsm_merge_insert_use_lsm_write_false(tmp_path): +def test_lsm_merge_insert_use_lsm_false(tmp_path): table = _bucket_table(tmp_path) # rows id = 1, 2, 3 - # use_lsm_write(False) opts out: the standard path runs and commits. + # use_lsm(False) opts out: the standard path runs and commits even with a spec. result = ( table.merge_insert("id") .when_not_matched_insert_all() - .use_lsm_write(False) + .use_lsm(False) .execute(_reader([3, 4, 5])) ) assert result.num_inserted_rows == 2 assert table.count_rows() == 5 +def test_lsm_merge_insert_use_lsm_true_without_spec_errors(tmp_path): + # A table with a primary key but no LSM write spec installed. + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) + table.set_unenforced_primary_key("id") + # use_lsm(True) demands MemWAL routing; without a spec it errors. + with pytest.raises(Exception, match="use_lsm"): + ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .use_lsm(True) + .execute(_reader([3, 4, 5])) + ) + + def test_lsm_merge_insert_validate_single_shard_off(tmp_path): table = _bucket_table(tmp_path) result = ( @@ -127,19 +144,20 @@ def test_lsm_merge_insert_validate_single_shard_off(tmp_path): assert result.num_rows == 3 -def test_lsm_merge_insert_use_lsm_write_true_requires_spec(tmp_path): +def test_lsm_merge_insert_no_spec_uses_standard_path(tmp_path): # A table with a primary key but no LSM write spec installed. db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) table = db.create_table("t", _reader([1, 2, 3])) table.set_unenforced_primary_key("id") - with pytest.raises(Exception, match="use_lsm_write"): - ( - table.merge_insert("id") - .when_matched_update_all() - .when_not_matched_insert_all() - .use_lsm_write(True) - .execute(_reader([4])) - ) + # With no spec, a default merge_insert uses the standard path and commits. + result = ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(_reader([3, 4, 5])) + ) + assert result.num_inserted_rows == 2 + assert table.count_rows() == 5 def test_lsm_merge_insert_rejects_on_not_primary_key(tmp_path): @@ -194,3 +212,445 @@ async def test_async_lsm_merge_insert(tmp_path): result = await builder.execute(_reader([3, 4, 5])) assert result.num_rows == 3 await table.close_lsm_writers() + + +def _lsm_upsert(table, ids): + """Upsert ``ids`` (value = 0..n) through the LSM merge_insert path.""" + ( + table.merge_insert([]) + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(_reader(ids)) + ) + + +def test_lsm_read_sees_active_memtable(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) # base ids 1,2,3 + table.set_unenforced_primary_key("id") + table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + + _lsm_upsert(table, [4, 5]) # active memtable only, not committed to base + + # Default read auto-routes through the LSM scanner: base ∪ active memtable. + lsm = table.search().to_arrow() + assert sorted(lsm["id"].to_pylist()) == [1, 2, 3, 4, 5] + + # use_lsm(False) bypasses the MemWAL and reads the base table only. + base_only = table.search().use_lsm(False).to_arrow() + assert sorted(base_only["id"].to_pylist()) == [1, 2, 3] + + +def test_lsm_read_dedup_newest_wins(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) # id 2 -> value 1 + table.set_unenforced_primary_key("id") + table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + + _lsm_upsert(table, [2, 3, 4]) # ids 2,3,4 -> values 0,1,2 + + lsm = table.search().to_arrow().sort_by("id") + assert lsm["id"].to_pylist() == [1, 2, 3, 4] + # id 1 from base (value 0); 2,3,4 from memtable (values 0,1,2). + assert lsm["value"].to_pylist() == [0, 0, 1, 2] + + +def test_lsm_read_without_spec_reads_base(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) + table.set_unenforced_primary_key("id") # no LSM write spec + + # No spec: default read and use_lsm(False) both read the base table, no error. + assert sorted(table.search().to_arrow()["id"].to_pylist()) == [1, 2, 3] + assert sorted(table.search().use_lsm(False).to_arrow()["id"].to_pylist()) == [ + 1, + 2, + 3, + ] + + +def test_lsm_read_unsupported_shape_errors_without_use_lsm_false(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) + table.set_unenforced_primary_key("id") + table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + _lsm_upsert(table, [4]) + + # with_row_id is unsupported by the LSM scanner; on a MemWAL table the default + # (auto-routed) read hard-errors instead of silently reading a stale base. + with pytest.raises(Exception): + table.search().with_row_id(True).to_arrow() + + # use_lsm(False) is the escape hatch: it reads the base table only. + base = table.search().with_row_id(True).use_lsm(False).to_arrow() + assert sorted(base["id"].to_pylist()) == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_async_lsm_read(tmp_path): + db = await lancedb.connect_async( + tmp_path, read_consistency_interval=timedelta(seconds=0) + ) + table = await db.create_table("t", _reader([1, 2, 3])) + await table.set_unenforced_primary_key("id") + await table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + + builder = ( + table.merge_insert([]).when_matched_update_all().when_not_matched_insert_all() + ) + await builder.execute(_reader([4, 5])) + + arrow = await table.query().to_arrow() + assert sorted(arrow["id"].to_pylist()) == [1, 2, 3, 4, 5] + + +VECTOR_DIM = 8 + +VECTOR_SCHEMA = pa.schema( + [ + pa.field("id", pa.int64(), nullable=False), + pa.field("category", pa.utf8(), nullable=False), + pa.field("vector", pa.list_(pa.float32(), VECTOR_DIM), nullable=False), + ] +) + + +def _vector_reader(rows): + """Rows are ``(id, category, [f32; VECTOR_DIM])`` tuples.""" + batch = pa.RecordBatch.from_arrays( + [ + pa.array([row[0] for row in rows], type=pa.int64()), + pa.array([row[1] for row in rows], type=pa.utf8()), + pa.array([row[2] for row in rows], type=pa.list_(pa.float32(), VECTOR_DIM)), + ], + schema=VECTOR_SCHEMA, + ) + return pa.RecordBatchReader.from_batches(VECTOR_SCHEMA, [batch]) + + +def _vector_table(tmp_path): + """Base table whose vector column is indexed so its rows are visible to the LSM + vector scanner (the base arm uses ``fast_search`` — indexed data only), plus an + unsharded LSM spec that maintains that index for the memtable. + + Rows 1,2 are category ``a``, row 3 is ``b``, and 4..60 are filler ``c`` that + give the tiny IVF index enough data to train. + """ + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + rows = [ + ( + i, + "a" if i in (1, 2) else "b" if i == 3 else "c", + [float((i * 7 + j) % 13) for j in range(VECTOR_DIM)], + ) + for i in range(1, 61) + ] + table = db.create_table("t", _vector_reader(rows)) + table.set_unenforced_primary_key("id") + # num_partitions=1 makes the search exhaustive within the single partition + # (deterministic); num_bits=4 keeps PQ training viable on a tiny dataset. + table.create_index( + "vector", config=IvfPq(num_partitions=1, num_sub_vectors=2, num_bits=4) + ) + index_name = table.list_indices()[0].name + table.set_lsm_write_spec( + LsmWriteSpec.unsharded().with_maintained_indexes([index_name]) + ) + return table + + +def _vector_upsert(table, rows): + ( + table.merge_insert([]) + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(_vector_reader(rows)) + ) + + +def test_lsm_read_vector_sees_memtable(tmp_path): + table = _vector_table(tmp_path) + # id 1000 lands in the active memtable, not committed to the base table. + _vector_upsert(table, [(1000, "a", [1.0] * VECTOR_DIM)]) + + query = [1.0] * VECTOR_DIM + # Vector search auto-routes through the LSM scanner: indexed base ∪ memtable. + ids = set(table.search(query).limit(100).to_arrow()["id"].to_pylist()) + assert {1, 2, 3} <= ids # indexed base rows + assert 1000 in ids # in-flight memtable row + + # use_lsm(False) bypasses the MemWAL, so the in-flight row is not visible. + base_ids = set( + table.search(query).use_lsm(False).limit(100).to_arrow()["id"].to_pylist() + ) + assert {1, 2, 3} <= base_ids + assert 1000 not in base_ids + + +def test_lsm_read_vector_prefilter(tmp_path): + table = _vector_table(tmp_path) + # in-flight rows in both categories. + _vector_upsert( + table, [(1000, "a", [1.0] * VECTOR_DIM), (1001, "b", [1.0] * VECTOR_DIM)] + ) + + query = [1.0] * VECTOR_DIM + # The `where` predicate must apply as a prefilter across base ∪ memtable — + # regression test for the vector arm silently dropping the filter. + rows = table.search(query).where("category = 'a'").limit(100).to_arrow() + assert set(rows["id"].to_pylist()) == {1, 2, 1000} + assert set(rows["category"].to_pylist()) == {"a"} + + # Sanity: without the filter, other categories are returned too. + unfiltered = set(table.search(query).limit(100).to_arrow()["category"].to_pylist()) + assert unfiltered != {"a"} + + +def test_lsm_read_plain_prefilter(tmp_path): + table = _vector_table(tmp_path) + _vector_upsert( + table, [(1000, "a", [1.0] * VECTOR_DIM), (1001, "b", [1.0] * VECTOR_DIM)] + ) + + # Plain scan + filter over base ∪ memtable: base 'a' rows 1,2 and memtable 1000. + rows = table.search().where("category = 'a'").to_arrow() + assert set(rows["id"].to_pylist()) == {1, 2, 1000} + + +FTS_SCHEMA = pa.schema( + [ + pa.field("id", pa.int64(), nullable=False), + pa.field("text", pa.utf8(), nullable=False), + ] +) + + +def _fts_reader(rows): + """Rows are ``(id, text)`` tuples.""" + batch = pa.RecordBatch.from_arrays( + [ + pa.array([row[0] for row in rows], type=pa.int64()), + pa.array([row[1] for row in rows], type=pa.utf8()), + ], + schema=FTS_SCHEMA, + ) + return pa.RecordBatchReader.from_batches(FTS_SCHEMA, [batch]) + + +def test_lsm_read_fts_sees_memtable(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table( + "t", + _fts_reader( + [ + (1, "the quick brown fox"), + (2, "lazy dog sleeps"), + (3, "quick red fox"), + ] + ), + ) + table.set_unenforced_primary_key("id") + # Native FTS index (tantivy is not compatible with the LSM memtable index). + table.create_index("text", config=FTS()) + index_name = table.list_indices()[0].name + table.set_lsm_write_spec( + LsmWriteSpec.unsharded().with_maintained_indexes([index_name]) + ) + + # in-flight doc 4 lands in the memtable's maintained FTS index. + ( + table.merge_insert([]) + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(_fts_reader([(4, "brown fox jumps")])) + ) + + # Full-text search auto-routes through the LSM scanner: base ∪ memtable. + ids = set( + table.search("fox", query_type="fts", fts_columns="text") + .limit(10) + .to_arrow()["id"] + .to_pylist() + ) + assert ids == {1, 3, 4} + + # Prefilter restricts the FTS results across both tiers. + filtered = set( + table.search("fox", query_type="fts", fts_columns="text") + .where("id > 1") + .limit(10) + .to_arrow()["id"] + .to_pylist() + ) + assert filtered == {3, 4} + + +def test_lsm_read_vector_unsupported_knobs_error(tmp_path): + table = _vector_table(tmp_path) + _vector_upsert(table, [(1000, "a", [1.0] * VECTOR_DIM)]) + query = [1.0] * VECTOR_DIM + + # distance_range and use_index(False) change the vector result set/mode, which + # the LSM scanner can't honor, so it hard-errors instead of silently returning + # wrong results (matching the prefilter / unsupported-shape contract). + with pytest.raises(Exception, match="distance_range"): + table.search(query).distance_range(0.0, 0.5).to_arrow() + with pytest.raises(Exception, match="use_index"): + table.search(query).bypass_vector_index().to_arrow() + + # use_lsm(False) is the escape hatch: the base-only standard path honors them. + base = table.search(query).distance_range(0.0, 100.0).use_lsm(False).to_arrow() + assert 1000 not in set(base["id"].to_pylist()) + + +def test_lsm_read_vector_limit_offset(tmp_path): + table = _vector_table(tmp_path) + _vector_upsert(table, [(1000, "a", [1.0] * VECTOR_DIM)]) + query = [1.0] * VECTOR_DIM + # Lance's plan_vector over-fetches k + offset internally, so paging is correct: + # the second page is a full page (not truncated) and disjoint from the first. + page1 = table.search(query).limit(3).offset(0).to_arrow()["id"].to_pylist() + page2 = table.search(query).limit(3).offset(3).to_arrow()["id"].to_pylist() + assert len(page1) == 3 + # If k ignored offset, page2 would be empty (limit - offset = 0); a full second + # page that differs from the first proves offset widens the candidate pool. + assert len(page2) == 3 + assert set(page1) != set(page2) + + +def test_lsm_read_vector_postfilter_errors(tmp_path): + table = _vector_table(tmp_path) + _vector_upsert(table, [(1000, "a", [1.0] * VECTOR_DIM)]) + query = [1.0] * VECTOR_DIM + # The LSM scanner always prefilters; a requested postfilter changes results, so + # it hard-errors rather than silently prefiltering. + with pytest.raises(Exception, match="postfilter"): + table.search(query).where("category = 'a'").postfilter().to_arrow() + + +def test_lsm_read_projection_excludes_pk(tmp_path): + table = _vector_table(tmp_path) + _vector_upsert(table, [(1000, "a", [1.0] * VECTOR_DIM)]) + # Selecting only 'category' must not leak the 'id' primary key Lance appends + # internally for dedup. + rows = table.search().select(["category"]).where("category = 'a'").to_arrow() + assert rows.column_names == ["category"] + + +def test_lsm_read_fts_unmaintained_index_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _fts_reader([(1, "quick fox"), (2, "lazy dog")])) + table.set_unenforced_primary_key("id") + 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()) + with pytest.raises(Exception, match="maintained"): + table.search("fox", query_type="fts", fts_columns="text").to_arrow() + + +def test_lsm_read_time_travel_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) + table.set_unenforced_primary_key("id") + table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + pinned = table.version + table.add(_reader([4, 5])) # standard add commits a newer version + table.checkout(pinned) # detached head at the historical version + + # The WAL/manifest expose current live state, so an LSM read at a pinned + # historical version is rejected. + with pytest.raises(Exception, match="time-travel"): + table.search().to_arrow() + # use_lsm(False) reads the base table at the pinned version. + base = table.search().use_lsm(False).to_arrow() + assert sorted(base["id"].to_pylist()) == [1, 2, 3] + + +def test_lsm_read_take_row_ids_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _reader([1, 2, 3])) + table.set_unenforced_primary_key("id") + table.set_lsm_write_spec(LsmWriteSpec.unsharded()) + _lsm_upsert(table, [4]) + # take-by-row-id auto-routes through the LSM scanner, which has no stable _rowid, + # so it hard-errors instead of failing with an opaque column-not-found error. + with pytest.raises(Exception, match="row id"): + table.take_row_ids([0, 1]).to_arrow() + # use_lsm(False) is the escape hatch: it reads the base table. + base = table.take_row_ids([0, 1]).use_lsm(False).to_arrow() + assert base.num_rows == 2 + + +def test_lsm_read_fts_postfilter_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _fts_reader([(1, "quick fox"), (2, "lazy dog")])) + table.set_unenforced_primary_key("id") + table.create_index("text", config=FTS()) + index_name = table.list_indices()[0].name + table.set_lsm_write_spec( + LsmWriteSpec.unsharded().with_maintained_indexes([index_name]) + ) + # The LSM scanner always prefilters; postfilter on FTS changes result semantics, + # so it hard-errors (previously only the vector arm rejected it). + with pytest.raises(Exception, match="postfilter"): + ( + table.search("fox", query_type="fts", fts_columns="text") + .where("id > 0") + .postfilter() + .to_arrow() + ) + + +def test_lsm_read_fts_multiple_same_type_indexes_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _fts_reader([(1, "quick fox"), (2, "lazy dog")])) + table.set_unenforced_primary_key("id") + table.create_index("text", config=FTS(), name="fts_a") + table.create_index("text", config=FTS(), name="fts_b", replace=False) + table.set_lsm_write_spec( + LsmWriteSpec.unsharded().with_maintained_indexes(["fts_a"]) + ) + # Two FTS indexes on the column: the base planner's chosen index is ambiguous, so + # the scanner can't pick a catch-up watermark and rejects rather than risk + # dropping rows the actually-used index has not caught up to. + with pytest.raises(Exception, match="multiple"): + table.search("fox", query_type="fts", fts_columns="text").to_arrow() + + +def test_lsm_read_vector_unmaintained_index_errors(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + rows = [ + (i, "a", [float((i * 7 + j) % 13) for j in range(VECTOR_DIM)]) + for i in range(1, 61) + ] + table = db.create_table("t", _vector_reader(rows)) + table.set_unenforced_primary_key("id") + table.create_index( + "vector", config=IvfPq(num_partitions=1, num_sub_vectors=2, num_bits=4) + ) + # 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()) + with pytest.raises(Exception, match="maintained"): + table.search([1.0] * VECTOR_DIM).to_arrow() + + +def test_lsm_read_fts_optimized_index_not_rejected(tmp_path): + db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0)) + table = db.create_table("t", _fts_reader([(i, "quick fox") for i in range(1, 6)])) + table.set_unenforced_primary_key("id") + table.create_index("text", config=FTS()) + table.add(_fts_reader([(i, "lazy fox") for i in range(6, 11)])) + table.optimize() # may split the FTS index into multiple physical segments + name = table.list_indices()[0].name + table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([name])) + # Multiple physical segments of one logical index must not be miscounted as + # multiple indexes and rejected. + ids = set( + table.search("fox", query_type="fts", fts_columns="text") + .limit(20) + .to_arrow()["id"] + .to_pylist() + ) + assert ids == set(range(1, 11)) diff --git a/python/python/tests/test_permutation.py b/python/python/tests/test_permutation.py index 7ff35e5e2..e23d2da6b 100644 --- a/python/python/tests/test_permutation.py +++ b/python/python/tests/test_permutation.py @@ -1136,3 +1136,61 @@ def test_take_offsets_empty_permutation(some_permutation: Permutation): result = some_permutation.take_offsets([]) assert result == [] + + +def test_select_rowid(some_permutation: Permutation): + """Test that _rowid can be selected alongside regular columns.""" + perm_with_rowid = some_permutation.select_columns(["_rowid", "id"]) + assert "_rowid" in perm_with_rowid.column_names + batches = list(perm_with_rowid.iter(100, skip_last_batch=False)) + for batch in batches: + assert "_rowid" in batch[0] + + +def test_select_rowid_only(some_permutation: Permutation): + """Test that _rowid can be selected as the sole column.""" + perm_rowid_only = some_permutation.select_columns(["_rowid"]) + assert perm_rowid_only.column_names == ["_rowid"] + batches = list(perm_rowid_only.iter(100, skip_last_batch=False)) + assert len(batches) > 0 + for batch in batches: + assert list(batch[0].keys()) == ["_rowid"] + + +def test_select_rowid_not_in_default(some_permutation: Permutation): + """Test that _rowid is NOT in the default column_names or schema.""" + assert "_rowid" not in some_permutation.column_names + assert "_rowid" not in some_permutation.schema.names + + +def test_select_rowid_identity_permutation(mem_db): + """Test that _rowid works with an identity permutation.""" + tbl = mem_db.create_table( + "test_rowid_identity", pa.table({"id": range(10), "value": range(10)}) + ) + perm = Permutation.identity(tbl) + perm_with_rowid = perm.select_columns(["_rowid", "id"]) + batches = list(perm_with_rowid.iter(10, skip_last_batch=False)) + assert len(batches) == 1 + assert "_rowid" in batches[0][0] + + +def test_rename_rowid(some_permutation: Permutation): + """Test that _rowid can be selected and then renamed.""" + perm_with_rowid = some_permutation.select_columns(["_rowid", "id"]) + renamed = perm_with_rowid.rename_column("_rowid", "my_row_id") + assert "my_row_id" in renamed.column_names + assert "_rowid" not in renamed.column_names + batches = list(renamed.iter(100, skip_last_batch=False)) + for batch in batches: + assert "my_row_id" in batch[0] + assert "_rowid" not in batch[0] + + +def test_remove_rowid_after_select(some_permutation: Permutation): + """Test that _rowid can be selected and then removed.""" + perm_with_rowid = some_permutation.select_columns(["_rowid", "id"]) + assert "_rowid" in perm_with_rowid.column_names + perm_without_rowid = perm_with_rowid.remove_columns(["_rowid"]) + assert "_rowid" not in perm_without_rowid.column_names + assert perm_without_rowid.column_names == ["id"] diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index 477ee7496..648a52b7a 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -768,7 +768,10 @@ def test_table_create_indices(): # Test create_fts_index with custom name (legacy method) with pytest.warns(DeprecationWarning, match="create_fts_index"): table.create_fts_index( - "text", wait_timeout=timedelta(seconds=2), name="custom_fts_idx" + "text", + wait_timeout=timedelta(seconds=2), + block_size=256, + name="custom_fts_idx", ) # Test create_index with custom name (legacy form: vector_column_name kwarg) @@ -791,6 +794,7 @@ def test_table_create_indices(): fts_req = received_requests[1] assert "name" in fts_req assert fts_req["name"] == "custom_fts_idx" + assert fts_req["block_size"] == 256 # Check vector index request has custom name vector_req = received_requests[2] @@ -876,7 +880,7 @@ def test_remote_create_index_new_api(): _warnings.simplefilter("error", DeprecationWarning) table.create_index("vector", config=IvfPq(distance_type="l2")) table.create_index("category", config=BTree()) - table.create_index("text", config=FTS()) + table.create_index("text", config=FTS(block_size=256)) # IvfRq via new API table.create_index("vector", config=IvfRq(distance_type="l2")) @@ -896,6 +900,7 @@ def test_remote_create_index_new_api(): "vector", "vector", ] + assert received_requests[2]["block_size"] == 256 def test_table_wait_for_index_timeout(): diff --git a/python/python/tests/test_rerankers.py b/python/python/tests/test_rerankers.py index 7c2b52ffb..372a6b0f7 100644 --- a/python/python/tests/test_rerankers.py +++ b/python/python/tests/test_rerankers.py @@ -644,6 +644,21 @@ def test_cross_encoder_reranker_return_all(tmp_path): assert "_distance" in result.column_names +def test_answerdotai_reranker_return_all(tmp_path): + pytest.importorskip("rerankers") + reranker = AnswerdotaiRerankers(return_score="all") + table, schema = get_test_table(tmp_path) + query = "single player experience" + result = ( + table.search(query, query_type="hybrid", vector_column_name="vector") + .rerank(reranker=reranker) + .to_arrow() + ) + assert "_relevance_score" in result.column_names + assert "_score" in result.column_names + assert "_distance" in result.column_names + + # --------------------------------------------------------------------------- # Regression tests for LinearCombinationReranker scoring bugs (issue #3154) # --------------------------------------------------------------------------- diff --git a/python/src/index.rs b/python/src/index.rs index 121d3875d..a98b90cf9 100644 --- a/python/src/index.rs +++ b/python/src/index.rs @@ -60,6 +60,9 @@ pub fn extract_index_params(source: &Option>) -> PyResult { @@ -207,6 +210,7 @@ struct FtsParams { ngram_min_length: u32, ngram_max_length: u32, prefix_only: bool, + block_size: usize, } #[derive(FromPyObject)] diff --git a/python/src/query.rs b/python/src/query.rs index 70ef3b281..affbdf4fd 100644 --- a/python/src/query.rs +++ b/python/src/query.rs @@ -294,6 +294,7 @@ pub struct PyQueryRequest { pub select: PySelect, pub fast_search: Option, pub with_row_id: Option, + pub use_lsm: Option, pub column: Option, pub query_vector: Option, pub minimum_nprobes: Option, @@ -324,6 +325,7 @@ impl From for PyQueryRequest { select: PySelect(query_request.select), fast_search: Some(query_request.fast_search), with_row_id: Some(query_request.with_row_id), + use_lsm: query_request.use_lsm, column: None, query_vector: None, minimum_nprobes: None, @@ -348,6 +350,7 @@ impl From for PyQueryRequest { select: PySelect(vector_query.base.select), fast_search: Some(vector_query.base.fast_search), with_row_id: Some(vector_query.base.with_row_id), + use_lsm: vector_query.base.use_lsm, column: vector_query.column, query_vector: Some(PyQueryVectors(vector_query.query_vector)), minimum_nprobes: Some(vector_query.minimum_nprobes), @@ -474,6 +477,10 @@ impl Query { self.inner = self.inner.clone().fast_search(); } + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + pub fn with_row_id(&mut self) { self.inner = self.inner.clone().with_row_id(); } @@ -636,6 +643,10 @@ impl TakeQuery { self.inner = self.inner.clone().with_row_id(); } + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + #[pyo3(signature = ())] pub fn output_schema(self_: PyRef<'_, Self>) -> PyResult> { let inner = self_.inner.clone(); @@ -745,6 +756,10 @@ impl FTSQuery { self.inner = self.inner.clone().fast_search(); } + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + pub fn with_row_id(&mut self) { self.inner = self.inner.clone().with_row_id(); } @@ -892,6 +907,10 @@ impl VectorQuery { self.inner = self.inner.clone().fast_search(); } + pub fn use_lsm(&mut self, enable: bool) { + self.inner = self.inner.clone().use_lsm(enable); + } + pub fn with_row_id(&mut self) { self.inner = self.inner.clone().with_row_id(); } @@ -1086,6 +1105,11 @@ impl HybridQuery { self.inner_fts.postfilter(); } + pub fn use_lsm(&mut self, enable: bool) { + self.inner_vec.use_lsm(enable); + self.inner_fts.use_lsm(enable); + } + pub fn add_query_vector(&mut self, vector: Bound<'_, PyAny>) -> PyResult<()> { self.inner_vec.add_query_vector(vector) } diff --git a/python/src/table.rs b/python/src/table.rs index 277a99b70..bb44fc023 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -1236,8 +1236,8 @@ impl Table { if let Some(use_index) = parameters.use_index { builder.use_index(use_index); } - if let Some(use_lsm_write) = parameters.use_lsm_write { - builder.use_lsm_write(use_lsm_write); + if let Some(use_lsm) = parameters.use_lsm { + builder.use_lsm(use_lsm); } if let Some(validate_single_shard) = parameters.validate_single_shard { builder.validate_single_shard(validate_single_shard); @@ -1475,7 +1475,7 @@ pub struct MergeInsertParams { when_not_matched_by_source_condition_expr: Option, timeout: Option, use_index: Option, - use_lsm_write: Option, + use_lsm: Option, validate_single_shard: Option, } diff --git a/release_process.md b/release_process.md index 6df46962c..f223504a7 100644 --- a/release_process.md +++ b/release_process.md @@ -1,12 +1,17 @@ # Release process -There are five total packages we release. Four are the `lancedb` packages -for Python, Rust, Java, and Node.js. The other one is the legacy `vectordb` -package node.js. +We release four `lancedb` packages: Python, Rust, Java, and Node.js. -The Python package is versioned and released separately from the Rust, Java, and Node.js -ones. For Node.js the release process is shared between `lancedb` and -`vectordb` for now. +All four share a single version number, defined by `current_version` in +`.bumpversion.toml`. One `vX.Y.Z` tag releases all of them, so a breaking change +in any SDK bumps the minor version for every SDK. + +> [!NOTE] +> Python used to be versioned separately, under `python-vX.Y.Z` tags. It ran +> three minor versions ahead of the other SDKs, which made the two numbers hard +> to reason about. Both tracks were merged at `v0.37.0`: the Python line went +> `0.36` → `0.37` as usual, while Rust, Java, and Node.js jumped `0.33` → `0.37` +> to catch up. Tags before `v0.37.0` follow the old split scheme. ## Preview releases @@ -27,20 +32,21 @@ The release process uses a handful of GitHub actions to automate the process. ┌─────────────────────┐ │Create Release Commit│ └─┬───────────────────┘ - │ ┌────────────┐ ┌──►Python GH Release - ├──►(tag) python-vX.Y.Z ───►│PyPI Publish├─┤ - │ └────────────┘ └──►Python Wheels - │ - │ ┌───────────┐ - └──►(tag) vX.Y.Z ───┬──────►│NPM Publish├──┬──►Rust/Node GH Release - │ └───────────┘ │ - │ └──►NPM Packages - │ ┌─────────────┐ - ├──────►│Cargo Publish├───►Cargo Release - │ └─────────────┘ - │ ┌─────────────┐ - └──────►│Maven Publish├───►Java Maven Repo Release - └─────────────┘ + │ ┌──────────────┐ + └──►(tag) vX.Y.Z ─┬─►│GitHub Release├───►GH Release + │ └──────────────┘ + │ ┌────────────┐ + ├─►│PyPI Publish├─────►Python Wheels + │ └────────────┘ + │ ┌───────────┐ + ├─►│NPM Publish├──────►NPM Packages + │ └───────────┘ + │ ┌─────────────┐ + ├─►│Cargo Publish├────►Cargo Release + │ └─────────────┘ + │ ┌─────────────┐ + └─►│Maven Publish├────►Java Maven Repo Release + └─────────────┘ ``` To start a release, trigger a `Create Release Commit` action from diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 3be464a71..ad6a4e2d6 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb" -version = "0.32.0-beta.2" +version = "0.37.0-beta.0" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true diff --git a/rust/lancedb/src/index.rs b/rust/lancedb/src/index.rs index 08706ce54..308a64ca1 100644 --- a/rust/lancedb/src/index.rs +++ b/rust/lancedb/src/index.rs @@ -54,7 +54,26 @@ pub enum Index { /// substrings of the raw bytes, unlike the tokenized [`Index::FTS`] index. Fm(FmIndexBuilder), - /// Full text search index using bm25. + /// Full text search index using BM25. + /// + /// The posting block size defaults to 128. Supported values are 128 and 256; + /// a value of 256 uses the experimental FTS V3 format and may introduce + /// breaking changes. + /// + /// ``` + /// use lancedb::index::{Index, scalar::FtsIndexBuilder}; + /// + /// # async fn create_fts_index( + /// # table: &lancedb::Table, + /// # ) -> Result<(), Box> { + /// let params = FtsIndexBuilder::default().block_size(256)?; + /// table + /// .create_index(&["text"], Index::FTS(params)) + /// .execute() + /// .await?; + /// # Ok(()) + /// # } + /// ``` FTS(FtsIndexBuilder), /// IVF index diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 057771bdc..3cdf33615 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -167,6 +167,11 @@ //! # } //! ``` +// The MemWAL LSM read path (`table::query::lsm`) deepens the `create_plan` future's +// type graph enough to overflow the default trait-recursion limit while evaluating +// auto-traits (`Send`) through the Linux io_uring build's moka cache. Raise it. +#![recursion_limit = "256"] + pub mod arrow; pub mod blob; pub mod connection; diff --git a/rust/lancedb/src/query.rs b/rust/lancedb/src/query.rs index 727d87c38..b76865043 100644 --- a/rust/lancedb/src/query.rs +++ b/rust/lancedb/src/query.rs @@ -523,6 +523,26 @@ pub trait QueryBase { /// /// This allows ordering query results by one or more columns in either ascending or descending order. fn order_by(self, ordering: Option>) -> Self; + + /// Control MemWAL read routing for this query. + /// + /// By default (unset), when the table carries a MemWAL write spec (see + /// [`crate::Table::set_lsm_write_spec`]), reads are routed through the LSM + /// scanner so they also return data written via the `merge_insert` LSM path + /// that has not yet been compacted into the base table (active/frozen + /// memtables and flushed generations); a table without a spec reads the base + /// table. + /// + /// - `use_lsm(true)` forces LSM routing and errors if the table has no + /// MemWAL write spec. + /// - `use_lsm(false)` bypasses the MemWAL and reads the base table only, + /// even when a spec is present. + /// + /// Note: the LSM scanner does not support every query shape (e.g. reranking, + /// hybrid search, `order_by`). On a MemWAL table those shapes error unless + /// `use_lsm(false)` is set, because a base-only read would silently + /// exclude un-compacted MemWAL data. + fn use_lsm(self, enable: bool) -> Self; } pub trait HasQuery { @@ -593,6 +613,11 @@ impl QueryBase for T { self.mut_query().order_by = ordering; self } + + fn use_lsm(mut self, enable: bool) -> Self { + self.mut_query().use_lsm = Some(enable); + self + } } /// Options for controlling the execution of a query @@ -844,6 +869,20 @@ pub struct QueryRequest { /// /// This allows ordering query results by one or more columns in either ascending or descending order. pub order_by: Option>, + + /// Controls MemWAL read routing. When unset (the default), a query against a + /// table that carries a MemWAL write spec (see + /// [`crate::Table::set_lsm_write_spec`]) is routed through the LSM scanner so + /// it also sees data written via the `merge_insert` LSM path that has not yet + /// been compacted into the base table — the active and frozen in-memory + /// memtables and the flushed (L0) generations, deduplicated by primary key + /// against the base table (newest generation wins); a table without a spec + /// reads the base table. + /// + /// - `Some(true)` forces LSM routing and errors if the table has no MemWAL + /// write spec. + /// - `Some(false)` reads only the base table, bypassing the MemWAL. + pub use_lsm: Option, } impl Default for QueryRequest { @@ -862,6 +901,7 @@ impl Default for QueryRequest { norm: None, disable_scoring_autoprojection: false, order_by: None, + use_lsm: None, } } } diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 2518ed65c..b17e6c2bb 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -617,6 +617,11 @@ impl RemoteTable { ) -> Result<()> { params.check_filter()?; body["prefilter"] = params.prefilter.into(); + // Only forward use_lsm when explicitly set; a server that predates it + // ignores the field and routes as it would by default. + if let Some(use_lsm) = params.use_lsm { + body["use_lsm"] = serde_json::Value::Bool(use_lsm); + } if let Some(offset) = params.offset { body["offset"] = serde_json::Value::Number(serde_json::Number::from(offset)); } @@ -2843,6 +2848,10 @@ struct MergeInsertRequest { // (the default is true) #[serde(skip_serializing_if = "is_true")] use_index: bool, + // Only serialize use_lsm when explicitly set (Some); a server that predates + // it ignores the field and routes as it would by default. + #[serde(skip_serializing_if = "Option::is_none")] + use_lsm: Option, } fn is_true(b: &bool) -> bool { @@ -2894,6 +2903,7 @@ impl TryFrom for MergeInsertRequest { when_not_matched_by_source_delete_filt, // Only serialize use_index when it's false for backwards compatibility use_index: value.use_index, + use_lsm: value.use_lsm, }) } } @@ -4496,6 +4506,15 @@ mod tests { serde_json::to_value(InvertedIndexParams::default()).unwrap(), Index::FTS(Default::default()), ), + ( + "FTS", + { + let mut body = serde_json::to_value(InvertedIndexParams::default()).unwrap(); + body["block_size"] = 256.into(); + body + }, + Index::FTS(InvertedIndexParams::default().block_size(256).unwrap()), + ), ]; for (index_type, expected_body, index) in cases { diff --git a/rust/lancedb/src/table/create_index.rs b/rust/lancedb/src/table/create_index.rs index 3af661b1a..6be9de0e6 100644 --- a/rust/lancedb/src/table/create_index.rs +++ b/rust/lancedb/src/table/create_index.rs @@ -382,7 +382,9 @@ mod tests { use crate::connect; use crate::connection::ConnectBuilder; use crate::index::Index; - use crate::index::scalar::{BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder}; + use crate::index::scalar::{ + BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, FtsIndexBuilder, + }; use crate::index::vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, }; @@ -1362,7 +1364,10 @@ mod tests { .unwrap(); table - .create_index(&["text"], Index::FTS(Default::default())) + .create_index( + &["text"], + Index::FTS(FtsIndexBuilder::default().block_size(256).unwrap()), + ) .execute() .await .unwrap(); @@ -1372,6 +1377,10 @@ mod tests { assert_eq!(index.index_type, crate::index::IndexType::FTS); assert_eq!(index.columns, vec!["text".to_string()]); assert_eq!(index.name, "text_idx"); + assert_eq!(index.index_version, Some(3)); + let index_params: FtsIndexBuilder = + serde_json::from_str(index.index_details.as_deref().unwrap()).unwrap(); + assert_eq!(index_params.posting_block_size(), 256); let num_rows = 120; let stats = table.index_stats("text_idx").await.unwrap().unwrap(); diff --git a/rust/lancedb/src/table/merge.rs b/rust/lancedb/src/table/merge.rs index a122ba2f2..13a633c67 100644 --- a/rust/lancedb/src/table/merge.rs +++ b/rust/lancedb/src/table/merge.rs @@ -73,7 +73,7 @@ pub struct MergeInsertBuilder { pub(crate) when_not_matched_by_source_delete_filt: Option, pub(crate) timeout: Option, pub(crate) use_index: bool, - pub(crate) use_lsm_write: Option, + pub(crate) use_lsm: Option, pub(crate) validate_single_shard: bool, } @@ -89,7 +89,7 @@ impl MergeInsertBuilder { when_not_matched_by_source_delete_filt: None, timeout: None, use_index: true, - use_lsm_write: None, + use_lsm: None, validate_single_shard: true, } } @@ -187,16 +187,17 @@ impl MergeInsertBuilder { self } - /// Controls whether `merge_insert` uses the MemWAL LSM write path. + /// Control MemWAL routing for this `merge_insert`. /// /// By default (unset), a `merge_insert` on a table with an - /// [`LsmWriteSpec`](super::LsmWriteSpec) installed is routed through - /// Lance's MemWAL shard writer, and a table without one uses the standard - /// path. Calling this with `false` forces the standard path even when a - /// spec is set. Calling it with `true` requires a spec — `merge_insert` - /// errors if none is installed. - pub fn use_lsm_write(&mut self, use_lsm_write: bool) -> &mut Self { - self.use_lsm_write = Some(use_lsm_write); + /// [`LsmWriteSpec`](super::LsmWriteSpec) installed is routed through Lance's + /// MemWAL shard writer; a table without one uses the standard path. + /// + /// - `use_lsm(true)` forces MemWAL routing and errors if the table has no + /// LSM write spec. + /// - `use_lsm(false)` forces the standard write path even when a spec is set. + pub fn use_lsm(&mut self, enable: bool) -> &mut Self { + self.use_lsm = Some(enable); self } @@ -626,7 +627,7 @@ mod lsm_tests { } #[tokio::test] - async fn lsm_merge_insert_use_lsm_write_false_falls_back() { + async fn lsm_merge_insert_use_lsm_false_falls_back() { let dir = tempdir().unwrap(); let table = id_value_table(&dir).await; table @@ -634,9 +635,10 @@ mod lsm_tests { .await .unwrap(); - // use_lsm_write(false) opts out: the standard path runs and commits. + // use_lsm(false) opts out: the standard path runs and commits even though + // a spec is installed. let mut builder = table.merge_insert(&["id"]); - builder.when_not_matched_insert_all().use_lsm_write(false); + builder.when_not_matched_insert_all().use_lsm(false); let result = builder .execute(id_value_reader(vec![3, 4, 5])) .await @@ -646,6 +648,25 @@ mod lsm_tests { assert_eq!(table.count_rows(None).await.unwrap(), 5); } + #[tokio::test] + async fn lsm_merge_insert_use_lsm_true_without_spec_errors() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; + + // use_lsm(true) demands MemWAL routing; without a write spec it errors + // rather than silently falling back to the standard path. + let mut builder = table.merge_insert(&["id"]); + builder + .when_matched_update_all(None) + .when_not_matched_insert_all() + .use_lsm(true); + let err = builder + .execute(id_value_reader(vec![3, 4, 5])) + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}"); + } + #[tokio::test] async fn lsm_merge_insert_rejects_on_not_primary_key() { let dir = tempdir().unwrap(); @@ -754,18 +775,23 @@ mod lsm_tests { } #[tokio::test] - async fn lsm_merge_insert_use_lsm_write_true_requires_spec() { + async fn lsm_merge_insert_no_spec_uses_standard_path() { let dir = tempdir().unwrap(); // id_value_table sets a primary key but no LSM write spec. let table = id_value_table(&dir).await; + // Without a spec, a default merge_insert (use_lsm unset) simply uses + // the standard path and commits — no opt-out required, no error. let mut builder = table.merge_insert(&["id"]); builder .when_matched_update_all(None) - .when_not_matched_insert_all() - .use_lsm_write(true); - let err = builder.execute(id_value_reader(vec![4])).await.unwrap_err(); - assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}"); + .when_not_matched_insert_all(); + let result = builder + .execute(id_value_reader(vec![3, 4, 5])) + .await + .unwrap(); + assert_eq!(result.num_inserted_rows, 2); + assert_eq!(table.count_rows(None).await.unwrap(), 5); } #[tokio::test] @@ -818,4 +844,387 @@ mod lsm_tests { .await .unwrap(); } + + // --------------------------------------------------------------------- + // LSM read path + // --------------------------------------------------------------------- + + use crate::arrow::SendableRecordBatchStream; + use crate::query::{ExecutableQuery, QueryBase}; + use arrow::array::AsArray; + use arrow::datatypes::Int64Type; + use futures::TryStreamExt; + + /// Collect `(id, value)` pairs from a result stream, sorted by id. + async fn collect_id_value(stream: SendableRecordBatchStream) -> Vec<(i64, i64)> { + let batches: Vec<_> = stream.try_collect().await.unwrap(); + let mut rows = Vec::new(); + for batch in &batches { + let ids = batch + .column_by_name("id") + .unwrap() + .as_primitive::(); + let values = batch + .column_by_name("value") + .unwrap() + .as_primitive::(); + for i in 0..batch.num_rows() { + rows.push((ids.value(i), values.value(i))); + } + } + rows.sort(); + rows + } + + /// Upsert `ids` (value = 0..n) through the LSM `merge_insert` path. + async fn lsm_upsert(table: &Table, ids: Vec) { + let mut builder = table.merge_insert(&[]); + builder + .when_matched_update_all(None) + .when_not_matched_insert_all(); + builder.execute(id_value_reader(ids)).await.unwrap(); + } + + #[tokio::test] + async fn lsm_read_sees_active_memtable() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; // base: ids 1,2,3 (value 0,1,2) + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + + // Insert ids 4,5 into the active memtable (not committed to base). + lsm_upsert(&table, vec![4, 5]).await; + + // Default read auto-routes through the LSM scanner: base ∪ active memtable. + let lsm = table.query().execute().await.unwrap(); + let rows = collect_id_value(lsm).await; + assert_eq!( + rows.iter().map(|(id, _)| *id).collect::>(), + vec![1, 2, 3, 4, 5] + ); + + // use_lsm(false) bypasses the MemWAL and reads the base table only. + let base_only = table.query().use_lsm(false).execute().await.unwrap(); + let rows = collect_id_value(base_only).await; + assert_eq!( + rows.iter().map(|(id, _)| *id).collect::>(), + vec![1, 2, 3] + ); + } + + #[tokio::test] + async fn lsm_read_dedup_newest_wins() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; // base: id 2 -> value 1 + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + + // Upsert ids 2,3,4 with values 0,1,2. id 2 and 3 shadow the base rows. + lsm_upsert(&table, vec![2, 3, 4]).await; + + let lsm = table.query().execute().await.unwrap(); + let rows = collect_id_value(lsm).await; + // id 1 from base (value 0); ids 2,3,4 from memtable (values 0,1,2). + assert_eq!(rows, vec![(1, 0), (2, 0), (3, 1), (4, 2)]); + } + + #[tokio::test] + async fn lsm_read_point_lookup_filter() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + lsm_upsert(&table, vec![2, 3, 4]).await; // id 2 -> value 0 (shadows base) + + let lsm = table.query().only_if("id = 2").execute().await.unwrap(); + let rows = collect_id_value(lsm).await; + assert_eq!(rows, vec![(2, 0)]); + } + + #[tokio::test] + async fn lsm_read_multi_shard() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; + table + .set_lsm_write_spec(LsmWriteSpec::bucket("id", 8)) + .await + .unwrap(); + + // Two single-row upserts that route to (likely) different buckets; each + // closes the writer so the next opens a fresh shard. + lsm_upsert(&table, vec![10]).await; + table.close_lsm_writers().await.unwrap(); + lsm_upsert(&table, vec![11]).await; + + let lsm = table.query().execute().await.unwrap(); + let rows = collect_id_value(lsm).await; + let ids: Vec = rows.iter().map(|(id, _)| *id).collect(); + // Base 1,2,3 + flushed/active shards for 10 and 11. + assert_eq!(ids, vec![1, 2, 3, 10, 11]); + } + + #[tokio::test] + async fn lsm_read_after_close_sees_flushed() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + lsm_upsert(&table, vec![4, 5]).await; + // close flushes the active memtable to an on-disk generation and drops + // the cached writer; the read must still see those rows via the shard + // manifest snapshot. + table.close_lsm_writers().await.unwrap(); + + let lsm = table.query().execute().await.unwrap(); + let ids: Vec = collect_id_value(lsm) + .await + .iter() + .map(|(id, _)| *id) + .collect(); + assert_eq!(ids, vec![1, 2, 3, 4, 5]); + } + + #[tokio::test] + async fn lsm_read_without_spec_reads_base() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; // no LSM write spec + + // With no spec installed there is nothing to route: the default read and + // an explicit use_lsm(false) both read the base table without error. + for query in [table.query(), table.query().use_lsm(false)] { + let rows = collect_id_value(query.execute().await.unwrap()).await; + assert_eq!( + rows.iter().map(|(id, _)| *id).collect::>(), + vec![1, 2, 3] + ); + } + } + + #[tokio::test] + async fn lsm_read_unsupported_shape_errors_without_use_lsm_false() { + let dir = tempdir().unwrap(); + let table = id_value_table(&dir).await; + table + .set_lsm_write_spec(LsmWriteSpec::unsharded()) + .await + .unwrap(); + lsm_upsert(&table, vec![4]).await; + + // `with_row_id` is a shape the LSM scanner cannot honor. On a MemWAL + // table the default (auto-routed) read hard-errors rather than silently + // reading a stale base-only result that would exclude un-compacted row 4. + let err = table + .query() + .with_row_id() + .execute() + .await + .err() + .expect("unsupported shape on a MemWAL table must error"); + assert!(matches!(err, Error::NotSupported { .. }), "got {err:?}"); + + // use_lsm(false) is the escape hatch: it reads the base table only. + let rows = collect_id_value( + table + .query() + .with_row_id() + .use_lsm(false) + .execute() + .await + .unwrap(), + ) + .await; + assert_eq!( + rows.iter().map(|(id, _)| *id).collect::>(), + vec![1, 2, 3] + ); + } + + /// A reader of `[id: Int64, text: Utf8]` rows. + fn id_text_reader(rows: Vec<(i64, &str)>) -> Box { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("text", DataType::Utf8, false), + ])); + let ids: Vec = rows.iter().map(|(id, _)| *id).collect(); + let texts: Vec<&str> = rows.iter().map(|(_, t)| *t).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(ids)), + Arc::new(StringArray::from(texts)), + ], + ) + .unwrap(); + Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema)) + } + + #[tokio::test] + async fn lsm_read_full_text_search() { + use crate::index::Index; + use lance_index::scalar::FullTextSearchQuery; + + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let table = conn + .create_table( + "t", + id_text_reader(vec![(1, "alpha"), (2, "beta"), (3, "gamma")]), + ) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["id"]).await.unwrap(); + table + .create_index(&["text"], Index::FTS(Default::default())) + .execute() + .await + .unwrap(); + let fts_index = table.list_indices().await.unwrap()[0].name.clone(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes([fts_index])) + .await + .unwrap(); + + // Insert a row whose term ("zebra") exists in no base row. + let mut builder = table.merge_insert(&[]); + builder + .when_matched_update_all(None) + .when_not_matched_insert_all(); + builder + .execute(id_text_reader(vec![(99, "zebra")])) + .await + .unwrap(); + + let search = |term: &str| { + let q = FullTextSearchQuery::new(term.to_string()) + .with_column("text".to_string()) + .unwrap(); + table.query().full_text_search(q) + }; + + // "zebra" lives only in the active memtable; LSM read finds it. + let stream = search("zebra").execute().await.unwrap(); + let batches: Vec<_> = stream.try_collect().await.unwrap(); + let rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(rows, 1, "LSM FTS must surface the memtable row"); + + // A base-only term still matches the base table through the LSM scan. + let stream = search("alpha").execute().await.unwrap(); + let batches: Vec<_> = stream.try_collect().await.unwrap(); + let rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(rows, 1, "LSM FTS must still see base rows"); + } + + #[tokio::test] + async fn lsm_read_vector_search() { + use crate::index::Index; + use crate::index::vector::IvfPqIndexBuilder; + use arrow::array::{FixedSizeListBuilder, Float32Builder}; + use arrow::datatypes::Int64Type; + + const DIM: i32 = 8; + const N: i64 = 256; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), DIM), + false, + ), + ])); + let make_batch = |rows: Vec<(i64, f32)>| -> RecordBatch { + let ids: Vec = rows.iter().map(|(id, _)| *id).collect(); + let mut vb = FixedSizeListBuilder::new(Float32Builder::new(), DIM); + for (_, fill) in &rows { + for _ in 0..DIM { + vb.values().append_value(*fill); + } + vb.append(true); + } + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(ids)), Arc::new(vb.finish())], + ) + .unwrap() + }; + + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + // Base rows fill each vector with its own id (0..256); all far from 1000. + let base = make_batch((0..N).map(|i| (i, i as f32)).collect()); + let base_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(base)], schema.clone())); + let table = conn.create_table("t", base_reader).execute().await.unwrap(); + table.set_unenforced_primary_key(["id"]).await.unwrap(); + table + .create_index( + &["vec"], + Index::IvfPq( + IvfPqIndexBuilder::default() + .num_partitions(1) + .num_sub_vectors(2), + ), + ) + .execute() + .await + .unwrap(); + let vec_index = table.list_indices().await.unwrap()[0].name.clone(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes([vec_index])) + .await + .unwrap(); + + // Insert a vector (filled with 1000) that is nearest to the query. + let mut builder = table.merge_insert(&[]); + builder + .when_matched_update_all(None) + .when_not_matched_insert_all(); + let insert_reader: Box = Box::new(RecordBatchIterator::new( + vec![Ok(make_batch(vec![(9999, 1000.0)]))], + schema.clone(), + )); + builder.execute(insert_reader).await.unwrap(); + + // KNN near [1000; DIM]: the default (auto-routed) read surfaces the + // memtable row. + let stream = table + .query() + .nearest_to(&[1000.0_f32; 8]) + .unwrap() + .limit(1) + .execute() + .await + .unwrap(); + let batches: Vec<_> = stream.try_collect().await.unwrap(); + let ids: Vec = batches + .iter() + .flat_map(|b| { + b.column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec() + }) + .collect(); + assert_eq!( + ids, + vec![9999], + "LSM vector search must rank the memtable row first" + ); + } } diff --git a/rust/lancedb/src/table/merge/lsm.rs b/rust/lancedb/src/table/merge/lsm.rs index 9bc6ad088..0eb7c0231 100644 --- a/rust/lancedb/src/table/merge/lsm.rs +++ b/rust/lancedb/src/table/merge/lsm.rs @@ -306,6 +306,39 @@ impl ShardWriterEntry { } Ok(()) } + + /// The cached writer's latest in-memory manifest (current generation + + /// flushed generations). `Ok(None)` if the writer was already closed. + /// Used by the LSM read path to snapshot this shard authoritatively + /// without re-reading the on-disk manifest. + async fn manifest(&self) -> Result> { + let guard = self.inner.read().await; + let Some(writer) = guard.as_ref() else { + return Ok(None); + }; + writer.manifest().await.map_err(|e| Error::Runtime { + message: format!("read: shard writer manifest read failed: {}", e), + }) + } + + /// Atomically capture the cached writer's active + frozen-awaiting-flush + /// memtables for unified LSM scanning. `Ok(None)` if the writer was + /// already closed. + async fn in_memory_memtable_refs( + &self, + ) -> Result> { + let guard = self.inner.read().await; + let Some(writer) = guard.as_ref() else { + return Ok(None); + }; + writer + .in_memory_memtable_refs() + .await + .map(Some) + .map_err(|e| Error::Runtime { + message: format!("read: shard writer memtable capture failed: {}", e), + }) + } } impl ShardWriterCache { @@ -345,6 +378,36 @@ impl ShardWriterCache { Ok(entry) } + /// Snapshot the cached writer's shard for the LSM read path: its shard id, + /// authoritative in-memory manifest, and active + frozen memtable refs. + /// Returns `None` when no writer is currently cached (e.g. nothing has been + /// written this session, or the writer was closed). + #[allow(clippy::redundant_pub_crate)] + pub(crate) async fn read_snapshot( + &self, + ) -> Result< + Option<( + Uuid, + Option, + Option, + )>, + > { + let cached = { + let guard = self.slot.read().await; + guard.as_ref().map(|(id, entry)| (*id, entry.clone())) + }; + let Some((shard_id, entry)) = cached else { + return Ok(None); + }; + // Capture memtables before the manifest. If a flush interleaves, dedup + // tolerates the same rows appearing in both a memtable and a freshly + // flushed generation, but would drop rows present in neither. Manifest + // last guarantees any generation flushed mid-capture is still covered. + let memtables = entry.in_memory_memtable_refs().await?; + let manifest = entry.manifest().await?; + Ok(Some((shard_id, manifest, memtables))) + } + /// Close the cached writer, if any, and clear the slot. #[allow(clippy::redundant_pub_crate)] pub(crate) async fn drain_and_close(&self) -> Result<()> { @@ -408,19 +471,20 @@ pub(crate) async fn lsm_dispatch_decision( table: &NativeTable, params: &MergeInsertBuilder, ) -> Result { - // `Some(false)` is an explicit opt-out: use the standard path. - if params.use_lsm_write == Some(false) { + // Explicit opt-out: use the standard path regardless of any installed spec. + if params.use_lsm == Some(false) { return Ok(LsmDispatch::Standard); } let dataset = table.dataset.get().await?; let Some(details) = dataset.mem_wal_index_details().await? else { - // No LSM write spec installed. `Some(true)` explicitly asked for the - // LSM path, which is meaningless without a spec; `None` (the default) - // just falls back to the standard path. - if params.use_lsm_write == Some(true) { + // No write spec installed. `use_lsm(true)` demanded MemWAL routing, so + // that is an error; otherwise fall back to the standard path. + if params.use_lsm == Some(true) { return Err(Error::InvalidInput { - message: "merge_insert: use_lsm_write(true) requires an LSM write spec on the table; call set_lsm_write_spec first".to_string(), + message: "use_lsm(true) was set but the table has no MemWAL write spec; \ + install one with set_lsm_write_spec or leave use_lsm unset" + .to_string(), }); } return Ok(LsmDispatch::Standard); @@ -449,7 +513,7 @@ pub(crate) async fn lsm_dispatch_decision( if !is_upsert_only(params) { return Err(Error::InvalidInput { - message: "merge_insert: when an LSM write spec is set, only the upsert form (when_matched_update_all without a filter + when_not_matched_insert_all, no by-source delete) is supported; call use_lsm_write(false) to use the standard merge_insert path".to_string(), + message: "merge_insert: when an LSM write spec is set, only the upsert form (when_matched_update_all without a filter + when_not_matched_insert_all, no by-source delete) is supported; call use_lsm(false) to use the standard merge_insert path".to_string(), }); } diff --git a/rust/lancedb/src/table/query.rs b/rust/lancedb/src/table/query.rs index 28845e96f..9feb9d5ab 100644 --- a/rust/lancedb/src/table/query.rs +++ b/rust/lancedb/src/table/query.rs @@ -3,6 +3,8 @@ use std::sync::Arc; +mod lsm; + use super::NativeTable; use crate::connection::NamespaceClientPushdownOperation; use crate::error::{Error, Result}; @@ -20,6 +22,7 @@ use datafusion_physical_plan::projection::ProjectionExec; use datafusion_physical_plan::repartition::RepartitionExec; use datafusion_physical_plan::union::UnionExec; use futures::future::try_join_all; +use lance::dataset::mem_wal::DatasetMemWalExt; use lance::dataset::scanner::DatasetRecordBatchStream; use lance::dataset::scanner::Scanner; use lance_datafusion::exec::{analyze_plan as lance_analyze_plan, execute_plan}; @@ -53,7 +56,7 @@ pub async fn execute_query( // QueryTable pushdown runs the query server-side, but only on the main // branch: the namespace request carries no branch yet, so a branch handle // must fall through to local execution. - if can_execute_namespace_query(table, query) + if can_execute_namespace_query(table, query).await? && let Some(ref namespace_client) = table.namespace_client { return execute_namespace_query(table, namespace_client.clone(), query, options).await; @@ -61,18 +64,35 @@ pub async fn execute_query( execute_generic_query(table, query, options).await } -fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> bool { - table +async fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> Result { + if !(table .pushdown_operations .contains(&NamespaceClientPushdownOperation::QueryTable) && table.namespace_client.is_some() && table.dataset.current_branch().is_none() - && !requires_local_namespace_execution(query) + && !requires_local_namespace_execution(query)) + { + return Ok(false); + } + // A MemWAL write spec means reads auto-route through the LSM scanner in + // `create_plan` even when `use_lsm` is unset. The namespace request has no + // use_lsm field, so pushing the default query down would silently omit + // un-compacted rows — force local execution whenever a spec is installed. + let dataset = table.dataset.get().await?; + if dataset.mem_wal_index_details().await?.is_some() { + return Ok(false); + } + Ok(true) } fn requires_local_namespace_execution(query: &AnyQuery) -> bool { - // The namespace QueryTable request has no approx_mode field yet, so - // pushing this query down would silently ignore the user's setting. + // The namespace QueryTable request has no approx_mode or use_lsm field yet, so + // pushing these down would silently ignore the user's setting. For use_lsm that + // is worse than a tuning miss: MemWAL read routing lives only in `create_plan`, + // so a pushed-down query would return stale base-only data with no error. + if query.base().use_lsm.is_some() { + return true; + } matches!( query, AnyQuery::VectorQuery(VectorQueryRequest { @@ -120,6 +140,29 @@ pub async fn create_plan( query.base.check_filter()?; let ds_ref = table.dataset.get().await?; + + // MemWAL read routing driven by `use_lsm`: + // * unset — route through the LSM scanner iff the table carries a write spec + // * Some(true) — force LSM routing; error if the table has no write spec + // * Some(false) — read the base table only, bypassing the MemWAL + // The LSM scanner surfaces in-flight `merge_insert` data (active/frozen + // memtables + flushed generations); validation and dispatch live in `lsm`. + let has_spec = ds_ref.mem_wal_index_details().await?.is_some(); + let use_lsm = match query.base.use_lsm { + Some(true) if !has_spec => { + return Err(Error::InvalidInput { + message: "use_lsm(true) was set but the table has no MemWAL write spec; \ + install one with set_lsm_write_spec or leave use_lsm unset" + .to_string(), + }); + } + Some(enable) => enable, + None => has_spec, + }; + if use_lsm { + return lsm::create_lsm_plan(table, ds_ref, query).await; + } + let schema = ds_ref.schema(); let mut column = query.column.clone(); @@ -904,6 +947,65 @@ mod tests { assert_eq!(namespace_client.query_table_calls.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn test_execute_query_use_lsm_with_namespace_pushdown_runs_locally() { + use crate::connect; + use crate::table::query::execute_query; + use arrow_array::{Int32Array, RecordBatch}; + use arrow_schema::{DataType, Field, Schema}; + + let conn = connect("memory://").execute().await.unwrap(); + + let vectors = Arc::new(fixed_size_list_array( + vec![0.0, 0.0, 10.0, 10.0, 20.0, 20.0], + 2, + )); + 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(vec![1, 2, 3])), vectors], + ) + .unwrap(); + + let table = conn + .create_table("test_use_lsm_namespace_fallback", batch) + .execute() + .await + .unwrap(); + let namespace_client = Arc::new(CountingNamespaceClient::default()); + let mut native_table = table.as_native().unwrap().clone(); + native_table.namespace_client = Some(namespace_client.clone()); + native_table + .pushdown_operations + .insert(NamespaceClientPushdownOperation::QueryTable); + + // `use_lsm` set (even to false) must force local execution — the namespace + // request has no use_lsm field, so a pushdown would silently ignore it. + let query_vector = Arc::new(Float32Array::from(vec![0.0, 0.0])); + let query = AnyQuery::VectorQuery(VectorQueryRequest { + base: QueryRequest { + limit: Some(1), + use_lsm: Some(false), + ..Default::default() + }, + column: Some("vector".to_string()), + query_vector: vec![query_vector as ArrayRef], + ..Default::default() + }); + + let stream = execute_query(&native_table, &query, QueryExecutionOptions::default()) + .await + .unwrap(); + let batches = stream.try_collect::>().await.unwrap(); + let count: usize = batches.iter().map(|b| b.num_rows()).sum(); + + assert_eq!(count, 1); + assert_eq!(namespace_client.query_table_calls.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn test_create_plan_multivector_structure() { use arrow_array::{Float32Array, RecordBatch}; diff --git a/rust/lancedb/src/table/query/lsm.rs b/rust/lancedb/src/table/query/lsm.rs new file mode 100644 index 000000000..074d13476 --- /dev/null +++ b/rust/lancedb/src/table/query/lsm.rs @@ -0,0 +1,786 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! MemWAL LSM read path. +//! +//! When a table has an LSM write spec installed (see [`set_lsm_write_spec`]), +//! reads are routed through Lance's [`LsmScanner`] instead of the plain +//! base-table scan unless the query sets +//! [`use_lsm(false)`](crate::query::QueryBase::use_lsm). This makes data +//! written via the LSM `merge_insert` path — which lives in the active/frozen +//! in-memory memtables and the flushed SSTable generations until an external +//! compaction merges it into the base table — visible to queries, deduplicated by +//! primary key (newest generation wins). +//! +//! Three query shapes are supported, mirroring the standard scan: a plain scan +//! (filter / projection / limit), full-text search, and vector (ANN) search. All +//! three run through a single [`LsmScanner`], so a `where` filter is honored as a +//! prefilter uniformly — including for vector search, where `LsmScanner` threads +//! it into the vector planner's prefilter. Shapes the LSM path cannot honor are +//! rejected with [`Error::NotSupported`]; the caller must set `use_lsm(false)` to +//! run those against the base table. +//! +//! [`set_lsm_write_spec`]: crate::Table::set_lsm_write_spec + +use std::collections::HashMap; +use std::sync::Arc; + +use arrow_array::Array; +use arrow_schema::{DataType, Schema as ArrowSchema}; +use datafusion_physical_plan::expressions::Column; +use datafusion_physical_plan::projection::ProjectionExec; +use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr}; +use lance::Dataset; +use lance::dataset::mem_wal::scanner::InMemoryMemTables; +use lance::dataset::mem_wal::{ + DatasetMemWalExt, LsmScanner, ShardManifestStore, ShardSnapshot, ShardWriterConfig, +}; +use lance_index::mem_wal::{MemWalIndexDetails, ShardManifest}; +use uuid::Uuid; + +use super::NativeTable; +use crate::DistanceType; +use crate::error::{Error, Result}; +use crate::query::{DEFAULT_TOP_K, QueryFilter, Select, VectorQueryRequest}; +use crate::utils::default_vector_column; + +/// Over-fetch factor for the LSM vector/FTS arms. With the default of `1.0` a +/// source blocked by cross-generation PK dedup fetches exactly `k` and can return +/// fewer than `k` live rows; upserts routinely create such blocked candidates, so +/// widen the per-source fetch to keep result pages filled. +const LSM_OVERFETCH_FACTOR: f64 = 2.0; + +/// Build the LSM read plan for a MemWAL-routed query. +/// +/// The caller guarantees `ds_ref` carries a MemWAL write spec (routing is decided +/// in [`create_plan`](super::create_plan)). Errors with [`Error::NotSupported`] +/// for query shapes the LSM scanner cannot honor — the caller must set +/// `use_lsm(false)` to run those against the base table. +pub(super) async fn create_lsm_plan( + table: &NativeTable, + ds_ref: Arc, + query: VectorQueryRequest, +) -> Result> { + reject_unsupported(&query)?; + + // A time-traveled (checked-out) handle pins an older dataset version, but the + // WAL manifests and cached writer expose current live state — mixing them would + // surface WAL rows written after the requested version. `use_lsm(false)` reads + // the base table at the pinned version. + if table.dataset.time_travel_version().is_some() { + return Err(Error::NotSupported { + message: "the MemWAL LSM scanner cannot read from a time-traveled dataset version; set use_lsm(false) to read the base table at this version".to_string(), + }); + } + + // Routing guarantees a write spec is installed (see `create_plan`). + let details = ds_ref + .mem_wal_index_details() + .await? + .ok_or_else(|| Error::Runtime { + message: "the MemWAL LSM write spec disappeared during read planning".to_string(), + })?; + + 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 limit = query.base.limit; + let offset = query.base.offset; + + let plan = if !query.query_vector.is_empty() { + vector_plan( + &ds_ref, + &query, + &details, + pk_columns.clone(), + snapshots, + in_memory, + limit, + offset, + ) + .await? + } else if let Some(fts) = &query.base.full_text_search { + fts_plan( + &ds_ref, + fts.clone(), + &query, + &details, + pk_columns.clone(), + snapshots, + in_memory, + limit, + offset, + ) + .await? + } else { + plain_plan( + &ds_ref, + &query, + pk_columns.clone(), + snapshots, + in_memory, + limit, + offset, + ) + .await? + }; + + // Lance appends the primary-key columns internally for dedup and keeps them in + // the output; drop the ones the user did not request so the projection matches. + restore_projection(plan, &query, &pk_columns) +} + +/// Reject query shapes the LSM read path does not implement. On a MemWAL table +/// reads route through the LSM scanner by default, so an unsupported shape is a +/// hard error rather than a silent fallback to the base-only scan — which would +/// exclude un-compacted MemWAL data. The caller must set `use_lsm(false)` to +/// run these against the base table, accepting that the results omit un-compacted +/// MemWAL data. +/// +/// A `where` filter is intentionally *not* rejected: every arm routes through +/// [`LsmScanner`], which applies it as a prefilter (see [`base_scanner`]). +fn reject_unsupported(query: &VectorQueryRequest) -> Result<()> { + let unsupported = |what: &str| { + Err(Error::NotSupported { + message: format!( + "the MemWAL LSM scanner does not support {what}; set use_lsm(false) to read the base table only (results will exclude un-compacted MemWAL data)" + ), + }) + }; + if query.query_vector.len() > 1 { + return unsupported("multiple query vectors"); + } + if !query.query_vector.is_empty() && query.base.full_text_search.is_some() { + return unsupported("hybrid (vector + full-text) search"); + } + if query.base.with_row_id { + return unsupported("with_row_id (the LSM scanner exposes _rowaddr, not a stable _rowid)"); + } + if query.base.reranker.is_some() { + return unsupported("reranking / hybrid search"); + } + if query.base.order_by.is_some() { + return unsupported("order_by"); + } + // Vector-only knobs the LSM scanner cannot honor. Both change results rather + // than just recall, so error instead of silently ignoring them: distance_range + // would return rows outside the bound, and use_index(false) asks for a + // brute-force search the index-only base arm can't do. (ef / approx_mode / + // maximum_nprobes are recall/speed knobs and are left to no-op — and + // maximum_nprobes defaults to Some, so it cannot be rejected on presence.) + if !query.query_vector.is_empty() { + if query.lower_bound.is_some() || query.upper_bound.is_some() { + return unsupported("distance_range on vector search"); + } + if !query.use_index { + return unsupported( + "use_index(false) / brute-force vector search (the LSM base arm is index-only)", + ); + } + } + // Postfilter changes result semantics for both vector and full-text search, and + // the LSM scanner always prefilters — reject a requested postfilter for either. + if (!query.query_vector.is_empty() || query.base.full_text_search.is_some()) + && !query.base.prefilter + { + return unsupported( + "postfilter on vector or full-text search (the LSM scanner always prefilters)", + ); + } + match &query.base.select { + Select::All | Select::Columns(_) => {} + Select::Dynamic(_) | Select::Expr(_) => return unsupported("dynamic column projection"), + } + if let Some(QueryFilter::Substrait(_)) = &query.base.filter { + return unsupported("Substrait filters"); + } + // Take-by-row-id / row-offset queries carry a `_rowid` / `_rowoffset` filter, + // columns the LSM scanner never exposes (only `_rowaddr`); reject with guidance + // rather than failing deep in datafusion with a column-not-found error. + if let Some(QueryFilter::Datafusion(expr)) = &query.base.filter + && expr + .column_refs() + .iter() + .any(|c| c.name == "_rowid" || c.name == "_rowoffset") + { + return unsupported( + "take by row id or row offset (the LSM scanner has no stable _rowid / _rowoffset)", + ); + } + Ok(()) +} + +/// Primary-key column names from the dataset's unenforced primary key. +fn pk_columns(dataset: &Dataset) -> Result> { + let pk: Vec = dataset + .schema() + .unenforced_primary_key() + .iter() + .map(|f| f.name.clone()) + .collect(); + if pk.is_empty() { + return Err(Error::InvalidInput { + message: + "the MemWAL LSM scanner requires an unenforced primary key, but the table has none" + .to_string(), + }); + } + 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`. +fn exclusion_watermarks( + details: &MemWalIndexDetails, + index_name: Option<&str>, +) -> 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 + .index_catchup + .iter() + .find(|icp| icp.index_name == name) + .and_then(|icp| icp.caught_up_generation_for_shard(&entry.shard_id)) + { + watermark = watermark.min(caught_up); + } + exclude.entry(entry.shard_id).or_insert(watermark); + } + exclude +} + +/// Assemble the per-shard snapshots (flushed SSTable generations) and the +/// in-memory memtables (active + frozen) for the table. +/// +/// Snapshots for all shards come from their on-disk manifests; for the shard +/// 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. +async fn build_read_context( + table: &NativeTable, + dataset: &Dataset, + details: &MemWalIndexDetails, + index_name: Option<&str>, +) -> Result<(Vec, HashMap)> { + let exclude = exclusion_watermarks(details, index_name); + + let shard_ids = dataset.list_mem_wal_latest_shard_ids().await?; + // Use the dataset's own object store (not `ObjectStore::from_uri`, which + // builds a fresh registry and would miss `memory://` and custom-registered + // stores). The base path matches `list_mem_wal_latest_shard_ids`. + let store = dataset.object_store(None).await?; + let base_path = dataset.branch_location().path; + let scan_batch_size = ShardWriterConfig::default().manifest_scan_batch_size; + + let mut snapshots: Vec = Vec::new(); + for shard_id in shard_ids { + let manifest_store = + ShardManifestStore::new(store.clone(), &base_path, shard_id, scan_batch_size); + if let Some(manifest) = manifest_store.read_latest().await? { + snapshots.push(snapshot_from_manifest(shard_id, &manifest, &exclude)); + } + } + + // WAL-only writers (enable_memtable=false) keep no in-memory memtable, and + // `in_memory_memtable_refs` errors in that mode; the on-disk manifests above + // already cover their flushed SSTables, so skip the live-writer snapshot. (Lance + // forbids maintained indexes in WAL-only mode, so only plain scans reach here.) + let wal_only = details + .writer_config_defaults + .get("enable_memtable") + .map(|v| v == "false") + .unwrap_or(false); + + // Override the active shard with the cached writer's in-memory view. + let mut in_memory: HashMap = HashMap::new(); + if !wal_only + && let Some((shard_id, manifest, memtables)) = + table.dataset.shard_writer().read_snapshot().await? + { + if let Some(manifest) = manifest { + let snapshot = snapshot_from_manifest(shard_id, &manifest, &exclude); + match snapshots.iter_mut().find(|s| s.shard_id == shard_id) { + Some(existing) => *existing = snapshot, + None => snapshots.push(snapshot), + } + } + if let Some(memtables) = memtables { + in_memory.insert(shard_id, memtables); + } + } + + Ok((snapshots, in_memory)) +} + +/// Convert a shard manifest into a read snapshot (current + not-yet-compacted +/// flushed SSTables). SSTable generations at or below the shard's compaction +/// watermark are already in the base table and are skipped. +fn snapshot_from_manifest( + shard_id: Uuid, + manifest: &ShardManifest, + compacted: &HashMap, +) -> ShardSnapshot { + let mut snapshot = ShardSnapshot::new(shard_id) + .with_spec_id(manifest.shard_spec_id) + .with_current_generation(manifest.current_generation); + let watermark = compacted.get(&shard_id).copied(); + for sstable in &manifest.sstables { + if watermark.is_some_and(|w| sstable.generation <= w) { + continue; + } + snapshot = snapshot.with_sstable(sstable.generation, sstable.path.clone()); + } + snapshot +} + +/// Columns selected by the query, if an explicit projection was requested. +fn selected_columns(query: &VectorQueryRequest) -> Option> { + match &query.base.select { + Select::Columns(columns) => Some(columns.clone()), + _ => None, + } +} + +/// Non-negative `Option` limit/offset as the `Option` the scanner +/// expects. +fn as_i64(value: Option) -> Option { + value.map(|v| v as i64) +} + +/// Build a base `LsmScanner` configured with sources, filter, and projection. +/// +/// The filter set here is applied as a prefilter across every arm — plain scan, +/// full-text search, and vector search — since all three terminate on this +/// scanner's `create_plan`. +fn base_scanner( + dataset: &Dataset, + query: &VectorQueryRequest, + pk_columns: Vec, + snapshots: Vec, + in_memory: HashMap, +) -> Result { + let mut scanner = LsmScanner::new(Arc::new(dataset.clone()), snapshots, pk_columns); + for (shard_id, memtables) in in_memory { + scanner = scanner.with_in_memory_memtables(shard_id, memtables); + } + if let Some(columns) = selected_columns(query) { + let refs: Vec<&str> = columns.iter().map(String::as_str).collect(); + scanner = scanner.project(&refs)?; + } + if let Some(filter) = &query.base.filter { + scanner = match filter { + QueryFilter::Sql(sql) => scanner.filter(sql)?, + QueryFilter::Datafusion(expr) => scanner.filter_expr(expr.clone()), + QueryFilter::Substrait(_) => { + return Err(Error::NotSupported { + message: "the MemWAL LSM scanner does not support Substrait filters; set use_lsm(false) to read the base table only".to_string(), + }); + } + }; + } + Ok(scanner) +} + +/// Plain scan: filter / projection / limit over base ∪ SSTables ∪ in-memory. +/// The plain scan applies limit and offset inside the planner. +async fn plain_plan( + dataset: &Dataset, + query: &VectorQueryRequest, + pk_columns: Vec, + snapshots: Vec, + in_memory: HashMap, + limit: Option, + offset: Option, +) -> Result> { + let scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)? + .limit(as_i64(limit), as_i64(offset))?; + Ok(scanner.create_plan().await?) +} + +/// Full-text search over base ∪ SSTables ∪ in-memory, merged by local BM25 score. +/// The scanner threads the query filter in as a prefilter and pages via limit/offset. +#[allow(clippy::too_many_arguments)] +async fn fts_plan( + dataset: &Dataset, + fts: lance_index::scalar::FullTextSearchQuery, + query: &VectorQueryRequest, + details: &MemWalIndexDetails, + pk_columns: Vec, + snapshots: Vec, + in_memory: HashMap, + limit: Option, + offset: Option, +) -> Result> { + // Pre-check for a lancedb-flavored error; `LsmScanner` also validates the + // single-column requirement, but without the `use_lsm(false)` guidance. + let columns: Vec = fts.columns().into_iter().collect(); + if columns.len() > 1 { + return Err(Error::NotSupported { + message: "the MemWAL LSM scanner full-text search supports a single column; set use_lsm(false) to read the base table only".to_string(), + }); + } + let column = columns.first().ok_or_else(|| Error::NotSupported { + message: "the MemWAL LSM scanner full-text search requires an explicit FTS column" + .to_string(), + })?; + + // Without a maintained in-memory FTS index for this column, the active memtable + // arm produces an empty plan (`active_source_can_execute_fts` returns false), so + // the search silently omits un-compacted documents. Reject rather than mislead. + if !index_maintained( + dataset, + column, + &details.maintained_indexes, + "InvertedIndexDetails", + ) + .await? + { + return Err(Error::NotSupported { + message: format!( + "the MemWAL LSM scanner full-text search requires the FTS index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise un-compacted documents are omitted. set use_lsm(false) to read the base table only" + ), + }); + } + + let scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)? + .with_overfetch_factor(LSM_OVERFETCH_FACTOR) + .full_text_search(fts)? + .limit(as_i64(limit), as_i64(offset))?; + Ok(scanner.create_plan().await?) +} + +/// Whether an index of `type_url_suffix` covering `column` is in the MemWAL spec's +/// maintained set. Only a maintained index has its catch-up tracked (so exclusion is +/// gated correctly) and its in-memory arm kept current; an unmaintained base index +/// falls back to the compaction watermark and can drop rows it has not re-indexed. +/// The type must match specifically — a maintained BTree on the same column is not +/// the FTS/vector index the arm relies on. +async fn index_maintained( + dataset: &Dataset, + column: &str, + maintained: &[String], + type_url_suffix: &str, +) -> Result { + use lance::index::DatasetIndexExt; + let Some(field) = dataset.schema().field(column) else { + return Ok(false); + }; + let indices = dataset.load_indices().await?; + Ok(indices.iter().any(|idx| { + idx.fields.contains(&field.id) + && maintained.iter().any(|m| m == &idx.name) + && idx + .index_details + .as_ref() + .is_some_and(|d| d.type_url.ends_with(type_url_suffix)) + })) +} + +/// 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( + dataset: &Dataset, + query: &VectorQueryRequest, + details: &MemWalIndexDetails, +) -> Result> { + use lance::index::DatasetIndexExt; + // Resolve the 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 arrow_schema = ArrowSchema::from(dataset.schema()); + let column = match &query.column { + Some(column) => column.clone(), + None => { + let dim = query.query_vector.first().map(|v| v.len() as i32); + 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); + }; + 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) +} + +/// Resolve the single logical index from the names of its matching physical +/// segments. `load_indices` returns one entry per segment, so one logical index can +/// appear multiple times (same name); dedupe by name before counting. Errors when +/// more than one *distinct* index covers the field — the base planner's choice is +/// ambiguous and their catch-up watermarks can diverge, so gating exclusion on the +/// wrong one could drop SSTables the used index has not caught up to. Otherwise +/// returns the name only when it is maintained (else the caller falls back to the +/// compaction watermark). +fn resolve_single_index( + mut names: Vec, + maintained: &[String], + arm: &str, + column: &str, +) -> Result> { + names.sort(); + names.dedup(); + if names.len() > 1 { + return Err(Error::NotSupported { + message: format!( + "the MemWAL LSM scanner cannot resolve the {arm} index catch-up watermark for '{column}': it has multiple {arm} indexes; set use_lsm(false) to read the base table only" + ), + }); + } + Ok(names + .into_iter() + .next() + .filter(|name| maintained.contains(name))) +} + +/// Drop the primary-key columns Lance appends internally for dedup when the user's +/// explicit projection did not request them, restoring the requested output schema. +/// `Select::All` legitimately includes the pk columns and is left untouched. +fn restore_projection( + plan: Arc, + query: &VectorQueryRequest, + pk_columns: &[String], +) -> Result> { + let Select::Columns(selected) = &query.base.select else { + return Ok(plan); + }; + let schema = plan.schema(); + // Keep a column unless it is a pk column the user did not select (this preserves + // user columns and score columns like `_distance`, dropping only leaked pk). + let keep: Vec<(Arc, String)> = schema + .fields() + .iter() + .enumerate() + .filter(|(_, f)| { + selected.iter().any(|c| c == f.name()) || !pk_columns.iter().any(|pk| pk == f.name()) + }) + .map(|(i, f)| { + ( + Arc::new(Column::new(f.name(), i)) as Arc, + f.name().clone(), + ) + }) + .collect(); + if keep.len() == schema.fields().len() { + return Ok(plan); + } + Ok(Arc::new(ProjectionExec::try_new(keep, plan)?)) +} + +/// Vector (ANN) search over base ∪ SSTables ∪ in-memory, routed through the same +/// [`LsmScanner`] as the other arms so the query filter is applied as a prefilter. +/// +/// Note: the base and SSTable arms use `fast_search` (indexed data only), so a +/// base-table row not covered by a vector index is invisible here — it surfaces +/// only via the memtable or `use_lsm(false)`. +#[allow(clippy::too_many_arguments)] +async fn vector_plan( + dataset: &Dataset, + query: &VectorQueryRequest, + details: &MemWalIndexDetails, + pk_columns: Vec, + snapshots: Vec, + in_memory: HashMap, + limit: Option, + offset: Option, +) -> Result> { + let query_vector = query + .query_vector + .first() + .cloned() + .ok_or_else(|| Error::InvalidInput { + message: "vector search requires a query vector".to_string(), + })?; + + let arrow_schema = ArrowSchema::from(dataset.schema()); + let column = match &query.column { + Some(column) => column.clone(), + None => default_vector_column(&arrow_schema, Some(query_vector.len() as i32))?, + }; + + // The base arm relies on the column's vector index (`fast_search`). Unless it is + // maintained, its catch-up is untracked and exclusion falls back to the + // compaction watermark — dropping compacted SSTables the (lagging) base index has + // not re-indexed. Reject rather than silently omit rows, mirroring the FTS arm. + if !index_maintained( + dataset, + &column, + &details.maintained_indexes, + "VectorIndexDetails", + ) + .await? + { + return Err(Error::NotSupported { + message: format!( + "the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise compacted rows not yet re-indexed are omitted. set use_lsm(false) to read the base table only" + ), + }); + } + + // The LSM vector planner is Float32-only; reject binary (uint8) vectors with a + // clear error rather than failing deep in the planner. + if is_binary_vector_column(&arrow_schema, &column) { + return Err(Error::NotSupported { + message: "the MemWAL LSM scanner does not support binary (uint8) vector search; set use_lsm(false) to read the base table only".to_string(), + }); + } + + let distance_type = resolve_distance_type(dataset, query, &column).await?; + + // `nearest` takes a flat query vector and builds the fixed-size list itself. + // Guard `k` to at least 1 so a degenerate `limit(0)` is trimmed by `limit` + // below rather than rejected by `nearest`. + let k = limit.unwrap_or(DEFAULT_TOP_K).max(1); + let mut scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)? + .with_overfetch_factor(LSM_OVERFETCH_FACTOR) + .nearest(&column, query_vector.as_ref(), k)? + .nprobes(query.minimum_nprobes) + .distance_metric(distance_type.into()); + if let Some(refine_factor) = query.refine_factor { + scanner = scanner.refine(refine_factor); + } + scanner = scanner.limit(as_i64(limit), as_i64(offset))?; + Ok(scanner.create_plan().await?) +} + +/// Whether `column` stores binary (uint8) vectors, which the LSM vector planner +/// does not support. +fn is_binary_vector_column(schema: &ArrowSchema, column: &str) -> bool { + matches!( + schema.field_with_name(column).map(|f| f.data_type()), + Ok(DataType::FixedSizeList(field, _)) if matches!(field.data_type(), DataType::UInt8) + ) +} + +/// Resolve the distance metric for the vector arm: the explicit query metric if +/// set, else the metric of the column's vector index, else L2. +async fn resolve_distance_type( + dataset: &Dataset, + query: &VectorQueryRequest, + column: &str, +) -> Result { + if let Some(dt) = query.distance_type { + return Ok(dt); + } + // Inherit the column's vector-index metric so cross-source distances match + // the metric the maintained memtable index was built with. + use lance::index::{DatasetIndexExt, DatasetIndexInternalExt}; + use lance_index::metrics::NoOpMetricsCollector; + let field = dataset.schema().field(column); + if let Some(field) = field { + let indices = dataset.load_indices().await?; + for index in indices.iter() { + if index.fields.contains(&field.id) + && let Ok(vector_index) = dataset + .open_vector_index(column, &index.uuid, &NoOpMetricsCollector) + .await + { + return Ok(vector_index.metric_type().into()); + } + } + } + Ok(DistanceType::L2) +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_index::mem_wal::{CompactedSsTable, IndexCatchupProgress}; + + #[test] + fn exclusion_watermark_gates_on_lagging_index_catchup() { + let shard = Uuid::from_u128(1); + let details = MemWalIndexDetails { + // Compaction has drained generations through 5 into the base table... + compacted_sstables: vec![CompactedSsTable::new(shard, 5)], + // ...but the FTS index has only caught up through generation 2. + index_catchup: vec![IndexCatchupProgress::new( + "fts_idx".to_string(), + vec![CompactedSsTable::new(shard, 2)], + )], + maintained_indexes: vec!["fts_idx".to_string()], + ..Default::default() + }; + + // Plain scan: drop every compacted generation (through 5). + assert_eq!(exclusion_watermarks(&details, None).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), + 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), + Some(&5) + ); + } + + #[test] + fn resolve_single_index_dedupes_segments() { + let maintained = vec!["fts_idx".to_string()]; + // Two physical segments of ONE logical index must not count as "multiple". + assert_eq!( + resolve_single_index( + vec!["fts_idx".to_string(), "fts_idx".to_string()], + &maintained, + "full-text", + "text" + ) + .unwrap(), + Some("fts_idx".to_string()) + ); + // Two distinct indexes on the field are ambiguous → error. + assert!( + resolve_single_index( + vec!["fts_a".to_string(), "fts_b".to_string()], + &maintained, + "full-text", + "text" + ) + .is_err() + ); + // A single unmaintained index resolves to None (compaction-watermark fallback). + assert_eq!( + resolve_single_index(vec!["other".to_string()], &maintained, "full-text", "text") + .unwrap(), + None + ); + } +}