From 91f775c0930bbaf33ed6795763636a42061117cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:21:16 -0700 Subject: [PATCH 01/12] chore(deps): bump the rust-minor-patch group across 1 directory with 19 updates (#3700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the rust-minor-patch group with 11 updates in the / directory: | Package | From | To | | --- | --- | --- | | [async-trait](https://github.com/dtolnay/async-trait) | `0.1.89` | `0.1.91` | | [datafusion](https://github.com/apache/datafusion) | `54.0.0` | `54.1.0` | | [regex](https://github.com/rust-lang/regex) | `1.13.0` | `1.13.1` | | [tokio](https://github.com/tokio-rs/tokio) | `1.52.3` | `1.53.1` | | [serde](https://github.com/serde-rs/serde) | `1.0.228` | `1.0.229` | | [serde_json](https://github.com/serde-rs/json) | `1.0.150` | `1.0.151` | | [uuid](https://github.com/uuid-rs/uuid) | `1.23.5` | `1.24.0` | | [anyhow](https://github.com/dtolnay/anyhow) | `1.0.103` | `1.0.104` | | [napi](https://github.com/napi-rs/napi-rs) | `3.10.5` | `3.11.0` | | [napi-derive](https://github.com/napi-rs/napi-rs) | `3.5.10` | `3.6.0` | | [libc](https://github.com/rust-lang/libc) | `0.2.186` | `0.2.189` | Updates `async-trait` from 0.1.89 to 0.1.91
Release notes

Sourced from async-trait's releases.

0.1.90

Commits

Updates `datafusion` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-catalog` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-common` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-execution` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-expr` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-functions` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-physical-plan` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-physical-expr` from 54.0.0 to 54.1.0
Commits

Updates `datafusion-sql` from 54.0.0 to 54.1.0
Commits

Updates `regex` from 1.13.0 to 1.13.1
Changelog

Sourced from regex's changelog.

1.13.1 (2026-07-15)

This is a release that fixes a bug where incorrect regex match offsets could be reported. Note that this doesn't impact whether a match occurs or not, just where it occurs. The match offsets are still valid for slicing, they just may not refer to the correct leftmost-first match. See #1364 for (many) more details.

Bug fixes:

Commits

Updates `tokio` from 1.52.3 to 1.53.1
Release notes

Sourced from tokio's releases.

Tokio v1.53.1

1.53.1 (July 20th, 2026)

Fixed

Fixed (unstable)

Documented

#8252: tokio-rs/tokio#8252 #8300: tokio-rs/tokio#8300 #8301: tokio-rs/tokio#8301

Tokio v1.53.0

1.53.0 (July 17th, 2026)

Added

Changed

Fixed

... (truncated)

Commits

Updates `serde` from 1.0.228 to 1.0.229
Release notes

Sourced from serde's releases.

v1.0.229

Commits

Updates `serde_json` from 1.0.150 to 1.0.151
Release notes

Sourced from serde_json's releases.

v1.0.151

Commits

Updates `uuid` from 1.23.5 to 1.24.0
Release notes

Sourced from uuid's releases.

v1.24.0

What's Changed

New Contributors

Full Changelog: https://github.com/uuid-rs/uuid/compare/v1.23.5...v1.24.0

Commits

Updates `anyhow` from 1.0.103 to 1.0.104
Release notes

Sourced from anyhow's releases.

1.0.104

Commits

Updates `napi` from 3.10.5 to 3.11.0
Release notes

Sourced from napi's releases.

napi-v3.11.0

Added

Fixed

Other

Commits

Updates `napi-derive` from 3.5.10 to 3.6.0
Release notes

Sourced from napi-derive's releases.

napi-derive-v3.6.0

Added

Commits

Updates `libc` from 0.2.186 to 0.2.189
Release notes

Sourced from libc's releases.

0.2.189

Added

Deprecated

Fixed

0.2.188

Changed

These were removed in 0.2.187 because libc does not actually make Send and Sync guarantees about DIR (or other extern types), but this caused some crates to break. The traits are added back for now to allow time to migrate, but will be removed again in the future; please make sure your crates are not relying on libc::DIR: Send or libc::DIR: Sync.

0.2.187

This release contains a number of improvements related to 64-bit time_t configuration. Of note the existing RUST_LIBC_UNSTABLE_* environment variables have been replaced with configuration options. The new way to use these is:

RUSTFLAGS='--cfg=libc_unstable_musl_v1_2_3' cargo
...
RUSTFLAGS='--cfg=libc_unstable_gnu_time_bits="64"' cargo ...

Being able to set this via RUSTFLAGS makes it easier to only apply configuration to specific targets (and notably, not the host if build scripts are used).

There are two other notable changes:

... (truncated)

Changelog

Sourced from libc's changelog.

0.2.189 - 2026-07-21

Added

Deprecated

Fixed

0.2.188 - 2026-07-21

Changed

These were removed in 0.2.187 because libc does not actually make Send and Sync guarantees about DIR (or other extern types), but this caused some crates to break. The traits are added back for now to allow time to migrate, but will be removed again in the future; please make sure your crates are not relying on libc::DIR: Send or libc::DIR: Sync.

0.2.187 - 2026-07-20

This release contains a number of improvements related to 64-bit time_t configuration. Of note the existing RUST_LIBC_UNSTABLE_* environment variables have been replaced with configuration options. The new way to use these is:

RUSTFLAGS='--cfg=libc_unstable_musl_v1_2_3' cargo
...
RUSTFLAGS='--cfg=libc_unstable_gnu_time_bits="64"' cargo ...

Being able to set this via RUSTFLAGS makes it easier to only apply configuration to specific targets (and notably, not the host if build scripts are used).

There are two other notable changes:

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 233 ++++++++++++++++++++++++++++------------------------- 1 file changed, 122 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57e5b8d84..07ead6dc8 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]] @@ -5591,9 +5591,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" @@ -6064,9 +6064,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", @@ -6089,9 +6089,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", @@ -6103,9 +6103,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", @@ -6116,9 +6116,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", ] @@ -6207,7 +6207,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]] @@ -7586,8 +7586,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", @@ -7606,7 +7606,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", @@ -7815,7 +7815,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -7853,9 +7853,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]] @@ -8161,9 +8161,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", @@ -8173,9 +8173,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", @@ -8629,7 +8629,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8700,7 +8700,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8905,9 +8905,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", @@ -8915,29 +8915,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", @@ -9268,7 +9268,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", @@ -9280,7 +9280,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", @@ -9596,6 +9596,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" @@ -9713,7 +9724,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9916,9 +9927,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", @@ -10403,9 +10414,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", @@ -10690,7 +10701,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]] From 0bd0944062a4929d9f3103056c4a1e4f4fef9928 Mon Sep 17 00:00:00 2001 From: Dan Tasse <105866+dantasse@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:29:06 -0400 Subject: [PATCH 02/12] feat: branch skill updates for merge (#3685) Skill updates for branch merging. Terra/Sol can do an end-to-end "create 3 branches, add a column, generate embeddings, merge the best" workflow now. --- .../skills/lancedb/references/branch_ops.md | 67 ++++++++++++++++++- .../references/python/api_reference.md | 11 ++- 2 files changed, 75 insertions(+), 3 deletions(-) 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. From d6f9f8560ec0dcdff418b0713c0c6d8b9ccdcfeb Mon Sep 17 00:00:00 2001 From: Prashanth Rao <35005448+prrao87@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:05:58 -0400 Subject: [PATCH 03/12] docs(java): fill Java API reference gaps (#3615) ## Summary This updates the Java API reference to close the documentation gaps that can be fixed from the current Java source and generated namespace API. The patch adds an empty table example, shows how to wrap returned Arrow IPC query bytes in a reusable `ArrowFileReader` helper, and documents the Java index operations that are currently exposed by the namespace client: vector indexes, scalar indexes, full text search indexes, and listing indexes. ## Issue Links Fixes https://github.com/lancedb/docs/issues/157 Fixes https://github.com/lancedb/docs/issues/160 Partially addresses https://github.com/lancedb/docs/issues/159 by documenting the index parameters currently exposed by Java. The requested `num_partitions` example is still blocked because `CreateTableIndexRequest` does not expose IVF training parameters yet. Not included: https://github.com/lancedb/docs/issues/158. The current Java docs and source remain remote namespace oriented, so local DB connection documentation should wait until the Java local DB API is available and can be verified. ## Validation - Built the Java core module with OpenJDK 17: `./mvnw -pl lancedb-core -am -DskipTests compile` - Checked the Markdown diff: `git diff --check -- docs/src/java/java.md` The Java build succeeds. It still reports pre-existing checkstyle warnings in the namespace client builder, but the Maven build is green. --- docs/src/java/java.md | 193 +++++++++++++++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 29 deletions(-) diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 9265c3a56..a175dafa7 100644 --- a/docs/src/java/java.md +++ b/docs/src/java/java.md @@ -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)); From 0bc081608ae6c244fe0b72dc5100d59c695e3b2a Mon Sep 17 00:00:00 2001 From: Eran Dagan Date: Thu, 23 Jul 2026 00:11:57 +0300 Subject: [PATCH 04/12] fix(python): allow selection of _rowid in Permutation (#3133) Closes #3132 --- python/python/lancedb/permutation.py | 13 ++++-- python/python/tests/test_permutation.py | 58 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) 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/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"] From c9d07ef6fccb8440579e3fd8a966f6ca9ee04f72 Mon Sep 17 00:00:00 2001 From: LanceDB Robot Date: Fri, 24 Jul 2026 15:01:30 -0700 Subject: [PATCH 05/12] chore: update lance dependency to v10.0.0-beta.3 (#3710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the Rust workspace and Java lance-core dependencies to [Lance v10.0.0-beta.3](https://github.com/lance-format/lance/releases/tag/v10.0.0-beta.3). Includes compatibility updates for Lance’s nullable blob payload and handle APIs. --- Cargo.lock | 84 ++++++++++++++++++++-------------------- Cargo.toml | 28 +++++++------- java/pom.xml | 2 +- rust/lancedb/src/blob.rs | 8 +++- 4 files changed, 63 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07ead6dc8..1eb45c8be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3421,8 +3421,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "rand 0.9.5", @@ -4777,8 +4777,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arc-swap", "arrow", @@ -4852,8 +4852,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-buffer", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-buffer", @@ -4889,7 +4889,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-schema", @@ -4898,8 +4898,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrayref", "crunchy", @@ -4909,8 +4909,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-buffer", @@ -4948,8 +4948,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "arrow-array", @@ -4979,8 +4979,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "arrow-array", @@ -4997,8 +4997,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "proc-macro2", "quote", @@ -5007,8 +5007,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-arith", "arrow-array", @@ -5043,8 +5043,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-arith", "arrow-array", @@ -5074,8 +5074,8 @@ dependencies = [ [[package]] name = "lance-index" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arc-swap", "arrow", @@ -5142,8 +5142,8 @@ dependencies = [ [[package]] name = "lance-index-core" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-schema", @@ -5165,8 +5165,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "arrow-arith", @@ -5209,8 +5209,8 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-buffer", @@ -5226,8 +5226,8 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "async-trait", @@ -5239,8 +5239,8 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "arrow-ipc", @@ -5294,8 +5294,8 @@ dependencies = [ [[package]] name = "lance-select" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-buffer", @@ -5310,8 +5310,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow", "arrow-array", @@ -5350,8 +5350,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "arrow-array", "arrow-schema", @@ -5364,8 +5364,8 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "9.1.0-beta.8" -source = "git+https://github.com/lance-format/lance.git?tag=v9.1.0-beta.8#5107a99e3f3912851c8cbb3822bd5b4cbf7fda2f" +version = "10.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" dependencies = [ "icu_segmenter", "jieba-rs", diff --git a/Cargo.toml b/Cargo.toml index 94b965d50..c65d6590e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,20 @@ categories = ["database-implementations"] rust-version = "1.91.0" [workspace.dependencies] -lance = { "version" = "=9.1.0-beta.8", default-features = false, "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=9.1.0-beta.8", default-features = false, "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=9.1.0-beta.8", default-features = false, "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=9.1.0-beta.8", "tag" = "v9.1.0-beta.8", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } ahash = "0.8" # Note that this one does not include pyarrow arrow = { version = "58.0.0", optional = false } diff --git a/java/pom.xml b/java/pom.xml index 78a393c30..c8bb5cc6d 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -28,7 +28,7 @@ UTF-8 15.0.0 - 9.1.0-beta.8 + 10.0.0-beta.3 false 2.30.0 1.7 diff --git a/rust/lancedb/src/blob.rs b/rust/lancedb/src/blob.rs index c8884f9b3..de666aa10 100644 --- a/rust/lancedb/src/blob.rs +++ b/rust/lancedb/src/blob.rs @@ -273,7 +273,11 @@ pub(crate) async fn take_blobs_aligned( if *is_null { builder.append_null(); } else { - builder.append_value(payloads[payload_idx].data.as_ref()); + if let Some(data) = &payloads[payload_idx].data { + builder.append_value(data); + } else { + builder.append_null(); + } payload_idx += 1; } } @@ -315,7 +319,7 @@ pub(crate) async fn take_blob_files_aligned( if *is_null { None } else { - Some(handles.next().unwrap()) + handles.next().flatten() } }) .collect()) From 18760f74cd43b24e2e8026d88baf9ace1facd3a3 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Sat, 25 Jul 2026 06:02:23 +0800 Subject: [PATCH 06/12] fix: crash in AnswerdotaiRerankers/ColbertReranker for return_score="all" (#3671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `AnswerdotaiRerankers(return_score="all").rerank_hybrid(...)` (and `ColbertReranker`, which subclasses it without overriding `rerank_hybrid`) raises: ``` pyarrow.lib.ArrowInvalid: Invalid sort key column: No match for FieldRef.Name(_relevance_score) in _rowid: int64 ... ``` ## Why ```python 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) ``` When `score == "all"`, `combined_results` is unconditionally overwritten by `_merge_and_keep_scores(vector_results, fts_results)` **after** `_rerank()` already computed and appended `_relevance_score` — discarding it. The following `sort_by("_relevance_score", ...)` then has nothing to sort on. Every sibling reranker that supports `return_score="all"` (`cross_encoder`, `openai`, `cohere`, `jinaai`, `voyageai`, `watsonx`) instead calls `_merge_and_keep_scores()` **before** `_rerank()`. This file is the one place the ordering got inverted when `"all"` support was added (#2509) — a copy/paste inconsistency across the six files that PR touched. Fix mirrors the pattern already used (and tested) by the other five rerankers. Also drops the now-stale `"Only 'relevance' is supported for now"` docstring line on both classes, left over from before `"all"` support existed. ## Testing Added `test_answerdotai_reranker_return_all`, mirroring the existing `test_cross_encoder_reranker_return_all`. Verified locally with the real built Rust extension: red (reproduces the exact `ArrowInvalid` above) → green, using the actual `rerank_hybrid`/`_rerank`/`base.py` code path with the model call mocked out — my local environment's `rerankers==0.10.0` fails to load the real ColBERT model against the available `transformers` version (`AttributeError: 'ColBERTModel' object has no attribute 'all_tied_weights_keys'`), which I confirmed also breaks the **pre-existing**, unmodified `test_colbert_reranker`/`test_answerdotai_reranker` baseline tests identically — an unrelated local dependency-version issue, not a regression from this change. `ruff check`/`ruff format` clean; full `test_rerankers.py` run: 9 passed / 8 skipped / 3 failed (the 3 failures are exactly those two pre-existing tests plus my new one, all failing at model-loading time for the same unrelated reason before reaching the changed code). --- Disclosure: this PR was drafted with AI assistance (Claude); I reviewed, tested, and take responsibility for the change. Co-authored-by: Claude Opus 4.8 --- python/python/lancedb/rerankers/answerdotai.py | 9 +++++---- python/python/lancedb/rerankers/colbert.py | 2 +- python/python/tests/test_rerankers.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) 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/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) # --------------------------------------------------------------------------- From 9dc5ec03aa6cd666b2e08183e5dcfe99e2dd28de Mon Sep 17 00:00:00 2001 From: Yang Cen Date: Sat, 25 Jul 2026 06:02:38 +0800 Subject: [PATCH 07/12] feat(fts): add block size configuration (#3691) ## What changed - add `block_size` to Python FTS configuration and the deprecated local/remote helpers - add `blockSize` to the TypeScript FTS options and propagate it through the NAPI binding - serialize the value as `block_size` for remote index creation - document the existing Rust builder API and generate the TypeScript API reference - add local, remote, metadata, search, and invalid-value regression coverage ## Why Lance supports configuring the number of documents per compressed FTS posting block, but LanceDB's Python and TypeScript APIs did not expose the setting. This made the experimental FTS V3 layout unavailable through those clients and allowed the value to be dropped before index creation. ## How it works The default remains `128`. Supported values are `128` and `256`; selecting `256` uses the experimental FTS V3 format. Invalid values are rejected by the Lance builder and surfaced as Python or JavaScript errors. ## Validation - `cargo check --quiet --features remote --tests --examples` - `cargo +1.94.0 clippy --quiet --features remote --tests --examples -- -D warnings` - targeted Rust local and remote index tests - Rust doctests: 34 passed - Python Ruff checks, doctest, and targeted local/remote tests: 5 passed - TypeScript build, Biome lint, generated docs, and targeted Jest tests: 9 passed - `git diff --check` ## Limitations The Java client remains unchanged because its external remote REST model does not currently expose `block_size`. Co-authored-by: Yang Cen --- docs/src/js/interfaces/FtsOptions.md | 13 +++++++ nodejs/__test__/remote.test.ts | 49 ++++++++++++++++++++++++++ nodejs/__test__/table.test.ts | 29 +++++++++++++++ nodejs/lancedb/indices.ts | 9 +++++ nodejs/src/index.rs | 14 +++++--- python/python/lancedb/index.py | 11 ++++++ python/python/lancedb/remote/table.py | 2 ++ python/python/lancedb/table.py | 10 ++++-- python/python/tests/test_fts.py | 17 +++++++++ python/python/tests/test_remote_db.py | 9 +++-- python/src/index.rs | 4 +++ rust/lancedb/src/index.rs | 21 ++++++++++- rust/lancedb/src/remote/table.rs | 9 +++++ rust/lancedb/src/table/create_index.rs | 13 +++++-- 14 files changed, 198 insertions(+), 12 deletions(-) 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/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..44edaa093 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -2527,6 +2527,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 = [ 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/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/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/remote/table.py b/python/python/lancedb/remote/table.py index 8c702aace..80c250bf5 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/table.py b/python/python/lancedb/table.py index 0e181b457..5f89f6308 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -1106,6 +1106,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, ): @@ -1177,6 +1178,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 @@ -3026,6 +3031,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. @@ -3075,9 +3081,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( 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_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/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/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/remote/table.rs b/rust/lancedb/src/remote/table.rs index 2518ed65c..92c4ad296 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -4496,6 +4496,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(); From 1b2670443ec47a46e3e01b842ff5b8071a201af8 Mon Sep 17 00:00:00 2001 From: Lance Release Date: Fri, 24 Jul 2026 22:03:30 +0000 Subject: [PATCH 08/12] =?UTF-8?q?Bump=20version:=200.35.0-beta.2=20?= =?UTF-8?q?=E2=86=92=200.35.0-beta.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/.bumpversion.toml | 2 +- python/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/.bumpversion.toml b/python/.bumpversion.toml index b1fcd48e0..15b9eefa9 100644 --- a/python/.bumpversion.toml +++ b/python/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.35.0-beta.2" +current_version = "0.35.0-beta.3" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/python/Cargo.toml b/python/Cargo.toml index 3850ab249..9a635cd64 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.35.0-beta.3" publish = false edition.workspace = true description = "Python bindings for LanceDB" From a00edef0e6aa3fc6b1d3f6f8f05977a4886e2652 Mon Sep 17 00:00:00 2001 From: Lance Release Date: Fri, 24 Jul 2026 22:03:48 +0000 Subject: [PATCH 09/12] =?UTF-8?q?Bump=20version:=200.32.0-beta.2=20?= =?UTF-8?q?=E2=86=92=200.32.0-beta.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.toml | 2 +- Cargo.lock | 6 +++--- docs/src/java/java.md | 2 +- java/lancedb-core/pom.xml | 2 +- java/pom.xml | 2 +- nodejs/Cargo.toml | 2 +- nodejs/npm/darwin-arm64/package.json | 2 +- nodejs/npm/linux-arm64-gnu/package.json | 2 +- nodejs/npm/linux-arm64-musl/package.json | 2 +- nodejs/npm/linux-x64-gnu/package.json | 2 +- nodejs/npm/linux-x64-musl/package.json | 2 +- nodejs/npm/win32-arm64-msvc/package.json | 2 +- nodejs/npm/win32-x64-msvc/package.json | 2 +- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- rust/lancedb/Cargo.toml | 2 +- 16 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 454e0d1ca..d36c4e158 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.32.0-beta.2" +current_version = "0.32.0-beta.3" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/Cargo.lock b/Cargo.lock index 1eb45c8be..26b64d182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5378,7 +5378,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.32.0-beta.2" +version = "0.32.0-beta.3" dependencies = [ "ahash", "anyhow", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.32.0-beta.2" +version = "0.32.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -5491,7 +5491,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.35.0-beta.2" +version = "0.35.0-beta.3" dependencies = [ "arrow", "async-trait", diff --git a/docs/src/java/java.md b/docs/src/java/java.md index a175dafa7..a7a010c99 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.32.0-beta.3 ``` diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 7ef3e1267..9333f0cc6 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.32.0-beta.3 ../pom.xml diff --git a/java/pom.xml b/java/pom.xml index c8bb5cc6d..721d61b93 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.32.0-beta.2 + 0.32.0-beta.3 pom ${project.artifactId} LanceDB Java SDK Parent POM diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index b42228ff9..dda9c681e 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.32.0-beta.3" publish = false license.workspace = true description.workspace = true diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index 2021be22e..f3d444fc6 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.32.0-beta.3", "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..9aabfec02 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.32.0-beta.3", "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..37f734fdc 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.32.0-beta.3", "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..cf7489468 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.32.0-beta.3", "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..46699201c 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.32.0-beta.3", "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..433d2615e 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.32.0-beta.3", "os": [ "win32" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index b18e34f1c..6ff05abc1 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.32.0-beta.3", "os": ["win32"], "cpu": ["x64"], "main": "lancedb.win32-x64-msvc.node", diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e7b5322bd..8cb52d0aa 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.32.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lancedb/lancedb", - "version": "0.32.0-beta.2", + "version": "0.32.0-beta.3", "cpu": [ "x64", "arm64" diff --git a/nodejs/package.json b/nodejs/package.json index 17c432b8c..749f4b816 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.32.0-beta.2", + "version": "0.32.0-beta.3", "main": "dist/index.js", "exports": { ".": "./dist/index.js", diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 3be464a71..7b016556f 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.32.0-beta.3" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true From bf15655c83ea4e8a0ac24cb8eb8ac0d3e7446c04 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Sat, 25 Jul 2026 09:22:35 -0700 Subject: [PATCH 10/12] chore: unify SDK versions and release tags on a single line (#3714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python was versioned and tagged separately from the Rust, Java, and Node.js SDKs, and had drifted three minor versions ahead (0.36 vs 0.33). Users had no way to tell which Python version corresponded to which Rust or Node release, and the gap had no meaning behind it. This unifies the two tracks so there is one version and one tag for all four SDKs. ## Version The shared version is set to `0.37.0-beta.0`. Python continues its own sequence (highest published: 0.36 → 0.37) while Rust, Java, and Node.js jump 0.33 → 0.37 to meet it. Picking Python's next minor means Python users see no discontinuity at all, and only the other SDKs skip forward. Note that `main` trails the `release/v0.32` branch on both lines (main is at 0.32.0-beta.3 / 0.35.0-beta.3; the release branch carries 0.33.0-beta.0 / 0.36.0-beta.0), so 0.37 is chosen to clear the highest tag on either branch. Every index stays monotonic: | index | publishes | last published | next | |---|---|---|---| | PyPI | stable only | 0.34.0 | 0.37.0 | | Fury | previews | 0.36.0b0 | 0.37.0-beta.1 | | npm | both | 0.33.0-beta.0 | 0.37.0-beta.1 | | crates.io | stable only | 0.31.0 | 0.37.0 | | Maven | both | 0.33.0-beta.0 | 0.37.0-beta.1 | A one-time jump for three SDKs, versus explaining the offset indefinitely. ## Mechanism * `python/.bumpversion.toml` is removed. `python/Cargo.toml` — the source of the Python package version, since `pyproject.toml` declares `dynamic = ["version"]` — becomes a tracked file of the root config. Its `cargo update -p lancedb-python` pre-commit hook is dropped as redundant: `ci/update_lockfiles.sh` already refreshes every workspace member version in `Cargo.lock`. * `pypi-publish.yml` triggers on `v*` instead of `python-v*`, so one tag releases all four packages. `ci/bump_version.sh` and `make-release-commit.yml` lose their now-dead tag-prefix and per-language plumbing, including the `python` / `other` dispatch inputs. * The two byte-identical GH release jobs in `npm-publish.yml` and `pypi-publish.yml` are replaced by a single `gh-release.yml`. One release per tag, named `LanceDB vX.Y.Z`, instead of separate "Python LanceDB" and "Node/Rust LanceDB" releases for the same commit. The trade-off: there is no longer a way to ship a Python-only patch without also releasing crates.io, Maven, and npm. That is the cost of making drift structurally impossible. ## Beta releases marked "Latest" (#3666) Both GH release jobs used: ```yaml prerelease: ${{ contains('beta', github.ref) }} ``` The arguments are reversed. `contains(search, item)` asks whether *`search`* contains *`item`*, so this evaluated "does the literal string `'beta'` contain `refs/tags/python-v0.35.0-beta.2`?" — always `false`. Every beta was published as a full release, and GitHub awards "Latest" to the newest non-prerelease. The new workflow derives the flag from the parsed version rather than the raw ref, and sets `make_latest` explicitly: ```yaml prerelease: ${{ steps.extract_version.outputs.prerelease }} make_latest: ${{ steps.extract_version.outputs.prerelease == 'false' }} ``` npm was never affected (`--tag preview` uses correct bash), and PyPI already excludes pre-releases from resolution. This only fixes releases published from here on. Already-published betas need a one-time backfill: ```shell gh api --paginate /repos/lancedb/lancedb/releases \ --jq '.[] | select(.prerelease == false) | select(.tag_name | test("beta")) | .id' \ | xargs -I{} gh api -X PATCH /repos/lancedb/lancedb/releases/{} -F prerelease=true ``` ## Verification Ran `ci/bump_version.sh` end-to-end against this branch with the release tooling installed: * `preview` → tags `v0.37.0-beta.1` (previous tag `v0.33.0-beta.0` detected, `pre_n` bump) * `stable` → tags `v0.37.0` * Both paths update `.bumpversion.toml`, `rust/lancedb/Cargo.toml`, `nodejs/Cargo.toml`, `python/Cargo.toml`, `nodejs/package.json`, the 7 `nodejs/npm/*/package.json` files, both Java poms, and `docs/src/java/java.md` together * `check_breaking_changes.py` resolves the last stable as `v0.31.0`, so the minor-version gate passes All five touched workflows parse as valid YAML and the pre-commit hooks pass. ## Notes for review * This targets `main` only, so it takes effect at the next release-branch cut. The in-flight `release/v0.32` branch still carries `v0.33.0-beta.0` / `python-v0.36.0-beta.0`; if we want the imminent stable to be 0.37.0, this needs to be applied there too. * Historical `python-v*` tags are left alone. The changelog builder scans `^v`, which does not match them, so the first unified release's notes will compute `fromTag` from the Rust/Node line only — a one-time gap in the Python-side changelog. * Pre-existing and not addressed here: `ci/update_lockfiles.sh --amend` amends the commit that `bump-my-version` has already tagged, so the lockfile update lands outside the tag on stable releases. Fixes #3666 --- .bumpversion.toml | 9 ++- .github/workflows/cargo-publish.yml | 1 - .github/workflows/gh-release.yml | 85 +++++++++++++++++++++++ .github/workflows/make-release-commit.yml | 36 ++-------- .github/workflows/npm-publish.yml | 67 ------------------ .github/workflows/pypi-publish.yml | 78 ++------------------- Cargo.lock | 6 +- ci/bump_version.sh | 6 +- docs/src/java/java.md | 2 +- java/lancedb-core/pom.xml | 2 +- java/pom.xml | 2 +- nodejs/Cargo.toml | 2 +- nodejs/npm/darwin-arm64/package.json | 2 +- nodejs/npm/linux-arm64-gnu/package.json | 2 +- nodejs/npm/linux-arm64-musl/package.json | 2 +- nodejs/npm/linux-x64-gnu/package.json | 2 +- nodejs/npm/linux-x64-musl/package.json | 2 +- nodejs/npm/win32-arm64-msvc/package.json | 2 +- nodejs/npm/win32-x64-msvc/package.json | 2 +- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- python/.bumpversion.toml | 49 ------------- python/Cargo.toml | 2 +- release_process.md | 46 ++++++------ rust/lancedb/Cargo.toml | 2 +- 25 files changed, 154 insertions(+), 261 deletions(-) create mode 100644 .github/workflows/gh-release.yml delete mode 100644 python/.bumpversion.toml diff --git a/.bumpversion.toml b/.bumpversion.toml index d36c4e158..57bcad5da 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.32.0-beta.3" +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 26b64d182..f8bde1b00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5378,7 +5378,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.32.0-beta.3" +version = "0.37.0-beta.0" dependencies = [ "ahash", "anyhow", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.32.0-beta.3" +version = "0.37.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -5491,7 +5491,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.35.0-beta.3" +version = "0.37.0-beta.0" dependencies = [ "arrow", "async-trait", 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 a7a010c99..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.3 + 0.37.0-beta.0 ``` diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 9333f0cc6..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.3 + 0.37.0-beta.0 ../pom.xml diff --git a/java/pom.xml b/java/pom.xml index 721d61b93..a6ebf1165 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.32.0-beta.3 + 0.37.0-beta.0 pom ${project.artifactId} LanceDB Java SDK Parent POM diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index dda9c681e..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.3" +version = "0.37.0-beta.0" publish = false license.workspace = true description.workspace = true diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index f3d444fc6..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.3", + "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 9aabfec02..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.3", + "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 37f734fdc..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.3", + "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 cf7489468..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.3", + "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 46699201c..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.3", + "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 433d2615e..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.3", + "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 6ff05abc1..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.3", + "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 8cb52d0aa..2317c89a4 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lancedb/lancedb", - "version": "0.32.0-beta.3", + "version": "0.37.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lancedb/lancedb", - "version": "0.32.0-beta.3", + "version": "0.37.0-beta.0", "cpu": [ "x64", "arm64" diff --git a/nodejs/package.json b/nodejs/package.json index 749f4b816..e9b861ad1 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.32.0-beta.3", + "version": "0.37.0-beta.0", "main": "dist/index.js", "exports": { ".": "./dist/index.js", diff --git a/python/.bumpversion.toml b/python/.bumpversion.toml deleted file mode 100644 index 15b9eefa9..000000000 --- a/python/.bumpversion.toml +++ /dev/null @@ -1,49 +0,0 @@ -[tool.bumpversion] -current_version = "0.35.0-beta.3" -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 9a635cd64..a0e9394ec 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb-python" -version = "0.35.0-beta.3" +version = "0.37.0-beta.0" publish = false edition.workspace = true description = "Python bindings for LanceDB" 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 7b016556f..ad6a4e2d6 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb" -version = "0.32.0-beta.3" +version = "0.37.0-beta.0" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true From f655f62e0938395ab54caaa722addcb5351eb8fe Mon Sep 17 00:00:00 2001 From: Heng Ge Date: Sat, 25 Jul 2026 23:45:27 -0700 Subject: [PATCH 11/12] feat(query): add use_lsm to read MemWAL LSM data (#3489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What MemWAL LSM **read** support. When a table has an LSM write spec (`set_lsm_write_spec`), `merge_insert` upserts live in the MemWAL active/frozen memtables and flushed SSTables until an external compaction merges them into the base table, so a normal scan returns **stale** data. This routes reads through Lance's `LsmScanner` so queries also surface that in-flight data, deduplicated by primary key (newest generation wins). ## How - Adds a **`use_lsm: Option`** query flag, symmetric with the `merge_insert` flag: - **unset** — auto-route through the LSM scanner when the table carries a write spec - **`use_lsm(true)`** — force the LSM path; error if there is no spec - **`use_lsm(false)`** — read the base table only (the escape hatch) - Plain scan, single-column full-text search, and single-vector ANN all run through one `LsmScanner` (assembled from on-disk shard manifests plus the cached writer's in-memory memtables), so a `where` predicate is honored as a **prefilter** uniformly — including for vector search. - **Compaction-aware snapshots:** an SSTable generation is dropped only once it is both compacted into the base table and covered by the arm's base-index catch-up (`index_catchup`); plain scans use the compaction watermark alone. - Query shapes the scanner cannot honor hard-error with guidance to set `use_lsm(false)`: hybrid, multi/binary vectors, `with_row_id`, reranking, `order_by`, dynamic/Substrait projection or filters, `distance_range`, `use_index(false)`, postfilter, take-by-row-id/offset, reads from a time-traveled version, and an unmaintained or ambiguous (multiple) FTS/vector index. Namespace-pushdown queries fall back to local execution when a spec is present; WAL-only writers are handled. - Exposed across the Rust core and the Python (`use_lsm`) and TypeScript (`useLsm`) bindings, including `TakeQuery`. Rebased from Lance `7.2.0-beta.3` to `10.0.0-beta.3`. --- docs/src/js/classes/MergeInsertBuilder.md | 17 +- docs/src/js/classes/Query.md | 36 + docs/src/js/classes/TakeQuery.md | 23 + docs/src/js/classes/VectorQuery.md | 36 + nodejs/__test__/table.test.ts | 44 +- nodejs/lancedb/merge.ts | 18 +- nodejs/lancedb/query.ts | 38 + nodejs/src/merge.rs | 4 +- nodejs/src/query.rs | 15 + python/python/lancedb/_lancedb.pyi | 6 + python/python/lancedb/merge.py | 22 +- python/python/lancedb/query.py | 79 ++ python/python/lancedb/table.py | 4 +- python/python/tests/test_merge_insert_lsm.py | 484 +++++++++++- python/src/query.rs | 24 + python/src/table.rs | 6 +- rust/lancedb/src/lib.rs | 5 + rust/lancedb/src/query.rs | 40 + rust/lancedb/src/remote/table.rs | 10 + rust/lancedb/src/table/merge.rs | 445 ++++++++++- rust/lancedb/src/table/merge/lsm.rs | 80 +- rust/lancedb/src/table/query.rs | 114 ++- rust/lancedb/src/table/query/lsm.rs | 786 +++++++++++++++++++ 23 files changed, 2253 insertions(+), 83 deletions(-) create mode 100644 rust/lancedb/src/table/query/lsm.rs 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/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 44edaa093..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( @@ -3199,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 }, @@ -3240,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/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/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/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index c922aa69b..9c000a831 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -391,6 +391,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]]): ... @@ -407,6 +408,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: ... @@ -425,6 +427,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: ... @@ -452,6 +455,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): ... @@ -475,6 +479,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): ... @@ -499,6 +504,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/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/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/table.py b/python/python/lancedb/table.py index 5f89f6308..f61a1f1cf 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -5363,6 +5363,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: @@ -5483,7 +5485,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_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/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 5d6910685..b62aa7377 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -1215,8 +1215,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); @@ -1454,7 +1454,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/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 0b2962682..35a564d41 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 92c4ad296..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, }) } } 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 + ); + } +} From ba4558a64f23f93ea42b4f78a9889bf0677977a3 Mon Sep 17 00:00:00 2001 From: LanceDB Robot Date: Mon, 27 Jul 2026 00:27:40 -0700 Subject: [PATCH 12/12] chore: update lance dependency to v10.0.0-beta.5 (#3718) Updates the Rust workspace Lance dependencies and Java lance-core dependency to v10.0.0-beta.5. No compatibility fixes were required; full-workspace Clippy passes with warnings denied. Lance tag: https://github.com/lance-format/lance/releases/tag/v10.0.0-beta.5 --- Cargo.lock | 97 +++++++++++++++++++++++++++++----------------------- Cargo.toml | 28 +++++++-------- java/pom.xml | 2 +- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8bde1b00..e80165a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3421,8 +3421,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "rand 0.9.5", @@ -4777,8 +4777,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arc-swap", "arrow", @@ -4852,8 +4852,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4889,7 +4889,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-schema", @@ -4898,8 +4898,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrayref", "crunchy", @@ -4909,8 +4909,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4933,6 +4933,7 @@ dependencies = [ "object_store", "pin-project", "prost", + "quick_cache", "rand 0.9.5", "roaring", "serde_json", @@ -4948,8 +4949,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "arrow-array", @@ -4979,8 +4980,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "arrow-array", @@ -4997,8 +4998,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "proc-macro2", "quote", @@ -5007,8 +5008,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-arith", "arrow-array", @@ -5043,8 +5044,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-arith", "arrow-array", @@ -5074,8 +5075,8 @@ dependencies = [ [[package]] name = "lance-index" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arc-swap", "arrow", @@ -5142,8 +5143,8 @@ dependencies = [ [[package]] name = "lance-index-core" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-schema", @@ -5165,8 +5166,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "arrow-arith", @@ -5209,8 +5210,8 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-buffer", @@ -5226,8 +5227,8 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "async-trait", @@ -5239,8 +5240,8 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "arrow-ipc", @@ -5294,8 +5295,8 @@ dependencies = [ [[package]] name = "lance-select" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-buffer", @@ -5310,8 +5311,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow", "arrow-array", @@ -5350,8 +5351,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "arrow-array", "arrow-schema", @@ -5364,8 +5365,8 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "10.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.3#ed078e7c19c9560906e1eae54b0baf01dc509986" +version = "10.0.0-beta.5" +source = "git+https://github.com/lance-format/lance.git?tag=v10.0.0-beta.5#ddb8e28ca238f29628b8e1795ddccbb7bf75e5c8" dependencies = [ "icu_segmenter", "jieba-rs", @@ -7802,6 +7803,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" diff --git a/Cargo.toml b/Cargo.toml index c65d6590e..fee01b732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,20 @@ categories = ["database-implementations"] rust-version = "1.91.0" [workspace.dependencies] -lance = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=10.0.0-beta.3", default-features = false, "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=10.0.0-beta.3", "tag" = "v10.0.0-beta.3", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=10.0.0-beta.5", default-features = false, "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=10.0.0-beta.5", default-features = false, "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=10.0.0-beta.5", default-features = false, "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=10.0.0-beta.5", "tag" = "v10.0.0-beta.5", "git" = "https://github.com/lance-format/lance.git" } ahash = "0.8" # Note that this one does not include pyarrow arrow = { version = "58.0.0", optional = false } diff --git a/java/pom.xml b/java/pom.xml index a6ebf1165..ab8ec2cc8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -28,7 +28,7 @@ UTF-8 15.0.0 - 10.0.0-beta.3 + 10.0.0-beta.5 false 2.30.0 1.7