Compare commits

..

99 Commits

Author SHA1 Message Date
Will Jones fcc6a89b92 feat: builder API for list_tables, deprecate table_names
`Connection::list_tables` took a `lance_namespace::models::ListTablesRequest`
directly, so its generated shape -- including `identity`, `context` and
`include_declared`, none of which lancedb reads -- was part of the public API,
and Node had no binding at all.

Replaces it with a `ListTablesBuilder` carrying `page_token`, `limit` and
`namespace`, matching every other operation on `Connection`. This is a breaking
change for Rust callers. Node gains `listTables` with `ListTablesOptions` and
`ListTablesResponse`; Python's public API is unchanged, since it already had
`list_tables` everywhere.

`table_names` and `TableNamesBuilder` are deprecated. Its `start_after` takes a
table name rather than an opaque token, which cannot be pushed down into a store
that resumes from a continuation token.

Also fixes the page boundary in `ListingDatabase::list_tables`: the token was the
first name of the next page while resuming skips names at or before the token, so
one table was dropped per boundary. Walking `[a, b, c, d, e]` with a limit of 2
returned `[a, b, d, e]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:28:26 -07:00
LanceDB Robot 27cea03b7d chore: update lance dependency to v11.0.0-beta.15 (#3968)
Bumps the Rust workspace Lance dependencies and Java lance-core to
v11.0.0-beta.15. Updates the computed-column refresh path for the new
`write_columns` API.

Release:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.15
2026-08-19 15:18:27 -05:00
Dan Rammer f1c4967eeb feat: bring the MemWAL LSM surface to parity across the SDKs (#3962)
## Why

Four of the eight LSM methods are **remote-only in the core**. `impl
BaseTable for NativeTable` implements only
`set`/`unset`/`get_lsm_write_spec` and `close_lsm_writers`; `flush_lsm`,
`compact_lsm` and `get_lsm_stats` fall through to trait defaults
returning `NotSupported` (`rust/lancedb/src/table.rs:679,687,696`), and
`checkpoint_lsm` is built on all three.

That explains the state of the bindings: Node had bound the four that
work against a local table and stopped, so a Cloud user could install an
LSM write spec but had no way to observe fresh-tier state or drive a
checkpoint. Java had none of it at all.

| SDK | set/unset/get spec | closeWriters | flush | compact | getStats |
checkpoint |
|---|---|---|---|---|---|---|
| Rust core |  |  |  |  |  |  |
| Python |  |  |  |  |  |  |
| Node *(before)* |  |  | — | — | — | — |
| **Node (after)** |  |  | **new** | **new** | **new** | **new** |
| Java *(before)* | — | — | — | — | — | — |
| **Java (after)** | **new** | n/a | **new** | **new** | **new** |
**new** |

Go and C are separate repos and are out of scope here. `closeLsmWriters`
drains cached in-process shard writers, so it has no meaning for Java,
which is a pure REST client.

## Node

Adds napi bindings for `flushLsm`, `compactLsm`, `checkpointLsm` and
`getLsmStats`, plus typed `LsmStats` / `BucketStats` / `GenerationStats`
/ `MemtableStats` objects — typed rather than a JSON blob, matching the
existing `LsmWriteSpec` object in the same file, with `u64` cast to
`i64` per that file's convention.

Because these four are remote-only, the new tests assert each binding
reaches the core and surfaces `NotSupported` against a local table. That
covers the wiring; behavior against a real endpoint stays covered by the
mocked-endpoint tests in `rust/lancedb/src/remote/table.rs`.

## Python

No new methods. All eight are on `LanceTable`, `AsyncTable` and
`RemoteTable` — the last four landed on the sync `RemoteTable` in #3961,
which is merged into this branch.

What was missing here was reachability. `LsmWriteSpec` was importable
only from the private `lancedb._lancedb`, appearing in `table.py` solely
under `if TYPE_CHECKING:`, and `docs/src/python/python.md` had no
mention of it, which per the repo's docs guidance means it rendered
nowhere in the API reference. It is now `lancedb.LsmWriteSpec`, in
`__all__`, and documented.

## Java

Java reaches LanceDB purely over REST through the generated Lance
Namespace client, and these routes are not in that spec, so they are
issued through a small dedicated client rather than added to the spec.
That call is revisitable — LSM is one of four unspecified route families
alongside `multipart_write`, `page_cache/prewarm` and
`branches/diff|merge`. If those are ever regularized into the spec as a
group, `LanceDbTableLsm` is one file that gets deleted.

`LsmWriteSpec` here is deliberately **not**
`org.lance.memwal.InitializeMemWalParams`. That type defaults to
maintaining *no* indexes where a spec here defaults to maintaining
*every* index, and it cannot express the `null` that asks the server to
resolve the set:

| Value | On the wire | Meaning |
|---|---|---|
| unset (null) | `null` | Server resolves **every** maintainable index |
| `Collections.emptyList()` | `[]` | Maintain **none** |
| `Arrays.asList("id_idx")` | `["id_idx"]` | Exactly those |

A dedicated test pins null and `[]` as distinct on the wire, since
collapsing them is the failure mode that motivated a LanceDB-owned type.

`checkpointLsm` is ported from `rust/lancedb/src/table/checkpoint.rs`
with its constants and status semantics intact: 429/503 retried in place
against an 8-budget, 421 restarting from flush against a 3-budget, 5s
poll, and a target watermark fixed after the seal so it terminates under
write load.

`getLsmStats` returns typed `LsmStats` / `BucketStats` /
`GenerationStats` / `MemtableStats`, mirroring the Rust structs in
`rust/lancedb/src/table/lsm_stats.rs` and the objects Node exposes.
Decoding is strict — see below.

## Review feedback

Both gatekeeper findings were real. Each was reproduced against the
scripted test server first, and each fix ships with the reproducer as a
regression test.

**The transport was doubling every checkpoint retry budget.**
`HttpClients.createDefault()` installs Apache's default response retry
strategy, whose retryable-status list is exactly 429 and 503 — the two
statuses `isRetryable` owns. A 429 held against `flush_lsm` issued
**18** wire requests where the loop intends 9, and `compact_lsm` was
retried in place despite the loop being built to fall through to a fresh
stats poll instead. Timing confirmed the mechanism: that run took 25.4s
≈ 16.3s of the loop's own backoff plus 9 × the transport's 1s retry
interval.

Automatic retries are now disabled, so the checkpoint loop is the sole
owner of the 421/429/503 transitions. A side effect worth noting:
`testCheckpointRetriesRetryableStatusInPlace` was passing on a
transport-absorbed 429 and never reaching `issue()`'s retry branch at
all. It now exercises the real path.

**Stats decoding failed open.** `getLsmStats` read the response with
Jackson's `path()`, which yields a missing node that iterates as an
empty array — making "malformed" indistinguishable from "no buckets",
which is indistinguishable from "drained". Four separate payloads made
`checkpointLsm()` report convergence for a checkpoint that never ran:

| Response | Before | Now |
|---|---|---|
| `{"lsm_stats": null}` or absent key | disabled ✓ | disabled ✓ |
| `{"lsm_stats": {}}` | **reported success** | `IllegalStateException` |
| empty response body | **reported success** | `IllegalStateException` |
| bucket missing required fields | **reported success** |
`IllegalStateException` |

The empty-body row is the one to weight: a proxy 200 with no body is a
realistic production event, and it silently reported a checkpoint that
never happened.

Decoding is now strict and fails closed, matching the serde contract on
the Rust side exactly. One deliberate deviation from the review comment,
which asked that *only* explicit JSON `null` count as disabled: Rust has
`#[serde(default)]` on `lsm_stats`, so an **absent key** decodes to
`None` there too. Java now matches that. It is an absent-or-malformed
**`buckets`** that fails closed, which is the case the comment was
actually protecting.

## Testing

- Java: **33 passing** (8 existing + 25 LSM) against a scripted
`com.sun.net.httpserver.HttpServer` — no new test dependency. Wire
assertions mirror `rust/lancedb/src/remote/table.rs:6581-6748`;
checkpoint tests cover convergence, not piling onto a latched bucket,
421 restart-from-flush, 429 retry-in-place, terminal-status propagation,
reissue exhaustion, the exact wire-request count against the retry
budget, and five malformed stats payloads.
- Node: **19 LSM tests passing**; `cargo check`, `npm run build`, `npm
run tsc`, `npm run lint`, `npm run docs` all clean.
- Python: `ruff format --check` and `ruff check` clean.
- Java formatting: `./mvnw -pl lancedb-core spotless:apply` and
`spotless:check` both clean under a JDK 11 toolchain.

## Note: spotless needs a pre-16 JDK

`./mvnw spotless:apply` fails on JDK 16+ with
`JCTree$JCImport.getQualifiedIdentifier()` — google-java-format 1.7,
pinned at `java/pom.xml:34`, predates JDK 16's compiler API change.
**This is pre-existing** and reproduces on a pristine `main` checkout.

It is not a blocker, just a toolchain requirement. Spotless was run
against these sources under JDK 11 and both `spotless:apply` and
`spotless:check` pass on the whole module:

```shell
JAVA_HOME=/path/to/jdk11 ./mvnw -pl lancedb-core spotless:apply
```

Bumping the plugin so it works on modern JDKs is still worth doing, but
separately from this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:44:46 -05:00
LanceDB Robot 11c1d81638 chore: update lance dependency to v11.0.0-beta.14 (#3965)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v11.0.0-beta.14. No compatibility fixes were required;
full workspace clippy with all features passes. Trigger:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.14

---------

Co-authored-by: Yang Cen <bubble-cal@outlook.com>
2026-08-19 21:04:45 +08:00
Lance Release f6efdc9e9f Bump version: 0.38.0-beta.1 → 0.38.0-beta.2 2026-08-19 01:59:27 +00:00
Dan Rammer cdebea118d feat(python): expose LSM checkpoint and stats on sync RemoteTable (#3961)
## Summary

The sync `RemoteTable` carried `set_lsm_write_spec`,
`unset_lsm_write_spec`, `get_lsm_write_spec`, and `close_lsm_writers`,
but not `checkpoint_lsm`, `flush_lsm`, `compact_lsm`, or
`get_lsm_stats`.

That left the four LSM control methods reachable from `AsyncTable` only.
They are also the four that *only* work against a remote table —
`NativeTable` does not override the `BaseTable` defaults, so on a local
table they return `NotSupported` (`rust/lancedb/src/table.rs:679-701`).
The net effect for sync users:

| | `checkpoint_lsm` / `get_lsm_stats` |
|---|---|
| `LanceTable` (sync, local) | present, but always `NotSupported` |
| `RemoteTable` (sync, remote) | `AttributeError` — method absent |
| `AsyncTable` (remote) | works |

So there was no working sync path at all, despite the Rust `RemoteTable`
implementing every one of these against real endpoints.

## Changes

* Add `checkpoint_lsm`, `flush_lsm`, `compact_lsm`, and `get_lsm_stats`
to `lancedb.remote.table.RemoteTable`, mirroring the delegation style of
their neighbours.
* Correct the docstrings on `set_lsm_write_spec` /
`unset_lsm_write_spec`, which read `"""Not supported on LanceDB
Cloud."""` although `rust/lancedb/src/remote/table.rs:2549-2601`
implements both against `/v1/table/{}/set_lsm_write_spec/` and
`/unset_lsm_write_spec/`. They appear to have been copy-pasted from
`set_unenforced_primary_key` directly above.

No Rust or PyO3 changes — the bindings and the `AsyncTable` methods
already existed. The `Table` ABC is left alone, matching how the
existing `*_lsm_write_spec` methods are declared on the concrete classes
only.

## Tests

Four new tests in `python/python/tests/test_remote_db.py`, against the
existing mock HTTP server:

* `test_get_lsm_stats_sync` — the server payload round-trips into the
dict, and `include_generation_rows` defaults to `False` and is forwarded
when set.
* `test_get_lsm_stats_sync_returns_none_when_lsm_disabled` — a
`{"lsm_stats": null}` envelope yields `None` rather than an error.
* `test_flush_and_compact_lsm_sync` — both are one-shot POSTs answered
`202` with no body.
* `test_checkpoint_lsm_sync` — pins the binding to the endpoints it
drives (`flush_lsm` then `get_lsm_stats`); the convergence loop itself
is already covered in Rust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:25:08 -05:00
Xuanwo 76942306b7 docs(java): add vended credentials example (#3958)
## Context

Java users opening catalog-backed tables with vended credentials
currently lack a documented workflow. Opening the catalog-returned URI
directly drops the namespace-provided storage options and automatic
credential refresh.

Document the namespace-backed `Dataset.open()` path so temporary object
store credentials are applied and refreshed transparently.
2026-08-18 20:03:20 +08:00
Adityaj0 d742b174c4 fix: hybrid search silently ignores .offset() (#3769)
## Summary

`LanceHybridQueryBuilder` (sync hybrid search,
`table.search(query_type="hybrid")`) silently ignored `.offset()`.
`self._offset` was never forwarded to the vector/FTS sub-queries and
never applied when slicing the final combined/reranked result, so
`.offset(N)` behaved identically to `.offset(0)` — no error, just wrong
pagination.

Fixes #3765

## Changes

- `_create_query_builders()`: each sub-query now fetches `limit +
offset` rows so there's enough data to slice the correct window out of
after combining/reranking.
- `_combine_hybrid_results()` / `to_arrow()`: the final table is sliced
with `offset=self._offset` instead of always starting at 0.

## Test plan

- [x] New regression test `test_hybrid_query_offset` in
`python/python/tests/test_hybrid_query.py`
- [x] `uv run --extra tests pytest python/tests/test_hybrid_query.py
-vv` — 13 passed
- [x] `uv run --extra dev ruff format` / `ruff check` — clean

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Will Jones <willjones127@gmail.com>
2026-08-17 11:38:38 -07:00
Igor Ganapolsky a075aa62f8 fix(python): treat naive lit(datetime) as UTC wall clock (#3262) (#3775)
## Summary

Fixes naive `lit(datetime)` equality filters against table timestamp
columns on non-UTC hosts, and adds the integration matrix from #3262.

## Failure (before)

On a machine in US Eastern (UTC−4 / EDT), with PyPI `lancedb==0.36.0`:

```python
from datetime import datetime
import lancedb
from lancedb.expr import col, lit

db = lancedb.connect("memory://")
ts = datetime(2024, 7, 1, 10, 0, 0)  # naive
table = db.create_table("t", [{"id": 1, "ts": ts}])
rows = table.search().where(col("ts") == lit(ts)).to_list()
# actual: []  (0 rows)
# expected: 1 row
```

### Root cause

In `python/src/expr.rs`, `expr_lit` converted every `datetime` via
Python's `.timestamp()`:

- **naive** `.timestamp()` = local wall → UTC epoch (shifted by host
offset)
- **PyArrow naive** storage = UTC wall-clock microseconds (no local
shift)

So `lit(naive)` became `CAST('2024-07-01 14:00:00' AS TIMESTAMP)` on EDT
while the table held `10:00:00`.

## After

Naive datetimes are interpreted as UTC wall clock
(`replace(tzinfo=timezone.utc).timestamp()`), matching Arrow storage.
Aware datetimes still use `.timestamp()` (correct epoch).

Same repro on this branch: **1 matching row**.

## Tests

Added `TestExprDatetimeTimezoneIntegration` covering:

| Case | Result |
|------|--------|
| both naive | match |
| both same TZ (UTC) | match |
| different TZs, same instant | match |
| table TZ + naive lit | match (wall clock) |
| table naive + aware lit | match |
| naive lit SQL is wall clock, not local-shifted | asserts `10:00:00` in
SQL |

### Verification

```bash
cd python
maturin develop
pytest python/tests/test_expr.py -v
```

**102 passed** (full `test_expr.py`, including the 6 new cases).

Closes #3262

---------

Co-authored-by: Will Jones <willjones127@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:48:02 -07:00
Lance Release 040a4120c8 Bump version: 0.38.0-beta.0 → 0.38.0-beta.1 2026-08-17 16:56:54 +00:00
Wyatt Alt 928c3dde2d feat: computed columns on remote tables (#3941)
LanceDB Cloud and Enterprise support computed columns through the REST
API,
so declaration dispatches per backend: local tables plan the expression
themselves, remote ones send {name, computed} entries for the server to
plan. A remote refresh is the server's backfill job --
refresh_column_async
submits it and returns a handle whose successful wait establishes a
read-freshness baseline on the submitting handle, unless a checkout has
pinned the handle by the time the job completes; the blocking form
refuses
rather than invent a fill count the server does not report.

Declaration entries are built from the namespace client's
AddColumnsEntry
model (lance-namespace 0.11.0, via the lance beta.13 pin), so the
payload
shape is compile-checked against the published contract.

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-14 17:21:55 -07:00
LanceDB Robot 980818df26 chore: update lance dependency to v11.0.0-beta.13 (#3947)
Updates the Lance Rust workspace dependencies and Java lance-core
dependency to
[v11.0.0-beta.13](https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.13).
Adds the required `ListTablesResponse.context` compatibility field and
validates the workspace with Clippy warnings denied.
2026-08-14 16:20:39 -07:00
Wyatt Alt c429863122 feat: refresh_column_async returns a job handle (#3939)
Mirrors create_index's dual surface: the blocking refresh_column keeps
returning {rows_filled, version}, and refresh_column_async returns the
same
Job handle create_index uses, running the refresh as an in-process task.
Invalid input is reported by the submitting call rather than by the job.

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-14 16:05:04 -07:00
Wyatt Alt fc0d917d32 feat: refresh computed columns (#3938)
table.refresh_column("doubled") fills the rows of a declared column that
hold no value, in two passes per fragment: the first scans only the
unfilled
live rows to count exact gains and decide staging, the second streams
the
fragment's physical rows into a standalone column file published in one
DataReplacement -- committed under the dataset's own session -- so peak
memory is bounded by a scan batch. A row that holds a value keeps it;
deleted and already-filled rows never reach the expression, so a poison
value in them cannot fail the refresh. Refresh refuses under an LSM
write
spec, including the mem-wal catch-up flag that outlives unset and marks
retained SSTable rows.

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-14 14:43:41 -07:00
Wyatt Alt def869bb78 feat: declare computed columns by SQL expression (#3937)
add_columns().computed("doubled", "x * 2") stores the expression in
field
metadata and commits the column empty; a later refresh fills it. Type
and
inputs are derived from the expression.

The declaration stays authoritative for its lifetime: writes that would
give
the column a value (append, update, merge, SQL insert), schema changes
that
would break the stored expression or reshape its output, metadata edits,
volatile expressions, declaration metadata arriving through any path but
the
validated declare call, and LSM write specs in either order against
latest
committed state are all refused. The LSM check also refuses on the
mem-wal
catch-up feature flag, which outlives unset and marks retained SSTable
rows.
Simultaneous declare/install interleavings conflict at commit via
lance's
mem-wal rule (lance#8539). Local tables only.

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-14 14:17:41 -07:00
LanceDB Robot 9e4d8bd1c7 chore: update lance dependency to v11.0.0-beta.11 (#3946)
Updates the Rust workspace Lance crates and Java lance-core dependency
to v11.0.0-beta.11. No compatibility fixes were required; formatting and
full-workspace clippy validation pass. Lance tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.11
2026-08-14 08:31:58 -07:00
XY Zhan 4148dfef72 feat(lsm): require recorded index catch-up, as an explicit activation (#3911)
> Stacked on #3780. Blocked only on #3922 (`lance` → `v11.0.0-beta.6`),
so CI
> stays red until that lands.

## Missing coverage must mean "not known to be covered"

#3780 caps the SSTable exclusion watermark at an index's recorded
catch-up when
there is one, and silently ignores the case where there is none. On a
table that
requires catch-up, an absent entry means the index is *not* known to
hold the
compacted rows — and the LSM base arm reads base through the index
(`fast_search`, no brute-force tail), so dropping that SSTable loses
those rows
for that query.

```rust
Some(caught_up) => watermark = watermark.min(caught_up),
None if catchup_required => watermark = 0,   // retain everything
None => {}
```

`catchup_required` reads the manifest feature bit directly, and requires
both
words: a half-set manifest is treated as legacy, which is the
conservative side.
Without the bit the field is not maintained at all, so absence carries
no
information and behaviour is unchanged.

## Activation, as a table-level entry point

`Table::require_mem_wal_index_catchup()` performs the one-way switch,
separate
from `set_lsm_write_spec`: a table carrying the bit retains every
generation
until something records catch-up, so it has to follow the deployment of
whatever
repairs coverage, not the creation of the table.

This is a convenience, not the only path — a writer holding the dataset
calls
the equivalent on `DatasetMemWalExt`, which is what the WAL pod does.
Lance
enforces the preconditions either way: the MemWAL index must exist, and
the
table must not already carry `compacted_sstables` from before this
protocol,
since those numbers cannot be validated.

## Still correct after the Lance rework

lance-format/lance#8481 replaced the transmitted `IndexCatchupAdvance`
with a
position derived at commit time from the version a transaction read.
That
changed how a writer earns coverage; it did not change what a reader may
conclude from its absence. The rule here, and the field it reads, are
unchanged.

## Tests

Existing `exclusion_watermarks` unit tests carry the new argument.
Coverage
against a real dataset follows once #3922 lands and this can build.
2026-08-14 09:32:02 -04:00
LanceDB Robot 0ac70a8b9f chore: update lance dependency to v11.0.0-beta.10 (#3944)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v11.0.0-beta.10. No compatibility fixes were required;
workspace clippy with all features and Rust formatting pass.

Lance tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.10
2026-08-14 18:46:46 +08:00
Lance Release 91c5f344d2 Bump version: 0.37.1-beta.1 → 0.38.0-beta.0 2026-08-14 01:09:50 +00:00
Jack Ye ffd35c1a8f feat: add asynchronous drop table API (#3936)
## Summary

- add `drop_table_async` and return a job handle while preserving
`drop_table`
- consume remote 202 responses with cleanup job IDs and retain
older-server compatibility
- expose the API through Python and TypeScript connection wrappers
2026-08-13 18:05:44 -07:00
Wyatt Alt 790d0c684c docs(ci): clarify tag input on codex-update-lance-dependency (#3924)
Say what resolving "latest" actually does: pick the newest release,
preferring stable over pre-release, and skip the run if it is not newer
than the version pinned in Cargo.toml.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:26:58 -07:00
XY Zhan 251f194696 refactor(lsm): gate SSTable exclusion on every index a query relies on (#3780)
`exclusion_watermarks` resolved a single index and capped SSTable
exclusion at that index's catch-up watermark. It now takes every index
the query relies on and retains to the **lowest** of them, and the
resolver collects arms together rather than returning at the first
match.

This is groundwork, not a fix for a reachable bug: `reject_unsupported`
refuses hybrid search, so the vector and full-text arms are mutually
exclusive and the list never holds more than one entry today. The
generalisation is what the remaining work below plugs into.

Unchanged: a plain scan uses the compaction watermark alone, an index
with no catch-up entry contributes no cap, and a caught-up index falls
back to the compaction watermark. Taking a minimum over more indexes can
only lower a watermark, so the failure direction is "read an SSTable
unnecessarily", never "miss rows".

## Tests

Three in `lsm`: the existing lagging-index test updated for the new
signature;
`exclusion_watermark_takes_the_minimum_across_every_index_used` (two
indexes at 7 and 4 against compaction at 9 — each alone stops at its own
watermark, together the lower governs, order-independent); and
`an_untracked_index_does_not_widen_a_lagging_sibling`.

`cargo test -p lancedb --lib` — 45 lsm tests, 484 in the crate. `cargo
fmt --check` clean.

## Follow-ups

This crate pins lance to a released tag, so anything needing unreleased
Lance symbols waits for a bump.

1. **Select legacy versus strict semantics from the feature bit.** On a
table with `FLAG_MEM_WAL_INDEX_CATCHUP` set, a *missing* entry must mean
"not caught up" and retain the SSTables, instead of leaving the
compaction watermark unchanged. Needs the bit from
lance-format/lance#8263. **This must land before any table is
activated** — otherwise the bit is set while queries still read
permissively.
2. **Collect scalar and bitmap-family prefilter indexes.** The genuinely
multi-index query is a vector search with a scalar prefilter, and it is
gated on the vector index alone today. Identifying the others needs the
planner's chosen indexes, not the columns the filter names, so it needs
a Lance-side helper.
3. **Verify a retained SSTable can actually answer.** Both base and
SSTable arms use `fast_search`; a source without a compatible index
contributes nothing, so retention alone does not guarantee its rows are
returned. Needs a flat-search fallback or an explicit error in Lance's
`LsmScanner`.
4. **Planner-level integration tests.** Current tests exercise the
watermark arithmetic directly. End-to-end coverage over real queries —
prefilter forms, legacy versus activated, missing index and missing
shard entries — depends on 1–3.
2026-08-13 13:23:37 -04:00
LanceDB Robot 4b7325bd74 chore: update lance dependency to v11.0.0-beta.8 (#3928)
Updates the Rust workspace and Java lance-core dependency to Lance
v11.0.0-beta.8, with refreshed Cargo lockfile metadata. No compatibility
fixes were required. Lance tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.8
2026-08-14 00:00:18 +08:00
Yang Cen 1d75638dea fix: make table existence manifest-authoritative (#3919)
## What is the bug?

#3731 tries to distinguish a missing table from a corrupt table after
Lance returns `DatasetNotFound`. It does that by listing the database
parent and treating a physical `<name>.lance` entry as evidence that the
table exists.

That premise is not sound for a listing database. Table creation writes
data before atomically committing the first manifest, so the same
physical prefix can represent a live concurrent create, abandoned
uncommitted data, or an old empty directory. It is not evidence of a
committed table. The parent listing also makes every missing-table open,
including the create-on-miss path, perform work proportional to the
number of sibling tables. Cloud `list_with_delimiter` exhausts all pages
before returning.

## How does this PR fix the problem?

This PR makes the committed Lance manifest the sole table-existence
authority for listing-database opens:

- `DatasetNotFound` maps directly to `TableNotFound`; no parent or
target storage probe runs.
- Other Lance load errors continue to propagate unchanged.
- A physical directory, object prefix, or uncommitted data file alone
does not block `Create`.
- Concurrent `Create` requests are arbitrated by the conditional
version-1 manifest commit: one succeeds and the loser receives
`TableAlreadyExists`.
- `table_names` is documented as physical discovery, not an atomic
table-existence check. Its snapshot can contain an entry that is still
being created, has only uncommitted storage, or is concurrently dropped.

This removes the need for a new Lance object-store capability. LanceDB
remains on the official Lance `v11.0.0-beta.6` dependency from `main`;
the merge commit for lance-format/lance#7722 is an ancestor of that tag,
so the ambiguous-GCS-500 corruption-prevention fix is retained.

## Performance evidence

Lower is better. The benchmark uses real `.lance` directories with
marker objects on the local filesystem; fixture creation and teardown
are outside the timed region. Baseline is `origin/main` at `6fb976cf`,
candidate is `e1240751`. Both were built from the same lockfile on the
same macOS arm64 machine with the repository's `release` profile (fat
LTO), then executed in alternating baseline/candidate order for three
pairs. Each run used 10 warmups and 100 distinct missing-table opens per
scale. The table reports the median of the three run-level percentiles.

| Scenario / metric | Baseline | This PR | Benefit |
| --- | ---: | ---: | ---: |
| 1,000 real sibling directories, p50 | 11.905 ms | 21.042 us | 566x
speedup |
| 10,000 real sibling directories, p50 | 143.630 ms | 18.375 us | 7,817x
speedup |
| 100,000 real sibling directories, p50 | 1.991 s | 19.917 us | 99,984x
speedup |
| 100,000 real sibling directories, p95 | 2.346 s | 25.792 us | 90,965x
speedup |

These results validate removal of the sibling-cardinality dependency in
this local-filesystem workload; they are not an extrapolation to
production GCS latency. A structural object-store regression test
separately asserts that opening one missing table performs zero
parent-scoped `list`, `list_with_offset`, or `list_with_delimiter`
calls.

Run with:

```bash
BENCH_SIBLINGS=1000,10000,100000 BENCH_WARMUPS=10 BENCH_TRIALS=100 \
  cargo run --locked --release --quiet -p lancedb --example bench_open_missing_table
```

## Correctness and compatibility boundaries

- An empty `.lance` directory or orphan data without a committed
manifest now opens as `TableNotFound` and may be replaced by a
successful `Create`.
- Two synchronized creators sharing one object store deterministically
produce one success and one conditional-manifest conflict mapped to
`TableAlreadyExists`.
- A readable manifest remains authoritative; non-`DatasetNotFound`
corruption, external-manifest, authorization, and object-store errors
are not folded into `TableNotFound`.
- `TableCorrupted` remains in the public error enum for compatibility,
but this listing-database fallback no longer synthesizes it from an
ambiguous physical footprint.
- Reliably distinguishing `Missing`, `Creating`, and `Corrupt` would
require explicit authoritative lifecycle/catalog metadata (for example a
leased creation record). It cannot be inferred from a directory or
prefix, and is outside this incident fix.

## Validation

- `cargo fmt --all -- --check`
- `cargo check --quiet --locked -p lancedb --features remote --tests
--examples`
- `cargo clippy --quiet --locked -p lancedb --features remote --tests
--examples -- -D warnings`
- `cargo test --quiet --locked -p lancedb --features remote --tests`
  - library: 843 passed, 1 ignored
  - integration groups: 39 passed, 6 passed, 5 passed
- focused coverage for empty directories, orphan data, physical listing
snapshots, zero parent listings, and concurrent manifest arbitration
2026-08-13 21:22:42 +08:00
LanceDB Robot 031c3585a8 chore: update lance dependency to v11.0.0-beta.7 (#3925)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v11.0.0-beta.7. No compatibility fixes were required;
full-workspace Clippy passes with warnings denied. Lance tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.7

---------

Co-authored-by: Yang Cen <159225399+BubbleCal@users.noreply.github.com>
2026-08-13 20:37:19 +08:00
LanceDB Robot 6fb976cf89 chore: update lance dependency to v11.0.0-beta.6 (#3922)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v11.0.0-beta.6. Includes compatibility updates for the new
concrete Lance file-version API. Trigger:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.6

---------

Co-authored-by: XYZhan <zhaner08@hotmail.com>
2026-08-12 02:43:44 -04:00
Sravan Avvaru a615306f39 feat(python): add on_transform_error fault tolerance to StreamingDataset (#3763)
Closes #3704

## Problem

Transforms can fail on bad data (e.g. nulls/NaNs from incomplete user
surveys). Today any transform exception aborts iteration, and there is
no way to skip invalid rows during loading.

## Solution

New `on_transform_error` parameter on `StreamingDataset`:

- `"raise"` (default, matches current behavior and the convention in
tf.data / WebDataset / Ray Data)
- `"skip"` — drop the failing rows and continue
- `"warn"` — like skip, plus a logged warning per failing batch
- a WebDataset-style callable `handler(exc) -> bool`, so users can skip
only expected error types

Key design points:

- **Row-granular skipping**: when a batch fails, the transform is re-run
on single-row slices so only the rows that actually fail are dropped
(avoids Ray-style whole-block loss). Skips are counted in a new
`rows_skipped` property.
- **No crash on uneven skips**: the round-robin loop now ends the epoch
at the last cycle where every split still has a row, instead of hitting
`IndexError` when a split runs dry early.
- **Exact resumability under skips**: checkpoints are now
position-based. `state_dict` gains `positions_consumed_per_split` (exact
for owned splits), and a new `merge_state_dicts` static method combines
per-rank states via elementwise max for elastic resume across topology
changes. Old checkpoints without the new key still load. Positions equal
sample counts when nothing is skipped, so existing behavior is
unchanged.
- **Guardrail**: transforms returning the wrong number of rows now raise
a clear `ValueError` instead of silently corrupting split accounting.

### Answers to the issue's open questions

- *Can we do this?* Yes — all transforms funnel through one guarded call
in the Stage 2 pipeline.
- *What do other libraries do?* tf.data `ignore_errors()`, WebDataset
`handler=`, Ray `max_errored_blocks`; MosaicML StreamingDataset offers
nothing (skipping conflicts with its determinism model). This design
follows the common conventions: raise by default, opt-in skipping,
count/log drops.
- *Error handling or pre-filtering?* Both: the existing `filter=`
remains the recommended tool for predictable bad data (splits are built
post-filter, so all guarantees hold — now documented);
`on_transform_error` covers failures not expressible as a predicate.
- *Impact on splits / elastic determinism?* Per-split sample sequences
stay deterministic (skips are data-dependent, not topology-dependent).
With unequal bad-row counts across splits the last few global steps of
an epoch can differ across topologies (bounded by the skew), which is
documented on the parameter. With equal counts per split, full
determinism is preserved — covered by a test.

## Testing

15 new tests in `test_elastic_dataloader.py` covering: default raise,
invalid values, uniform and uneven skips (including epoch-end
truncation), warn logging, selective callable handlers, wrong-row-count
guardrail, determinism across runs and across world sizes (1/2/3/4) with
skips, exact mid-epoch resume with skips on the same topology, elastic
resume via `merge_state_dicts` (ws=2 → ws=1), merge validation, and
backward-compat loading of old checkpoints.

Note: relying on CI for the test run — my local machine OOMs during the
final link of the native extension. The change itself is pure Python.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:22:06 -07:00
Xuanwo 920fc0e455 fix(python): set native module metadata (#3913)
PyO3 defaults native extension classes to `builtins`, so
mkdocstrings/Griffe could not resolve the newly documented
`lancedb.Session` alias and `Deploy docs to Pages` failed on `main`.
Declare the extension module for the public native types referenced by
the Python API docs so Griffe resolves them through `lancedb._lancedb`
and Pages can build again.

Validated with the docs toolchain used by CI (`griffe==0.49.0`,
`mkdocstrings==0.25.2`, and `mkdocs==1.6.1`); `PYTHONPATH=. mkdocs
build` succeeds.
2026-08-10 21:40:31 +08:00
Xuanwo 5acce6782e ci(docs): report link checker failures through issues (#3909) 2026-08-10 15:08:36 +08:00
ForwardXu 12405a4077 chore: drop explicit goosefs-sdk pin in favor of opendal 0.58.1 transitive dep (#3910)
## Summary

`opendal 0.58.1` (the version pulled in transitively via Lance) already
ships
`goosefs-sdk 0.1.9`, which includes the upstream fix for the 0.1.6
compile
break. The explicit version pin that lancedb has been carrying since the
GooseFS feature was introduced is therefore no longer necessary and is
now
redundant work to maintain.

## Changes

- Remove the direct `goosefs-sdk` dependency from
`rust/lancedb/Cargo.toml`
(it was pinned to `=0.1.9` with a comment referencing the 0.1.6 compile
  break).
- Remove the `dep:goosefs-sdk` entry from the `goosefs` cargo feature,
since
  no source file in lancedb imports the crate directly.
- Refresh `Cargo.lock`; `goosefs-sdk 0.1.9` now resolves transitively
through
  `lance` → `opendal 0.58.1`.

## Verification

- `cargo fmt --all` — clean
- `cargo check --features remote,goosefs --tests --examples` — passes
- `Cargo.lock` confirms `goosefs-sdk 0.1.9` is still resolved (now
transitively), so the `goosefs` feature continues to enable the same set
of
  Lance/IOPaths as before.

## Backwards compatibility

No public API changes. The `goosefs` cargo feature still activates
`lance/goosefs`, `lance-io/goosefs`, and
`lance-namespace-impls/dir-goosefs`,
and the same `goosefs-sdk 0.1.9` version is selected by the resolver.
2026-08-10 12:16:21 +08:00
lancedb-gatefixer[bot] 36054be576 fix(node): preserve nested Arrow data across versions (#3900)
<!-- lance-gatekeeper-fix:v1 agent=613a074d606e626c5169d601373a32d8
generation=1 -->

## Root cause

When LanceDB accepted an Arrow table created by a different installed
Arrow package, its compatibility sanitizer rebuilt each Data node
without converting the foreign type or preserving nested children. It
also dropped the separate dictionary vector payload and did not preserve
identity shared by dictionary schema types, vector wrappers, or growing
dictionary chunks.

## Fix

Recursively sanitize nested Arrow data types and child data. Use one
table-scoped sanitization context to rebuild and memoize source type
objects, dictionary vectors, and Data nodes in the local Arrow realm,
preserving all identities required by Arrow IPC.

Add Arrow 15 through 18 regressions for list serialization, ordinary
dictionaries, dictionaries shared across fields and batches, growing
dictionaries, and IPC round trips.

## Validation

- pnpm test __test__/arrow.test.ts --runInBand (188 passed)
- pnpm lint
- pnpm build
- pnpm test --runInBand (706 passed, 5 skipped)
- pnpm run docs

Fixes #2256

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-09 03:34:39 +08:00
Dan Tasse 77a93fee76 fix: get table size from metadata, not files (#3790)
Some issues:
- file_size_bytes is optional in the manifest, so if it's not there (old
writer I guess) it'll under-report the table size.
- it changes results a little bit from the old way by including per-file
footers and metadata (probably not a big difference at real scale)

---------

Co-authored-by: Will Jones <willjones127@gmail.com>
2026-08-07 17:41:41 -04:00
Lance Release 7bb501839a Bump version: 0.37.1-beta.0 → 0.37.1-beta.1 2026-08-07 21:16:07 +00:00
Andrew Chen 5b347afd99 fix: avoid AttributeError in JinaEmbeddings image input for str/Path (#3670)
## What

`JinaEmbeddings._generate_image_input_dict()` crashes with
`AttributeError: 'function' object has no attribute 'urlparse'` on any
image given as a URL string, local path string, or `pathlib.Path` — i.e.
every documented `jina-clip-v1` image-embedding use case except raw
`bytes`.

## Why

```python
from urllib.parse import urlparse
...
parsed = urlparse.urlparse(image)
```

`urlparse` is imported as a function, then called as if it were the
`urllib.parse` module (`urlparse.urlparse(...)`). The module-level
`is_valid_url()` a few lines above does it correctly (`urlparse(text)`),
which is why this reads as a typo rather than intentional. Fixed to
`urlparse(str(image))` — `str()` is needed because `urlparse()` only
accepts `str`/`bytes` and raises a different `AttributeError` on a raw
`Path`.

## Testing

Added `test_jina_generate_image_input_dict_local_path`, which fails with
the original `AttributeError` before the fix and passes after, covering
both a `str` path and a `pathlib.Path`. Verified locally (built the Rust
extension, ran red→green, then the full `test_embeddings.py` file: 15
passed / 8 skipped, no regressions) and with `ruff check`/`ruff format`.

---
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 <noreply@anthropic.com>
2026-08-07 14:05:45 -07:00
Dan Rammer 706a9c327f feat: infer maintained indexes when an LsmWriteSpec omits them (#3748)
## What

`LsmWriteSpec::maintained_indexes` becomes `Option<Vec<String>>`:

| value | meaning |
|---|---|
| `None` (new default) | every index the MemWAL supports, resolved when
the spec is installed |
| `Some([])` | maintain nothing — a scan/filter-only WAL table |
| `Some([..])` | exactly these, taken verbatim |

`with_maintained_indexes` keeps its signature;
`with_no_maintained_indexes()` is new. Surfaced through the remote path
(null on the wire), Python, and Node.

## Why

Callers had to state the maintained set by hand every time, which is
both tedious and easy to get wrong — the common case is "maintain what I
already built."

Resolution filters on `IndexConfig::is_memwal_maintainable`, delegating
to lance's `is_maintainable_index_type`. This is load-bearing rather
than cosmetic: lance does **not** skip an index type its memtable cannot
build, it errors when the shard writer opens, so sweeping up a bitmap
index would fail every memtable claim and leave the table unwritable.
The inferred set excludes those, and an explicit list naming one is now
rejected at spec time instead of at claim time.

## Behavior change

A freshly constructed spec used to maintain **nothing**; it now
maintains **everything supported**. This flipped because napi collapses
`undefined` and `null` to `None`, so TypeScript cannot express "absent
means nothing, null means all" — any other choice makes the bindings
disagree with the wire. The error direction also favors it: an unwanted
maintained index costs memory, while a silently unmaintained one
degrades FTS to an unscored scan.

Three existing tests encoded the old default and are updated rather than
worked around.

## Caveat

The resolved set is a snapshot, not a subscription. An index created
after the spec is installed is not maintained until the spec is unset
and set again. `get_lsm_write_spec` therefore always reports a concrete
list — `None` never round-trips.

## Dependency

Needs a lance release carrying `is_maintainable_index_type`
(lance-format/lance#8095) before this builds against the pinned tag.
Draft until then.

## Testing

38 Rust LSM tests and 10 Python tests pass against a local lance build,
including new coverage that a bitmap index is excluded from inference
and rejected when named, and that `[]` stays distinguishable from null
on the wire.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:50:22 -05:00
LanceDB Robot be290447d9 chore: update lance dependency to v11.0.0-beta.3 (#3896)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v11.0.0-beta.3. No compatibility fixes were required;
all-features clippy and Rust formatting pass. Triggering tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.3
2026-08-07 13:54:32 -05:00
Dan Rammer 79ba076429 feat(table): checkpoint_lsm, flush_lsm, compact_lsm, get_lsm_stats (#3736)
Converge a table's LSM write path into its base table, and inspect it.

`checkpoint_lsm` is `flush` then `compact`, repeated until the fresh
tier is empty — and the loop runs **client-side**. Putting it on the
server would mean a background task, which means a single-flight intent,
an intent that leaks on panic, a bounded-iteration policy, an "is it
done" observable, and a story for every way a client can vanish
mid-operation. None of that exists in this shape: each request does a
bounded unit of work and reports what is left, so completion is *carried
in the responses* rather than inferred from a shared counter that cannot
distinguish "converged" from "hasn't started yet".

Best-effort by construction. Nothing is frozen, so `converged` means L0
was empty as of the last pass. It is idempotent, abandonable at any
point with zero consequence, and safe to run on a cadence — an
already-converged table costs one round trip and zero compaction passes,
because `flush` reports `generations_remaining` and the loop is never
entered.

## The failure taxonomy is the load-bearing part

Five distinct conditions used to arrive at a client as one 503.
`Error::LsmRoute` carries a classification read from the response body's
namespace error code **at the point of receipt** — before any generic
helper folds the body into a string and keeps only the status.

| condition | wire | client action |
|---|---|---|
| contention (latch held / pool saturated) | 429, code 21 | retry with
backoff |
| owning node draining | 503, code 19 `InvalidTableState` | **stop** |
| fenced / no slot / transport | 503, code 17 | retry with backoff |
| registry entry vanished | 404 | re-issue from `flush` (capped) |
| table being dropped / not WAL-backed | 409 / 400 | stop |

Draining is terminal because the drain gate is a one-way latch —
retrying spins until the deadline to report a failure that was knowable
on the first response. Transport retry is disabled on these routes for
the same reason: it treats every 503 alike and would burn its budget
before the classifier ever saw the body.

`get_lsm_stats` returns `Option<LsmStats>`, matching
`get_lsm_write_spec` — `None` only when the table has no LSM write path,
since a struct of zeros would read as measurements.

Python bindings mirror all four, preserving per-bucket detail rather
than flattening to a table-level summary.

## Testing

Six new unit tests against the mocked endpoint, plus the taxonomy
round-trip:
- flush into an empty L0 issues **zero** compact calls (asserts the call
count — `generations_consumed: 0` is also true of a loop that ran a
pointless pass)
- the loop drives compact until the server reports zero remaining
- **contention is not draining**: a 429 retries and converges; asserts
the retry count
- a draining node stops after **exactly one** request, no retries
- stats round-trips fully populated; `include_generation_rows` off by
default
- every `(status, code)` pair classifies correctly, including
unparseable 503 bodies falling back to *retryable* rather than terminal

`cargo test -p lancedb --features remote --lib`: 723 passed.

## Notes for review

- Depends on the sibling lance change returning `SealedGeneration` from
`force_seal_active` only at the *server* level — no lance API is used
here.
- The branch is based on `codex/update-lance-10-0-0-beta-5`, so it
carries one extra commit (`chore: update lance dependency to
v10.0.0-beta.5`) that is not part of this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: lancedb automation <robot@lancedb.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:44:49 -05:00
lancedb-gatefixer[bot] ec21e37040 test(rust): cover Hugging Face table symlinks (#3887)
## Summary

- cover Hugging Face cache layouts where both manifests and Lance data
files are relative symlinks into a blob directory
- reconnect with a fresh session before opening so the test exercises
filesystem discovery instead of cached manifest metadata
- scan the reopened table to verify both manifest recovery and data-file
reads

## Root cause

Lance 3.0.1 recorded Unix symlink metadata as the known manifest size,
so the short link length caused a file size is too small error. The
current Lance v11.0.0-beta.2 dependency repairs this by detecting an
invalid footer from a stale known size and retrying with the target file
metadata. This regression test locks that behavior into the LanceDB
open-table path used by Node.

## Validation

- cargo fmt --all
- cargo test --quiet --features remote -p lancedb --lib
test_open_table_follows_hugging_face_symlinks -- --nocapture
- cargo test --quiet --features remote -p lancedb --lib
database::listing::tests
- cargo clippy --quiet --features remote -p lancedb --lib --tests -- -D
warnings
- cargo check --quiet --features remote --tests --examples

Fixes #3197

<!-- lance-gatekeeper-fix:v1 agent=4aadcf04e9ac93b97d499d7448b67e19
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:37:37 +08:00
lancedb-gatefixer[bot] 6ba80a960c fix(node): cover offset pagination in search (#3814)
## Summary

- add Node regression coverage for vector-search offset pagination
- add equivalent coverage for full-text search
- compare later pages with the corresponding complete-result slice and
assert page sizes

## Root cause

The historical query path requested only the user limit from
nearest-neighbor or full-text search before applying the offset, so a
page became empty when its offset reached that limit. The production
query path on current main already incorporates the later fix from
#2592; this change adds the missing Node binding coverage for the
still-open report and protects both affected APIs from regression.

## Validation

- corepack pnpm build
- corepack pnpm test -- query.test.ts --runInBand
--testNamePattern="Search pagination"
- corepack pnpm lint-ci
- corepack pnpm tsc
- corepack pnpm run docs

Fixes #2229

<!-- lance-gatekeeper-fix:v1 agent=8ba8b18a18260a68a3e605d1bbfa518e
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:33:13 +08:00
lancedb-gatefixer[bot] 11f24b1df4 fix: explain unsupported object storage mounts (#3823)
## Summary

- classify unsupported local-filesystem operations from Lance as a
NotSupported error
- explain that object-storage mounts cannot provide the safe commit
operations Lance requires and direct users to native object-store URIs
- preserve existing error behavior for other local I/O failures and
non-local backends

## Root cause

Mountpoint for Amazon S3 exposes an S3 bucket as a local path but does
not implement atomic rename. Lance uses atomic rename for safe local
commits, and the resulting unsupported I/O error was previously passed
through as a generic Lance error, leaving Python users with an opaque
low-level failure. Transparent support for such mounts is not safe;
direct s3:// access remains the supported path.

## Validation

- cargo test --quiet --features remote -p lancedb error::tests
- cargo test --quiet --features remote -p lancedb --lib (807 passed, 1
ignored)
- cargo check --quiet --features remote --tests --examples
- cargo clippy --quiet --features remote --tests --examples
- cargo fmt --all -- --check

Fixes #2016

<!-- lance-gatekeeper-fix:v1 agent=d53283c18fdb00a3a1b69448b1f40529
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:33:02 +08:00
lancedb-gatefixer[bot] 2ba7407dc3 fix(node): cover non-nullable embedding schema append (#3835)
## Summary

- Add an issue-specific regression for appending generated embeddings to
an empty table with a non-nullable vector field.
- Verify the custom embedding function produces the declared Float64
vectors and both appended rows are readable.

## Root cause

In v0.4.19, records without a vector value were materialized against the
explicit schema before embeddings were inserted. Apache Arrow inferred
the generated batch vector field as nullable while the table retained
the user-provided non-nullable field, then rejected the mismatched
schemas.

The current conversion path excludes the generated field from the
initial record conversion and realigns the completed batch to the stored
schema after embedding, but the reported empty-table append sequence
lacked permanent regression coverage.

## Validation

- `pnpm exec biome format --write __test__/embedding.test.ts`
- `pnpm lint-ci`
- `pnpm test -- --runInBand __test__/embedding.test.ts` (12 passed, 1
skipped integration test)
- `pnpm build`
- `pnpm run docs`

Fixes #1281

<!-- lance-gatekeeper-fix:v1 agent=6b7270aeb92e6b6c6f5b45022fa83f6a
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:32:39 +08:00
lancedb-gatefixer[bot] 607e556927 test(python): cover search after schema merge (#3784)
## Summary

- add an end-to-end regression for indexed vector search after merging a
pandas column
- verify unmatched rows retain a null merged value instead of failing
Arrow batch assembly

## Root cause

Historical Lance readers could assemble schema-evolved columns in
physical data-file order. Indexed row-ID reads after a merge could
therefore omit or misorder the newly merged column for unmatched rows.
The currently pinned Lance release contains the reader correction, but
LanceDB did not cover the reported merge-then-search path.

## Validation

- uv run --extra tests pytest python/tests/test_table.py::test_merge
python/tests/test_table.py::test_search_after_merge -q
- uv run --project python --extra dev ruff check .
- uv run --project python --extra dev ruff format --check
python/python/tests/test_table.py

Fixes #599

<!-- lance-gatekeeper-fix:v1 agent=4e17331e0542c132eae31e86da508629
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:32:28 +08:00
lancedb-gatefixer[bot] 564e5d0d56 fix(python): support Polars 1.32 table scans (#3801)
## Root cause

`Table.to_polars()` disabled PyArrow predicate pushdown by selecting the
non-PyArrow Polars scan callback. Polars 1.32.3 invokes that callback
with `batch_size` both positionally and through its partial, so
collecting the returned lazy frame raises `TypeError:
_scan_pyarrow_dataset_impl() got multiple values for argument
batch_size`.

## Fix

- Keep the compatible PyArrow callback path.
- Add an identity `map_batches` barrier so predicates stay in Polars
instead of reaching the LanceDB adapter as unsupported PyArrow
expressions.
- Extend the tested Polars range through 1.32.3 and retain lazy-frame
regression coverage.

## Validation

- `python/tests/test_table.py::test_polars` with Polars 1.32.3
- `python/tests/test_table.py::test_polars` with the locked Polars 1.3.0
baseline
- `ruff format --check` on the changed Python files
- `ruff check .`
- `uv lock --check`

Fixes #2619

<!-- lance-gatekeeper-fix:v1 agent=0d42bcda944ac42765b25f2c19ff729f
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:32:17 +08:00
lancedb-gatefixer[bot] dd5cb4d805 test(python): cover float16 table creation from Arrow data (#3785)
## Summary

- exercise float16 sanitization through the reported direct Arrow-data
table creation path
- assert that the inferred fixed-size vector schema remains float16
- retain end-to-end index creation and vector search coverage

## Root cause and fix

PyArrow 16 does not provide an is_nan kernel for half-float arrays, so
passing float16 vector values directly to that kernel raises
ArrowNotImplementedError. LanceDB's sanitizer already carries the
compatibility fix from #837: it casts float16 values to float32 only for
NaN detection while preserving the stored vector type.

The existing end-to-end regression created an empty schema-defined table
and added data afterward. This change aligns that regression with the
issue reproduction by creating a table directly from a
FixedSizeList<float16> Arrow table and verifying the persisted schema.

## Validation

- uv run --extra tests pytest
python/tests/test_table.py::test_create_f16_table_from_arrow_data -q
- direct 1,000-row by 128-dimension float16 Arrow-table reproduction
- PyArrow 16.1 half-float is_nan kernel reproduction
- uvx ruff@0.15.20 format --check python/python/tests/test_table.py
- uvx ruff@0.15.20 check .

Fixes #835

<!-- lance-gatekeeper-fix:v1 agent=dd0a32a959f691f49de958d4333fb29d
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:32:05 +08:00
lancedb-gatefixer[bot] dbc3687c7b fix(node): require compatible Node.js types (#3829)
## Summary

- require Node.js 18-compatible type declarations when TypeScript
consumers install them
- keep the type peer optional for JavaScript-only consumers
- add a regression test tying the Node type peer range to the supported
runtime

## Root cause

LanceDB requires Node.js 18 or newer, and its public types expose Apache
Arrow declarations that import built-ins through the node: scheme. The
package did not declare a matching @types/node peer requirement, so npm
accepted projects pinned to Node 12 declarations and TypeScript then
reported that node:stream and node:fs/promises did not exist.

## Validation

- pnpm lint
- pnpm build
- pnpm run docs
- pnpm test --runInBand (678 passed, 5 skipped)
- packed-package consumer probe rejects @types/node 12.20.55 and
installs with @types/node 18.19.130

Fixes #1713

<!-- lance-gatekeeper-fix:v1 agent=7a2b68f3daad20bed9e46cb8892d6e6c
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:31:53 +08:00
lancedb-gatefixer[bot] ec80acb668 fix(python): expose inline types to downstream checkers (#3817)
## Summary

- publish the PEP 561 `py.typed` marker so downstream type checkers
consume the inline public annotations
- add a Pyright contract test that distinguishes synchronous `connect`
from awaited `connect_async`
- verify the marker is present in the installed package

## Root cause

The public Python module already annotated `lancedb.connect` as
synchronous and `lancedb.connect_async` as asynchronous. The private
native `_lancedb.connect` stub is intentionally awaitable because it
backs `connect_async`. However, the distribution did not include a PEP
561 marker, so downstream tools such as mypy could ignore the public
inline annotations and expose misleading or incomplete type information.

## Validation

- `python/.venv/bin/ruff format --check python/python/tests/test_db.py
python/python/type_tests/connect.py`
- `python/.venv/bin/ruff check .`
- `cd python && .venv/bin/pytest
python/tests/test_db.py::test_package_includes_pep_561_marker -q`
- `cd python && .venv/bin/pyright --pythonpath .venv/bin/python`
- downstream mypy contract check for both public connection functions

Fixes #2159

<!-- lance-gatekeeper-fix:v1 agent=b07901451487187fc03f61890d3aa6bb
generation=1 -->

Co-authored-by: lancedb-gatefixer[bot] <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:31:42 +08:00
lancedb-gatefixer[bot] fc44535cee fix(python): clarify bare Vector annotations (#3809)
## Summary

- raise a clear `TypeError` when `Vector` is used without a dimension
- preserve normal `Vector(dim)` behavior across Pydantic v1 and v2
- add a regression test that defines a model without importing PyArrow

## Root cause

Pydantic interpreted the bare `Vector` factory as a callable field type
and inspected its postponed annotations in the user model's namespace.
Because that namespace did not define LanceDB's internal `pa` alias,
model construction failed with the misleading `NameError: name 'pa' is
not defined` instead of explaining that `Vector` must be parameterized.

The factory now exposes Pydantic's v1 and v2 schema hooks and rejects
bare use before signature introspection with guidance to use
`Vector(dim)`.

## Validation

- `uvx --from 'ruff==0.15.20' ruff check .`
- `uvx --from 'ruff==0.15.20' ruff format --check
python/python/lancedb/pydantic.py python/python/tests/test_pydantic.py`
- `cd python && uv run --extra tests pytest
python/tests/test_pydantic.py::test_bare_vector_raises_clear_error -q`
- `cd python && uv run --extra tests pytest
python/tests/test_pydantic.py -q`
- compatibility checks with Pydantic 1.10.22, 2.11.4, and 2.13.4

Fixes #2384

<!-- lance-gatekeeper-fix:v1 agent=71e7473e18c91db5137a3c0d3bb73640
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:31:30 +08:00
lancedb-gatefixer[bot] 4048150fdd test(python): cover nullable fixed-size-list ingestion (#3812)
## Summary

- add regression coverage for adding dictionary rows with a nullable
fixed-size-list column
- verify ordinary list columns remain aligned alongside the null
fixed-size-list value

## Root cause

PyArrow infers an all-`None` dictionary column as the generic `null`
type. The original schema-alignment path treated the target
fixed-size-list type as proof that the inferred source was also
list-like and unconditionally accessed `value_field`, which raised
`AttributeError`. Current alignment logic correctly falls back to the
target type when the source is not list-like; this test locks in that
repair for the reported ingestion path.

## Validation

- `uv run --extra tests pytest
python/tests/test_table.py::test_add_with_empty_fixed_size_list_drops_bad_rows
python/tests/test_table.py::test_add_nullable_fixed_size_list_with_none
python/tests/test_table.py::test_add_nullable_struct_with_none -q`
- `uv run --with pyarrow==19.0.1 --extra tests pytest
python/tests/test_table.py::test_add_nullable_fixed_size_list_with_none
-q`
- `uv run --project python --extra dev ruff format --check
python/python/tests/test_table.py`
- `uv run --project python --extra dev ruff check .`

Fixes #2340

<!-- lance-gatekeeper-fix:v1 agent=cb0475e85e764f79bd03b35eb8955ec4
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:31:19 +08:00
lancedb-gatefixer[bot] 2922c171f7 test(rust): cover Azure table URI separators (#3837)
## Root cause

The former listing-database table URI builder used OS-native
`Path::join` for object-store URIs. On Windows this inserted backslashes
into `az://` table paths, so `table_names` found slash-delimited objects
while `open_table` addressed a different key. The production path now
builds URI paths with forward slashes after the equivalent S3 report was
fixed in #2575, but #1072 remained open without Azure-specific
regression coverage.

## Fix

- Add Azure URI regression assertions at the Rust table URI construction
boundary.
- Cover connection bases both with and without a trailing slash,
matching the behavior reported in #1072.
- Verify the resulting table URI always uses forward slashes on every
platform.

## Validation

- `cargo fmt --all -- --check`
- `cargo test --quiet -p lancedb --lib
database::listing::tests::test_table_uri`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `cargo test --quiet --features remote --tests` (866 passed, 1 ignored)

Fixes #1072

<!-- lance-gatekeeper-fix:v1 agent=7d385255a072ed89ddc3ff4d08f82218
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:31:08 +08:00
lancedb-gatefixer[bot] c5f9efefe9 test(python): cover local sync multiple-vector search (#3830)
## Summary
- add regression coverage for multiple query vectors in the local
synchronous Python API
- verify that each query vector receives its own limited
nearest-neighbor result and `query_index`

## Root cause
In LanceDB v0.16, the local synchronous scanner passed a nested vector
array as one query, unlike the async and remote implementations. The
subsequent sync-to-async table migration supplied the correct shared
runtime path, but this local sync behavior was never regression-tested
and issue #1857 remained open.

## Validation
- `uv run --extra tests pytest
python/tests/test_query.py::test_query_multiple_vectors -q`
- `uv run --project python --extra tests --extra dev ruff format --check
python/python/tests/test_query.py`
- `uv run --project python --extra tests --extra dev ruff check .`

Fixes #1857

<!-- lance-gatekeeper-fix:v1 agent=6b25bc529d76813c3db7627c8be947ef
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-07 17:30:55 +08:00
David Tolnay f4c668e244 chore(deps): declare more specific futures dependency (#3800)
Lancedb does not work with any other version of `futures`.

With futures 0.1 it fails like this:

```console
error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/arrow.rs:21:23
   |
21 | use futures::{Stream, StreamExt, TryStreamExt};
   |                       ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |                       |
   |                       no `StreamExt` in the root
   |

error[E0432]: unresolved import `futures::StreamExt`
  --> rust/lancedb/src/data/scannable.rs:24:5
   |
24 | use futures::StreamExt;
   |     ^^^^^^^^^^^^^^^^^^ no `StreamExt` in the root
   |

error[E0432]: unresolved import `futures::TryStreamExt`
 --> rust/lancedb/src/dataloader/permutation/builder.rs:9:5
  |
9 | use futures::TryStreamExt;
  |     ^^^^^^^^^^^^^^^^^^^^^ no `TryStreamExt` in the root

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/dataloader/permutation/reader.rs:25:15
   |
25 | use futures::{StreamExt, TryStreamExt};
   |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |               |
   |               no `StreamExt` in the root
   |

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
 --> rust/lancedb/src/dataloader/permutation/shuffle.rs:8:15
  |
8 | use futures::{StreamExt, TryStreamExt};
  |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
  |               |
  |               no `StreamExt` in the root
  |

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/dataloader/permutation/split.rs:12:15
   |
12 | use futures::{StreamExt, TryStreamExt};
   |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |               |
   |               no `StreamExt` in the root
   |

error[E0432]: unresolved import `futures::TryStreamExt`
 --> rust/lancedb/src/dataloader/permutation/util.rs:9:5
  |
9 | use futures::TryStreamExt;
  |     ^^^^^^^^^^^^^^^^^^^^^ no `TryStreamExt` in the root

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryFutureExt`
 --> rust/lancedb/src/io/object_store.rs:8:15
  |
8 | use futures::{StreamExt, TryFutureExt, stream::BoxStream};
  |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryFutureExt` in the root
  |               |
  |               no `StreamExt` in the root
  |

error[E0432]: unresolved imports `futures::FutureExt`, `futures::TryFutureExt`, `futures::TryStreamExt`, `futures::try_join`
  --> rust/lancedb/src/query.rs:12:15
   |
12 | use futures::{FutureExt, TryFutureExt, TryStreamExt, stream, try_join};
   |               ^^^^^^^^^  ^^^^^^^^^^^^  ^^^^^^^^^^^^          ^^^^^^^^ no `try_join` in the root
   |               |          |             |
   |               |          |             no `TryStreamExt` in the root
   |               |          no `TryFutureExt` in the root
   |               no `FutureExt` in the root
   |

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/remote/table/blobs.rs:13:15
   |
13 | use futures::{StreamExt, TryStreamExt};
   |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |               |
   |               no `StreamExt` in the root
   |

error[E0432]: unresolved imports `futures::SinkExt`, `futures::StreamExt`
  --> rust/lancedb/src/remote/table/insert.rs:20:15
   |
20 | use futures::{SinkExt, StreamExt};
   |               ^^^^^^^  ^^^^^^^^^ no `StreamExt` in the root
   |               |
   |               no `SinkExt` in the root
   |

error[E0432]: unresolved imports `futures::StreamExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/remote/table.rs:58:15
   |
58 | use futures::{StreamExt, TryStreamExt};
   |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |               |
   |               no `StreamExt` in the root
   |

error[E0432]: unresolved import `futures::StreamExt`
 --> rust/lancedb/src/remote/util.rs:5:23
  |
5 | use futures::{Stream, StreamExt};
  |                       ^^^^^^^^^ no `StreamExt` in the root
  |

error[E0432]: unresolved import `futures::StreamExt`
  --> rust/lancedb/src/table.rs:14:5
   |
14 | use futures::StreamExt;
   |     ^^^^^^^^^^^^^^^^^^ no `StreamExt` in the root
   |

error[E0432]: unresolved import `futures::TryStreamExt`
  --> rust/lancedb/src/table/datafusion/insert.rs:20:5
   |
20 | use futures::TryStreamExt;
   |     ^^^^^^^^^^^^^^^^^^^^^ no `TryStreamExt` in the root

error[E0432]: unresolved import `futures::TryStreamExt`
  --> rust/lancedb/src/table/datafusion/scannable_exec.rs:14:5
   |
14 | use futures::TryStreamExt;
   |     ^^^^^^^^^^^^^^^^^^^^^ no `TryStreamExt` in the root

error[E0432]: unresolved imports `futures::TryFutureExt`, `futures::TryStreamExt`
  --> rust/lancedb/src/table/datafusion.rs:25:15
   |
25 | use futures::{TryFutureExt, TryStreamExt};
   |               ^^^^^^^^^^^^  ^^^^^^^^^^^^ no `TryStreamExt` in the root
   |               |
   |               no `TryFutureExt` in the root

error[E0432]: unresolved import `futures::FutureExt`
 --> rust/lancedb/src/table/delete.rs:3:5
  |
3 | use futures::FutureExt;
  |     ^^^^^^^^^^^^^^^^^^ no `FutureExt` in the root
  |

error[E0432]: unresolved imports `futures::FutureExt`, `futures::TryFutureExt`
 --> rust/lancedb/src/table/merge.rs:9:15
  |
9 | use futures::{FutureExt, TryFutureExt};
  |               ^^^^^^^^^  ^^^^^^^^^^^^ no `TryFutureExt` in the root
  |               |
  |               no `FutureExt` in the root
  |

error[E0432]: unresolved import `futures::future::try_join_all`
  --> rust/lancedb/src/table/query.rs:24:5
   |
24 | use futures::future::try_join_all;
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `try_join_all` in `future`
   |

error[E0432]: unresolved import `futures::FutureExt`
  --> rust/lancedb/src/utils/background_cache.rs:12:5
   |
12 | use futures::FutureExt;
   |     ^^^^^^^^^^^^^^^^^^ no `FutureExt` in the root
   |

error[E0432]: unresolved import `futures::FutureExt`
  --> rust/lancedb/src/utils/mod.rs:12:15
   |
12 | use futures::{FutureExt, Stream};
   |               ^^^^^^^^^ no `FutureExt` in the root
   |

error[E0433]: cannot find `join` in `futures`
   --> rust/lancedb/src/remote/table/insert.rs:504:55
    |
504 |         let (producer_result, send_result) = futures::join!(producer, send);
    |                                                       ^^^^ could not find `join` in `futures`

error[E0407]: method `poll_next` is not a member of trait `Stream`
   --> rust/lancedb/src/arrow.rs:108:5
    |
108 | /     fn poll_next(
109 | |         self: Pin<&mut Self>,
110 | |         cx: &mut std::task::Context<'_>,
111 | |     ) -> std::task::Poll<Option<Self::Item>> {
112 | |         let this = self.project();
113 | |         this.stream.poll_next(cx)
114 | |     }
    | |_____^ not a member of trait `Stream`

error[E0407]: method `poll_next` is not a member of trait `Stream`
   --> rust/lancedb/src/utils/mod.rs:362:5
    |
362 | /     fn poll_next(
363 | |         mut self: std::pin::Pin<&mut Self>,
364 | |         cx: &mut std::task::Context<'_>,
365 | |     ) -> std::task::Poll<Option<Self::Item>> {
...   |
391 | |     }
    | |_____^ not a member of trait `Stream`

error[E0407]: method `poll_next` is not a member of trait `Stream`
   --> rust/lancedb/src/utils/mod.rs:433:5
    |
433 | /     fn poll_next(
434 | |         mut self: Pin<&mut Self>,
435 | |         cx: &mut std::task::Context<'_>,
436 | |     ) -> std::task::Poll<Option<Self::Item>> {
...   |
470 | |     }
    | |_____^ not a member of trait `Stream`

error[E0425]: cannot find function `try_unfold` in module `futures::stream`
   --> rust/lancedb/src/remote/table/insert.rs:230:39
    |
230 |         let stream = futures::stream::try_unfold(
    |                                       ^^^^^^^^^^ not found in `futures::stream`

error[E0433]: cannot find `channel` in `futures`
   --> rust/lancedb/src/remote/table/insert.rs:418:22
    |
418 |             futures::channel::mpsc::channel::<Result<Vec<u8>, std::io::Error>>(2);
    |                      ^^^^^^^ could not find `channel` in `futures`
    |

error[E0425]: cannot find function `try_join_all` in module `futures::future`
    --> rust/lancedb/src/remote/table.rs:1062:40
     |
1062 |           let streams = futures::future::try_join_all(futures);
     |                                          ^^^^^^^^^^^^
     |
    ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:76:1
     |
  76 | / pub fn join_all<I>(i: I) -> JoinAll<I>
  77 | |     where I: IntoIterator,
  78 | |           I::Item: IntoFuture,
     | |______________________________- similarly named function `join_all` defined here
     |

error[E0425]: cannot find function `try_join_all` in module `futures::future`
    --> rust/lancedb/src/remote/table.rs:1660:40
     |
1660 |           let results = futures::future::try_join_all(futures).await?;
     |                                          ^^^^^^^^^^^^
     |
    ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:76:1
     |
  76 | / pub fn join_all<I>(i: I) -> JoinAll<I>
  77 | |     where I: IntoIterator,
  78 | |           I::Item: IntoFuture,
     | |______________________________- similarly named function `join_all` defined here
     |

error[E0425]: cannot find function `try_join_all` in module `futures::future`
    --> rust/lancedb/src/remote/table.rs:2243:43
     |
2243 |           let plan_texts = futures::future::try_join_all(futures).await?;
     |                                             ^^^^^^^^^^^^
     |
    ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:76:1
     |
  76 | / pub fn join_all<I>(i: I) -> JoinAll<I>
  77 | |     where I: IntoIterator,
  78 | |           I::Item: IntoFuture,
     | |______________________________- similarly named function `join_all` defined here
     |

error[E0425]: cannot find function `try_join_all` in module `futures::future`
    --> rust/lancedb/src/remote/table.rs:2290:53
     |
2290 |           let analyze_result_texts = futures::future::try_join_all(futures).await?;
     |                                                       ^^^^^^^^^^^^
     |
    ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:76:1
     |
  76 | / pub fn join_all<I>(i: I) -> JoinAll<I>
  77 | |     where I: IntoIterator,
  78 | |           I::Item: IntoFuture,
     | |______________________________- similarly named function `join_all` defined here
     |

error[E0425]: cannot find function `try_unfold` in module `futures::stream`
  --> rust/lancedb/src/remote/util.rs:21:35
   |
21 |     let stream = futures::stream::try_unfold(
   |                                   ^^^^^^^^^^ not found in `futures::stream`

error[E0191]: the value of the associated type `Error` in `futures::Stream` must be specified
  --> rust/lancedb/src/arrow.rs:70:50
   |
70 | pub type SendableRecordBatchStream = Pin<Box<dyn RecordBatchStream + Send>>;
   |                                                  ^^^^^^^^^^^^^^^^^
   |
help: specify the associated type
   |
70 | pub type SendableRecordBatchStream = Pin<Box<dyn RecordBatchStream<Error = /* Type */> + Send>>;
   |                                                                   ++++++++++++++++++++

error[E0107]: type alias takes 0 lifetime arguments but 1 lifetime argument was supplied
   --> rust/lancedb/src/utils/background_cache.rs:15:31
    |
 15 | type SharedFut<V, E> = Shared<BoxFuture<'static, Result<V, Arc<E>>>>;
    |                               ^^^^^^^^^ ------- help: remove the lifetime argument
    |                               |
    |                               expected 0 lifetime arguments
    |
note: type alias defined here, with 0 lifetime parameters
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/mod.rs:106:14
    |
106 |     pub type BoxFuture<T, E> = ::std::boxed::Box<Future<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^

error[E0107]: type alias takes 2 generic arguments but 1 generic argument was supplied
   --> rust/lancedb/src/utils/background_cache.rs:15:31
    |
 15 | type SharedFut<V, E> = Shared<BoxFuture<'static, Result<V, Arc<E>>>>;
    |                               ^^^^^^^^^          ----------------- supplied 1 generic argument
    |                               |
    |                               expected 2 generic arguments
    |
note: type alias defined here, with 2 generic parameters: `T`, `E`
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/mod.rs:106:14
    |
106 |     pub type BoxFuture<T, E> = ::std::boxed::Box<Future<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^ -  -
help: add missing generic argument
    |
 15 | type SharedFut<V, E> = Shared<BoxFuture<'static, Result<V, Arc<E>>, E>>;
    |                                                                   +++

error[E0046]: not all trait items implemented, missing: `Error`, `poll`
   --> rust/lancedb/src/arrow.rs:105:1
    |
105 | impl<S: Stream<Item = Result<arrow_array::RecordBatch>>> Stream for SimpleRecordBatchStream<S> {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `Error`, `poll` in implementation
    |
    = help: implement the missing item: `type Error = /* Type */;`
    = help: implement the missing item: `fn poll(&mut self) -> std::result::Result<Async<std::option::Option<<Self as futures::Stream>::Item>>, <Self as futures::Stream>::Error> { todo!() }`

error[E0107]: type alias takes 0 lifetime arguments but 1 lifetime argument was supplied
   --> rust/lancedb/src/io/object_store.rs:97:46
    |
 97 |     fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
    |                                              ^^^^^^^^^ ------- help: remove the lifetime argument
    |                                              |
    |                                              expected 0 lifetime arguments
    |
note: type alias defined here, with 0 lifetime parameters
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^

error[E0107]: type alias takes 2 generic arguments but 1 generic argument was supplied
   --> rust/lancedb/src/io/object_store.rs:97:46
    |
 97 |     fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
    |                                              ^^^^^^^^^          ------------------ supplied 1 generic argument
    |                                              |
    |                                              expected 2 generic arguments
    |
note: type alias defined here, with 2 generic parameters: `T`, `E`
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^ -  -
help: add missing generic argument
    |
 97 |     fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>, E> {
    |                                                                                   +++

error[E0107]: type alias takes 0 lifetime arguments but 1 lifetime argument was supplied
   --> rust/lancedb/src/io/object_store.rs:107:20
    |
107 |         locations: BoxStream<'static, Result<Path>>,
    |                    ^^^^^^^^^ ------- help: remove the lifetime argument
    |                    |
    |                    expected 0 lifetime arguments
    |
note: type alias defined here, with 0 lifetime parameters
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^

error[E0107]: type alias takes 2 generic arguments but 1 generic argument was supplied
   --> rust/lancedb/src/io/object_store.rs:107:20
    |
107 |         locations: BoxStream<'static, Result<Path>>,
    |                    ^^^^^^^^^          ------------ supplied 1 generic argument
    |                    |
    |                    expected 2 generic arguments
    |
note: type alias defined here, with 2 generic parameters: `T`, `E`
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^ -  -
help: add missing generic argument
    |
107 |         locations: BoxStream<'static, Result<Path>, E>,
    |                                                   +++

error[E0107]: type alias takes 0 lifetime arguments but 1 lifetime argument was supplied
   --> rust/lancedb/src/io/object_store.rs:108:10
    |
108 |     ) -> BoxStream<'static, Result<Path>> {
    |          ^^^^^^^^^ ------- help: remove the lifetime argument
    |          |
    |          expected 0 lifetime arguments
    |
note: type alias defined here, with 0 lifetime parameters
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^

error[E0107]: type alias takes 2 generic arguments but 1 generic argument was supplied
   --> rust/lancedb/src/io/object_store.rs:108:10
    |
108 |     ) -> BoxStream<'static, Result<Path>> {
    |          ^^^^^^^^^          ------------ supplied 1 generic argument
    |          |
    |          expected 2 generic arguments
    |
note: type alias defined here, with 2 generic parameters: `T`, `E`
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:132:14
    |
132 |     pub type BoxStream<T, E> = ::std::boxed::Box<Stream<Item = T, Error = E> + Send>;
    |              ^^^^^^^^^ -  -
help: add missing generic argument
    |
108 |     ) -> BoxStream<'static, Result<Path>, E> {
    |                                         +++

error[E0599]: no method named `map_err` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/dataloader/permutation/builder.rs:208:32
    |
208 |         let stream = df_stream.map_err(|e| Error::Other {
    |                      ----------^^^^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`
    |
   ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.32/src/stream/try_stream/mod.rs:248:8
    |
248 |     fn map_err<E, F>(self, f: F) -> MapErr<Self, F>
    |        ------- the method is available for `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` here
    |

error[E0599]: no method named `try_collect` found for struct `DatasetRecordBatchStream` in the current scope
    --> rust/lancedb/src/dataloader/permutation/reader.rs:220:28
     |
 220 |         let batches = data.try_collect::<Vec<_>>().await?;
     |                            ^^^^^^^^^^^
     |

error[E0599]: no method named `map_err` found for struct `DatasetRecordBatchStream` in the current scope
   --> rust/lancedb/src/dataloader/permutation/reader.rs:287:14
    |
286 |           let mut stream = row_ids
    |  __________________________-
287 | |             .map_err(Error::from)
    | |             -^^^^^^^ method not found in `DatasetRecordBatchStream`
    | |_____________|
    |

error[E0599]: the method `chain` exists for struct `futures::stream::Once<_, _>`, but its trait bounds were not satisfied
   --> rust/lancedb/src/dataloader/permutation/reader.rs:307:81
    |
307 |         let stream = futures::stream::once(std::future::ready(Ok(first_batch))).chain(stream);
    |                                                                                 ^^^^^ method cannot be called on `futures::stream::Once<_, _>` due to unsatisfied trait bounds

error[E0308]: mismatched types
   --> rust/lancedb/src/dataloader/permutation/shuffle.rs:120:35
    |
120 |             futures::stream::once(async move { Ok(shuffled) }),
    |             --------------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found `async` block
    |             |
    |             arguments to this function are incorrect
    |
    = note:       expected enum `std::result::Result<_, _>`
            found `async` block `{async block@rust/lancedb/src/dataloader/permutation/shuffle.rs:120:35: 120:45}`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
120 |             futures::stream::once(Ok(async move { Ok(shuffled) })),
    |                                   +++                           +
120 |             futures::stream::once(Err(async move { Ok(shuffled) })),
    |                                   ++++                           +

error[E0271]: type mismatch resolving `<Range<u64> as IntoIterator>::Item == Result<_, _>`
   --> rust/lancedb/src/dataloader/permutation/shuffle.rs:228:44
    |
228 |         let stream = futures::stream::iter(0..num_files)
    |                      --------------------- ^^^^^^^^^^^^ expected `Result<_, _>`, found `u64`
    |                      |
    |                      required by a bound introduced by this call
    |
    = note: expected enum `std::result::Result<_, _>`
               found type `u64`
note: required by a bound in `futures::stream::iter`
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/iter.rs:31:27
    |
 30 | pub fn iter<J, T, E>(i: J) -> Iter<J::IntoIter>
    |        ---- required by a bound in this function
 31 |     where J: IntoIterator<Item=Result<T, E>>,
    |                           ^^^^^^^^^^^^^^^^^ required by this bound in `iter`

error[E0599]: no method named `then` found for struct `IterStream<I>` in the current scope
   --> rust/lancedb/src/dataloader/permutation/shuffle.rs:229:14
    |
228 |           let stream = futures::stream::iter(0..num_files)
    |  ______________________-
229 | |             .then(move |file_index| {
    | |             -^^^^ method not found in `IterStream<std::ops::Range<u64>>`
    | |_____________|
    |

error[E0599]: no method named `try_collect` found for struct `Pin<Box<dyn lance::io::RecordBatchStream>>` in the current scope
    --> rust/lancedb/src/dataloader/permutation/shuffle.rs:258:26
     |
 250 |                       let batches = reader
     |  ___________________________________-
 251 | |                         .read_stream(
 252 | |                             ReadBatchParams::RangeFull,
 253 | |                             reader.num_rows() as u32,
...    |
 257 | |                         .await?
 258 | |                         .try_collect::<Vec<_>>()
     | |_________________________-^^^^^^^^^^^

error[E0599]: no method named `and_then` found for associated type `impl Future<Output = Result<Arc<...>, ...>> + Send` in the current scope
   --> rust/lancedb/src/query.rs:766:14
    |
765 | /         self.create_plan(QueryExecutionOptions::default())
766 | |             .and_then(|plan| std::future::ready(Ok(plan.schema())))
    | |_____________-^^^^^^^^
    |
   ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.32/src/future/try_future/mod.rs:395:8
    |
395 |       fn and_then<Fut, F>(self, f: F) -> AndThen<Self, Fut, F>
    |          -------- the method is available for `impl std::future::Future<Output = std::result::Result<Arc<(dyn ExecutionPlan + 'static)>, error::Error>> + std::marker::Send` here

error[E0599]: no method named `boxed` found for `async` block `{async block@rust/lancedb/src/query.rs:1492:33: 1492:43}` in the current scope
    --> rust/lancedb/src/query.rs:1493:18
     |
1492 |               let hybrid_result = async move { self.execute_hybrid(options).await }
     |  _________________________________-
1493 | |                 .boxed()
     | |                 -^^^^^ method not found in `{async block@rust/lancedb/src/query.rs:1492:33: 1492:43}`
     | |_________________|

error[E0271]: expected `{closure@blobs.rs:181:58}` to return `Result<_, _>`, but it returns `impl Future<Output = Result<Bytes, Error>>`
   --> rust/lancedb/src/remote/table/blobs.rs:181:66
    |
181 |         futures::stream::iter(ranges.iter().cloned().map(|range| self.read_range(range)))
    |         ---------------------                            ------- ^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found future
    |         |                                                |
    |         |                                                this closure
    |         required by a bound introduced by this call

error[E0599]: no method named `buffered` found for struct `IterStream<I>` in the current scope
   --> rust/lancedb/src/remote/table/blobs.rs:182:14
    |
181 | /         futures::stream::iter(ranges.iter().cloned().map(|range| self.read_range(range)))
182 | |             .buffered(BLOB_REQUEST_CONCURRENCY)
    | |             -^^^^^^^^ method not found in `Iter<Map<Cloned<Iter<'_, Range<u64>>>, {closure@...}>>`
    | |_____________|

error[E0599]: no method named `try_next` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/remote/table/blobs.rs:379:40
    |
379 |         while let Some(batch) = stream.try_next().await? {
    |                                        ^^^^^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0271]: type mismatch resolving `<Vec<...> as IntoIterator>::Item == Result<_, _>`
   --> rust/lancedb/src/remote/table/blobs.rs:481:27
    |
481 |     futures::stream::iter(probe_futures)
    |     --------------------- ^^^^^^^^^^^^^ expected `Result<_, _>`, found future
    |     |
    |     required by a bound introduced by this call

error[E0599]: no method named `buffered` found for struct `IterStream<I>` in the current scope
   --> rust/lancedb/src/remote/table/blobs.rs:482:10
    |
481 | /     futures::stream::iter(probe_futures)
482 | |         .buffered(BLOB_REQUEST_CONCURRENCY)
    | |         -^^^^^^^^ method not found in `Iter<IntoIter<impl Future<Output = Result<..., ...>>>>`
    | |_________|

error[E0599]: no method named `next` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/remote/table/insert.rs:324:37
    |
324 |         let mut first = match input.next().await {
    |                                     ^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0599]: no method named `next` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/remote/table/insert.rs:345:33
    |
345 |             first = match input.next().await {
    |                                 ^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0599]: the method `next` exists for mutable reference `&mut Pin<Box<dyn RecordBatchStream + Send>>`, but its trait bounds were not satisfied
   --> rust/lancedb/src/remote/table/insert.rs:446:41
    |
446 |                     None => match input.next().await {
    |                                         ^^^^ method cannot be called on `&mut Pin<Box<dyn RecordBatchStream + Send>>` due to unsatisfied trait bounds
    |
    = note: the following trait bounds were not satisfied:
            `Pin<Box<(dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send + 'static)>>: Iterator`
            which is required by `&mut Pin<Box<(dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send + 'static)>>: Iterator`

error[E0599]: no method named `map_err` found for struct `IterStream<I>` in the current scope
   --> rust/lancedb/src/remote/table.rs:688:53
    |
688 |         let stream = futures::stream::iter(batches).map_err(DataFusionError::from);
    |                                                     ^^^^^^^ method not found in `Iter<Box<dyn Iterator<Item = Result<..., ...>> + Send>>`

error[E0599]: no method named `try_collect` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
    --> rust/lancedb/src/remote/table.rs:1378:49
     |
1378 |             let result: Result<Vec<_>> = stream.try_collect().await.map_err(Error::from);
     |                                                 ^^^^^^^^^^^

error[E0599]: no method named `next` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
    --> rust/lancedb/src/remote/table.rs:1509:48
     |
1509 |                 while let Some(batch) = stream.next().await {
     |                                                ^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0599]: no method named `boxed` found for opaque type `impl Future<Output = Result<DeleteResult, Error>>` in the current scope
  --> rust/lancedb/src/table/delete.rs:35:51
   |
35 |             let delete_result = dataset.delete(s).boxed().await?;
   |                                                   ^^^^^ method not found in `impl Future<Output = Result<DeleteResult, Error>>`

error[E0599]: no variant, associated function, or constant named `Left` found for enum `Either<A, B>` in the current scope
   --> rust/lancedb/src/table/merge.rs:292:17
    |
292 |         Either::Left(tokio::time::timeout(timeout, future).map(|res| match res {
    |                 ^^^^ variant, associated function, or constant not found in `Either<_, _>`

error[E0599]: `Timeout<impl Future<Output = Result<(Arc<...>, ...), ...>>>` is not an iterator
   --> rust/lancedb/src/table/merge.rs:292:60
    |
292 |           Either::Left(tokio::time::timeout(timeout, future).map(|res| match res {
    |                        --------------------------------------^^^ `Timeout<impl Future<Output = Result<(Arc<...>, ...), ...>>>` is not an iterator
    |
   ::: $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/pin-project-lite-0.2.17/src/lib.rs:745:9
    |
745 | /         $vis struct $ident $($def_generics)*
746 | |         $(where
747 | |             $($where_clause)*)?
...   |
751 | |             ),+
752 | |         }
    | |_________- doesn't satisfy `_: Iterator`
    |
    = note: the following trait bounds were not satisfied:
            `tokio::time::Timeout<impl std::future::Future<Output = std::result::Result<(Arc<lance::Dataset>, MergeStats), lance::Error>>>: Iterator`
            which is required by `&mut tokio::time::Timeout<impl std::future::Future<Output = std::result::Result<(Arc<lance::Dataset>, MergeStats), lance::Error>>>: Iterator`

error[E0599]: no variant, associated function, or constant named `Right` found for enum `Either<A, B>` in the current scope
   --> rust/lancedb/src/table/merge.rs:301:17
    |
301 |         Either::Right(job.execute_reader(new_data).map_err(|e| e.into()))
    |                 ^^^^^ variant, associated function, or constant not found in `Either<_, _>`

error[E0599]: no method named `map_err` found for opaque type `impl Future<Output = Result<(Arc<Dataset>, ...), ...>>` in the current scope
   --> rust/lancedb/src/table/merge.rs:301:52
    |
301 |         Either::Right(job.execute_reader(new_data).map_err(|e| e.into()))
    |                                                    ^^^^^^^ method not found in `impl Future<Output = Result<(Arc<Dataset>, ...), ...>>`

error[E0277]: the trait bound `Iter<Map<IntoIter<RecordBatch>, ...>>: Stream` is not satisfied
   --> rust/lancedb/src/table/query.rs:681:38
    |
681 |     Ok(DatasetRecordBatchStream::new(record_batch_stream))
    |                                      ^^^^^^^^^^^^^^^^^^^ the trait `futures_core::stream::Stream` is not implemented for `Iter<Map<IntoIter<RecordBatch>, ...>>`

error[E0277]: the trait bound `TimeoutStream: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/utils/mod.rs:353:28
    |
353 | impl RecordBatchStream for TimeoutStream {
    |                            ^^^^^^^^^^^^^ unsatisfied trait bound

error[E0046]: not all trait items implemented, missing: `Error`, `poll`
   --> rust/lancedb/src/utils/mod.rs:359:1
    |
359 | impl Stream for TimeoutStream {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `Error`, `poll` in implementation
    |
    = help: implement the missing item: `type Error = /* Type */;`
    = help: implement the missing item: `fn poll(&mut self) -> std::result::Result<Async<std::option::Option<<Self as futures::Stream>::Item>>, <Self as futures::Stream>::Error> { todo!() }`

error[E0277]: the trait bound `MaxBatchLengthStream: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/utils/mod.rs:424:28
    |
424 | impl RecordBatchStream for MaxBatchLengthStream {
    |                            ^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound

error[E0046]: not all trait items implemented, missing: `Error`, `poll`
   --> rust/lancedb/src/utils/mod.rs:430:1
    |
430 | impl Stream for MaxBatchLengthStream {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `Error`, `poll` in implementation
    |
    = help: implement the missing item: `type Error = /* Type */;`
    = help: implement the missing item: `fn poll(&mut self) -> std::result::Result<Async<std::option::Option<<Self as futures::Stream>::Item>>, <Self as futures::Stream>::Error> { todo!() }`

error[E0599]: no method named `map` found for type parameter `I` in the current scope
  --> rust/lancedb/src/arrow.rs:75:45
   |
72 | impl<I: lance::io::RecordBatchStream + 'static> From<I> for SendableRecordBatchStream {
   |      - method `map` not found for this type parameter
...
75 |         let mapped_stream = Box::pin(stream.map(|r| r.map_err(Into::into)));
   |                                             ^^^

error[E0599]: no method named `poll_next` found for struct `Pin<&mut S>` in the current scope
   --> rust/lancedb/src/arrow.rs:113:21
    |
113 |         this.stream.poll_next(cx)
    |                     ^^^^^^^^^
    |
    = help: items from traits can only be used if the trait is implemented and in scope
    = note: the following traits define an item `poll_next`, perhaps you need to implement one of them:
            candidate #1: `futures_core::stream::Stream`
            candidate #2: `sorts::stream::PartitionedStream`
help: there is a method `collect` with a similar name, but with different arguments
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:563:5
    |
563 | /     fn collect(self) -> Collect<Self>
564 | |         where Self: Sized
    | |_________________________^

error[E0599]: the method `map_err` exists for struct `Pin<Box<dyn Stream<Item = Result<..., ...>> + Send>>`, but its trait bounds were not satisfied
   --> rust/lancedb/src/arrow.rs:150:29
    |
150 |         let stream = stream.map_err(|err| Error::Arrow { source: err });
    |                             ^^^^^^^ method cannot be called due to unsatisfied trait bounds

error[E0308]: mismatched types
  --> rust/lancedb/src/data/scannable.rs:80:26
   |
80 |             stream: once(async move { Ok(batch) }),
   |                     ---- ^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found `async` block
   |                     |
   |                     arguments to this function are incorrect
   |
   = note:       expected enum `std::result::Result<_, _>`
           found `async` block `{async block@rust/lancedb/src/data/scannable.rs:80:26: 80:36}`
note: function defined here
  --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
   |
20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
   |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
   |
80 |             stream: once(Ok(async move { Ok(batch) })),
   |                          +++                        +
80 |             stream: once(Err(async move { Ok(batch) })),
   |                          ++++                        +

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:107:30
    |
107 |                   stream: once(async {
    |  _________________________----_^
    | |                         |
    | |                         arguments to this function are incorrect
108 | |                     Err(Error::InvalidInput {
109 | |                         message: "Cannot scan an empty Vec<RecordBatch>".to_string(),
110 | |                     })
111 | |                 }),
    | |_________________^ expected `Result<_, _>`, found `async` block
    |
    = note:       expected enum `std::result::Result<_, _>`
            found `async` block `{async block@rust/lancedb/src/data/scannable.rs:107:30: 107:35}`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
107 ~                 stream: once(Ok(async {
108 |                     Err(Error::InvalidInput {
109 |                         message: "Cannot scan an empty Vec<RecordBatch>".to_string(),
110 |                     })
111 ~                 })),
    |
107 ~                 stream: once(Err(async {
108 |                     Err(Error::InvalidInput {
109 |                         message: "Cannot scan an empty Vec<RecordBatch>".to_string(),
110 |                     })
111 ~                 })),
    |

error[E0271]: expected `Ok` to return `Result<Result<RecordBatch, Error>, _>`, but it returns `Result<RecordBatch, _>`
   --> rust/lancedb/src/data/scannable.rs:117:52
    |
117 |         Box::pin(SimpleRecordBatchStream { schema, stream })
    |                                                    ^^^^^^ expected `Result<Result<RecordBatch, Error>, _>`, found `Result<RecordBatch, _>`

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:158:59
    |
158 |           let stream = futures::stream::unfold(rx, |mut rx| async move {
    |  ___________________________________________________________^
159 | |             rx.recv().await.map(|batch| (batch, rx))
160 | |         })
    | |_________^ expected `Option<_>`, found `async` block
    |
    = note:       expected enum `std::option::Option<_>`
            found `async` block `{async block@rust/lancedb/src/data/scannable.rs:158:59: 158:69}`
help: try wrapping the expression in `Some`
    |
158 ~         let stream = futures::stream::unfold(rx, |mut rx| Some(async move {
159 |             rx.recv().await.map(|batch| (batch, rx))
160 ~         }))
    |

error[E0599]: the method `fuse` exists for struct `Unfold<Receiver<Result<RecordBatch, Error>>, ..., _>`, but its trait bounds were not satisfied
   --> rust/lancedb/src/data/scannable.rs:161:10
    |
158 |           let stream = futures::stream::unfold(rx, |mut rx| async move {
    |  ______________________-
159 | |             rx.recv().await.map(|batch| (batch, rx))
160 | |         })
161 | |         .fuse();
    | |         -^^^^ method cannot be called due to unsatisfied trait bounds
    | |_________|

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:178:26
    |
178 |               stream: once(async {
    |  _____________________----_^
    | |                     |
    | |                     arguments to this function are incorrect
179 | |                 Err(Error::InvalidInput {
180 | |                     message: "Stream has already been consumed".to_string(),
181 | |                 })
182 | |             }),
    | |_____________^ expected `Result<_, _>`, found `async` block
    |
    = note:       expected enum `std::result::Result<_, _>`
            found `async` block `{async block@rust/lancedb/src/data/scannable.rs:178:26: 178:31}`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
178 ~             stream: once(Ok(async {
179 |                 Err(Error::InvalidInput {
180 |                     message: "Stream has already been consumed".to_string(),
181 |                 })
182 ~             })),
    |
178 ~             stream: once(Err(async {
179 |                 Err(Error::InvalidInput {
180 |                     message: "Stream has already been consumed".to_string(),
181 |                 })
182 ~             })),
    |

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:474:53
    |
474 |                 let prepend = futures::stream::once(std::future::ready(Ok(batch)));
    |                               --------------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found `Ready<Result<RecordBatch, _>>`
    |                               |
    |                               arguments to this function are incorrect
    |
    = note: expected enum `std::result::Result<_, _>`
             found struct `std::future::Ready<std::result::Result<arrow_array::RecordBatch, _>>`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
474 |                 let prepend = futures::stream::once(Ok(std::future::ready(Ok(batch))));
    |                                                     +++                             +
474 |                 let prepend = futures::stream::once(Err(std::future::ready(Ok(batch))));
    |                                                     ++++                             +

error[E0599]: the method `chain` exists for struct `futures::stream::Once<_, _>`, but its trait bounds were not satisfied
   --> rust/lancedb/src/data/scannable.rs:477:37
    |
477 |                     stream: prepend.chain(rest),
    |                                     ^^^^^ method cannot be called on `futures::stream::Once<_, _>` due to unsatisfied trait bounds

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:482:47
    |
482 |                 stream: futures::stream::once(std::future::ready(Ok(batch))),
    |                         --------------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found `Ready<Result<RecordBatch, _>>`
    |                         |
    |                         arguments to this function are incorrect
    |
    = note: expected enum `std::result::Result<_, _>`
             found struct `std::future::Ready<std::result::Result<arrow_array::RecordBatch, _>>`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
482 |                 stream: futures::stream::once(Ok(std::future::ready(Ok(batch)))),
    |                                               +++                             +
482 |                 stream: futures::stream::once(Err(std::future::ready(Ok(batch)))),
    |                                               ++++                             +

error[E0308]: mismatched types
   --> rust/lancedb/src/data/scannable.rs:486:56
    |
486 |                     let stream = futures::stream::once(std::future::ready(err));
    |                                  --------------------- ^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, _>`, found `Ready<Result<_, Error>>`
    |                                  |
    |                                  arguments to this function are incorrect
    |
    = note: expected enum `std::result::Result<_, _>`
             found struct `std::future::Ready<std::result::Result<_, error::Error>>`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
486 |                     let stream = futures::stream::once(Ok(std::future::ready(err)));
    |                                                        +++                       +
486 |                     let stream = futures::stream::once(Err(std::future::ready(err)));
    |                                                        ++++                       +

error[E0599]: no method named `and_then` found for struct `Pin<Box<dyn Future<Output = Result<(), Error>> + Send>>` in the current scope
   --> rust/lancedb/src/io/object_store.rs:153:32
    |
153 |         Box::pin(put_secondary.and_then(|_| put_primary))
    |                                ^^^^^^^^

error[E0271]: expected `IntoIter<Result<RecordBatch, _>, 1>` to be an iterator that yields `Result<Result<RecordBatch, Error>, _>`, but it yields `Result<RecordBatch, _>`
    --> rust/lancedb/src/query.rs:1465:25
     |
1465 |         return Box::pin(SimpleRecordBatchStream::new(
     |                         ^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<Result<RecordBatch, Error>, _>`, found `Result<RecordBatch, _>`

error[E0271]: expected `IntoIter<Result<RecordBatch, _>>` to be an iterator that yields `Result<Result<RecordBatch, Error>, _>`, but it yields `Result<RecordBatch, _>`
    --> rust/lancedb/src/query.rs:1478:14
     |
1478 |     Box::pin(SimpleRecordBatchStream::new(stream::iter(batches), schema))
     |              ^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<Result<RecordBatch, Error>, _>`, found `Result<RecordBatch, _>`

error[E0308]: mismatched types
   --> rust/lancedb/src/remote/table/insert.rs:626:44
    |
626 |           let stream = futures::stream::once(async move {
    |  ______________________---------------------_^
    | |                      |
    | |                      arguments to this function are incorrect
...   |
791 | |             Ok::<_, DataFusionError>(batch)
792 | |         });
    | |_________^ expected `Result<_, _>`, found `async` block
    |
    = note:       expected enum `std::result::Result<_, _>`
            found `async` block `{async block@rust/lancedb/src/remote/table/insert.rs:626:44: 626:54}`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
626 ~         let stream = futures::stream::once(Ok(async move {
627 |             // Multipart writes with a byte budget split the partition into
...
791 |             Ok::<_, DataFusionError>(batch)
792 ~         }));
    |
626 ~         let stream = futures::stream::once(Err(async move {
627 |             // Multipart writes with a byte budget split the partition into
...
791 |             Ok::<_, DataFusionError>(batch)
792 ~         }));
    |

error[E0277]: the trait bound `futures::stream::Once<_, _>: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/remote/table/insert.rs:794:12
    |
794 |           Ok(Box::pin(RecordBatchStreamAdapter::new(
    |  ____________^
795 | |             COUNT_SCHEMA.clone(),
796 | |             stream,
797 | |         )))
    | |__________^ the trait `futures_core::stream::Stream` is not implemented for `futures::stream::Once<_, _>`

error[E0599]: no method named `try_collect` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
    --> rust/lancedb/src/remote/table.rs:2442:49
     |
2442 |             let result: Result<Vec<_>> = stream.try_collect().await.map_err(Error::from);
     |                                                 ^^^^^^^^^^^

error[E0277]: the trait bound `impl Stream<Item = Result<Bytes, Error>>: TryStream` is not satisfied
  --> rust/lancedb/src/remote/util.rs:47:35
   |
47 |     Ok(reqwest::Body::wrap_stream(stream))
   |        -------------------------- ^^^^^^ unsatisfied trait bound
   |        |
   |        required by a bound introduced by this call

error[E0599]: no method named `map_ok` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/table/datafusion/insert.rs:200:30
    |
200 |                 input_stream.map_ok(move |batch| {
    |                 -------------^^^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0308]: mismatched types
   --> rust/lancedb/src/table/datafusion/insert.rs:208:44
    |
208 |           let stream = futures::stream::once(async move {
    |  ______________________---------------------_^
    | |                      |
    | |                      arguments to this function are incorrect
209 | |             if let Some(tracker) = tracker
210 | |                 && write_params.write_progress.is_none()
...   |
255 | |             )?)
256 | |         });
    | |_________^ expected `Result<_, _>`, found `async` block
    |
    = note:       expected enum `std::result::Result<_, _>`
            found `async` block `{async block@rust/lancedb/src/table/datafusion/insert.rs:208:44: 208:54}`
note: function defined here
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/once.rs:20:8
    |
 20 | pub fn once<T, E>(item: Result<T, E>) -> Once<T, E> {
    |        ^^^^
help: try wrapping the expression in a variant of `std::result::Result`
    |
208 ~         let stream = futures::stream::once(Ok(async move {
209 |             if let Some(tracker) = tracker
...
255 |             )?)
256 ~         }));
    |
208 ~         let stream = futures::stream::once(Err(async move {
209 |             if let Some(tracker) = tracker
...
255 |             )?)
256 ~         }));
    |

error[E0277]: the trait bound `futures::stream::Once<_, _>: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/table/datafusion/insert.rs:258:12
    |
258 |           Ok(Box::pin(RecordBatchStreamAdapter::new(
    |  ____________^
259 | |             COUNT_SCHEMA.clone(),
260 | |             stream,
261 | |         )))
    | |__________^ the trait `futures_core::stream::Stream` is not implemented for `futures::stream::Once<_, _>`

error[E0599]: no method named `map_ok` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
   --> rust/lancedb/src/table/datafusion.rs:128:29
    |
128 |         let stream = stream.map_ok(move |batch| {
    |                      -------^^^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0599]: no method named `map_err` found for struct `Pin<Box<dyn Future<Output = Result<Arc<...>, ...>> + Send>>` in the current scope
   --> rust/lancedb/src/table/datafusion.rs:245:14
    |
242 |           let plan = self
    |  ____________________-
243 | |             .table
244 | |             .create_plan(&AnyQuery::Query(query), options)
245 | |             .map_err(|err| DataFusionError::External(err.into()))
    | |             -^^^^^^^ method not found in `Pin<Box<dyn Future<Output = Result<Arc<...>, ...>> + Send>>`
    | |_____________|

error[E0599]: no method named `next` found for struct `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>` in the current scope
    --> rust/lancedb/src/table.rs:3048:48
     |
3048 |                 while let Some(batch) = stream.next().await {
     |                                                ^^^^ method not found in `Pin<Box<dyn datafusion_physical_plan::RecordBatchStream + std::marker::Send>>`

error[E0277]: the trait bound `JoinHandle<Result<(), Error>>: Future` is not satisfied
    --> rust/lancedb/src/table.rs:3038:23
     |
3038 |         let handles = FuturesUnordered::new();
     |                       ^^^^^^^^^^^^^^^^^^^^^^^ the trait `futures::Future` is not implemented for `tokio::task::JoinHandle<std::result::Result<(), error::Error>>`

error[E0277]: `FuturesUnordered<JoinHandle<Result<(), Error>>>` is not an iterator
    --> rust/lancedb/src/table.rs:3054:23
     |
3054 |         for handle in handles {
     |                       ^^^^^^^ `FuturesUnordered<JoinHandle<Result<(), Error>>>` is not an iterator

error[E0277]: the trait bound `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}: futures::IntoFuture` is not satisfied
    --> rust/lancedb/src/table.rs:3450:13
     |
3449 |           let mut sorted_sizes = join_all(
     |                                  -------- required by a bound introduced by this call
3450 | /             frags
3451 | |                 .iter()
3452 | |                 .map(|frag| async move { frag.physical_rows().await.unwrap_or(0) }),
     | |___________________________________________________________________________________^ the trait `futures::Future` is not implemented for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}`
     |
     = note: `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` implements similarly named trait `std::future::Future`, but not `futures::Future`
     = help: the following other types implement trait `futures::Future`:
               &'a mut F
               AssertUnwindSafe<F>
               BiLockAcquire<T>
               Box<F>
               Concat2<S>
               Either<A, B>
               Finished<T, E>
               Fold<S, F, Fut, T>
             and 43 others
     = note: required for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` to implement `futures::IntoFuture`
note: required by a bound in `join_all`
    --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:78:20
     |
  76 | pub fn join_all<I>(i: I) -> JoinAll<I>
     |        -------- required by a bound in this function
  77 |     where I: IntoIterator,
  78 |           I::Item: IntoFuture,
     |                    ^^^^^^^^^^ required by this bound in `join_all`

error[E0277]: the trait bound `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}: futures::Future` is not satisfied
    --> rust/lancedb/src/table.rs:3449:32
     |
3449 |           let mut sorted_sizes = join_all(
     |  ________________________________^
3450 | |             frags
3451 | |                 .iter()
3452 | |                 .map(|frag| async move { frag.physical_rows().await.unwrap_or(0) }),
3453 | |         )
     | |_________^ the trait `futures::Future` is not implemented for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}`
     |
     = note: `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` implements similarly named trait `std::future::Future`, but not `futures::Future`
     = help: the following other types implement trait `futures::Future`:
               &'a mut F
               AssertUnwindSafe<F>
               BiLockAcquire<T>
               Box<F>
               Concat2<S>
               Either<A, B>
               Finished<T, E>
               Fold<S, F, Fut, T>
             and 43 others
     = note: required for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` to implement `futures::IntoFuture`
note: required by a bound in `JoinAll`
    --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:24:20
     |
  22 | pub struct JoinAll<I>
     |            ------- required by a bound in this struct
  23 |     where I: IntoIterator,
  24 |           I::Item: IntoFuture,
     |                    ^^^^^^^^^^ required by this bound in `JoinAll`

error[E0277]: `JoinAll<Map<Iter<'_, FileFragment>, {closure@...}>>` is not a future
    --> rust/lancedb/src/table.rs:3454:10
     |
3449 |           let mut sorted_sizes = join_all(
     |  ________________________________-
3450 | |             frags
3451 | |                 .iter()
3452 | |                 .map(|frag| async move { frag.physical_rows().await.unwrap_or(0) }),
3453 | |         )
     | |_________- this call returns `JoinAll<std::iter::Map<std::slice::Iter<'_, FileFragment>, {closure@rust/lancedb/src/table.rs:3452:22: 3452:28}>>`
3454 |           .await;
     |            ^^^^^ `JoinAll<Map<Iter<'_, FileFragment>, {closure@...}>>` is not a future

error[E0277]: the trait bound `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}: futures::Future` is not satisfied
    --> rust/lancedb/src/table.rs:3454:10
     |
3454 |         .await;
     |          ^^^^^ the trait `futures::Future` is not implemented for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}`
     |
     = note: `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` implements similarly named trait `std::future::Future`, but not `futures::Future`
     = help: the following other types implement trait `futures::Future`:
               &'a mut F
               AssertUnwindSafe<F>
               BiLockAcquire<T>
               Box<F>
               Concat2<S>
               Either<A, B>
               Finished<T, E>
               Fold<S, F, Fut, T>
             and 43 others
     = note: required for `{async block@rust/lancedb/src/table.rs:3452:29: 3452:39}` to implement `futures::IntoFuture`
note: required by a bound in `JoinAll`
    --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/future/join_all.rs:24:20
     |
  22 | pub struct JoinAll<I>
     |            ------- required by a bound in this struct
  23 |     where I: IntoIterator,
  24 |           I::Item: IntoFuture,
     |                    ^^^^^^^^^^ required by this bound in `JoinAll`

error[E0282]: type annotations needed
   --> rust/lancedb/src/utils/background_cache.rs:119:40
    |
119 |               inner: Arc::new(Mutex::new(CacheInner {
    |  ________________________________________^
120 | |                 state: State::Empty,
121 | |                 generation: 0,
122 | |             })),
    | |_____________^ cannot infer type of the type parameter `E` declared on the struct `CacheInner`
    |
help: consider specifying the generic arguments
    |
119 |             inner: Arc::new(Mutex::new(CacheInner::<V, E> {
    |                                                  ++++++++

error[E0282]: type annotations needed
   --> rust/lancedb/src/utils/background_cache.rs:134:9
    |
134 |         cache.state.fresh_value(self.ttl, self.refresh_window)
    |         ^^^^^^^^^^^ cannot infer type for type parameter `E`

error[E0282]: type annotations needed
   --> rust/lancedb/src/utils/background_cache.rs:173:23
    |
173 |         cache.state = State::Current(value, clock::now());
    |                       ^^^^^^^^^^^^^^ cannot infer type of the type parameter `E` declared on the enum `State`
    |
help: consider specifying the generic arguments
    |
173 |         cache.state = State::<V, E>::Current(value, clock::now());
    |                            ++++++++

error[E0282]: type annotations needed
   --> rust/lancedb/src/utils/background_cache.rs:182:23
    |
182 |         cache.state = State::Empty;
    |                       ^^^^^^^^^^^^ cannot infer type of the type parameter `E` declared on the enum `State`
    |
help: consider specifying the generic arguments
    |
182 |         cache.state = State::<V, E>::Empty;
    |                            ++++++++

error[E0599]: no method named `boxed` found for `async` block `{async block@rust/lancedb/src/utils/background_cache.rs:269:22: 269:32}` in the current scope
   --> rust/lancedb/src/utils/background_cache.rs:270:14
    |
269 |           let shared = async move { (fetch)().await.map_err(Arc::new) }
    |  ______________________-
270 | |             .boxed()
    | |             -^^^^^ method not found in `{async block@rust/lancedb/src/utils/background_cache.rs:269:22: 269:32}`
    | |_____________|

error[E0277]: the trait bound `TimeoutStream: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/utils/mod.rs:345:9
    |
345 |         Box::pin(Self::new(inner, timeout))
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound

error[E0599]: no method named `poll_next` found for struct `Pin<&mut TimeoutStream>` in the current scope
   --> rust/lancedb/src/utils/mod.rs:376:22
    |
376 |                 self.poll_next(cx)
    |                      ^^^^^^^^^
    |
    = help: items from traits can only be used if the trait is implemented and in scope
    = note: the following traits define an item `poll_next`, perhaps you need to implement one of them:
            candidate #1: `futures_core::stream::Stream`
            candidate #2: `sorts::stream::PartitionedStream`
help: there is a method `collect` with a similar name, but with different arguments
   --> $CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/futures-0.1.31/src/stream/mod.rs:563:5
    |
563 | /     fn collect(self) -> Collect<Self>
564 | |         where Self: Sized
    | |_________________________^

error[E0599]: no method named `poll_unpin` found for mutable reference `&mut Pin<Box<Sleep>>` in the current scope
   --> rust/lancedb/src/utils/mod.rs:378:75
    |
378 |             TimeoutState::Started { deadline, timeout } => match deadline.poll_unpin(cx) {
    |                                                                           ^^^^^^^^^^ method not found in `&mut Pin<Box<Sleep>>`

error[E0599]: no method named `poll_next` found for struct `Pin<&mut Pin<Box<dyn RecordBatchStream + Send>>>` in the current scope
   --> rust/lancedb/src/utils/mod.rs:386:27
    |
386 |                     inner.poll_next(cx)
    |                           ^^^^^^^^^

error[E0277]: the trait bound `MaxBatchLengthStream: futures_core::stream::Stream` is not satisfied
   --> rust/lancedb/src/utils/mod.rs:419:13
    |
419 |             Box::pin(Self::new(inner, max_batch_length))
    |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound

error[E0599]: no method named `poll_next` found for struct `Pin<&mut Pin<Box<dyn RecordBatchStream + Send>>>` in the current scope
   --> rust/lancedb/src/utils/mod.rs:439:50
    |
439 |                 return Pin::new(&mut self.inner).poll_next(cx);
    |                                                  ^^^^^^^^^

error[E0599]: no method named `poll_next` found for struct `Pin<&mut Pin<Box<dyn RecordBatchStream + Send>>>` in the current scope
   --> rust/lancedb/src/utils/mod.rs:459:45
    |
459 |             match Pin::new(&mut self.inner).poll_next(cx) {
    |                                             ^^^^^^^^^

Some errors have detailed explanations: E0046, E0107, E0191, E0271, E0277, E0282, E0308, E0407, E0425...
For more information about an error, try `rustc --explain E0046`.
error: could not compile `lancedb` (lib) due to 118 previous errors
```
2026-08-07 17:24:38 +08:00
Xuanwo b1cfe6edb1 ci(docs): add scheduled doc link check (#3888)
The docs have no link checking at all, so external links rot silently: a
trial run already found `docs/src/python/python.md` pointing at
`lancedb.github.io/lance-namespace`, which returns 404 since the
repository moved to the lance-format org.

Checking external links on the blocking path would be the wrong trade:
third-party hosts rate-limit automated clients, reject non-browser user
agents, and go down temporarily, so any of them having a bad minute
would turn unrelated PRs red. Following lance-format/lance#8315, this
adds a daily `lychee` run that reports broken links into a single
tracking issue, rewritten in place on each run and closed automatically
once every link resolves. The scan job runs the downloaded lychee binary
with a read-only token; everything that writes lives in a separate
report job, and a non-verdict lychee exit fails the run instead of
publishing a bogus report.

The check is restricted to http(s) links because much of `docs/src` is
generated API reference (the `js/` tree comes from `npm run docs`) and
the hand-written pages use mkdocstrings cross-references and
nav-relative paths that only resolve in the site mkdocs builds, so
relative links would be reported as broken on every run.

The one broken link the trial run surfaced is fixed here; after the fix,
a local run over all 154 files reports 0 errors across 216 unique links.
2026-08-07 16:31:40 +08:00
LanceDB Robot 001237c7a4 chore: update lance dependency to v11.0.0-beta.2 (#3886)
Updates the Lance dependencies and Java lance-core to v11.0.0-beta.2.
Includes required compatibility fixes for the LanceFileVersion module
move and the updated GooseFS/OpenDAL dependency. Lance tag:
https://github.com/lance-format/lance/releases/tag/v11.0.0-beta.2

---------

Co-authored-by: Daniel Rammer <hamersaw@protonmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:00:22 -05:00
lancedb-gatefixer[bot] 369b10a377 test(rust): cover object store reuse on table open (#3831)
## Summary

- add regression coverage for repeated table opens through one database
connection
- assert that each open reuses the connection object-store client
without another registry miss
- exercise the table after every open so the test covers the complete
dataset-loading path

## Root cause

At the commit reported in #1600, opening a table constructed a separate
object-store client rather than reusing the client that had already
connected to the database. On S3 this repeated credential discovery,
which could fail intermittently in AWS Lambda and surface as
TableNotFound. The connection-owned Session reuse added later fixed the
runtime path, but no focused test protected the open-table invariant.

## Fix

Add a regression test backed by ObjectStoreRegistry statistics. Three
successive opens must add cache hits while leaving the miss count
unchanged, proving that open_table uses the connection Session and its
authenticated object-store client.

## Validation

- cargo fmt --all
- cargo test --quiet --features remote -p lancedb
database::listing::tests::test_open_table_reuses_connection_object_store
- cargo check --quiet --features remote --tests --examples
- cargo clippy --quiet --features remote --tests --examples
- cargo test --quiet --features remote --tests

Fixes #1600

<!-- lance-gatekeeper-fix:v1 agent=974491978c3e42840f32dbc35492d856
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:54:59 +08:00
lancedb-gatefixer[bot] 1c3cd1d918 fix(python): accept Arrow scalars in table updates (#3838)
## Summary
- convert PyArrow scalar values through their Python representation
before SQL literal rendering
- add an end-to-end regression for updating a fixed-size-list vector
from a queried FixedSizeListScalar

## Root cause
Python update literal conversion used single dispatch for native Python
and NumPy values but had no PyArrow Scalar registration. A
FixedSizeListScalar returned by a query therefore reached the
unsupported generic conversion instead of the existing recursive list
converter.

## Validation
- uv run --extra tests pytest python/tests/test_table.py::test_update
python/tests/test_table.py::test_update_with_arrow_scalar
python/tests/test_table.py::test_update_types -q
- uv run --extra tests pytest python/tests/test_util.py -q
- uv run --project python --extra tests --extra dev ruff format --check
python/python/lancedb/util.py python/python/tests/test_table.py
- uv run --project python --extra tests --extra dev ruff check .

Fixes #1228

<!-- lance-gatekeeper-fix:v1 agent=950dd892194e53b61c203d5e3715cac7
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:54:04 +08:00
lancedb-gatefixer[bot] 9707966943 test(rust): cover named memory databases on Windows (#3839)
## Summary\n\n- add a create-table regression for a named database\n-
assert that the derived table URI uses URL separators\n- restore the
four query tests that were moved to temporary files for #1051\n\n## Root
cause\n\n historically joined table names with . On Windows this
inserted a backslash into , so Lance interpreted the URI as an invalid
local filename. The production URI builder now preserves forward slashes
for URI schemes; this change restores the issue-specific tests and adds
direct regression coverage for table creation and the derived URI.\n\n##
Validation\n\n- \n- \n- (passes with four pre-existing warnings in
unrelated remote-table code)\n-
running 814 tests

.......................................................................................
87/814

.....................................i.................................................
174/814

.......................................................................................
261/814

.......................................................................................
348/814

.......................................................................................
435/814

.......................................................................................
522/814

.......................................................................................
609/814

.......................................................................................
696/814

.......................................................................................
783/814
...............................
test result: ok. 813 passed; 0 failed; 1 ignored; 0 measured; 0 filtered
out; finished in 7.76s


running 39 tests
.......................................
test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.23s


running 6 tests
......
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.03s


running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.10s


running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s


running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s


running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s (867 passed, 1 ignored)\n- focused named-memory
create and restored query tests\n\nFixes #1051\n\n<!--
lance-gatekeeper-fix:v1 agent=5ddf7a9520292b4cbaa58b9ea5a1fe76
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:53:32 +08:00
Wyatt Alt 62fe413a52 fix: percent-encode index names in per-index remote REST paths (#3840)
Nothing validates index names, so a `/` in one is reachable, and the
remote client interpolates it straight into the URL, splitting the path
so the router 404s. The index then reads back as missing and cannot be
dropped, while `create_index` keeps succeeding because it sends the name
in the body.

Encode at the three affected sites, mirroring `fetch_blob_files`. The
shared Rust client covers all bindings.
2026-08-06 16:53:09 +08:00
lancedb-gatefixer[bot] 1493ece3de test(node): cover remote table server errors (#3841)
## Summary

- add a public Node API regression test for JSON server errors from
remote table operations
- verify countRows reports the server message instead of an ArrayBuffer
decoding TypeError

## Root cause and fix

The former TypeScript remote HTTP client passed an Axios-decoded JSON
error object to TextDecoder, which masked the server response with an
ArrayBuffer TypeError. The current Rust-backed remote client consumes
non-success response bodies as text and propagates them through the Node
error chain. This test exercises that corrected path through countRows
and prevents the original failure from regressing.

## Validation

- pnpm build
- pnpm lint-ci
- pnpm test --runInBand __test__/remote.test.ts
- pnpm run docs

Fixes #825

<!-- lance-gatekeeper-fix:v1 agent=91591c3d6b065796e6166664ef638aa7
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:50:05 +08:00
lancedb-gatefixer[bot] e6444ecc05 fix(rust): handle missing mirrored copy sources (#3843)
## Summary

- treat `NotFound` from the mirrored secondary copy as a cache miss
while preserving every other secondary error
- perform the durable primary copy after either a successful secondary
copy or a secondary cache miss
- cover both an initially missing secondary manifest and eviction
immediately before the secondary copy

## Root cause

Readers can use process-local secondary stores that do not contain a
staging manifest written by another process, or that evict it before
finalization. `MirroringObjectStore::copy_opts` propagated that
secondary `NotFound`, so older object_store versions could loop
indefinitely and the locked version aborted before performing the
durable primary copy.

## Validation

- `cargo fmt --all -- --check`
- `cargo test --quiet --features remote -p lancedb
io::object_store::test::test_copy_when -- --nocapture`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `cargo test --quiet --features remote --tests`

Fixes #1176

<!-- lance-gatekeeper-fix:v1 agent=636210af9dcd25b6dceadebd2fcafc6f
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:49:49 +08:00
lancedb-gatefixer[bot] cc0139c136 test(node): cover foreign Float64 vector schema workflow (#3844)
## Summary

- add an end-to-end regression for schemas created by a different Apache
Arrow package instance
- cover seeded table creation, filtered scanning, and Float64 vector
search across Arrow 15–18

## Root cause

Apache Arrow's runtime identity checks historically rejected schemas
created by another installed Arrow instance, producing the constructor
failures reported in the issue. LanceDB's peer dependency and
foreign-schema sanitization now handle that boundary, but the complete
reported workflow was only covered by separate unit tests. This
regression keeps the repaired behavior protected end to end.

## Validation

- `pnpm exec jest --runInBand __test__/table.test.ts` (281 passed)
- `pnpm lint-ci`
- `pnpm build`
- `pnpm run docs`

Fixes #882

<!-- lance-gatekeeper-fix:v1 agent=43b19dea581cfbc83ee1e9ed21a335a6
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:49:13 +08:00
lancedb-gatefixer[bot] b20696ef9c fix(remote): validate cloud DNS hostnames (#3845)
## Summary

- validate the generated LanceDB Cloud hostname during connection setup
- return a clear invalid-input error for empty, overlong, or oversized
DNS names before network resolution
- add Rust and Python regression coverage for malformed `db://`
authorities

## Root cause

The `db://` authority and region were interpolated into the Cloud API
hostname without DNS length validation. Empty or overlong labels
therefore reached the resolver and surfaced as an opaque IDNA
`UnicodeError` instead of a useful connection error.

## Validation

- `cargo test --quiet --features remote -p lancedb
test_rejects_invalid_cloud_dns_hostname --lib`
- `cargo check --quiet --features remote --tests --examples`
- `uv run --no-sync --extra tests pytest
python/tests/test_remote_db.py::test_async_remote_db
python/tests/test_remote_db.py::test_connect_rejects_invalid_cloud_dns_hostname
-q`
- `cargo fmt --all -- --check`
- `ruff check .`
- `ruff format --check python/python/tests/test_remote_db.py`

Fixes #799

<!-- lance-gatekeeper-fix:v1 agent=4d1597b3d244b58f0603ed40a8a59cf9
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:48:56 +08:00
lancedb-gatefixer[bot] 772bdeced8 fix(rust): prevent vector optimize regression after deletes (#3848)
## Summary

- Adds a LanceDB regression for stable row IDs, scattered deletes,
IVF_RQ, and default index optimization.
- Verifies optimization completes and preserves the expected live-row
count.

## Root cause

Lance 3.0.1 built the stable-row-ID address list by dropping deleted IDs
while retaining the original ID list. The subsequent positional zip
misaligned IDs and addresses, so vector partition joins requested
deleted rows and failed with batch.num_rows() != chunk.len(). Lance PR
https://github.com/lance-format/lance/pull/7704 corrected the generic
filter, and the LanceDB dependency currently pinned on main contains
that correction.

## Fix

Add regression coverage at the Rust Table optimize surface using the
IVF_RQ configuration from the report. This locks the upstream correction
into the LanceDB workflow that originally crashed.

## Validation

- cargo fmt --all -- --check
- cargo test --quiet --features remote -p lancedb table::optimize::tests
(14 passed)
- cargo check --quiet --features remote --tests --examples
- cargo clippy --quiet --features remote --tests --examples -p lancedb

Fixes #3330

<!-- lance-gatekeeper-fix:v1 agent=4c2c25373942aab9ba9f7444977de7e3
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:48:24 +08:00
lancedb-gatefixer[bot] c1a3fa7f51 fix(python): preserve repeated indexed merge inserts (#3850)
## Summary

- add a Python regression test for two partial-schema merge inserts
against the same BTree-indexed rows
- verify repeated updates retain one copy of every row and the final
update values

## Root cause

Lance 4.0, used by LanceDB 0.30.2, removed a rewritten fragment from the
index bitmap while stale BTree entries for that fragment remained
searchable. The next merge found each target through both the stale
index and the unindexed-fragment scan, producing the ambiguous-match
error. Lance fixed the root cause in lance-format/lance#6563 by applying
the fragment-bitmap allow-list to index results, and the Lance release
pinned by current LanceDB includes that fix. This test preserves the
corrected behavior through the Python API.

## Validation

- `cd python && uv run --extra tests pytest python/tests/test_table.py
-k merge_insert -q` (9 passed)
- `cd python && uv run --extra tests --extra dev ruff format --check
python/tests/test_table.py`
- `cd python && uv run --extra tests --extra dev ruff check
python/tests/test_table.py`

Repository-wide Ruff also reports 20 pre-existing violations in
untouched CI and plugin scripts.

Fixes #3280

<!-- lance-gatekeeper-fix:v1 agent=ee6b9565f9780712026076930566f116
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:48:01 +08:00
lancedb-gatefixer[bot] 0ba82873c5 fix(python): cover nullable list v2.2 decoding (#3853)
## Summary

- add a minimized regression for mostly-null `list<float32>` data at the
v2.2 structural page boundary
- verify scans preserve all 64,885 rows, including 64,668 null list
values

## Root cause

Lance 3.0.0 sliced repetition/definition state using top-level row
offsets in the complex all-null decoder. At this page boundary, the list
and validity children were materialized at different lengths. The
current Lance dependency contains the upstream decoder repair; this test
locks that behavior into the LanceDB Python suite without duplicating
decoder logic.

## Validation

- reproduced the attached 1,892,466-row case on `lancedb==0.30.0` with
`expected 1024 got 285`
- verified the full attachment reads on the current branch
- `python/.venv/bin/ruff format --check
python/python/tests/test_table.py`
- `python/.venv/bin/ruff check .`
- `cd python && uv run --extra tests pytest
python/tests/test_table.py::test_read_mostly_null_list_v2_2_page_boundary
-q`
- `cd python && uv run --extra tests pytest python/tests/test_table.py
-q` (137 passed)

Fixes #3194

<!-- lance-gatekeeper-fix:v1 agent=0445adc5303a3302152cea3d2110bed1
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:47:29 +08:00
lancedb-gatefixer[bot] 3af51541a0 test(rust): cover fixed-size binary merge insert regression (#3854)
## Summary

- add a LanceDB regression for `merge_insert` with a non-nullable
`FixedSizeBinary` column
- exercise matched updates, unmatched inserts, and source-missing
deletes
- assert the exact merge statistics and final row count

## Root cause

The Arrow `take` kernel previously ignored nulls in the index array for
`FixedSizeBinary`. DataFusion uses that kernel while constructing
outer-join results, so the join behind
`when_not_matched_by_source_delete` could place invalid values into
non-nullable columns. The current Arrow dependency contains the upstream
fix; this test locks the corrected behavior at the LanceDB API boundary.

## Validation

- `cargo fmt --all -- --check`
- `cargo test --quiet --features remote --tests`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`

Fixes #2869

<!-- lance-gatekeeper-fix:v1 agent=e275446044185ef4e8cf88da6af3e70b
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:46:49 +08:00
lancedb-gatefixer[bot] 2c06a48bd8 test(python): cover Arrow buffer release after add (#3860)
## Summary

- add deterministic regression coverage that `Table.add()` releases
backing Arrow buffers without cyclic garbage collection
- track the foreign buffer owner rather than RSS, separating live input
retention from allocator high-water behavior
- preserve the bounded-lifetime behavior of the Scannable writer that
superseded the historical preprocessing path

## Root cause

The historical Python preprocessing/write path produced a high allocator
RSS while ingesting very wide IPC batches. The current Scannable writer
releases each input buffer when `Table.add()` completes; remaining RSS
is allocator high-water rather than a live Arrow reference. The resolved
behavior had no regression coverage, so a future native lifetime
regression could silently reintroduce the original failure mode.

## Validation

- `uv run --extra tests --extra dev maturin develop`
- `uv run --project python --extra tests pytest
python/python/tests/test_table.py::test_add
python/python/tests/test_table.py::test_add_releases_arrow_buffers_without_gc
-q`
- `uv run --project python --extra dev ruff format --check
python/python/tests/test_table.py`
- `uv run --project python --extra dev ruff check .`

Fixes #2512

<!-- lance-gatekeeper-fix:v1 agent=29226408a8d07da592daf341d5384e37
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:45:10 +08:00
lancedb-gatefixer[bot] ac8b28c010 fix(python): support nullable pandas merge input (#3864)
## Summary

- add an end-to-end Python regression for pandas DataFrame inputs merged
into a table created from a Pydantic model
- verify reordered, nullable Arrow source fields can update and insert
into a non-nullable target schema when the values contain no nulls

## Root cause

Lance merge_insert previously compared source schema nullability with
the target, unlike add. The upstream fix now pinned by LanceDB ignores
declared nullability during schema compatibility and validates actual
null values at write time. LanceDB lacked regression coverage for the
full pandas-to-Pydantic path, so this test locks in the correct behavior
without falsifying the input schema nullability.

## Validation

- 5 focused merge-insert tests passed
- Ruff lint passed for the repository
- Ruff format check passed for the changed file
- git diff --check passed

Fixes #2366

<!-- lance-gatekeeper-fix:v1 agent=f897fccfa206620c8a2acdc3bcd1c21f
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:44:38 +08:00
lancedb-gatefixer[bot] 173f889d2a test(python): cover stale scalar prefilters in hybrid search (#3865)
## Summary

- capture the stale-index state behind the reported fixed-size-binary
panic: the vector and FTS indices cover newer fragments while the BTree
prefilter does not
- verify vector, FTS, and hybrid searches return matches from both
scalar-indexed and unindexed fragments without panicking
- preserve binding-level coverage for the Lance fix in
https://github.com/lance-format/lance/pull/3768, which restricts
incomplete scalar prefilters when search indices are further ahead

The production root cause is in Lance and the current LanceDB dependency
already contains that fix, so this change adds the missing LanceDB
Python regression coverage.

## Validation

- `cd python && uv run --no-sync pytest
python/tests/test_hybrid_query.py::test_hybrid_query_with_stale_fixed_size_binary_prefilter
-q`
- `cd python && uv run --no-sync pytest
python/tests/test_hybrid_query.py -q`
- `python/.venv/bin/ruff check .`
- `python/.venv/bin/ruff format --check
python/python/tests/test_hybrid_query.py`

Fixes #2370

<!-- lance-gatekeeper-fix:v1 agent=5d16e59b9e513fd9247e0698732fa283
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:44:20 +08:00
lancedb-gatefixer[bot] 03b52e5877 test(node): cover fixed-size list schemas with typed arrays (#3866)
## Summary

- cover explicit FixedSizeList schemas populated from Float32Array
values
- verify the original vector.0 failure stays fixed across Arrow 15, 16,
17, and 18

## Root cause and fix

In v0.16, schema subset inference treated typed-array vectors as nested
objects and looked up numeric paths such as vector.0, which do not exist
in a FixedSizeList schema. Current typed-array handling correctly
recognizes ArrayBuffer views as vector values instead of traversing
their elements. This change adds the missing regression coverage for the
reported explicit-schema path so that behavior cannot regress unnoticed.

## Validation

- pnpm test __test__/arrow.test.ts --runInBand
- pnpm lint
- pnpm build
- pnpm run docs
- pnpm test --runInBand (681 passed, 5 skipped)

Fixes #2134

<!-- lance-gatekeeper-fix:v1 agent=1d548cb70f6df110ce0a5b119395b52a
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:43:59 +08:00
lancedb-gatefixer[bot] 798e5364fb test(python): cover VoyageAI text source routing (#3872)
## Summary

- add fast regression coverage for VoyageAI `voyage-3` source embeddings
- verify table text uses `client.embed` and never
`client.multimodal_embed`

## Root cause

The original VoyageAI source-embedding path treated table source values
as images and always invoked the multimodal API. Production routing was
corrected by later merged changes, but the table regression was covered
only by API-gated slow tests. This test locks the corrected text routing
into the regular unit suite.

## Validation

- `cd python && uv run --extra tests pytest
python/tests/test_voyageai_embeddings.py -q`
- `uv run --project python --extra tests --extra dev ruff format --check
python/python/tests/test_voyageai_embeddings.py`
- `uv run --project python --extra tests --extra dev ruff check .`

Fixes #2059

<!-- lance-gatekeeper-fix:v1 agent=49b9e2daeed95a78ce827e2bf90abda0
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:42:20 +08:00
lancedb-gatefixer[bot] f1f34dfdd3 fix(python): instruct dimension probe for instructor embeddings (#3874)
## Summary

- pass an Instructor-compatible `[instruction, text]` pair when
detecting embedding dimensions
- add a regression test that verifies the dimension probe uses the
configured source instruction

## Root cause

`InstructorEmbeddingFunction.ndims()` encoded a bare string even though
Instructor models require instruction/text pairs. With affected
`sentence-transformers` versions, the bare input omitted
`instruction_mask` and raised `KeyError` while defining the LanceDB
schema.

## Validation

- `uv run --extra tests pytest python/tests/test_embeddings.py -q` (`14
passed, 9 skipped`)
- `uv run --project python --extra tests --extra dev ruff format --check
python/python/lancedb/embeddings/instructor.py
python/python/tests/test_embeddings.py`
- `uv run --project python --extra tests --extra dev ruff check .`

Fixes #2041

<!-- lance-gatekeeper-fix:v1 agent=4b05e0d9f3eef17bccfb446e788294f4
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:41:11 +08:00
lancedb-gatefixer[bot] 123c921c4f test(python): cover sliced nullable table search (#3875)
## Summary

- add a Python regression for vector search over a sliced Arrow table
with nullable scalar columns
- verify the nearest row retains its non-null score values after the
table is written

## Root cause

Lance 0.19.2 deep-copied a validity bitmap without preserving its
non-zero bit offset. For a sliced nullable table, scalar values and
vectors began at the slice while the copied validity bitmap began at the
parent table's first row. That made valid score values appear null even
though the corresponding vector stayed intact. The upstream Lance repair
is already present in the current dependency; this adds a LanceDB-level
guard for the reported create/search path.

## Validation

- reproduced on Python 3.12 with LanceDB 0.16.0, pylance 0.19.2, PyArrow
18.0.0, and Polars 1.14.0
- `uv run --project python --extra dev ruff format --check
python/python/tests/test_table.py`
- `uv run --project python --extra dev ruff check .`
- `cd python && uv run --extra tests pytest
python/tests/test_table.py::test_search_preserves_nulls_from_sliced_arrow_table
-q`

Fixes #1879

<!-- lance-gatekeeper-fix:v1 agent=bfa0551793f8e3cf3980cf64ad89908a
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:40:41 +08:00
lancedb-gatefixer[bot] 99a68db78c test(rust): cover concurrent appends during compaction (#3878)
## Summary

- add a LanceDB core regression for compaction overlapping appends
through separate table handles
- verify concurrent commits preserve fragment ID order on an indexed
table
- run the follow-up compaction that exposed the original row-ID ordering
failure and verify all rows remain

## Root cause

Older Lance versions could reserve fragment IDs for compaction, allow
concurrent appends to commit later IDs, and then commit the reserved
compaction fragments at the end of the manifest. A later compaction
could consequently receive row IDs out of order. Current Lance sorts
fragments at the transaction boundary; this adds the missing
LanceDB-level regression coverage for the Node-visible concurrency
contract.

## Validation

- `cargo fmt --all`
- focused regression passed once with output and 20 repeated runs
- `cargo test --quiet --features remote -p lancedb
table::optimize::tests` (14 passed)
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `cargo test --quiet --features remote --tests` (867 passed, 1 ignored)

Fixes #1498

<!-- lance-gatekeeper-fix:v1 agent=93aaefb15507dca52d064e15388773d7
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:40:01 +08:00
lancedb-gatefixer[bot] 9e73d440a3 test(python): cover schema-only vector table creation (#3882)
## Summary

- make the existing #1968 regression explicitly assert that schema-only
table creation succeeds
- verify the new table has zero rows and preserves the requested
fixed-size vector schema before accepting subsequent data

## Root cause

In v0.16.0, schema-only table creation sent an empty table through
vector sanitization, which calculated a remainder using `len(data)` and
raised `ZeroDivisionError`. Later refactors removed that runtime path,
but the issue-specific regression only asserted the final row count
after a subsequent add. This change makes the reported operation and its
expected empty-table state explicit so the original defect remains
directly covered.

## Validation

- `uv run --extra tests pytest
python/tests/test_table.py::test_create_table_without_data_with_vector_schema
-q`
- `uv --project python run --extra tests --extra dev ruff format --check
python/python/tests/test_table.py`
- `uv --project python run --extra tests --extra dev ruff check .`
- `git diff --check`

Fixes #1968

<!-- lance-gatekeeper-fix:v1 agent=b8ec6f40f4bba2f9beeaaae12233e5c4
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:39:26 +08:00
lancedb-gatefixer[bot] 3956d9dbfa fix(python): prevent OpenSSL linkage in Linux wheels (#3877)
## Summary

- select rustls with native certificate roots explicitly for LanceDB's
remote HTTP client
- add a Linux regression test that rejects `libssl` or `libcrypto`
dependencies in the built Python extension

## Root cause

The Python remote client originally enabled reqwest's native TLS
backend. During manylinux wheel repair, that caused OpenSSL 1.1
libraries to be bundled into the wheel. Loading those libraries on RHEL
9 with FIPS enabled aborts during the OpenSSL self-test before `import
lancedb` can complete.

LanceDB has since moved away from native TLS, but its own reqwest
dependency relied on transitive rustls feature selection and the built
extension had no regression guard. This change makes rustls selection
explicit and tests the produced Linux native module's dynamic
dependencies.

## Validation

- `uv run --no-sync pytest python/tests/test_import.py -q`
- `ruff format --check python`
- `ruff check .`
- `cargo fmt --all -- --check`
- `cargo check --quiet --features remote --tests --examples`
- `ldd python/lancedb/_lancedb.abi3.so` (no `libssl` or `libcrypto`
dependency)
- verified the resolved Python Rust dependency graph contains rustls and
no `openssl-sys` or `native-tls`

Fixes #1884

<!-- lance-gatekeeper-fix:v1 agent=31f916c7ac5c072bbbd54f3539d24f71
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:38:27 +08:00
lancedb-gatefixer[bot] 16e1967efc fix(python): align wheel ABI with supported versions (#3884)
## Summary

- align the PyO3 runtime and build ABI floor with the declared Python
3.10 minimum
- add a regression test that keeps both ABI features synchronized with
`requires-python`

## Root cause

The Python 3.10 support-floor update originally changed PyO3 to
`abi3-py310`, but a later dependency update reverted both PyO3 features
to `abi3-py39`. Published Windows wheels were consequently tagged
`cp39-abi3` while importing `PyCMethod_New`, a stable-ABI procedure
absent from CPython 3.9.0 and 3.9.1. Windows reports that mismatch as
“The specified procedure could not be found” while loading `_lancedb`.

Restoring `abi3-py310` makes the wheel tag and native imports agree with
the package metadata and prevents future wheels from advertising
unsupported Python 3.9 compatibility.

## Validation

- `uv run --extra tests pytest python/tests/test_package_metadata.py -q`
- `uv run --extra tests --extra dev ruff format --check .`
- `uv run --extra tests --extra dev ruff check .`
- `cargo fmt --all`
- `cargo check --quiet -p lancedb-python`
- `uvx --from maturin==1.12.4 maturin build --profile ci` (built
`lancedb-0.37.1b0-cp310-abi3-manylinux_2_34_x86_64.whl`)

Fixes #2051

<!-- lance-gatekeeper-fix:v1 agent=d66c984498190d2207d1c5126cba5047
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:36:58 +08:00
lancedb-gatefixer[bot] 27dd92c67e test(python): cover debugger-safe connection inspection (#3880)
## Summary

- expand the synchronous debugger regression to enumerate every exposed
connection attribute while the Python background loop is unavailable
- retain direct representation checks for connections and tables

## Root cause

VS Code debugpy suspends Python threads at a breakpoint and inspects
local variables. Connection representation and property access
previously dispatched asynchronous work to LanceDBBackgroundEventLoop
and waited for the suspended loop thread, deadlocking the debugger. The
production safeguards landed in #3620 and #3788; this regression
exercises debugger-style whole-object expansion so a newly exposed
property cannot reintroduce the original failure.

## Validation

- uv run --no-sync pytest
python/tests/test_db.py::test_sync_debugger_inspection_does_not_use_background_loop
python/tests/test_db.py::test_read_consistency_interval_does_not_use_background_loop
-q (2 passed)
- uv run --no-sync pytest python/tests/test_db.py -q (48 passed)
- python/.venv/bin/ruff format --check python/python/tests/test_db.py
- python/.venv/bin/ruff check .
- git diff --check

Fixes #3611

<!-- lance-gatekeeper-fix:v1 agent=cdf4b39b2ce2ccb3eb5fe501acae77bb
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:36:27 +08:00
lancedb-gatefixer[bot] 9e2e711c7a test(python): cover OpenAI registry variable round-trip (#3863)
## Summary

- replace the synthetic registry-variable metadata test with the OpenAI
embedding function reported in #2387
- verify the resolved API key survives table metadata reconstruction
- assert the OpenAI client receives the resolved key while serialized
metadata retains the variable reference

## Root cause

LanceDB 0.22.0 reconstructed embedding functions from table metadata
with the model constructor, bypassing EmbeddingFunction.create and
leaving the literal $var:api_key placeholder in OpenAI configuration.
The production path was corrected for duplicate #2181 by #2640; this
change gives that fix direct, network-free OpenAI regression coverage
for #2387.

## Validation

- uv run --extra tests pytest python/tests/test_embeddings.py -q (13
passed, 9 skipped)
- uv run --project python --extra dev ruff check .
- uv run --project python --extra dev ruff format --check
python/python/tests/test_embeddings.py
- git diff --check

Fixes #2387

<!-- lance-gatekeeper-fix:v1 agent=d453b1b9b2a298a776f2e4ea1b1449b5
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:36:05 +08:00
lancedb-gatefixer[bot] c3176a47ce fix(python): report unsplittable IVF partition errors (#3846)
## Summary

- add Python regression coverage for an IVF build that cannot form all
requested non-empty partitions
- verify hierarchical k-means returns an actionable RuntimeError instead
of panicking or silently creating a degenerate index
- exercise the current Lance v10.1.0-beta.1 dependency, which contains
the upstream error-return fix

## Root cause

Hierarchical k-means previously guarded a shortfall in generated
clusters with only a debug assertion. Debug builds panicked, while
release builds could silently publish an index with many empty
partitions. The upstream Lance fix now returns a descriptive error and
is already included in the dependency pinned on main; this test locks in
propagation through the LanceDB Python API.

## Validation

- uv run --extra tests pytest python/tests/test_index.py -q (24 passed)
- uv run --extra tests pytest
python/tests/test_index.py::test_create_ivf_index_reports_unsplittable_partitions
-q (1 passed)
- python/.venv/bin/ruff format python/python/tests/test_index.py
- python/.venv/bin/ruff check .
- git diff --check

Fixes #3649

<!-- lance-gatekeeper-fix:v1 agent=a4d34448a9d350a3e2e659f33f5db6f2
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-06 16:13:54 +08:00
lancedb-gatefixer[bot] 7357d63e87 fix(python): guard concurrent table deletes (#3787)
<!-- lance-gatekeeper-fix:v1 agent=5c80c44c083b3b8ad0da595419d468fc
generation=1 -->

## Root cause

The legacy synchronous Python table called `delete` on a shared, mutable
`lance.Dataset`. Concurrent table operations could hold a PyO3 borrow
while delete requested an exclusive borrow, producing `RuntimeError:
Already borrowed`. The current async-backed binding fixes this by
cloning its thread-safe Rust table handle before awaiting, but that
concurrency contract had no regression coverage.

## Fix

- Document why delete must clone the Rust table handle before entering
its async future.
- Add a barrier-synchronized regression test that deletes distinct rows
through one shared table from eight Python threads.
- Verify every delete commits exactly one row, every commit gets a
distinct version, and no rows remain.

## Validation

- `cargo check --quiet --features remote --tests --examples`
- `cargo fmt --all -- --check`
- `uv run --extra tests --extra dev ruff format --check
python/tests/test_table.py`
- `uv run --extra tests --extra dev ruff check
python/tests/test_table.py`
- `uv run --extra tests --extra dev pytest
python/tests/test_table.py::test_concurrent_deletes_are_thread_safe
python/tests/test_table.py::test_delete
python/tests/test_table.py::test_delete_expr
python/tests/test_table.py::test_delete_expr_async -q` (4 passed)
- Manual stress reproduction: 100 concurrent deletes on one table
completed at versions 2–101 with zero rows remaining.

Fixes #530

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-05 15:17:04 -07:00
lancedb-gatefixer[bot] 624a75edf7 fix(python): avoid debugger deadlock during connection inspection (#3788)
## Summary

- cache the immutable read consistency interval on synchronous
connection wrappers
- keep debugger property expansion from dispatching to the background
event loop
- cover direct connections and wrappers reconstructed from native
connections

## Root cause

The debugger expands connection variables by evaluating properties after
suspending all Python threads.
`LanceDBConnection.read_consistency_interval` dispatched a coroutine to
`LanceDBBackgroundEventLoop` and synchronously waited for it, but that
loop thread was also suspended, causing a deadlock.

## Validation

- `uv run --no-sync pytest python/tests/test_db.py -q` (48 passed)
- `ruff format --check python/python/lancedb/db.py
python/python/tests/test_db.py`
- `ruff check .`
- `git diff --check`

Fixes #3773

<!-- lance-gatekeeper-fix:v1 agent=e2e612236d722d926f64245d3f682bbc
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-05 15:15:49 -07:00
Justin Miller c7ea91f3ea test: cover blob null/empty preservation across Table::optimize (#3774)
## Description

`Table::optimize()` compacts through
`lance::dataset::optimize::compact_files`
(`rust/lancedb/src/table/optimize.rs:155`). Until
lance-format/lance#7965 that rewrite corrupted blob columns holding null
or empty values, which is what #3744 reports:

- **storage 2.0** (legacy v1 `lance-encoding:blob` descriptors): every
payload following a null or empty row in the same fragment was rewritten
as `{position: 0, size: 0}`, so it read back as `b""` and the new
fragment no longer referenced the bytes — silent payload loss,
unrecoverable once the pre-optimize versions are pruned.
- **storage 2.2** (blob v2): a valid empty value was rewritten as null,
destroying the null-vs-empty distinction.

Both manifestations share one root cause: `is_inline_null_blob`
classified any inline blob with `position == 0 && size == 0` as null,
which is also exactly what a *valid empty value* looks like. Such rows
were dropped from `blob_read_addrs`, misaligning every payload that
followed.

The behaviour is already correct on `main`: the vendored lance crate
first carried the fix at `v10.0.0-beta.3` (#3710) and is now
`v10.1.0-beta.1` (#3757). What was missing is coverage — nothing in this
repo exercised a blob column containing a null or empty value through
`optimize()`, which is why this shipped unnoticed. This PR adds that
guard.

## Tests

Two tests in `rust/lancedb/tests/blob_integration.rs`, reusing the
file's existing 64 KiB dedicated-blob helpers and a delete-triggered
fragment rewrite. After `id IN (1, 4)` is deleted the surviving rows are
`2` (null), `3` (valid empty), `5` and `6` (payloads) — payloads sit
immediately after the null/empty, which is where the misalignment
landed.

- `optimize_preserves_v1_blob_payloads_with_null_and_empty` — storage
2.0; asserts the **payload bytes** are unchanged across
`OptimizeAction::All` (what the Python/Node `optimize()` bindings
invoke). Payloads are read through `lance::Dataset::take_blobs`, since
`Table::fetch_blobs` rejects legacy v1 columns. The before/after
descriptors are reported on failure but deliberately *not* asserted:
compaction repacks the blob file, so they shift legitimately (id 5
`(131072, 65536)` → `(0, 65536)`, id 6 `(196608, 65536)` → `(65536,
65536)`). Note that a post-compaction `position: 0` is both the
legitimate first-payload offset and the bug's signature, so asserting
descriptors would be actively misleading.
- `optimize_preserves_blob_v2_null_and_empty_distinction` — storage >=
2.2; asserts a null stays null and a valid empty value stays non-null
empty.

Both assert the pre-optimize state first, so a setup change that stops
producing the null/empty/payload mix fails loudly instead of passing
vacuously.

Both also assert the returned `CompactionMetrics` show a fragment was
actually rewritten. These tests depend on `delete("id IN (1, 4)")`
pushing the fragment past lance's `materialize_deletions_threshold` (0.1
by default; 2 of 6 rows here). That coupling is invisible and unasserted
otherwise: against a forced no-op (`materialize_deletions_threshold:
1.5`) the metrics come back all zeroes and *every payload assertion
still passes*. Since the whole point of these tests is to survive
dependency changes, they check that the rewrite happened rather than
trusting the planner to keep selecting the fragment.

Guard verified against a pre-fix lance: with the published
`lancedb==0.36.0` wheel (vendors lance 9.0.0), `Table.optimize()` on the
same data rewrites the descriptors of the two rows following the
null/empty from `(131072, 65536)` and `(196608, 65536)` to `(0, 0)`, and
the payloads read back empty. Against the pinned `v10.1.0-beta.1`, all
39 tests in the file pass, adding roughly 10–20 ms to the file's
runtime.

## Not addressed here

- **No released artifact has the fix yet.** PyPI `lancedb` 0.36.0
(2026-07-29) vendors lance 9.0.0; npm `@lancedb/lancedb` 0.37.1-beta.0
predates the bump. No 9.x lance tag carries the fix: `v10.0.0-beta.3` is
the first tag containing it, every `v9.1.0-beta.1`…`beta.8` is behind
it, and `v9.0.0` / `v9.0.1-rc.1` sit on a diverged branch without it. A
stable lancedb release needs a stable lance >= 10.
- **The version skew #3744 flagged is still live.**
`python/pyproject.toml` pins `pylance==9.0.0rc1` for the `tests` extra
against a vendored `10.1.0-beta.1`, so Python CI still cannot observe
this class of divergence.
- **Only the single-fragment rewrite shape is covered.** Both tests
rewrite one fragment by materializing deletions. lance's own
`test_compact_blob_v1/v2_preserves_null_empty_and_payload_order` cover
the multi-fragment merge shape (3 fragments → 1) at unit level, so this
PR is complementary rather than redundant — it covers the binding-level
path through `Table::optimize` — but it would not catch a regression
that only appears when *merging* fragments.
`multi_fragment_dedicated_blob_table` in the same file makes that a
cheap follow-up.

Closes #3744

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:23:03 -07:00
Wyatt Alt 8e24dd3828 feat(rust)!: make add_columns a builder (#3778)
Table::add_columns now takes no arguments and returns AddColumnsBuilder,
so calls become .add_columns().transform(t).execute().

read_columns was the second positional argument but reaches only one of
the five transform variants. In lance's add_columns_to_fragments only
BatchUDF receives the caller's value: SqlExpressions replaces it with
the columns its expressions reference, Stream and Reader pass None, and
AllNulls reads nothing. So it was mandatory on every call -- all
eighteen call sites here passed None -- and silently discarded four
times out of five. As a builder method it is optional, and setting it
where lance would discard it is now an error, which does reject a call
that previously succeeded while ignoring the argument.

Matches the builders add, update, and merge_insert already use.
2026-08-04 11:18:22 -07:00
Adityaj0 f79dc017c4 fix: when_not_matched_by_source_delete() doesn't reset a previously-set condition (#3771)
## Summary

`LanceMergeInsertBuilder.when_not_matched_by_source_delete()` didn't
clear a previously-set condition when called again with no argument (or
a different condition type). Per the docstring, `condition=None` means
"delete all unmatched rows," but if the builder had already been
configured with a string/Expr condition, a later no-arg call left the
stale condition in place instead of widening the delete to
unconditional.

Fixes #3767

## Change

Each call now unconditionally sets both
`_when_not_matched_by_source_condition` and
`_when_not_matched_by_source_condition_expr` (one to the new value, the
other to `None`), so the latest call always wins — consistent with every
other setter on this builder (e.g.
`when_matched_update_all(where=...)`).

## Test plan

- [x] New regression test
`test_merge_insert_by_source_delete_reconfigure` in
`python/python/tests/test_table.py`
- [x] `uv run --extra tests pytest
python/tests/test_table.py::test_merge_insert_by_source_delete_reconfigure
python/tests/test_table.py::test_merge_insert_by_source_delete_expr
python/tests/test_table.py::test_merge_insert_by_source_delete_expr_async
-vv` — 3 passed
- [x] `uv run --extra dev ruff format` / `ruff check` — clean

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:49:52 -07:00
Adityaj0 e6ae93f52a fix: hybrid search minimum_nprobes(0) silently no-ops instead of raising (#3770)
## Summary

`LanceHybridQueryBuilder._create_query_builders()` checked
`self._minimum_nprobes` for truthiness instead of `is not None` — the
very next line correctly checks `is not None` for
`self._maximum_nprobes`. Since `0` is falsy in Python,
`.minimum_nprobes(0)` on a hybrid query silently dropped the value
instead of forwarding it to the vector sub-query, where it would raise
the same `ValueError` a plain vector query raises for the same input
(`minimum_nprobes must be greater than 0`, validated in
`rust/lancedb/src/query.rs` and covered for the plain-query path by
`test_invalid_nprobes_sync`).

Fixes #3766

## Change

One-line fix: `if self._minimum_nprobes:` → `if self._minimum_nprobes is
not None:`, matching the existing `maximum_nprobes` check right below
it.

## Test plan

- [x] New regression test
`test_hybrid_query_minimum_nprobes_zero_raises` in
`python/python/tests/test_hybrid_query.py`
- [x] `uv run --extra tests pytest python/tests/test_hybrid_query.py
-vv` — 13 passed
- [x] `uv run --extra dev ruff format` / `ruff check` — clean

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 12:54:41 -07:00
Drew Gallardo 3dd9c598e9 feat(remote): add seekable blob range reads (#3750)
## Summary

- Implements Cloud `fetch_blob_files`: returns real seekable `BlobFile`
handles over HTTP Range instead of `NotSupported`.
- Completes the second Cloud blob read verb after #3684 (`fetch_blobs` =
eager whole bytes; this = lazy / partial / sequential reads).
- Same public handle API as local (`read_range`, `read_up_to`, `seek`,
`tell`, `close`), so one code path works for local and Cloud.

Large blobs (video, audio, PDFs) should not require downloading the
whole object to inspect a header or stream a slice. After search,
callers open a handle and read only what they need:

```python
hits = table.search(vec).select(["id", "video"]).limit(5).to_arrow()

with table.fetch_blob_files("video", hits)[0] as f:
    header = f.read_range(0, 256)
    f.seek(keyframe_offset)
    chunk = f.read_up_to(1 << 20)
```

### Behavior

- Handle creation probes size with `bytes=0-0` (bounded concurrency,
input order preserved).
- `204` → null (`None`); `416` with `bytes */0` → valid empty blob;
other `416` → error.
- `read_range` validates `Content-Range` and body length; OOB ranges
fail with `invalid_input` before the request (aligned with Lance).
- `read_up_to` reuses one open-ended Range response across sequential
reads; `seek` drops it.
- Servers older than 0.5.0 get a clear `NotSupported` (does not suggest
`fetch_blobs`, which they also lack).

## Testing

- `cargo test --features remote -p lancedb remote_blob`
- `cargo test --features remote -p lancedb test_blob`
- `cargo clippy --features remote --tests --examples` (no new warnings
from this change)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:38:08 -07:00
dependabot[bot] 9e26bf3fba chore(deps): bump the rust-minor-patch group with 3 updates (#3758)
Bumps the rust-minor-patch group with 3 updates:
[http](https://github.com/hyperium/http),
[napi-derive](https://github.com/napi-rs/napi-rs) and
[napi-build](https://github.com/napi-rs/napi-rs).

Updates `http` from 1.4.2 to 1.5.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/hyperium/http/releases">http's
releases</a>.</em></p>
<blockquote>
<h2>v1.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(method): add QUERY method by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/hyperium/http/pull/798">hyperium/http#798</a></li>
<li>fix(uri): allow empty paths in uri::Builder by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/hyperium/http/pull/853">hyperium/http#853</a></li>
<li>perf(header,uri): faster value validation, URI parse/format, map
inserts by <a
href="https://github.com/geeknoid"><code>@​geeknoid</code></a> in <a
href="https://redirect.github.com/hyperium/http/pull/852">hyperium/http#852</a></li>
<li>fix(uri): enforce max length in PathAndQuery by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/hyperium/http/pull/856">hyperium/http#856</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/geeknoid"><code>@​geeknoid</code></a>
made their first contribution in <a
href="https://redirect.github.com/hyperium/http/pull/852">hyperium/http#852</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/hyperium/http/compare/v1.4.2...v1.5.0">https://github.com/hyperium/http/compare/v1.4.2...v1.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/hyperium/http/blob/master/CHANGELOG.md">http's
changelog</a>.</em></p>
<blockquote>
<h1>1.5.0 (July 29, 2026)</h1>
<ul>
<li>Add <code>Method::QUERY</code> constant for the new QUERY method
defined in RFC 10008.</li>
<li>Fix <code>uri::Builder::path_and_query()</code> to allow empty
strings to mean no path.</li>
<li>Fix <code>uri::PathAndQuery</code> parsing to enforce URI max
length.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/hyperium/http/commit/16fc9a7b840c2181e7f8b37397c107b0ffcd050d"><code>16fc9a7</code></a>
v1.5.0</li>
<li><a
href="https://github.com/hyperium/http/commit/e559023f67e3fad6ecc3ee91307be178e0f13626"><code>e559023</code></a>
fix(uri): enforce max length in PathAndQuery (<a
href="https://redirect.github.com/hyperium/http/issues/856">#856</a>)</li>
<li><a
href="https://github.com/hyperium/http/commit/2178e175c4e247a33ba5f6ca3503afb1afbaabba"><code>2178e17</code></a>
perf(header,uri): faster value validation, URI parse/format, map inserts
(<a
href="https://redirect.github.com/hyperium/http/issues/852">#852</a>)</li>
<li><a
href="https://github.com/hyperium/http/commit/03c8cd7faeddfad00873b4d58a45ecdf74ebebe6"><code>03c8cd7</code></a>
fix(uri): allow empty paths in uri::Builder (<a
href="https://redirect.github.com/hyperium/http/issues/853">#853</a>)</li>
<li><a
href="https://github.com/hyperium/http/commit/bb8705b25cdb6e29081edf9ade2ea124f6783e18"><code>bb8705b</code></a>
feat(method): add QUERY method (<a
href="https://redirect.github.com/hyperium/http/issues/798">#798</a>)</li>
<li>See full diff in <a
href="https://github.com/hyperium/http/compare/v1.4.2...v1.5.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `napi-derive` from 3.6.0 to 3.6.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/napi-rs/napi-rs/releases">napi-derive's
releases</a>.</em></p>
<blockquote>
<h2>napi-derive-v3.6.1</h2>
<h3>Other</h3>
<ul>
<li>updated the following local packages: napi-derive-backend</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/58bd87fa524a837a7c962ab4103e5588557ccd81"><code>58bd87f</code></a>
chore: release (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3414">#3414</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/9da87236dbc4fef99f066b7a130f4d0377308d44"><code>9da8723</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/8d22196aa98a1e6e70584561f5446d117d9c802c"><code>8d22196</code></a>
chore(deps): update dependency oxc-parser to ^0.142.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3422">#3422</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/abc30fbafc2e3967d499cef970c68b3edfefd850"><code>abc30fb</code></a>
build(deps): bump postcss from 8.5.17 to 8.5.23 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3421">#3421</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/55421392cbaa24d4df69419e4c6d4958fbcb6a12"><code>5542139</code></a>
build(deps): bump fast-xml-parser from 5.9.3 to 5.10.1 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3418">#3418</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/dc4ee8c89cc27ce30e239482199b3b3d786bf8b6"><code>dc4ee8c</code></a>
build(deps): bump fast-uri from 3.1.3 to 3.1.4 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3419">#3419</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/050d985196174b4be830cdb813d09e2705258455"><code>050d985</code></a>
feat(async-runtime): drain-linger surface + lock-free scheduler
internals (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3">#3</a>...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/e0b87086eefe0e7efeea6d269e9403c4be4ba9aa"><code>e0b8708</code></a>
chore(deps): update dependency oxc-parser to ^0.141.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3417">#3417</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/fc8494010697d078a93a528c3180271f6f187504"><code>fc84940</code></a>
chore(deps): update actions/setup-node action to v7 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3413">#3413</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/ee598db45985ef11e18c7340801c28bb2452b688"><code>ee598db</code></a>
build(deps): bump protobufjs from 7.6.4 to 7.6.5 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3410">#3410</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/napi-rs/napi-rs/compare/napi-derive-v3.6.0...napi-derive-v3.6.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `napi-build` from 2.3.2 to 2.4.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/napi-rs/napi-rs/releases">napi-build's
releases</a>.</em></p>
<blockquote>
<h2>napi-build-v2.4.0</h2>
<h3>Added</h3>
<ul>
<li><em>(cli)</em> support non-threaded WASI targets (<a
href="https://redirect.github.com/napi-rs/napi-rs/pull/3353">#3353</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/58bd87fa524a837a7c962ab4103e5588557ccd81"><code>58bd87f</code></a>
chore: release (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3414">#3414</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/9da87236dbc4fef99f066b7a130f4d0377308d44"><code>9da8723</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/8d22196aa98a1e6e70584561f5446d117d9c802c"><code>8d22196</code></a>
chore(deps): update dependency oxc-parser to ^0.142.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3422">#3422</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/abc30fbafc2e3967d499cef970c68b3edfefd850"><code>abc30fb</code></a>
build(deps): bump postcss from 8.5.17 to 8.5.23 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3421">#3421</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/55421392cbaa24d4df69419e4c6d4958fbcb6a12"><code>5542139</code></a>
build(deps): bump fast-xml-parser from 5.9.3 to 5.10.1 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3418">#3418</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/dc4ee8c89cc27ce30e239482199b3b3d786bf8b6"><code>dc4ee8c</code></a>
build(deps): bump fast-uri from 3.1.3 to 3.1.4 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3419">#3419</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/050d985196174b4be830cdb813d09e2705258455"><code>050d985</code></a>
feat(async-runtime): drain-linger surface + lock-free scheduler
internals (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3">#3</a>...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/e0b87086eefe0e7efeea6d269e9403c4be4ba9aa"><code>e0b8708</code></a>
chore(deps): update dependency oxc-parser to ^0.141.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3417">#3417</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/fc8494010697d078a93a528c3180271f6f187504"><code>fc84940</code></a>
chore(deps): update actions/setup-node action to v7 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3413">#3413</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/ee598db45985ef11e18c7340801c28bb2452b688"><code>ee598db</code></a>
build(deps): bump protobufjs from 7.6.4 to 7.6.5 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3410">#3410</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/napi-rs/napi-rs/compare/napi-build-v2.3.2...napi-build-v2.4.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 09:53:09 -07:00
Will Jones 93354baf34 chore: upgrade rust toolchain to 1.97.0 (#3643)
Bumps the pinned Rust toolchain from 1.95.0 to the latest stable
(1.97.0).

Rust 1.97's clippy adds `useless_borrows_in_formatting`, which flags a
redundant `&` in `format!`/`debug!` arguments in a few places. This PR
removes those to keep `cargo clippy` clean.

No behavior change; the MSRV (`rust-version = "1.91.0"`) is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 16:40:58 -07:00
LuQQiu 05602ec7d5 chore: update lance dependency to v10.1.0-beta.1 (#3757)
Updates the Lance Rust workspace dependencies and Java lance-core
version to v10.1.0-beta.1.

Includes a compatibility fix for the Lance file writer API by using the
explicit V2_1 writer creation path for permutation shuffle spill files.

Triggered by
https://github.com/lance-format/lance/releases/tag/v10.1.0-beta.1
2026-07-31 16:16:17 -07:00
Wyatt Alt e3b472c212 feat: connection-level job operations (#3755)
Adds job operations to the connection surface, building on the Job
handle from #3742: job(id), list_jobs, get_job, cancel_job, and
job_history, plus a non-blocking Job.status(). Implemented on the
Database trait (defaulting to NotSupported), the remote backend
(/v1/jobs), and the Python and Node bindings; job_history returns Arrow
batches.

errors() and progress() are not included.

Tested with mocked endpoints in all three languages.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:51:43 -07:00
Wyatt Alt a6418b6cb9 feat: create_index returns a Job handle (#3742)
IndexBuilder::execute now returns a Job with wait and cancel methods.
Local tables build the index synchronously and return an already-done
job. Remote tables read the job id the server returns from create_index
and track it through the /v1/jobs API: wait polls describe until the job
reaches a terminal state and cancel posts a cancellation. Servers that
return no job id yield a done job, so behavior against older servers is
unchanged. The job id is not exposed on the handle.

The Python and TypeScript bindings keep their current signatures and
discard the handle; exposing Job there is left to follow-ups.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 07:32:28 -07:00
Cohen Karnell dd2b11eda2 fix(python): log when storage_options is ignored in RemoteDBConnection.open_table (#3743)
`RemoteDBConnection.open_table` accepts `storage_options` and never uses
it:

```python
def open_table(
    self,
    name: str,
    *,
    namespace_path: Optional[List[str]] = None,
    storage_options: Optional[Dict[str, str]] = None,
    index_cache_size: Optional[int] = None,
    ...
) -> Table:
    ...
    if index_cache_size is not None:
        logging.info("index_cache_size is ignored in LanceDb Cloud ...")

    table = LOOP.run(self._conn.open_table(name, namespace_path=namespace_path))
```

The value is never passed down and never mentioned. `index_cache_size`
is ignored on Cloud in the
same way, but it says so.

I checked this at runtime on 0.34.0, not just by reading it: swapping
the inner connection for a
recorder, `open_table("t", storage_options={...})` hands the layer below
`['namespace_path']` and
nothing else, no log record is emitted, and the same probe shows
`index_cache_size` producing its
message as expected.

This adds the matching log line, so the two ignored parameters behave
the same way. `ruff check` and
`ruff format --check` are clean on the file.

A note on severity. This is not a security hole and nothing is exposed.
Someone passing credentials
there gets silence instead of an error, and finds out later.

One thing I am unsure about, and it changes the fix. I have assumed
per-table storage options are
meaningless on Cloud, which is what the `index_cache_size` line next to
it implies about managed
storage. If they are supposed to work, then the right change is to pass
them through to
`self._conn.open_table` instead and this patch is the wrong one. Happy
to redo it that way.

I did not check whether `create_table` or the async connection have the
same gap.
2026-07-30 19:32:31 -07:00
Will Jones 5a1015ba72 docs(python): fill gaps in the Python API reference (#3746)
`docs/src/python/python.md` is the whole Python API reference, but it is
maintained by hand and had drifted from the public API. Anything not
listed there simply doesn't get rendered, so a number of public,
documented, tested APIs were invisible to users — most notably branch
management, where `diff` and `merge` live.

I audited every public symbol reachable from `lancedb` and its
subpackages against the `:::` directives on the page. This adds the
missing ones:

- **Branching** — `Branches`, `AsyncBranches` (`list` / `create` /
`checkout` / `delete` / `diff` / `merge`)
- **Tables** — `TableStatistics` (returned by `Table.stats()`; the
fragment-level stats classes were already listed)
- **Full text queries** — `FullTextQuery`, `MatchQuery`, `PhraseQuery`,
`BoostQuery`, `MultiMatchQuery`, `BooleanQuery`, `FullTextOperator`,
`Occur`
- **Querying** — `LanceEmptyQueryBuilder`, `LanceTakeQueryBuilder`,
`AsyncTakeQuery`
- **Indices** — `Fm` (the FM-index for substring search), `IndexConfig`
- **Blobs** — `blob`, `BlobType`, `BlobFile`
- **Namespaces** — `connect_namespace`, `connect_namespace_async`, and
both namespace connection classes
- **Remote config** — `TlsConfig`, `HeaderProvider`, `OAuthConfig`,
`OAuthFlowType`
- **Rerankers** — the `Reranker` base class plus `JinaReranker`,
`RRFReranker`, `MRRReranker`, `AnswerdotaiRerankers`,
`VoyageAIReranker`, `WatsonxReranker` (5 of 12 were listed)
- **Embeddings** — `get_registry`, `register`, and the 14 embedding
functions that were missing (3 of 17 were listed)
- **PyTorch** — `StreamingDataset` and the permutation API it is built
on
- **Misc** — `Session`, `tokenize`, `FtsToken`, `pydantic.Vector`,
`pydantic.MultiVector`, `instrument_lancedb_metrics`, and the two
exception types

It also repairs cross-references in docstrings that no longer resolve:
links into guide pages that have since moved to lancedb.com
(`querying-an-ann-index`, `experimental-full-text-search`),
`lance.dataset` references with no inventory behind them, and the
relative targets `[Table](Table)` and `[PyArrow Table](pyarrow.Table)`.

Deliberately left out: concrete implementation classes reached through
their abstract base (`LanceTable`, `LanceDBConnection`,
`RemoteDBConnection`), query base classes already covered by
`inherited_members: true`, and internal plumbing such as
`FullTextSearchQuery` and `ColumnOrdering`.

## Testing

The docs job only runs on pushes to `main`, so I built the site locally
and compared against a build of `upstream/main`: every added entry
resolves, and no symbol that was rendered before stopped being rendered
when the four packages moved to automodule. `mkdocs build --strict`
exits 0 on this branch, against 61 warnings on `main`.

## Also in this PR

`lancedb.index`, `lancedb.embeddings`, `lancedb.remote` and
`lancedb.rerankers` are now rendered by a single mkdocstrings directive
each, driven by the module's `__all__`, rather than a hand-maintained
list. These four are where most of the drift was, and `__all__` is
harder to forget than a docs page. `lancedb.embeddings` had no
`__all__`; without one mkdocstrings renders no members at all for a
re-export package, so one is added. AGENTS.md gains a section on how the
page is wired up and how to build the docs locally.

Rendering all that code for the first time surfaced ~100 more build
warnings, which would have made #3707 (turning on `mkdocs build
--strict`) harder to land, so the warning backlog is cleared here too.
97 of the 158 warnings were one systematic false positive — griffe
cannot see the generated `__init__` of a pydantic dataclass, so every
documented parameter looks unknown — switched off via
`warn_unknown_params`. The remaining 61 came from 15 docstrings with
real bugs: prose trailing a `Parameters` section (we were rendering
parameters called `The`, `you` and `To`), types dropped because numpydoc
needs spaces around the colon, `num_partitions, default sqrt(num_rows)`
parsing as a list of names and inventing a `default` parameter, and one
parameter indented five spaces. `mkdocs build --strict` now exits 0.

---

#3747 (the coverage test that keeps this from happening again) is
stacked on this branch, so review it after this one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:50:05 -07:00
Farmer.Chillax 48945d0658 feat(python): add namespace/table exist support (#3460)
In the current LanceDB usage implementation, there is no way to check
whether a table or namespace already exists. This PR introduces the
namespace_exists and table_exists methods to determine the existence of
tables and namespaces.

useage like this:
```
# check table exists
db.table_exists(table_id=['xxx'])

# check namespace exists
db.namespace_exists(namespace_id=['xxx'])
```

fixes: #3419

---------

Signed-off-by: farmer <farmerchillax@outlook.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-30 15:20:47 -07:00
Drew Gallardo 77208fd464 feat(remote): add RemoteTable fetch_blobs HTTP client (#3684)
Remote half of the blob read path. #3578 did local Python. This makes
`RemoteTable` hit the server.

- `fetch_blobs(column, row_ids or hits)` → bytes over `POST
/v1/table/{id}/fetch_blobs/`
- `blob_columns()` from the cached schema (describe already has the
metadata, no extra route)
- search then `fetch_blobs` works. row identity rides inside the blob
descriptor so you do not need a public `_rowid`
- `fetch_blob_files` still `NotSupported` on remote. use `fetch_blobs`
for full bytes for now. Range is a follow up

Accepts Binary / LargeBinary / BinaryView on the way back. Empty
`row_ids` short-circuits. Version + branch go in the request body same
as other read calls.

### Example

```python
db = lancedb.connect(uri="db://my-project", api_key=...)
table = db.open_table("clips")

hits = table.search(query_vec).select(["id", "video"]).limit(10).to_arrow()
# hits is just id + video. row ids are stashed on the descriptor
blobs = table.fetch_blobs("video", hits)  # null-aligned, same length as hits
```

Or pass ids yourself:

```python
blobs = table.fetch_blobs("video", [10, 20, 30])
```

### Testing

- `cargo test -p lancedb --features remote --lib`
- `cargo test -p lancedb --features remote --test blob_integration`
- `pytest python/tests/test_remote_db.py -k remote_blob`
- live e2e against a local 0.5.0 remote server (search → fetch, nulls,
nested path, old server gate)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:16:01 -07:00
Joaquin Hui b505dc1315 fix: distinguish corrupt table from missing in open_table (#3731)
`table_names()` lists any `*.lance` directory, but `open_table()` maps
every `DatasetNotFound` to `TableNotFound`, so a corrupt or
partially-written table looks identical to one that never existed
(#3127). This takes the issue's Option 2: on `DatasetNotFound`, check
the parent listing for the table's `.lance` entry — the same predicate
`table_names()` uses — and return a new `TableCorrupted` error when the
directory is present. The check runs only on the error path, and any
failure in the recheck falls back to the previous `TableNotFound`
behavior.

Tests cover the reporter's empty-dir repro, a deleted-manifest case,
true absence (still `TableNotFound`), and an end-to-end list-then-open
assertion; the three new corrupt-case tests fail without the src change.
`cargo test -p lancedb --lib` 732 passed, clippy/fmt clean, `cargo check
--workspace --all-targets` clean (both language bindings end in wildcard
error arms).

Two notes for review: `Error` isn't `#[non_exhaustive]`, so the new
variant is technically semver-breaking for exhaustive matchers (pre-1.0,
and the alternative — changing `TableNotFound`'s shape — breaks more);
and on the Python side corrupt tables now surface as `RuntimeError`
rather than `ValueError`, which is the intended distinction but worth a
maintainer's eye. `open_from_namespace` was left unchanged since
namespace listings come from a server-side registry, not directory
globbing.

Closes #3127
2026-07-30 07:56:43 -07:00
sanskar singh bhardwaj 7dfdfe6401 fix(remote): surface masked merge_insert stream errors under HTTP2 (#2339) (#3711)
## Summary

Fixes #2339. `merge_insert()` on the remote client could mask the real
cause of a mid-stream input error, reporting only:

> stream error sent by user: unexpected internal error

## Root cause

There were two divergent streaming-write code paths in the remote
client:

- `add()` uses `RemoteInsertExec`, which streams the request body
through a `tokio::sync::oneshot` error side-channel and drains it before
reporting the HTTP result. If the input stream errors mid-body, the
original error is recovered.
- `merge_insert()` used a legacy path (`send_streaming` ->
`reader_as_body`) that piped arrow `Some(Err(e))` straight into the
HTTP2 request body. Hyper swallows body-stream errors under HTTP2 (see
hyperium/hyper#2547), so the original error was lost and only the
generic transport error surfaced.

## Fix

Consolidate both write paths onto the side-channel mechanism instead of
patching the legacy path:

- Generalize `RemoteInsertExec` into `RemoteWriteExec`, carrying a
`WriteOp` enum (`Insert { overwrite }` | `MergeInsert { query, timeout
}`) that selects the endpoint, query params, request-timeout header, and
response parsing. The executor returns a `WriteResult` enum (`Add` |
`Merge`) with typed accessors, and `with_new_children` still resets the
result so the rescannable retry loop is unaffected.
- Route `merge_insert()` through `RemoteWriteExec`. The public API only
accepts a `RecordBatchReader` (not rescannable), so the reader is
buffered into a `Vec<RecordBatch>` before the retry loop to preserve the
previous retry-on-retryable-status behaviour. This mirrors what the old
`send_streaming(with_retry=true)` path already did.
- Remove the now-unused `send_streaming` / `reader_as_body` /
`buffer_reader` / `make_reader` helpers. Multipart stays insert-only
(the server has no multipart merge_insert endpoint), so that hot path is
behaviorally unchanged.

## Testing

- Added `test_merge_insert_input_error_surfaces_original`, which drives
an erroring input through the single-request `merge_insert` path and
asserts the original error (`boom`) is surfaced rather than the masked
HTTP error. Confirmed it fails without the side-channel drain (it then
reports a masked `500 ... request or response body error`).
- Full suite green: `cargo test -p lancedb --lib --features remote` ->
694 passed, 0 failed. Includes the existing
`test_merge_insert_retries_on_409`, confirming retry behaviour is
preserved.
2026-07-30 07:56:25 -07:00
heart 4dc2d9a0f2 fix(python): avoid async work in sync reprs (#3620)
## Summary

- keep the existing synchronous `connect()` path unchanged
- make `LanceDBConnection.__repr__` and `LanceTable.__repr__`
side-effect-free
- add a regression test that verifies sync reprs do not call the Python
background loop

## Root cause

The freeze is caused by debugger rendering, not by `connect()` itself:

1. debugpy stops at a breakpoint and suspends all Python threads.
2. The debugger renders the new `db_connection` local by calling
`repr()`.
3. `LanceDBConnection.__repr__` reads `read_consistency_interval`.
4. That property calls `LOOP.run(...).result()`.
5. The `LanceDBBackgroundEventLoop` thread is suspended by the debugger,
so `repr()` waits for a thread that cannot run.

This explains why the symptom appears immediately after `connect()`: it
is the first point where a connection object exists in locals and is
automatically rendered. `LanceTable.__repr__` had the same problem
because it also read the connection's consistency interval.

This follows the same principle as #3411: `__repr__` must not trigger
async work or I/O that a debugger assumes is lightweight.

## Evidence

I reproduced the behavior with the real LanceDB classes and debugpy
1.8.21 using a DAP client:

- latest `main` (`ff6ff099`): the debugger reported `allThreadsStopped:
true`, and evaluating `repr(db_connection)` timed out
- this branch (`5755a5ba`): the same evaluation returned
`LanceDBConnection(uri='/tmp/lancedb-debug-repro')` immediately
- setting `PYDEVD_UNBLOCK_THREADS_TIMEOUT=0` also allowed the original
repr path to complete, independently confirming that it was waiting on a
suspended thread

The regression test creates a connection and table, replaces `LOOP.run`
with a function that fails, and verifies that both reprs still work.

## Validation

- `maturin develop --manifest-path python/Cargo.toml`
- `python -m pytest
python/python/tests/test_db.py::test_sync_repr_does_not_use_background_loop
python/python/tests/test_table.py::test_consistency -q` (`4 passed`)
- `ruff check .`
- `ruff format --check python/python/lancedb/db.py
python/python/lancedb/table.py python/python/tests/test_db.py
python/python/tests/test_table.py`
- `git diff --check`

Refs #3611.
2026-07-30 07:56:13 -07:00
LanceDB Robot 1ad6ce3a4e chore: update lance dependency to v10.0.0-beta.7 (#3745)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v10.0.0-beta.7. No compatibility fixes were required; full
workspace clippy passed with warnings denied. Lance tag:
https://github.com/lance-format/lance/releases/tag/v10.0.0-beta.7

---------

Co-authored-by: Lu Qiu <luqiujob@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:14:27 -07:00
156 changed files with 21472 additions and 2374 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "0.37.1-beta.0"
current_version = "0.38.0-beta.2"
parse = """(?x)
(?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\.
@@ -4,14 +4,14 @@ on:
workflow_call:
inputs:
tag:
description: "Tag name from Lance. If omitted, the skill will use the latest Lance release that needs an update."
description: "Tag name from Lance (e.g. `v7.2.0-beta.1`). If omitted, the newest release is resolved automatically — stable releases are preferred over pre-releases — and the run is skipped if it is not newer than the version currently pinned in Cargo.toml."
required: false
default: ""
type: string
workflow_dispatch:
inputs:
tag:
description: "Tag name from Lance. Leave empty to use the latest Lance release that needs an update."
description: "Tag name from Lance (e.g. `v7.2.0-beta.1`). Leave empty to resolve the newest release automatically — stable releases are preferred over pre-releases — and skip the run if it is not newer than the version currently pinned in Cargo.toml."
required: false
default: ""
type: string
+243
View File
@@ -0,0 +1,243 @@
name: Check doc links
# Checking external links is inherently noisy: third-party sites rate-limit
# automated clients, reject non-browser user agents, and go down temporarily.
# Blocking pull requests on that trades a lot of false failures for very little
# signal, so this runs on a schedule and reports findings in a single tracking
# issue instead of failing anyone's build.
on:
schedule:
- cron: "0 7 * * *"
workflow_dispatch:
# The report lives in one repository-global issue, so runs must not overlap: a
# lookup racing a create produces duplicate issues, and a healthy run closing
# the issue while a failing run only rewrites its body would leave a broken
# report closed. The group is deliberately ref-independent so that a manual
# dispatch serializes against the scheduled run.
concurrency:
group: docs-link-check
cancel-in-progress: false
permissions: {}
env:
REPORT_TITLE: "Docs link checker report"
jobs:
scan:
name: Scan links
runs-on: ubuntu-24.04
# lychee-action is pinned by SHA, but its wrapper downloads the lychee
# release tarball at run time without verifying a digest, and hands the
# resulting binary a GitHub token. Release assets remain replaceable, so
# that binary is confined to a job whose token can only read public
# content; everything that writes runs in the report job below.
permissions:
contents: read
outputs:
checker_outcome: ${{ steps.lychee.outcome }}
exit_code: ${{ steps.lychee.outputs.exit_code }}
status: ${{ steps.validate.outputs.status }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# workflow_dispatch can run from any ref, but the report is
# repository-global. Always measure the default branch so a manual
# run from a topic branch cannot close a report that main warrants,
# or overwrite it with branch-only findings.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check links
id: lychee
continue-on-error: true
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
# Restricted to http(s) on purpose. Much of docs/src is generated
# API reference (the js/ tree comes from `npm run docs` in nodejs)
# and the hand-written pages use mkdocstrings cross-references and
# nav-relative paths that only resolve in the site mkdocs builds,
# not in this checkout, so relative links would be reported as
# broken on every run.
args: >-
--scheme https
--scheme http
--no-progress
--max-retries 3
--timeout 20
'docs/src/**/*.md'
format: json
output: ./lychee/out.json
jobSummary: false
# The report issue, not a red workflow run, is the signal for link
# findings and checker failures alike.
fail: false
- name: Validate report
id: validate
# lychee does not reserve exit code 2 for broken links: its CLI
# parser also exits 2 on an invalid option, before any link was
# checked or any report written. Only a parseable report whose
# counts agree with a completed exit code (0 or 2) counts as a link
# verdict. Everything else becomes a checker-error report instead of
# failing the workflow. Exit 2 covers timeouts as well as errors, and a
# timed-out host is exactly the transient unavailability this report
# exists to surface, so both count as findings. Requiring total > 0
# also catches a glob that silently stopped matching any file.
if: always()
env:
CHECKER_OUTCOME: ${{ steps.lychee.outcome }}
EXIT_CODE: ${{ steps.lychee.outputs.exit_code }}
run: |
status=checker-error
if [[ "$CHECKER_OUTCOME" == success ]] &&
[[ "$EXIT_CODE" == 0 || "$EXIT_CODE" == 2 ]] &&
jq -e --argjson code "$EXIT_CODE" '
(.total > 0) and
(if $code == 0
then .errors == 0 and .timeouts == 0
and (.error_map | length == 0) and (.timeout_map | length == 0)
else (.errors + .timeouts) > 0
and ((.error_map | length) + (.timeout_map | length)) > 0
end)
' ./lychee/out.json
then
if [[ "$EXIT_CODE" == 0 ]]; then
status=healthy
else
status=findings
fi
fi
echo "status=$status" >> "$GITHUB_OUTPUT"
echo "Validated link check as $status"
- name: Upload report
if: steps.validate.outputs.status == 'findings'
uses: actions/upload-artifact@v7
with:
name: link-report
path: ./lychee/out.json
retention-days: 7
report:
name: Update report issue
needs: scan
runs-on: ubuntu-24.04
# Deliberately no checkout: this job needs the report artifact and the
# issues API, not the repository contents.
permissions:
issues: write
env:
CHECKER_OUTCOME: ${{ needs.scan.outputs.checker_outcome }}
EXIT_CODE: ${{ needs.scan.outputs.exit_code }}
STATUS: ${{ needs.scan.outputs.status }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Find existing report issue
id: report
# Matched on title alone, and through search rather than a listing:
# the issue action applies labels in a separate call after creating the
# issue, so a label filter misses a half-created report, and this
# repository has far more open issues than one listing page holds.
# Closed issues are included because a healthy run closes the report:
# an open-only lookup would forget that identity and the next failing
# run would open a duplicate. The oldest match stays the canonical
# report and is reopened below when a problem recurs.
run: |
match=$(gh issue list --repo "$GITHUB_REPOSITORY" --state all \
--search "in:title \"$REPORT_TITLE\" author:app/github-actions" \
--limit 50 --json number,title,state \
--jq "[.[] | select(.title == \"$REPORT_TITLE\")] | sort_by(.number) | first // empty")
echo "number=$(jq -r '.number // empty' <<<"$match")" >> "$GITHUB_OUTPUT"
echo "state=$(jq -r '.state // empty' <<<"$match")" >> "$GITHUB_OUTPUT"
- name: Download report
if: env.STATUS == 'findings'
uses: actions/download-artifact@v8
with:
name: link-report
path: ./lychee
- name: Compose report
if: env.STATUS == 'findings'
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
{
echo "Broken documentation links found by [\`$GITHUB_WORKFLOW\`]($run_url)."
echo
echo "This issue is rewritten by every scheduled run and closed automatically once all links resolve."
echo
echo "Entries can be false positives: some sites rate-limit or block automated clients while working fine in a browser. Confirm before editing the docs, and add persistent offenders to \`--exclude\` in \`.github/workflows/docs-link-check.yml\`."
echo
# Timeouts are reported alongside errors: entries land in
# timeout_map with a status text instead of an HTTP code.
jq -r '
"\(.errors) of \(.total) links failed, \(.timeouts) timed out.",
"",
([(.error_map | to_entries[]), (.timeout_map | to_entries[])]
| group_by(.key)[] |
"### Errors in \(.[0].key)",
"",
(map(.value[])[] | "* [\(.status.code // .status.text // "ERR")] <\(.url)> — \(.status.details // .status.text // "unknown error")"),
"")
' ./lychee/out.json
} > ./lychee/issue.md
- name: Compose checker error report
if: env.STATUS == 'checker-error'
run: |
mkdir -p ./lychee
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
{
echo "The documentation link check did not complete in [the latest run]($run_url)."
echo
echo "This issue is rewritten by every scheduled run and closed automatically once a trustworthy run finds that all links resolve."
echo
echo "The checker did not produce a trustworthy link verdict. Treat the previous result, if any, as stale until a later run completes."
echo
echo "* Action outcome: \`$CHECKER_OUTCOME\`"
echo "* Exit code: \`${EXIT_CODE:-not reported}\`"
echo "* Verdict validation: \`failed\`"
} > ./lychee/issue.md
- name: Reopen report issue
# A healthy run closes the report, and the issue action below only
# rewrites the body of whatever number it is given. Without an
# explicit reopen, a later finding or checker error would rewrite a
# closed issue. A CLOSED state implies the lookup found a canonical
# issue, so no separate emptiness check.
if: >-
env.STATUS != 'healthy' &&
steps.report.outputs.state == 'CLOSED'
env:
ISSUE_NUMBER: ${{ steps.report.outputs.number }}
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
gh issue reopen "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--comment "The documentation link checker reported a problem again in [the latest run]($run_url)."
- name: Report link-check problem
if: env.STATUS != 'healthy'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0
with:
# Empty on the first failing run, which creates the issue; afterwards
# the same issue is updated in place.
issue-number: ${{ steps.report.outputs.number }}
title: ${{ env.REPORT_TITLE }}
content-filepath: ./lychee/issue.md
labels: documentation
- name: Close report issue once links are healthy
# An OPEN state implies the lookup found a canonical issue; a report
# that is already closed needs nothing.
if: >-
env.STATUS == 'healthy' &&
steps.report.outputs.state == 'OPEN'
env:
ISSUE_NUMBER: ${{ steps.report.outputs.number }}
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--comment "All documentation links resolved in [the latest run]($run_url)."
+10
View File
@@ -69,6 +69,16 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.10"
- name: Add swap for Arm fat LTO
if: matrix.config.platform == 'aarch64'
shell: bash
run: |
swap_file="$RUNNER_TEMP/lancedb-swap"
sudo fallocate --length 16G "$swap_file"
sudo chmod 600 "$swap_file"
sudo mkswap "$swap_file"
sudo swapon "$swap_file"
free -h
- uses: ./.github/workflows/build_linux_wheel
with:
python-minor-version: 10
+8 -6
View File
@@ -296,16 +296,18 @@ jobs:
cargo update -p aws-types --precise 1.3.9
cargo update -p aws-sigv4 --precise 1.3.5
cargo update -p aws-credential-types --precise 1.2.8
cargo update -p aws-smithy-checksums --precise 0.63.9
# aws-smithy-checksums must stay at or above 0.63.13: OpenDAL's S3
# service needs crc-fast ~1.9, and older releases pin it to ~1.3.
cargo update -p aws-smithy-checksums --precise 0.63.13
cargo update -p aws-smithy-runtime --precise 1.9.3
cargo update -p aws-smithy-http --precise 0.62.4
cargo update -p aws-smithy-eventstream --precise 0.60.12
cargo update -p aws-smithy-http --precise 0.62.6
cargo update -p aws-smithy-eventstream --precise 0.60.14
cargo update -p aws-smithy-http-client --precise 1.1.3
cargo update -p aws-smithy-observability --precise 0.1.4
cargo update -p aws-smithy-query --precise 0.60.8
cargo update -p aws-smithy-runtime-api --precise 1.9.1
cargo update -p aws-smithy-async --precise 1.2.6
cargo update -p aws-smithy-types --precise 1.3.5
cargo update -p aws-smithy-runtime-api --precise 1.9.3
cargo update -p aws-smithy-async --precise 1.2.7
cargo update -p aws-smithy-types --precise 1.3.6
cargo update -p aws-smithy-xml --precise 0.60.11
cargo update -p home --precise 0.5.9
- name: cargo +${{ matrix.msrv }} check
Generated
+336 -329
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
rust-version = "1.91.0"
[workspace.dependencies]
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" }
lance = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=11.0.0-beta.15", default-features = false, "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=11.0.0-beta.15", "tag" = "v11.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
ahash = "0.8"
# Note that this one does not include pyarrow
arrow = { version = "58.0.0", optional = false }
@@ -52,7 +52,7 @@ env_logger = "0.11"
half = { "version" = "2.7.1", default-features = false, features = [
"num-traits",
] }
futures = "0"
futures = "0.3"
log = "0.4"
metrics = "0.24"
metrics-util = "0.19"
+13
View File
@@ -101,6 +101,19 @@ ignore = [
# https://rustsec.org/advisories/RUSTSEC-2026-0195
{ id = "RUSTSEC-2026-0194", reason = "transitive via inferno/lance/opendal; XML from trusted cloud endpoints, not attacker-controlled" },
{ id = "RUSTSEC-2026-0195", reason = "transitive via inferno/lance/opendal; XML from trusted cloud endpoints, not attacker-controlled" },
# smartstring: unmaintained — the repository was archived by its author on
# 2026-05-03. Not a vulnerability. Reached only transitively through polars
# (polars-core/-io/-ops/-time/-utils); nothing in LanceDB depends on it directly.
# The advisory states no safe upgrade is available: upstream recommends
# compact_str/smol_str, so clearing this requires polars to migrate.
# https://rustsec.org/advisories/RUSTSEC-2026-0249
{ id = "RUSTSEC-2026-0249", reason = "smartstring unmaintained via polars; no fixed upstream release" },
# h2 0.3: empty DATA frames can be queued without limit. The patched
# h2 0.4 line is locked to 0.4.16, but no patched 0.3 release exists.
# The old copy is pulled in by aws-smithy's legacy hyper 0.14 client.
# https://rustsec.org/advisories/RUSTSEC-2026-0258
{ id = "RUSTSEC-2026-0258", reason = "h2 0.3 via legacy aws-smithy/hyper 0.14; no patched 0.3 release" },
]
# ---------------------------------------------------------------------------
+33 -1
View File
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
<dependency>
<groupId>com.lancedb</groupId>
<artifactId>lancedb-core</artifactId>
<version>0.37.1-beta.0</version>
<version>0.38.0-beta.2</version>
</dependency>
```
@@ -55,6 +55,38 @@ LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder()
| `region(String)` | AWS region (default: "us-east-1") | No |
| `config(String, String)` | Additional configuration parameters | No |
### Opening a Table with Vended Credentials
When the catalog vends temporary object store credentials, open the table through the
namespace client. The Lance dataset builder fetches the table location and storage options
from the catalog and refreshes the credentials when they expire.
```java
import com.lancedb.LanceDbNamespaceClientBuilder;
import org.lance.Dataset;
import org.lance.namespace.LanceNamespace;
import java.util.Arrays;
LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder()
.apiKey(System.getenv("LANCEDB_API_KEY"))
.database(System.getenv("LANCEDB_DATABASE"))
// Set the endpoint for a LanceDB Enterprise deployment.
// .endpoint("https://your-enterprise-endpoint")
.build();
try (Dataset dataset = Dataset.open()
.namespaceClient(namespaceClient)
.tableId(Arrays.asList("my_namespace", "my_table"))
.build()) {
System.out.println("Rows: " + dataset.countRows());
}
```
Do not call `describeTable()` and then open the returned location with `Dataset.open(uri)`.
Opening through `namespaceClient()` is what applies the vended storage options and enables
automatic credential refresh. No object store credentials need to be passed by the application.
## Metadata Operations
### Creating a Namespace Path
+194 -1
View File
@@ -25,6 +25,27 @@ the underlying connection has been closed.
## Methods
### cancelJob()
```ts
abstract cancelJob(jobId): Promise<boolean>
```
Request cancellation of a server-side job by id.
Resolves to true if the server accepted the cancellation, false if no
such job exists. Cancelling an already-terminal job is a no-op success.
#### Parameters
* **jobId**: `string`
#### Returns
`Promise`&lt;`boolean`&gt;
***
### cloneTable()
```ts
@@ -365,6 +386,49 @@ Drop an existing table.
***
### dropTableAsync()
```ts
abstract dropTableAsync(name, namespacePath?): Promise<Job>
```
Start dropping a table and return its cleanup job.
The table may become unavailable before its data files are removed. Wait
on the returned job to know when cleanup has finished.
#### Parameters
* **name**: `string`
* **namespacePath?**: `string`[]
#### Returns
`Promise`&lt;[`Job`](Job.md)&gt;
***
### getJob()
```ts
abstract getJob(jobId): Promise<null | JobDescription>
```
Describe a single server-side job by id.
Resolves to `null` when the server has no such job.
#### Parameters
* **jobId**: `string`
#### Returns
`Promise`&lt;`null` \| [`JobDescription`](../interfaces/JobDescription.md)&gt;
***
### isOpen()
```ts
@@ -379,6 +443,62 @@ Return true if the connection has not been closed
***
### job()
```ts
abstract job(jobId): Job
```
A [Job](Job.md) handle for a server-side job by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect on
the job itself.
#### Parameters
* **jobId**: `string`
#### Returns
[`Job`](Job.md)
***
### jobHistory()
```ts
abstract jobHistory(jobId?): Promise<Table<any>>
```
The lifecycle event history of a server-side job, as an Arrow table.
Lists history across all jobs when `jobId` is omitted.
#### Parameters
* **jobId?**: `string`
#### Returns
`Promise`&lt;`Table`&lt;`any`&gt;&gt;
***
### listJobs()
```ts
abstract listJobs(): Promise<JobInfo[]>
```
List server-side jobs across the database's tables.
#### Returns
`Promise`&lt;[`JobInfo`](../interfaces/JobInfo.md)[]&gt;
***
### listNamespaces()
```ts
@@ -409,6 +529,71 @@ Child namespace names and
***
### listTables()
#### listTables(options)
```ts
abstract listTables(options?): Promise<ListTablesResponse>
```
List a page of tables in this database.
Results may be paginated. To retrieve subsequent pages, pass the
`pageToken` returned by a previous call. A page may be shorter than
`limit` without being the last one, so walk until the response carries no
page token:
```ts
const names = [];
let pageToken = undefined;
do {
const page = await conn.listTables({ pageToken, limit: 100 });
names.push(...page.tables);
pageToken = page.pageToken;
} while (pageToken);
```
##### Parameters
* **options?**: `Partial`&lt;[`ListTablesOptions`](../interfaces/ListTablesOptions.md)&gt;
Pagination options
(`pageToken`, `limit`).
##### Returns
`Promise`&lt;[`ListTablesResponse`](../interfaces/ListTablesResponse.md)&gt;
Table names and an optional token
for fetching the next page.
#### listTables(namespacePath, options)
```ts
abstract listTables(namespacePath?, options?): Promise<ListTablesResponse>
```
List a page of tables in this database.
##### Parameters
* **namespacePath?**: `string`[]
The namespace path to list tables from
(defaults to root namespace)
* **options?**: `Partial`&lt;[`ListTablesOptions`](../interfaces/ListTablesOptions.md)&gt;
Pagination options
(`pageToken`, `limit`).
##### Returns
`Promise`&lt;[`ListTablesResponse`](../interfaces/ListTablesResponse.md)&gt;
Table names and an optional token
for fetching the next page.
***
### openTable()
```ts
@@ -470,7 +655,7 @@ a "not supported" error.
***
### tableNames()
### ~~tableNames()~~
#### tableNames(options)
@@ -492,6 +677,10 @@ Tables will be returned in lexicographical order.
`Promise`&lt;`string`[]&gt;
##### Deprecated
Use [Connection.listTables](Connection.md#listtables) instead.
#### tableNames(namespacePath, options)
```ts
@@ -514,3 +703,7 @@ Tables will be returned in lexicographical order.
##### Returns
`Promise`&lt;`string`[]&gt;
##### Deprecated
Use [Connection.listTables](Connection.md#listtables) instead.
+83
View File
@@ -0,0 +1,83 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / Job
# Class: Job
A handle to an operation that may still be running.
## Constructors
### new Job()
```ts
new Job(): Job
```
#### Returns
[`Job`](Job.md)
## Accessors
### id
```ts
get id(): null | string
```
Identifies the operation on the server that is running it. Operations
that run in this process have no server id. The value is opaque.
#### Returns
`null` \| `string`
## Methods
### cancel()
```ts
cancel(): Promise<void>
```
Request cancellation. Cancelling a finished operation is a no-op.
#### Returns
`Promise`&lt;`void`&gt;
***
### status()
```ts
status(): Promise<string>
```
The operation's current lifecycle state: "running", "finished",
"failed", or "cancelled".
A point snapshot; unlike [Job.wait](Job.md#wait) it does not block or reject
on a terminal failure state. States a newer server reports that this
client version does not know pass through as-is.
#### Returns
`Promise`&lt;`string`&gt;
***
### wait()
```ts
wait(): Promise<void>
```
Wait until the operation reaches a terminal state.
#### Returns
`Promise`&lt;`void`&gt;
+214 -4
View File
@@ -69,14 +69,34 @@ abstract addColumns(newColumnTransforms): Promise<AddColumnsResult>
Add new columns with defined values.
The `{ computed }` form stores the expression rather than evaluating it
now: the column is committed with no values, and rows get them from
[Table#refreshColumn](Table.md#refreshcolumn). Declaring one therefore costs the same on a
large table as on an empty one.
A refresh does not revisit rows it has already filled, so mutating an
input leaves the value computed at fill time; recomputing means dropping
the column and declaring it again. While a declaration reads a column,
that column cannot be renamed, retyped or dropped.
On LanceDB Cloud and Enterprise the expression is planned by the
server, and the refresh runs as a server job -- see
[Table#refreshColumnAsync](Table.md#refreshcolumnasync).
#### Parameters
* **newColumnTransforms**: `Field`&lt;`any`&gt; \| `Field`&lt;`any`&gt;[] \| `Schema`&lt;`any`&gt; \| [`AddColumnsSql`](../interfaces/AddColumnsSql.md)[]
* **newColumnTransforms**:
\| `Field`&lt;`any`&gt;
\| `Field`&lt;`any`&gt;[]
\| `Schema`&lt;`any`&gt;
\| [`AddColumnsSql`](../interfaces/AddColumnsSql.md)[]
\| `object`
Either:
- An array of objects with column names and SQL expressions to calculate values
- A single Arrow Field defining one column with its data type (column will be initialized with null values)
- An array of Arrow Fields defining columns with their data types (columns will be initialized with null values)
- An Arrow Schema defining columns with their data types (columns will be initialized with null values)
- `{ computed }`, declaring columns defined by a SQL expression whose type and inputs are derived from it
#### Returns
@@ -85,6 +105,13 @@ Add new columns with defined values.
A promise that resolves to an object
containing the new version number of the table after adding the columns.
#### Example
```ts
await table.addColumns({ computed: [{ name: "doubled", valueSql: "x * 2" }] });
const { rowsFilled } = await table.refreshColumn("doubled");
```
***
### alterColumns()
@@ -186,6 +213,39 @@ version of the table.
***
### checkpointLsm()
```ts
abstract checkpointLsm(): Promise<void>
```
Converge this table's LSM write path into its base table.
Seals once, then triggers compaction and polls until the L0 that existed
at the start is gone. The target set is fixed at the start, so
generations created *during* the checkpoint are ignored — that is what
lets it terminate under write load, and what makes it best-effort: it
converges the fresh tier as of some instant. Idempotent, abandonable at
any point, and safe to run on a cadence.
There is no liveness bound — the compactor pool is shared across tables,
so a checkpoint queued behind unrelated work looks exactly like one that
is merging. The caller owns the deadline.
#### Returns
`Promise`&lt;`void`&gt;
#### Example
```ts
const before = await table.getLsmStats();
await table.checkpointLsm();
const after = await table.getLsmStats();
```
***
### close()
```ts
@@ -223,6 +283,24 @@ It is a no-op when no writers are cached.
***
### compactLsm()
```ts
abstract compactLsm(): Promise<void>
```
Trigger a background L0 → base compaction pass per bucket.
Returns once the passes are *dispatched*, not once they finish — watch
[Table#getLsmStats](Table.md#getlsmstats) for progress, or use
[Table#checkpointLsm](Table.md#checkpointlsm) to wait for convergence.
#### Returns
`Promise`&lt;`void`&gt;
***
### countRows()
```ts
@@ -295,6 +373,29 @@ await table.createIndex("my_float_col");
***
### createIndexAsync()
```ts
abstract createIndexAsync(column, options?): Promise<Job>
```
Create an index, returning a handle to the indexing job.
The job may already be complete when returned; callers must not assume
the index exists until [Job.wait](Job.md#wait) resolves.
#### Parameters
* **column**: `string`
* **options?**: `Partial`&lt;[`IndexOptions`](../interfaces/IndexOptions.md)&gt;
#### Returns
`Promise`&lt;[`Job`](Job.md)&gt;
***
### currentBranch()
```ts
@@ -398,6 +499,48 @@ Drop an index from the table.
***
### flushLsm()
```ts
abstract flushLsm(): Promise<void>
```
Seal every bucket's active memtable into a new L0 generation.
Returns once the seal is committed. Sealing an empty memtable is a no-op,
so this is safe to call repeatedly.
#### Returns
`Promise`&lt;`void`&gt;
***
### getLsmStats()
```ts
abstract getLsmStats(includeGenerationRows?): Promise<undefined | LsmStats>
```
Read live per-bucket LSM state.
Answers "how far behind is my fresh tier", "which bucket is hot", and
"why is my fresh-tier vector search brute-force". Mutates no table state.
Resolves to `undefined` only when the LSM write path is not enabled.
#### Parameters
* **includeGenerationRows?**: `boolean`
Also count rows per L0 generation.
Off by default because each count opens an uncached Lance dataset.
#### Returns
`Promise`&lt;`undefined` \| [`LsmStats`](../interfaces/LsmStats.md)&gt;
***
### getLsmWriteSpec()
```ts
@@ -408,9 +551,10 @@ Read the [LsmWriteSpec](../interfaces/LsmWriteSpec.md) currently installed on th
Resolves to `undefined` when the MemWAL LSM write path is not enabled (no
spec has been set, or it was removed with [Table#unsetLsmWriteSpec](Table.md#unsetlsmwritespec)).
The returned spec — including its `maintainedIndexes` and
`writerConfigDefaults` — mirrors what was passed to
[Table#setLsmWriteSpec](Table.md#setlsmwritespec).
The returned spec mirrors what was passed to
[Table#setLsmWriteSpec](Table.md#setlsmwritespec), except that `maintainedIndexes` always
reports the concrete list resolved when the spec was set — `undefined`
never round-trips.
#### Returns
@@ -694,6 +838,67 @@ for await (const batch of table.query()) {
***
### refreshColumn()
```ts
abstract refreshColumn(column): Promise<RefreshColumnResult>
```
Fill the rows of a computed column that hold no value yet.
Rows appended since the last refresh are filled by the next one; rows
already filled are left as they are, so the call is idempotent and does
not observe a mutated input. Local tables only: a remote refresh runs
as a server job, through [Table#refreshColumnAsync](Table.md#refreshcolumnasync).
#### Parameters
* **column**: `string`
The name of the computed column to fill.
#### Returns
`Promise`&lt;[`RefreshColumnResult`](../interfaces/RefreshColumnResult.md)&gt;
A promise that resolves to the
number of rows filled and the new version number of the table.
***
### refreshColumnAsync()
```ts
abstract refreshColumnAsync(column): Promise<Job>
```
Like [Table#refreshColumn](Table.md#refreshcolumn), but returns a handle to the refresh
job instead of blocking until it completes.
The job may already be complete when returned; callers must not assume
the column is filled until [Job.wait](Job.md#wait) resolves. Invalid input --
an unknown column, or one that is not computed -- rejects here rather
than failing the job. On local tables the job runs in-process; on
LanceDB Cloud and Enterprise it is the server's backfill job.
#### Parameters
* **column**: `string`
The name of the computed column to fill.
#### Returns
`Promise`&lt;[`Job`](Job.md)&gt;
#### Example
```ts
const job = await table.refreshColumnAsync("doubled");
await job.wait();
console.log(await job.status()); // "finished"
```
***
### restore()
```ts
@@ -783,6 +988,11 @@ All variants require the table to have an unenforced primary key
([Table#setUnenforcedPrimaryKey](Table.md#setunenforcedprimarykey)); bucket sharding additionally
requires it to be the single column being bucketed.
Omitting `maintainedIndexes` maintains every index on the table, resolved
here, failing if one cannot be maintained — name them to install anyway.
Naming them pins an exact set, and a still-building index is rejected
rather than quietly omitted.
#### Parameters
* **spec**: [`LsmWriteSpec`](../interfaces/LsmWriteSpec.md)
+11
View File
@@ -25,6 +25,7 @@
- [Connection](classes/Connection.md)
- [HeaderProvider](classes/HeaderProvider.md)
- [Index](classes/Index.md)
- [Job](classes/Job.md)
- [MakeArrowTableOptions](classes/MakeArrowTableOptions.md)
- [MatchQuery](classes/MatchQuery.md)
- [MergeInsertBuilder](classes/MergeInsertBuilder.md)
@@ -57,6 +58,7 @@
- [BranchDiff](interfaces/BranchDiff.md)
- [BranchIndexSummary](interfaces/BranchIndexSummary.md)
- [BranchRowCountSummary](interfaces/BranchRowCountSummary.md)
- [BucketStats](interfaces/BucketStats.md)
- [ClientConfig](interfaces/ClientConfig.md)
- [ColumnAlteration](interfaces/ColumnAlteration.md)
- [ColumnOrdering](interfaces/ColumnOrdering.md)
@@ -80,6 +82,7 @@
- [FtsToken](interfaces/FtsToken.md)
- [FullTextQuery](interfaces/FullTextQuery.md)
- [FullTextSearchOptions](interfaces/FullTextSearchOptions.md)
- [GenerationStats](interfaces/GenerationStats.md)
- [HnswPqOptions](interfaces/HnswPqOptions.md)
- [HnswSqOptions](interfaces/HnswSqOptions.md)
- [IndexConfig](interfaces/IndexConfig.md)
@@ -88,9 +91,16 @@
- [IvfFlatOptions](interfaces/IvfFlatOptions.md)
- [IvfPqOptions](interfaces/IvfPqOptions.md)
- [IvfRqOptions](interfaces/IvfRqOptions.md)
- [JobDescription](interfaces/JobDescription.md)
- [JobFailureInfo](interfaces/JobFailureInfo.md)
- [JobInfo](interfaces/JobInfo.md)
- [ListNamespacesOptions](interfaces/ListNamespacesOptions.md)
- [ListNamespacesResponse](interfaces/ListNamespacesResponse.md)
- [ListTablesOptions](interfaces/ListTablesOptions.md)
- [ListTablesResponse](interfaces/ListTablesResponse.md)
- [LsmStats](interfaces/LsmStats.md)
- [LsmWriteSpec](interfaces/LsmWriteSpec.md)
- [MemtableStats](interfaces/MemtableStats.md)
- [MergeBlocker](interfaces/MergeBlocker.md)
- [MergeBranchResult](interfaces/MergeBranchResult.md)
- [MergePreview](interfaces/MergePreview.md)
@@ -101,6 +111,7 @@
- [OptimizeOptions](interfaces/OptimizeOptions.md)
- [OptimizeStats](interfaces/OptimizeStats.md)
- [QueryExecutionOptions](interfaces/QueryExecutionOptions.md)
- [RefreshColumnResult](interfaces/RefreshColumnResult.md)
- [RemovalStats](interfaces/RemovalStats.md)
- [RenameTableOptions](interfaces/RenameTableOptions.md)
- [RestNamespaceConfig](interfaces/RestNamespaceConfig.md)
+116
View File
@@ -0,0 +1,116 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / BucketStats
# Interface: BucketStats
Live state of one bucket. A table is N buckets on one node; flattening to a
single number hides the one hot bucket that is usually why someone opened
this endpoint.
## Properties
### compacting
```ts
compacting: boolean;
```
Whether a pass owns this bucket's compaction latch right now. Says *a*
driver is running, not *whose*, and the latch is held from dispatch —
including while the pass queues for a pod-wide compactor permit. Read it
as "do not pile on", never as "mine is progressing".
***
### currentGeneration
```ts
currentGeneration: number;
```
The generation the active memtable will become.
***
### generations
```ts
generations: GenerationStats[];
```
Flushed L0 generations not yet merged into the base table.
***
### manifestVersion
```ts
manifestVersion: number;
```
Version of the shard manifest these numbers were read from.
***
### memtables?
```ts
optional memtables: MemtableStats[];
```
Oldest first, active last. Absent for a `"Sealed"` bucket, whose
in-memory state is torn down.
***
### replayAfterWalEntryPosition
```ts
replayAfterWalEntryPosition: number;
```
WAL position replay resumes from.
***
### shardId
```ts
shardId: string;
```
The shard this bucket writes.
***
### status
```ts
status: string;
```
`"Active"` or `"Sealed"` (drop-table 2PC in flight).
***
### walEntryPositionLastSeen
```ts
walEntryPositionLastSeen: number;
```
Highest WAL position the writer has seen. The difference against
`replayAfterWalEntryPosition` is the WAL lag.
***
### writerEpoch
```ts
writerEpoch: number;
```
Epoch of the writer that currently owns the shard.
+40
View File
@@ -0,0 +1,40 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / GenerationStats
# Interface: GenerationStats
One flushed L0 generation.
## Properties
### bytes
```ts
bytes: number;
```
On-disk size of the generation.
***
### generation
```ts
generation: number;
```
The generation number. Increases as memtables are sealed into L0.
***
### rows?
```ts
optional rows: number;
```
Present only when `includeGenerationRows` was requested. Off by default
because each count opens an uncached Lance dataset.
+66
View File
@@ -0,0 +1,66 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / JobDescription
# Interface: JobDescription
A described job from `Connection.getJob`.
## Properties
### creationMs
```ts
creationMs: number;
```
When the job was created, in milliseconds since the epoch.
***
### failure?
```ts
optional failure: JobFailureInfo;
```
Why the job failed, when the job is failed and the server reports a
reason.
***
### jobId
```ts
jobId: string;
```
***
### jobType
```ts
jobType: string;
```
***
### specJson?
```ts
optional specJson: string;
```
The job-type-specific specification as a JSON string, when present.
***
### state
```ts
state: string;
```
Lifecycle state: "running", "finished", "failed", or "cancelled".
+33
View File
@@ -0,0 +1,33 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / JobFailureInfo
# Interface: JobFailureInfo
The server's account of why a job failed.
## Properties
### message?
```ts
optional message: string;
```
***
### phase?
```ts
optional phase: string;
```
***
### retryable?
```ts
optional retryable: boolean;
```
+58
View File
@@ -0,0 +1,58 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / JobInfo
# Interface: JobInfo
A row from `Connection.listJobs`: one server-side job.
## Properties
### createdAtMillis
```ts
createdAtMillis: number;
```
When the job was created, in milliseconds since the epoch.
***
### jobId
```ts
jobId: string;
```
The job id -- what `Connection.getJob` and `Connection.cancelJob`
accept.
***
### jobType
```ts
jobType: string;
```
***
### state
```ts
state: string;
```
Lifecycle state: "running", "finished", "failed", or "cancelled".
***
### table
```ts
table: string;
```
The table the job runs against, without URI or namespace.
@@ -0,0 +1,33 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / ListTablesOptions
# Interface: ListTablesOptions
## Properties
### limit?
```ts
optional limit: number;
```
An upper bound on how many tables to return.
A page may hold fewer than this and still not be the last one, so continue
while the response carries a page token rather than while pages are full.
***
### pageToken?
```ts
optional pageToken: string;
```
Token from a previous response for pagination.
The token is opaque: it carries whatever the database needs to resume, and
callers should not construct or interpret one.
@@ -0,0 +1,23 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / ListTablesResponse
# Interface: ListTablesResponse
## Properties
### pageToken?
```ts
optional pageToken: string;
```
***
### tables
```ts
tables: string[];
```
+22
View File
@@ -0,0 +1,22 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / LsmStats
# Interface: LsmStats
Live per-bucket LSM state, as returned by `Table#getLsmStats`.
Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are
the caller's to compute.
## Properties
### buckets
```ts
buckets: BucketStats[];
```
One entry per bucket backing this table.
+3 -1
View File
@@ -34,7 +34,9 @@ Bucket and identity variants: the sharding column.
optional maintainedIndexes: string[];
```
Names of indexes the MemWAL should keep up to date during writes.
Indexes the MemWAL keeps up to date. Omit to maintain every supported
index, resolved on install — a snapshot, so indexes created later are not
maintained. Pass `[]` for none.
***
+60
View File
@@ -0,0 +1,60 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / MemtableStats
# Interface: MemtableStats
One in-memory memtable.
## Properties
### batches
```ts
batches: number;
```
Record batches currently buffered.
***
### bytes
```ts
bytes: number;
```
Estimated in-memory size.
***
### generation
```ts
generation: number;
```
The generation this memtable will become once sealed.
***
### indexes
```ts
indexes: string[];
```
Names of the indexes this memtable carries. An absent name is the whole
answer to "why is my fresh-tier search on that column brute-force".
***
### rows
```ts
rows: number;
```
Rows currently buffered.
@@ -0,0 +1,23 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / RefreshColumnResult
# Interface: RefreshColumnResult
## Properties
### rowsFilled
```ts
rowsFilled: number;
```
***
### version
```ts
version: number;
```
+8 -3
View File
@@ -4,11 +4,16 @@
[@lancedb/lancedb](../globals.md) / TableNamesOptions
# Interface: TableNamesOptions
# Interface: ~~TableNamesOptions~~
## Deprecated
Use [ListTablesOptions](ListTablesOptions.md) with [Connection.listTables](../classes/Connection.md#listtables)
instead.
## Properties
### limit?
### ~~limit?~~
```ts
optional limit: number;
@@ -18,7 +23,7 @@ An optional limit to the number of results to return.
***
### startAfter?
### ~~startAfter?~~
```ts
optional startAfter: string;
+4 -1
View File
@@ -44,4 +44,7 @@ The number of rows in the table
totalBytes: number;
```
The total number of bytes in the table
The total size, in bytes, of the table's data files, index files, and
overlay files
Read from the manifest, so this excludes deletion files and manifests.
+3 -1
View File
@@ -31,7 +31,7 @@ is also an [asynchronous API client](#connections-asynchronous).
## Namespaces (Synchronous)
A namespace-backed connection resolves tables through a
[Lance namespace](https://lancedb.github.io/lance-namespace/) service instead of
[Lance namespace](https://lance-format.github.io/lance-namespace/) service instead of
listing a storage directory.
::: lancedb.connect_namespace
@@ -52,6 +52,8 @@ listing a storage directory.
::: lancedb.table.Branches
::: lancedb.LsmWriteSpec
## Expressions
Type-safe expression builder for filters and projections. Use these instead
+42
View File
@@ -29,6 +29,48 @@ LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder()
.build();
```
## MemWAL LSM write path
Most table operations reach LanceDB through the `LanceNamespace` above, which is
generated from the Lance Namespace specification. The MemWAL LSM routes are not part
of that specification, so they are issued through a separate client:
```java
import com.lancedb.LanceDbRestClient;
import com.lancedb.LanceDbTableLsm;
import com.lancedb.LsmWriteSpec;
LanceDbRestClient client = LanceDbNamespaceClientBuilder.newBuilder()
.apiKey("your_lancedb_cloud_api_key")
.database("your_database_name")
.buildRestClient();
LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
// Route future merge_insert upserts through the MemWAL, hash-bucketed by `id`.
lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
// ... merge_insert traffic ...
// Converge the fresh tier into the base table.
lsm.checkpointLsm();
// Inspect live per-bucket state.
lsm.getLsmStats().ifPresent(stats -> stats.buckets().forEach(bucket ->
System.out.println(bucket.shardId() + ": " + bucket.generations().size() + " L0 generations")));
client.close();
```
`maintainedIndexes` is tri-state, and the null default is the opposite of what a Java
reader usually expects:
| Value | Meaning |
| --- | --- |
| unset (null) | Maintain **every** index the MemWAL can, resolved on install |
| `Collections.emptyList()` | Maintain **none** |
| `Arrays.asList("id_idx")` | Maintain exactly those |
## Development
Build:
+15 -1
View File
@@ -8,7 +8,7 @@
<parent>
<groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId>
<version>0.37.1-beta.0</version>
<version>0.38.0-beta.2</version>
<relativePath>../pom.xml</relativePath>
</parent>
@@ -33,6 +33,20 @@
<artifactId>arrow-memory-netty</artifactId>
</dependency>
<!-- Transport for the LanceDB routes outside the Lance Namespace spec.
Versions match what lance-namespace-apache-client resolves to. -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.2.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -0,0 +1,194 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Live state of one bucket. A table is N buckets on one node; flattening to a single number hides
* the one hot bucket that is usually why someone opened this endpoint.
*/
public class BucketStats {
private static final String CONTEXT = "bucket stats";
private final String shardId;
private final String status;
private final long writerEpoch;
private final long manifestVersion;
private final long currentGeneration;
private final long replayAfterWalEntryPosition;
private final long walEntryPositionLastSeen;
private final List<GenerationStats> generations;
private final boolean compacting;
private final List<MemtableStats> memtables;
BucketStats(
String shardId,
String status,
long writerEpoch,
long manifestVersion,
long currentGeneration,
long replayAfterWalEntryPosition,
long walEntryPositionLastSeen,
List<GenerationStats> generations,
boolean compacting,
List<MemtableStats> memtables) {
this.shardId = shardId;
this.status = status;
this.writerEpoch = writerEpoch;
this.manifestVersion = manifestVersion;
this.currentGeneration = currentGeneration;
this.replayAfterWalEntryPosition = replayAfterWalEntryPosition;
this.walEntryPositionLastSeen = walEntryPositionLastSeen;
this.generations = Collections.unmodifiableList(generations);
this.compacting = compacting;
this.memtables = memtables == null ? null : Collections.unmodifiableList(memtables);
}
/** The shard this bucket writes. */
public String shardId() {
return shardId;
}
/** {@code "Active"} or {@code "Sealed"} (drop-table 2PC in flight). */
public String status() {
return status;
}
/** Epoch of the writer that currently owns the shard. */
public long writerEpoch() {
return writerEpoch;
}
/** Version of the shard manifest these numbers were read from. */
public long manifestVersion() {
return manifestVersion;
}
/** The generation the active memtable will become. */
public long currentGeneration() {
return currentGeneration;
}
/** WAL position replay resumes from. */
public long replayAfterWalEntryPosition() {
return replayAfterWalEntryPosition;
}
/**
* Highest WAL position the writer has seen. The difference against {@link
* #replayAfterWalEntryPosition()} is the WAL lag.
*/
public long walEntryPositionLastSeen() {
return walEntryPositionLastSeen;
}
/** Flushed L0 generations not yet merged into the base table. */
public List<GenerationStats> generations() {
return generations;
}
/**
* Whether a pass owns this bucket's compaction latch right now. Says <em>a</em> driver is
* running, not <em>whose</em>, and the latch is held from dispatch — including while the pass
* queues for a pod-wide compactor permit. Read it as "do not pile on", never as "mine is
* progressing".
*/
public boolean compacting() {
return compacting;
}
/** Oldest first, active last. Empty for a {@code "Sealed"} bucket, whose state is torn down. */
public Optional<List<MemtableStats>> memtables() {
return Optional.ofNullable(memtables);
}
/** The newest flushed generation, or empty when L0 is empty. */
OptionalLong newestGeneration() {
OptionalLong newest = OptionalLong.empty();
for (GenerationStats generation : generations) {
if (!newest.isPresent() || generation.generation() > newest.getAsLong()) {
newest = OptionalLong.of(generation.generation());
}
}
return newest;
}
/**
* How many generations at or below {@code target} are still in L0.
*
* <p>A count, not a boolean: one pass drains a bounded prefix rather than the whole target set,
* so a boolean would read as "no progress" for every pass but the last. Compaction drains
* oldest-first, so this decreases monotonically.
*/
long outstandingGenerations(long target) {
long count = 0;
for (GenerationStats generation : generations) {
if (generation.generation() <= target) {
count++;
}
}
return count;
}
static BucketStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
List<GenerationStats> generations = new ArrayList<GenerationStats>();
for (JsonNode generation : JsonFields.requiredArray(node, "generations", CONTEXT)) {
generations.add(GenerationStats.fromJson(generation));
}
JsonNode memtablesNode = JsonFields.optionalArray(node, "memtables", CONTEXT);
List<MemtableStats> memtables = null;
if (memtablesNode != null) {
memtables = new ArrayList<MemtableStats>();
for (JsonNode memtable : memtablesNode) {
memtables.add(MemtableStats.fromJson(memtable));
}
}
return new BucketStats(
JsonFields.requiredText(node, "shard_id", CONTEXT),
JsonFields.requiredText(node, "status", CONTEXT),
JsonFields.requiredLong(node, "writer_epoch", CONTEXT),
JsonFields.requiredLong(node, "manifest_version", CONTEXT),
JsonFields.requiredLong(node, "current_generation", CONTEXT),
JsonFields.requiredLong(node, "replay_after_wal_entry_position", CONTEXT),
JsonFields.requiredLong(node, "wal_entry_position_last_seen", CONTEXT),
generations,
JsonFields.requiredBoolean(node, "compacting", CONTEXT),
memtables);
}
@Override
public String toString() {
return "BucketStats{shardId="
+ shardId
+ ", status="
+ status
+ ", currentGeneration="
+ currentGeneration
+ ", generations="
+ generations
+ ", compacting="
+ compacting
+ "}";
}
}
@@ -0,0 +1,64 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.OptionalLong;
/** One flushed L0 generation. */
public class GenerationStats {
private static final String CONTEXT = "generation stats";
private final long generation;
private final long bytes;
private final Long rows;
GenerationStats(long generation, long bytes, Long rows) {
this.generation = generation;
this.bytes = bytes;
this.rows = rows;
}
/** The generation number. Increases as memtables are sealed into L0. */
public long generation() {
return generation;
}
/** On-disk size of the generation. */
public long bytes() {
return bytes;
}
/**
* Rows in this generation, present only when {@code includeGenerationRows} was requested. Off by
* default because each count opens an uncached Lance dataset.
*/
public OptionalLong rows() {
return rows == null ? OptionalLong.empty() : OptionalLong.of(rows);
}
static GenerationStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
return new GenerationStats(
JsonFields.requiredLong(node, "generation", CONTEXT),
JsonFields.requiredLong(node, "bytes", CONTEXT),
JsonFields.optionalLong(node, "rows", CONTEXT));
}
@Override
public String toString() {
return "GenerationStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}";
}
}
@@ -0,0 +1,109 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Strict readers for decoding LanceDB JSON responses.
*
* <p>Every reader fails closed: a missing, null, or wrong-typed field throws rather than
* defaulting. That mirrors the serde decoding the Rust client applies to the same payloads in
* {@code rust/lancedb/src/table/lsm_stats.rs}, where a required field has no default and a
* malformed response is an error rather than a zero.
*
* <p>The alternative — Jackson's {@code path()}, which yields a missing node that reads as an empty
* array or a zero — is unsafe here because {@link LanceDbTableLsm#checkpointLsm()} decides
* convergence from these numbers. A defaulted {@code generations} array is indistinguishable from a
* drained one, so a malformed response would report a checkpoint that never happened.
*/
final class JsonFields {
private JsonFields() {}
/** The node itself, once confirmed to be a JSON object. */
static JsonNode requiredObject(JsonNode node, String context) {
if (node == null || !node.isObject()) {
throw new IllegalStateException(context + " is not a JSON object: " + node);
}
return node;
}
static String requiredText(JsonNode owner, String field, String context) {
JsonNode value = required(owner, field, context);
if (!value.isTextual()) {
throw new IllegalStateException(fieldIs(context, field, "a string", value));
}
return value.asText();
}
static long requiredLong(JsonNode owner, String field, String context) {
JsonNode value = required(owner, field, context);
if (!value.isIntegralNumber()) {
throw new IllegalStateException(fieldIs(context, field, "an integer", value));
}
return value.asLong();
}
static boolean requiredBoolean(JsonNode owner, String field, String context) {
JsonNode value = required(owner, field, context);
if (!value.isBoolean()) {
throw new IllegalStateException(fieldIs(context, field, "a boolean", value));
}
return value.asBoolean();
}
static JsonNode requiredArray(JsonNode owner, String field, String context) {
JsonNode value = required(owner, field, context);
if (!value.isArray()) {
throw new IllegalStateException(fieldIs(context, field, "an array", value));
}
return value;
}
/** Null when the field is absent or JSON null, mirroring a serde {@code Option}. */
static Long optionalLong(JsonNode owner, String field, String context) {
JsonNode value = owner.get(field);
if (value == null || value.isNull()) {
return null;
}
if (!value.isIntegralNumber()) {
throw new IllegalStateException(fieldIs(context, field, "an integer", value));
}
return value.asLong();
}
/** Null when the field is absent or JSON null, mirroring a serde {@code Option}. */
static JsonNode optionalArray(JsonNode owner, String field, String context) {
JsonNode value = owner.get(field);
if (value == null || value.isNull()) {
return null;
}
if (!value.isArray()) {
throw new IllegalStateException(fieldIs(context, field, "an array", value));
}
return value;
}
private static JsonNode required(JsonNode owner, String field, String context) {
JsonNode value = owner.get(field);
if (value == null || value.isNull()) {
throw new IllegalStateException(context + " is missing required field '" + field + "'");
}
return value;
}
private static String fieldIs(String context, String field, String expected, JsonNode value) {
return context + " field '" + field + "' is not " + expected + ": " + value;
}
}
@@ -136,29 +136,48 @@ public class LanceDbNamespaceClientBuilder {
* @throws IllegalStateException if required parameters are missing
*/
public LanceNamespace build() {
// Validate required fields
validate();
// Build configuration map
Map<String, String> config = new HashMap<>(additionalConfig);
config.put("header.x-lancedb-database", database);
config.put("header.x-api-key", apiKey);
config.put("uri", resolveUri());
return LanceNamespace.connect("rest", config, null);
}
/**
* Build a {@link LanceDbRestClient} for the same endpoint.
*
* <p>Needed only for LanceDB routes that the Lance Namespace specification does not cover — the
* MemWAL LSM write path, reached through {@link LanceDbTableLsm}. Every other table operation
* belongs on the {@link LanceNamespace} from {@link #build()}.
*
* <p>The returned client owns an HTTP connection pool; close it when you are done with it.
*
* @return A configured LanceDbRestClient
* @throws IllegalStateException if required parameters are missing
*/
public LanceDbRestClient buildRestClient() {
validate();
return new LanceDbRestClient(resolveUri(), apiKey, database);
}
private void validate() {
if (apiKey == null) {
throw new IllegalStateException("API key is required");
}
if (database == null) {
throw new IllegalStateException("Database is required");
}
}
// Build configuration map
Map<String, String> config = new HashMap<>(additionalConfig);
config.put("header.x-lancedb-database", database);
config.put("header.x-api-key", apiKey);
// Determine base URL
String uri;
/** The custom endpoint when set, else the LanceDB Cloud URL for this database and region. */
private String resolveUri() {
if (endpoint.isPresent()) {
uri = endpoint.get();
} else {
String effectiveRegion = region.orElse(DEFAULT_REGION);
uri = String.format(CLOUD_URL_PATTERN, database, effectiveRegion);
return endpoint.get();
}
config.put("uri", uri);
return LanceNamespace.connect("rest", config, null);
return String.format(CLOUD_URL_PATTERN, database, region.orElse(DEFAULT_REGION));
}
}
@@ -0,0 +1,119 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import java.io.Closeable;
import java.io.IOException;
import java.io.UncheckedIOException;
/**
* Minimal HTTP client for LanceDB Cloud and Enterprise routes that the Lance Namespace
* specification does not cover.
*
* <p>Most table operations reach LanceDB through {@link org.lance.namespace.LanceNamespace}, which
* is generated from the namespace spec. A handful of routes — the MemWAL LSM write path in
* particular — are served by the same endpoint but are not part of that spec, so they are issued
* directly here. See {@link LanceDbTableLsm}.
*
* <p>Obtain one from {@link LanceDbNamespaceClientBuilder#buildRestClient()}.
*/
public class LanceDbRestClient implements Closeable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String baseUri;
private final String apiKey;
private final String database;
private final CloseableHttpClient http;
LanceDbRestClient(String baseUri, String apiKey, String database) {
this.baseUri = baseUri.endsWith("/") ? baseUri.substring(0, baseUri.length() - 1) : baseUri;
this.apiKey = apiKey;
this.database = database;
// Automatic retries off, deliberately. The default strategy retries 429 and 503 —
// exactly the two statuses LanceDbTableLsm.checkpointLsm() acts on — which would
// silently double its explicit retry budget and would also retry compact_lsm in
// place, where the loop is designed to fall through to a fresh stats poll instead.
// The checkpoint loop owns the 421/429/503 transitions; the transport must not.
this.http = HttpClients.custom().disableAutomaticRetries().build();
}
/**
* POST {@code path}, sending {@code body} as JSON when it is non-null.
*
* @param path Absolute request path, beginning with {@code /}.
* @param body Object to serialize as the request body, or null to send no body.
* @return The parsed response body, or null when the response carried no content.
* @throws HttpException if the server returned a non-2xx status.
*/
public JsonNode post(String path, Object body) {
HttpPost request = new HttpPost(baseUri + path);
request.setHeader("x-api-key", apiKey);
request.setHeader("x-lancedb-database", database);
try {
if (body != null) {
request.setEntity(
new StringEntity(MAPPER.writeValueAsString(body), ContentType.APPLICATION_JSON));
}
return http.execute(
request,
response -> {
String text =
response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity());
int status = response.getCode();
if (status < 200 || status >= 300) {
throw new HttpException(status, "LanceDB request to " + path + " failed: " + text);
}
return text.isEmpty() ? null : MAPPER.readTree(text);
});
} catch (IOException e) {
throw new UncheckedIOException("LanceDB request to " + path + " failed", e);
}
}
@Override
public void close() throws IOException {
http.close();
}
/**
* A non-2xx response.
*
* <p>The status is exposed because callers act on it: {@link LanceDbTableLsm#checkpointLsm()}
* treats 429 and 503 as retryable and 421 as a lost node claim.
*/
public static class HttpException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final int statusCode;
public HttpException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
/** The HTTP status the failed response carried. */
public int statusCode() {
return statusCode;
}
}
}
@@ -0,0 +1,394 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
/**
* The MemWAL LSM write path for one LanceDB Cloud or Enterprise table.
*
* <p>Installing an {@link LsmWriteSpec} routes {@code mergeInsert} upserts through Lance's MemWAL —
* an LSM-style append — instead of the standard merge path. Rows land in an in-memory memtable,
* seal into L0 generations, and are merged into the base table by compaction.
*
* <p>These routes are not part of the Lance Namespace specification, so they are issued directly
* rather than through {@link org.lance.namespace.LanceNamespace}.
*
* <pre>{@code
* LanceDbRestClient client = LanceDbNamespaceClientBuilder.newBuilder()
* .apiKey("your_lancedb_cloud_api_key")
* .database("your_database_name")
* .buildRestClient();
*
* LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
* lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
* // ... merge_insert traffic ...
* lsm.checkpointLsm();
* }</pre>
*/
public class LanceDbTableLsm {
/**
* Interval between {@code get_lsm_stats} polls during a checkpoint. One interval is roughly one
* compaction pass, the granularity at which the answer can change.
*/
private static final long POLL_INTERVAL_MS = 5_000L;
/**
* Cap on re-issues from {@code flushLsm} after a 421, so a crash-looping node cannot turn flush →
* compact → 421 → flush into a spin.
*
* <p>Deliberately not shared with {@link #MAX_RETRIES}: a claim that keeps evaporating is a
* broken node, while contention is routine and wants a real budget.
*/
private static final int MAX_REISSUES = 3;
/**
* Retryable faults tolerated on a <em>single</em> request, reset on every success — scattered
* contention across a long checkpoint must not accumulate toward a cap.
*/
private static final int MAX_RETRIES = 8;
private static final long RETRY_BACKOFF_BASE_MS = 100L;
private static final long RETRY_BACKOFF_MAX_MS = 5_000L;
private final LanceDbRestClient client;
private final String tableIdentifier;
/**
* Bind the LSM routes for one table.
*
* @param client Transport for the LanceDB endpoint.
* @param tableIdentifier The table's full identifier, {@code $}-delimited when it sits inside a
* namespace, such as {@code analytics$events}.
*/
public LanceDbTableLsm(LanceDbRestClient client, String tableIdentifier) {
if (client == null) {
throw new IllegalArgumentException("Client cannot be null");
}
if (tableIdentifier == null || tableIdentifier.trim().isEmpty()) {
throw new IllegalArgumentException("Table identifier cannot be null or empty");
}
this.client = client;
this.tableIdentifier = tableIdentifier;
}
/**
* Install an {@link LsmWriteSpec} on this table, selecting the MemWAL LSM write path for future
* {@code mergeInsert} calls.
*
* <p>All variants require the table to have an unenforced primary key; bucket sharding
* additionally requires it to be the single column being bucketed.
*/
public void setLsmWriteSpec(LsmWriteSpec spec) {
if (spec == null) {
throw new IllegalArgumentException("Spec cannot be null");
}
client.post(route("set_lsm_write_spec"), spec.toRequestBody());
}
/**
* Remove the {@link LsmWriteSpec} from this table, reverting to the standard {@code mergeInsert}
* write path.
*
* <p>Errors if no spec is currently set.
*/
public void unsetLsmWriteSpec() {
client.post(route("unset_lsm_write_spec"), null);
}
/**
* Read the {@link LsmWriteSpec} currently installed on this table.
*
* <p>Empty when the LSM write path is not enabled. The returned spec mirrors what was installed,
* except that {@link LsmWriteSpec#maintainedIndexes()} always reports the concrete list resolved
* when the spec was set — a null selection never round-trips.
*/
public Optional<LsmWriteSpec> getLsmWriteSpec() {
JsonNode response = client.post(route("get_lsm_write_spec"), null);
if (response == null || !response.hasNonNull("lsm_write_spec")) {
return Optional.empty();
}
return Optional.of(LsmWriteSpec.fromJson(response.get("lsm_write_spec")));
}
/**
* Seal every bucket's active memtable into a new L0 generation.
*
* <p>Returns once the seal is committed. Sealing an empty memtable is a no-op, so this is safe to
* call repeatedly.
*/
public void flushLsm() {
client.post(route("flush_lsm"), null);
}
/**
* Trigger a background L0 → base compaction pass per bucket.
*
* <p>Returns once the passes are <em>dispatched</em>, not once they finish — watch {@link
* #getLsmStats}, or use {@link #checkpointLsm} to wait for convergence.
*/
public void compactLsm() {
client.post(route("compact_lsm"), null);
}
/**
* Read live per-bucket LSM state.
*
* <p>Answers "how far behind is my fresh tier", "which bucket is hot", and "why is my fresh-tier
* vector search brute-force". Mutates no table state.
*
* <p>Empty only when the LSM write path is not enabled — that is, when the server sends an absent
* or null {@code lsm_stats}. A stats object that is present is decoded strictly, and a malformed
* one throws rather than decoding to something empty, because {@link #checkpointLsm} reads
* convergence out of these numbers and cannot tell a defaulted array from a drained one.
*
* @param includeGenerationRows Also count rows per L0 generation. Off by default because each
* count opens an uncached Lance dataset.
* @throws IllegalStateException if the response is absent or does not decode.
*/
public Optional<LsmStats> getLsmStats(boolean includeGenerationRows) {
Map<String, Object> body = new LinkedHashMap<String, Object>();
body.put("include_generation_rows", includeGenerationRows);
JsonNode response = client.post(route("get_lsm_stats"), body);
if (response == null) {
throw new IllegalStateException("get_lsm_stats returned an empty response body");
}
JsonNode stats = response.get("lsm_stats");
if (stats == null || stats.isNull()) {
return Optional.empty();
}
return Optional.of(LsmStats.fromJson(stats));
}
/** Equivalent to {@code getLsmStats(false)}. */
public Optional<LsmStats> getLsmStats() {
return getLsmStats(false);
}
/**
* Converge this table's LSM write path into its base table.
*
* <p>Seals once, fixes a target watermark from the resulting L0, then triggers compaction and
* polls until that L0 is gone. The target set is fixed at the start, so generations created
* <em>during</em> the checkpoint are ignored — that is what lets it terminate under write load,
* and what makes it best-effort: it converges the fresh tier as of some instant. Idempotent,
* abandonable at any point, safe on a cadence.
*
* <p>The loop runs here, not on the server: {@link #compactLsm} dispatches a pass and returns, so
* nothing holds a socket and a client can vanish mid-operation with nothing to reconcile.
* Completion is read from generation numbers in the shard manifest — durable state, unlike a
* count in a compact response, which a concurrent write invalidates.
*
* <p>No liveness bound — the caller owns the deadline. The compactor pool is shared across
* tables, so a checkpoint queued behind unrelated work looks exactly like one that is merging.
*/
public void checkpointLsm() {
for (int reissue = 0; reissue <= MAX_REISSUES; reissue++) {
// The seal turns everything written before this call into a generation, so the
// watermark has to be read after it. Idempotent: sealing an empty memtable is a
// no-op, so a re-issue does not churn empty generations.
if (issueVoid(this::flushLsm)) {
backoff(reissue);
continue;
}
Attempt<Optional<LsmStats>> stats = issue(() -> getLsmStats(false));
if (stats.lostClaim) {
backoff(reissue);
continue;
}
if (!stats.value.isPresent()) {
// Not WAL-backed; flushLsm would have errored first but for a race.
return;
}
Map<String, Long> targets = newestGenerations(stats.value.get());
if (targets.isEmpty()) {
return;
}
if (drainToTargets(targets)) {
return;
}
backoff(reissue);
}
throw new IllegalStateException(
"checkpointLsm: the owning node kept losing its claim; re-issued from flush the maximum "
+ "number of times");
}
/**
* Trigger and poll until no bucket holds a generation at or below its target.
*
* @return true when the drain finished, false when the table needs re-claiming from flush.
*/
private boolean drainToTargets(Map<String, Long> targets) {
while (true) {
Attempt<Optional<LsmStats>> stats = issue(() -> getLsmStats(false));
if (stats.lostClaim) {
return false;
}
if (!stats.value.isPresent()) {
return true;
}
// `compacting` is the bucket's compaction latch, held from dispatch until the pass
// ends — including while it waits on a pod-wide permit. So it answers one question
// only: do not pile on. Buckets with nothing outstanding are skipped, not counted
// as idle.
long outstanding = 0;
boolean allCompacting = true;
for (BucketStats bucket : stats.value.get().buckets()) {
Long target = targets.get(bucket.shardId());
if (target == null) {
continue;
}
long remaining = bucket.outstandingGenerations(target);
if (remaining > 0) {
outstanding += remaining;
allCompacting &= bucket.compacting();
}
}
if (outstanding == 0) {
return true;
}
if (!allCompacting) {
try {
compactLsm();
} catch (LanceDbRestClient.HttpException e) {
if (isLostClaim(e)) {
return false;
}
if (!isRetryable(e)) {
throw e;
}
// A 429 here means the server could latch no bucket at all, which the poll
// above already handles. Not retried in place: the latch it would contend for
// is the one doing the work, so fall through and re-read — POLL_INTERVAL_MS is
// the backoff.
}
}
sleep(POLL_INTERVAL_MS);
}
}
/** The newest generation held by each bucket, skipping buckets holding none. */
private static Map<String, Long> newestGenerations(LsmStats stats) {
Map<String, Long> targets = new HashMap<String, Long>();
for (BucketStats bucket : stats.buckets()) {
OptionalLong newest = bucket.newestGeneration();
if (newest.isPresent()) {
targets.put(bucket.shardId(), newest.getAsLong());
}
}
return targets;
}
/**
* 429 (latch held, pool saturated, or the pod replaying its WAL) and 503 (a draining node, or a
* proxy between here and it).
*/
private static boolean isRetryable(LanceDbRestClient.HttpException e) {
return e.statusCode() == 429 || e.statusCode() == 503;
}
/**
* 421: the owning node holds no claim. Only {@code flush} re-claims and replays, so this cannot
* be retried in place — the caller has to start over.
*/
private static boolean isLostClaim(LanceDbRestClient.HttpException e) {
return e.statusCode() == 421;
}
/**
* Issue one LSM request, retrying in place while the fault is retryable.
*
* <p>The two recoverable faults have separate budgets: contention clears on its own and retries
* here against {@link #MAX_RETRIES}, while a 421 needs {@code flush} to re-claim, which only the
* caller can drive.
*
* <p>An exhausted budget propagates the last error as itself rather than a synthesized one — "429
* after nine tries" beats "checkpoint failed".
*/
private static <T> Attempt<T> issue(Call<T> call) {
int retries = 0;
while (true) {
try {
return new Attempt<T>(call.run(), false);
} catch (LanceDbRestClient.HttpException e) {
if (isLostClaim(e)) {
return new Attempt<T>(null, true);
}
if (!isRetryable(e) || retries >= MAX_RETRIES) {
throw e;
}
backoff(retries);
retries++;
}
}
}
/** {@link #issue} for a call with no return value. Returns true when the claim was lost. */
private static boolean issueVoid(Runnable call) {
return issue(
() -> {
call.run();
return Boolean.TRUE;
})
.lostClaim;
}
/** Sleep before re-issuing a retryable request. Doubles up to {@link #RETRY_BACKOFF_MAX_MS}. */
private static void backoff(int attempt) {
long delay = RETRY_BACKOFF_BASE_MS << Math.min(attempt, 8);
sleep(Math.min(delay, RETRY_BACKOFF_MAX_MS));
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting on the LSM checkpoint", e);
}
}
private String route(String operation) {
return "/v1/table/" + tableIdentifier + "/" + operation + "/";
}
/** What one LSM request produced: its value, or word that the owning node holds no claim. */
private static final class Attempt<T> {
private final T value;
private final boolean lostClaim;
private Attempt(T value, boolean lostClaim) {
this.value = value;
this.lostClaim = lostClaim;
}
}
@FunctionalInterface
private interface Call<T> {
T run();
}
}
@@ -0,0 +1,56 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Live per-bucket LSM state, as returned by {@link LanceDbTableLsm#getLsmStats()}.
*
* <p>Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are the caller's to
* compute. There is no "LSM is off" shape — that case is an empty {@link java.util.Optional},
* because a stats object of zeros would read as measurements.
*/
public class LsmStats {
private static final String CONTEXT = "lsm stats";
private final List<BucketStats> buckets;
LsmStats(List<BucketStats> buckets) {
this.buckets = Collections.unmodifiableList(buckets);
}
/** One entry per bucket. */
public List<BucketStats> buckets() {
return buckets;
}
static LsmStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
List<BucketStats> buckets = new ArrayList<BucketStats>();
for (JsonNode bucket : JsonFields.requiredArray(node, "buckets", CONTEXT)) {
buckets.add(BucketStats.fromJson(bucket));
}
return new LsmStats(buckets);
}
@Override
public String toString() {
return "LsmStats{buckets=" + buckets + "}";
}
}
@@ -0,0 +1,260 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Specification selecting Lance's MemWAL LSM-style write path for {@code mergeInsert}.
*
* <p>Construct via {@link #bucket}, {@link #identity}, or {@link #unsharded}, then optionally chain
* {@link #withMaintainedIndexes} and {@link #withWriterConfigDefaults}. Install it with {@link
* LanceDbTableLsm#setLsmWriteSpec} and remove it with {@link LanceDbTableLsm#unsetLsmWriteSpec}.
*
* <p>This is deliberately not {@code org.lance.memwal.InitializeMemWalParams}. That type is Lance's
* own, and its maintained-index default is the opposite of this one: it defaults to maintaining
* <em>nothing</em>, while a fresh spec here maintains <em>every</em> index. It also cannot express
* the null that asks the server to resolve the set.
*/
public class LsmWriteSpec {
/** How writes are routed to MemWAL shards. */
public enum Sharding {
/** Hash-bucket writes by a scalar column. */
BUCKET("bucket"),
/** Shard by the raw value of a scalar column. */
IDENTITY("identity"),
/** Route every write to a single shard. */
UNSHARDED("unsharded");
private final String wireName;
Sharding(String wireName) {
this.wireName = wireName;
}
String wireName() {
return wireName;
}
static Sharding fromWireName(String name) {
for (Sharding s : values()) {
if (s.wireName.equals(name)) {
return s;
}
}
throw new IllegalArgumentException("Unknown sharding mode: " + name);
}
}
private final Sharding sharding;
private final String column;
private final Integer numBuckets;
private final List<String> maintainedIndexes;
private final Map<String, String> writerConfigDefaults;
private LsmWriteSpec(
Sharding sharding,
String column,
Integer numBuckets,
List<String> maintainedIndexes,
Map<String, String> writerConfigDefaults) {
this.sharding = sharding;
this.column = column;
this.numBuckets = numBuckets;
this.maintainedIndexes = maintainedIndexes;
this.writerConfigDefaults = writerConfigDefaults;
}
/**
* Hash-bucket sharding by a scalar column, maintaining every index on the table.
*
* <p>Iceberg-compatible Murmur3-x86-32 (seed 0) is used, so each row's {@code bucket(column,
* numBuckets)} value is stable across processes.
*
* @param column A non-nested column with a supported scalar type.
* @param numBuckets The number of buckets, in {@code [1, 1024]}.
*/
public static LsmWriteSpec bucket(String column, int numBuckets) {
if (column == null || column.trim().isEmpty()) {
throw new IllegalArgumentException("Column cannot be null or empty");
}
return new LsmWriteSpec(
Sharding.BUCKET, column, numBuckets, null, new HashMap<String, String>());
}
/**
* Identity sharding — shard by the raw value of {@code column} — maintaining every index on the
* table.
*
* <p>{@code column} must be a deterministic function of the unenforced primary key: every row
* with a given primary key must always produce the same {@code column} value, or upserts of that
* key can land in different shards and a stale version can win.
*/
public static LsmWriteSpec identity(String column) {
if (column == null || column.trim().isEmpty()) {
throw new IllegalArgumentException("Column cannot be null or empty");
}
return new LsmWriteSpec(Sharding.IDENTITY, column, null, null, new HashMap<String, String>());
}
/** No sharding — every write goes to a single MemWAL shard — maintaining every index. */
public static LsmWriteSpec unsharded() {
return new LsmWriteSpec(Sharding.UNSHARDED, null, null, null, new HashMap<String, String>());
}
/**
* Set the indexes the MemWAL keeps up to date as rows are appended.
*
* <p>Pass {@code null} — the default for a fresh spec — to maintain every index the MemWAL can,
* resolved when the spec is installed. That is a snapshot: indexes created later are not
* maintained until the spec is unset and set again. Pass an empty list to maintain none.
*
* <p>Note that {@code null} and the empty list mean opposite things here.
*/
public LsmWriteSpec withMaintainedIndexes(List<String> maintainedIndexes) {
return new LsmWriteSpec(
sharding,
column,
numBuckets,
maintainedIndexes == null ? null : new ArrayList<String>(maintainedIndexes),
writerConfigDefaults);
}
/**
* Set default {@code ShardWriter} configuration recorded in the MemWAL index.
*
* <p>A sparse override map — only the keys you set are recorded. Recognized keys include {@code
* durable_write}, {@code max_wal_buffer_size}, {@code max_memtable_size}, {@code
* max_memtable_rows}, {@code max_memtable_batches}, {@code manifest_scan_batch_size}, {@code
* max_unflushed_memtable_bytes}, and {@code enable_memtable}. Duration knobs carry an {@code _ms}
* suffix, such as {@code max_wal_flush_interval_ms}.
*/
public LsmWriteSpec withWriterConfigDefaults(Map<String, String> writerConfigDefaults) {
if (writerConfigDefaults == null) {
throw new IllegalArgumentException("writerConfigDefaults cannot be null");
}
return new LsmWriteSpec(
sharding,
column,
numBuckets,
maintainedIndexes,
new HashMap<String, String>(writerConfigDefaults));
}
/** How writes are routed to shards. */
public Sharding sharding() {
return sharding;
}
/** The sharding column for {@link Sharding#BUCKET} and {@link Sharding#IDENTITY}, else null. */
public String column() {
return column;
}
/** The bucket count for {@link Sharding#BUCKET}, else null. */
public Integer numBuckets() {
return numBuckets;
}
/**
* The indexes the MemWAL maintains, or null to have the server resolve every maintainable index
* on install. An empty list means none.
*/
public List<String> maintainedIndexes() {
return maintainedIndexes == null ? null : Collections.unmodifiableList(maintainedIndexes);
}
/** Default {@code ShardWriter} configuration recorded in the MemWAL index. */
public Map<String, String> writerConfigDefaults() {
return Collections.unmodifiableMap(writerConfigDefaults);
}
/** Render this spec as the {@code set_lsm_write_spec} request body. */
Map<String, Object> toRequestBody() {
Map<String, Object> shardingBody = new LinkedHashMap<String, Object>();
shardingBody.put("mode", sharding.wireName());
if (column != null) {
shardingBody.put("column", column);
}
if (numBuckets != null) {
shardingBody.put("num_buckets", numBuckets);
}
Map<String, Object> body = new LinkedHashMap<String, Object>();
body.put("sharding", shardingBody);
// Null is meaningful: it asks the server to resolve every maintainable index.
body.put("maintained_indexes", maintainedIndexes);
body.put("writer_config_defaults", writerConfigDefaults);
return body;
}
/**
* Rebuild a spec from a {@code get_lsm_write_spec} response body.
*
* <p>The server always reports a concrete maintained-index list, so a null selection never
* round-trips.
*/
static LsmWriteSpec fromJson(JsonNode node) {
JsonNode shardingNode = node.get("sharding");
if (shardingNode == null || shardingNode.get("mode") == null) {
throw new IllegalStateException("get_lsm_write_spec response has no sharding mode");
}
Sharding sharding = Sharding.fromWireName(shardingNode.get("mode").asText());
String column = shardingNode.hasNonNull("column") ? shardingNode.get("column").asText() : null;
Integer numBuckets =
shardingNode.hasNonNull("num_buckets") ? shardingNode.get("num_buckets").asInt() : null;
List<String> maintainedIndexes = new ArrayList<String>();
JsonNode indexesNode = node.get("maintained_indexes");
if (indexesNode != null && indexesNode.isArray()) {
for (JsonNode index : indexesNode) {
maintainedIndexes.add(index.asText());
}
}
Map<String, String> defaults = new HashMap<String, String>();
JsonNode defaultsNode = node.get("writer_config_defaults");
if (defaultsNode != null && defaultsNode.isObject()) {
defaultsNode
.fieldNames()
.forEachRemaining(name -> defaults.put(name, defaultsNode.get(name).asText()));
}
return new LsmWriteSpec(sharding, column, numBuckets, maintainedIndexes, defaults);
}
@Override
public String toString() {
return "LsmWriteSpec{sharding="
+ sharding
+ ", column="
+ column
+ ", numBuckets="
+ numBuckets
+ ", maintainedIndexes="
+ maintainedIndexes
+ ", writerConfigDefaults="
+ writerConfigDefaults
+ "}";
}
}
@@ -0,0 +1,99 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** One in-memory memtable. */
public class MemtableStats {
private static final String CONTEXT = "memtable stats";
private final long generation;
private final long rows;
private final long bytes;
private final long batches;
private final List<String> indexes;
MemtableStats(long generation, long rows, long bytes, long batches, List<String> indexes) {
this.generation = generation;
this.rows = rows;
this.bytes = bytes;
this.batches = batches;
this.indexes = Collections.unmodifiableList(indexes);
}
/** The generation this memtable will become once sealed. */
public long generation() {
return generation;
}
/** Rows currently buffered. */
public long rows() {
return rows;
}
/** Estimated in-memory size. */
public long bytes() {
return bytes;
}
/** Record batches currently buffered. */
public long batches() {
return batches;
}
/**
* Names of the indexes this memtable carries. An absent name is the whole answer to "why is my
* fresh-tier search on that column brute-force".
*/
public List<String> indexes() {
return indexes;
}
static MemtableStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
List<String> indexes = new ArrayList<String>();
for (JsonNode index : JsonFields.requiredArray(node, "indexes", CONTEXT)) {
if (!index.isTextual()) {
throw new IllegalStateException(CONTEXT + " has a non-string index name: " + index);
}
indexes.add(index.asText());
}
return new MemtableStats(
JsonFields.requiredLong(node, "generation", CONTEXT),
JsonFields.requiredLong(node, "rows", CONTEXT),
JsonFields.requiredLong(node, "bytes", CONTEXT),
JsonFields.requiredLong(node, "batches", CONTEXT),
indexes);
}
@Override
public String toString() {
return "MemtableStats{generation="
+ generation
+ ", rows="
+ rows
+ ", bytes="
+ bytes
+ ", batches="
+ batches
+ ", indexes="
+ indexes
+ "}";
}
}
@@ -0,0 +1,570 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lancedb;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the MemWAL LSM routes, run against a scripted local HTTP server.
*
* <p>The wire assertions mirror the Rust mocked-endpoint tests in {@code
* rust/lancedb/src/remote/table.rs}, which are the contract these routes have to match.
*/
public class LanceDbTableLsmTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private HttpServer server;
private LanceDbRestClient client;
private LanceDbTableLsm lsm;
private final List<String> requestPaths = Collections.synchronizedList(new ArrayList<String>());
private final List<String> requestBodies = Collections.synchronizedList(new ArrayList<String>());
private final Map<String, Deque<Reply>> replies = new ConcurrentHashMap<String, Deque<Reply>>();
@BeforeEach
public void setUp() throws IOException {
start();
}
/** Tear down and restart the scripted server, for a test that scripts several exchanges. */
private void setUpFresh() {
try {
client.close();
server.stop(0);
requestPaths.clear();
requestBodies.clear();
replies.clear();
start();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void start() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(
"/",
exchange -> {
String path = exchange.getRequestURI().getPath();
requestPaths.add(path);
requestBodies.add(readAll(exchange.getRequestBody()));
Reply reply = nextReply(path);
byte[] out = reply.body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(reply.status, out.length == 0 ? -1 : out.length);
if (out.length > 0) {
exchange.getResponseBody().write(out);
}
exchange.close();
});
server.start();
client =
LanceDbNamespaceClientBuilder.newBuilder()
.apiKey("test-key")
.database("test-db")
.endpoint("http://127.0.0.1:" + server.getAddress().getPort())
.buildRestClient();
lsm = new LanceDbTableLsm(client, "my_table");
}
@AfterEach
public void tearDown() throws IOException {
client.close();
server.stop(0);
}
// ===========================================================================
// set / unset / get spec
// ===========================================================================
@Test
public void testSetLsmWriteSpecUnsharded() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded());
assertEquals("/v1/table/my_table/set_lsm_write_spec/", requestPaths.get(0));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("unsharded", body.get("sharding").get("mode").asText());
assertFalse(body.get("sharding").has("column"));
assertFalse(body.get("sharding").has("num_buckets"));
}
@Test
public void testSetLsmWriteSpecBucket() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
lsm.setLsmWriteSpec(
LsmWriteSpec.bucket("id", 16).withMaintainedIndexes(Arrays.asList("id_idx")));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("bucket", body.get("sharding").get("mode").asText());
assertEquals("id", body.get("sharding").get("column").asText());
assertEquals(16, body.get("sharding").get("num_buckets").asInt());
assertEquals(1, body.get("maintained_indexes").size());
assertEquals("id_idx", body.get("maintained_indexes").get(0).asText());
}
@Test
public void testSetLsmWriteSpecIdentity() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.identity("tenant"));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("identity", body.get("sharding").get("mode").asText());
assertEquals("tenant", body.get("sharding").get("column").asText());
assertFalse(body.get("sharding").has("num_buckets"));
}
/**
* The tri-state that motivated a LanceDB-owned spec type: a null selection asks the server to
* resolve every maintainable index, while an empty list asks for none. They must not collapse.
*/
@Test
public void testMaintainedIndexesNullAndEmptyAreDistinctOnTheWire() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded());
JsonNode fresh = MAPPER.readTree(requestBodies.get(0));
assertTrue(fresh.has("maintained_indexes"), "the key must be present");
assertTrue(fresh.get("maintained_indexes").isNull(), "a fresh spec sends null, not []");
lsm.setLsmWriteSpec(
LsmWriteSpec.unsharded().withMaintainedIndexes(Collections.<String>emptyList()));
JsonNode none = MAPPER.readTree(requestBodies.get(1));
assertTrue(none.get("maintained_indexes").isArray());
assertEquals(0, none.get("maintained_indexes").size());
}
@Test
public void testSetLsmWriteSpecWriterConfigDefaults() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
Map<String, String> defaults = new HashMap<String, String>();
defaults.put("max_memtable_rows", "50000");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded().withWriterConfigDefaults(defaults));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("50000", body.get("writer_config_defaults").get("max_memtable_rows").asText());
}
@Test
public void testUnsetLsmWriteSpec() {
enqueue("unset_lsm_write_spec", 200, "");
lsm.unsetLsmWriteSpec();
assertEquals("/v1/table/my_table/unset_lsm_write_spec/", requestPaths.get(0));
assertEquals("", requestBodies.get(0));
}
@Test
public void testGetLsmWriteSpec() {
enqueue(
"get_lsm_write_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"bucket\",\"column\":\"id\","
+ "\"num_buckets\":16},\"maintained_indexes\":[\"id_idx\"],"
+ "\"writer_config_defaults\":{\"durable_write\":\"true\"}}}");
Optional<LsmWriteSpec> spec = lsm.getLsmWriteSpec();
assertTrue(spec.isPresent());
assertEquals(LsmWriteSpec.Sharding.BUCKET, spec.get().sharding());
assertEquals("id", spec.get().column());
assertEquals(Integer.valueOf(16), spec.get().numBuckets());
assertEquals(Arrays.asList("id_idx"), spec.get().maintainedIndexes());
assertEquals("true", spec.get().writerConfigDefaults().get("durable_write"));
}
@Test
public void testGetLsmWriteSpecAbsent() {
enqueue("get_lsm_write_spec", 200, "{\"lsm_write_spec\":null}");
assertFalse(lsm.getLsmWriteSpec().isPresent());
}
// ===========================================================================
// stats
// ===========================================================================
@Test
public void testGetLsmStats() throws Exception {
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
Optional<LsmStats> got = lsm.getLsmStats(true);
assertEquals("/v1/table/my_table/get_lsm_stats/", requestPaths.get(0));
assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
assertTrue(got.isPresent());
BucketStats decoded = got.get().buckets().get(0);
assertEquals("shard-0", decoded.shardId());
assertEquals("Active", decoded.status());
assertEquals(1, decoded.writerEpoch());
assertEquals(2, decoded.manifestVersion());
assertEquals(9, decoded.currentGeneration());
assertFalse(decoded.compacting());
assertEquals(Arrays.asList(7L, 8L), generationNumbers(decoded));
assertEquals(1024, decoded.generations().get(0).bytes());
assertFalse(decoded.generations().get(0).rows().isPresent(), "rows absent unless requested");
assertFalse(decoded.memtables().isPresent(), "absent memtables stay absent");
}
/** The optional fields decode when the server does send them. */
@Test
public void testGetLsmStatsDecodesOptionalFields() {
enqueue(
"get_lsm_stats",
200,
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":3,\"wal_entry_position_last_seen\":11,"
+ "\"generations\":[{\"generation\":7,\"bytes\":1024,\"rows\":42}],"
+ "\"compacting\":true,\"memtables\":[{\"generation\":8,\"rows\":5,"
+ "\"bytes\":64,\"batches\":2,\"indexes\":[\"id_idx\"]}]}]}}");
BucketStats decoded = lsm.getLsmStats(true).get().buckets().get(0);
assertEquals(3, decoded.replayAfterWalEntryPosition());
assertEquals(11, decoded.walEntryPositionLastSeen());
assertTrue(decoded.compacting());
assertEquals(42, decoded.generations().get(0).rows().getAsLong());
assertTrue(decoded.memtables().isPresent());
MemtableStats memtable = decoded.memtables().get().get(0);
assertEquals(8, memtable.generation());
assertEquals(5, memtable.rows());
assertEquals(64, memtable.bytes());
assertEquals(2, memtable.batches());
assertEquals(Arrays.asList("id_idx"), memtable.indexes());
}
@Test
public void testGetLsmStatsAbsentWhenLsmDisabled() {
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
assertFalse(lsm.getLsmStats().isPresent());
}
@Test
public void testGetLsmStatsDefaultsToExcludingGenerationRows() throws Exception {
enqueue("get_lsm_stats", 200, stats());
lsm.getLsmStats();
assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
}
// ===========================================================================
// flush / compact
// ===========================================================================
@Test
public void testFlushAndCompactRoutes() {
enqueue("flush_lsm", 200, "");
enqueue("compact_lsm", 200, "");
lsm.flushLsm();
lsm.compactLsm();
assertEquals("/v1/table/my_table/flush_lsm/", requestPaths.get(0));
assertEquals("/v1/table/my_table/compact_lsm/", requestPaths.get(1));
}
@Test
public void testHttpErrorCarriesStatus() {
enqueue("flush_lsm", 404, "no such table");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.flushLsm());
assertEquals(404, e.statusCode());
}
// ===========================================================================
// checkpoint
// ===========================================================================
@Test
public void testCheckpointReturnsWhenLsmDisabled() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
lsm.checkpointLsm();
assertEquals(0, countCalls("compact_lsm"), "nothing to compact when the LSM path is off");
}
@Test
public void testCheckpointReturnsWhenNoGenerationsOutstanding() {
enqueue("flush_lsm", 200, "");
// A bucket with no L0 generations yields no target, so the drain never starts.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
assertEquals(0, countCalls("compact_lsm"));
}
@Test
public void testCheckpointConvergesOnceTargetGenerationsAreGone() {
enqueue("flush_lsm", 200, "");
// Watermark read: shard-0 holds generations 7 and 8, so target = 8.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
// First drain poll: both still outstanding, nothing compacting -> dispatch a pass.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
// Second drain poll: drained past the target -> done.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 9L)));
enqueue("compact_lsm", 200, "");
lsm.checkpointLsm();
assertEquals(1, countCalls("compact_lsm"), "one pass dispatched");
assertEquals(3, countCalls("get_lsm_stats"), "watermark read plus two drain polls");
}
@Test
public void testCheckpointDoesNotPileOnWhileEveryTargetBucketIsCompacting() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L)));
// Still compacting on the first poll, so no pass is dispatched; then it drains.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 5L)));
lsm.checkpointLsm();
assertEquals(0, countCalls("compact_lsm"), "a latched bucket is left alone");
}
@Test
public void testCheckpointRetriesFromFlushAfterLostClaim() {
// 421 on the watermark read: the node lost its claim, so the whole thing restarts
// from flush rather than retrying the read in place.
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 421, "no claim");
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
assertEquals(2, countCalls("flush_lsm"), "re-issued from flush");
}
@Test
public void testCheckpointRetriesRetryableStatusInPlace() {
enqueue("flush_lsm", 429, "latch held");
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
assertEquals(2, countCalls("flush_lsm"), "429 retried in place, not re-issued");
}
@Test
public void testCheckpointPropagatesTerminalStatus() {
enqueue("flush_lsm", 400, "bad request");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm());
assertEquals(400, e.statusCode());
assertEquals(1, countCalls("flush_lsm"), "a terminal status is not retried");
}
@Test
public void testCheckpointGivesUpAfterRepeatedLostClaims() {
enqueue("flush_lsm", 421, "no claim");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> lsm.checkpointLsm());
assertTrue(e.getMessage().contains("kept losing its claim"), e.getMessage());
assertEquals(4, countCalls("flush_lsm"), "the initial attempt plus MAX_REISSUES");
}
// ===========================================================================
// strict decoding
// ===========================================================================
/**
* A stats payload that does not decode must fail closed. Every one of these bodies used to be
* read as "no buckets", which is indistinguishable from a drained table, so {@code checkpointLsm}
* reported convergence for a checkpoint that never ran.
*/
@Test
public void testCheckpointRejectsMalformedStats() {
Map<String, String> malformed = new LinkedHashMap<String, String>();
malformed.put("no response body at all", "");
malformed.put("stats object with no buckets", "{\"lsm_stats\":{}}");
malformed.put("bucket missing its required fields", "{\"lsm_stats\":{\"buckets\":[{}]}}");
malformed.put(
"bucket missing generations",
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0,"
+ "\"compacting\":false}]}}");
malformed.put(
"generation with a non-numeric generation number",
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0,"
+ "\"generations\":[{\"generation\":\"7\",\"bytes\":1024}],"
+ "\"compacting\":false}]}}");
for (Map.Entry<String, String> each : malformed.entrySet()) {
setUpFresh();
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, each.getValue());
assertThrows(
IllegalStateException.class,
() -> lsm.checkpointLsm(),
each.getKey() + " must not report convergence");
}
}
/** The one shape that legitimately means "this table has no LSM write path". */
@Test
public void testCheckpointTreatsNullStatsAsNotWalBacked() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
lsm.checkpointLsm();
assertEquals(1, countCalls("get_lsm_stats"));
}
// ===========================================================================
// retry budget
// ===========================================================================
/**
* The transport must not retry on the checkpoint loop's behalf. Apache HttpClient's default
* strategy retries exactly 429 and 503 — the two statuses {@code isRetryable} owns — which
* doubled every budget here and also retried {@code compact_lsm} in place, where the loop is
* built to fall through to a fresh stats poll instead.
*/
@Test
public void testCheckpointRetryBudgetIsNotDoubledByTheTransport() {
enqueue("flush_lsm", 429, "latch held");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm());
assertEquals(429, e.statusCode(), "the exhausted budget propagates the last error as itself");
assertEquals(9, countCalls("flush_lsm"), "the initial request plus MAX_RETRIES, and no more");
}
// ===========================================================================
// harness
// ===========================================================================
private static List<Long> generationNumbers(BucketStats bucket) {
List<Long> numbers = new ArrayList<Long>();
for (GenerationStats generation : bucket.generations()) {
numbers.add(generation.generation());
}
return numbers;
}
/** Build an {@code lsm_stats} response body from bucket fragments. */
private static String stats(String... buckets) {
return "{\"lsm_stats\":{\"buckets\":[" + String.join(",", buckets) + "]}}";
}
private static String bucket(String shardId, boolean compacting, Long... generations) {
StringBuilder gens = new StringBuilder();
for (Long generation : generations) {
if (gens.length() > 0) {
gens.append(",");
}
gens.append("{\"generation\":").append(generation).append(",\"bytes\":1024}");
}
return "{\"shard_id\":\""
+ shardId
+ "\",\"status\":\"Active\",\"writer_epoch\":1,\"manifest_version\":2,"
+ "\"current_generation\":9,\"replay_after_wal_entry_position\":0,"
+ "\"wal_entry_position_last_seen\":0,\"generations\":["
+ gens
+ "],\"compacting\":"
+ compacting
+ "}";
}
/** Queue a reply for an operation. The last queued reply repeats once the queue drains. */
private void enqueue(String operation, int status, String body) {
replies.computeIfAbsent(operation, key -> new ArrayDeque<Reply>()).add(new Reply(status, body));
}
private Reply nextReply(String path) {
String operation = operationOf(path);
Deque<Reply> queued = replies.get(operation);
if (queued == null || queued.isEmpty()) {
return new Reply(200, "");
}
return queued.size() > 1 ? queued.poll() : queued.peek();
}
private long countCalls(String operation) {
return requestPaths.stream().filter(path -> operationOf(path).equals(operation)).count();
}
/** {@code /v1/table/my_table/flush_lsm/} -> {@code flush_lsm}. */
private static String operationOf(String path) {
String[] segments = path.split("/");
return segments.length == 0 ? "" : segments[segments.length - 1];
}
private static String readAll(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
private static final class Reply {
private final int status;
private final String body;
private Reply(int status, String body) {
this.status = status;
this.body = body;
}
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId>
<version>0.37.1-beta.0</version>
<version>0.38.0-beta.2</version>
<packaging>pom</packaging>
<name>${project.artifactId}</name>
<description>LanceDB Java SDK Parent POM</description>
@@ -28,7 +28,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<arrow.version>15.0.0</arrow.version>
<lance-core.version>10.0.0-beta.5</lance-core.version>
<lance-core.version>11.0.0-beta.15</lance-core.version>
<spotless.skip>false</spotless.skip>
<spotless.version>2.30.0</spotless.version>
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "lancedb-nodejs"
edition.workspace = true
version = "0.37.1-beta.0"
version = "0.38.0-beta.2"
publish = false
license.workspace = true
description.workspace = true
+144
View File
@@ -6,7 +6,9 @@ import * as arrow17 from "apache-arrow-17";
import * as arrow18 from "apache-arrow-18";
import {
Vector as CurrentVector,
convertToTable,
tableFromIPC as currentTableFromIPC,
fromBufferToRecordBatch,
fromDataToBuffer,
fromRecordBatchToBuffer,
@@ -19,6 +21,7 @@ import {
FunctionOptions,
} from "../lancedb/embedding/embedding_function";
import { EmbeddingFunctionConfig } from "../lancedb/embedding/registry";
import { sanitizeTable } from "../lancedb/sanitize";
// biome-ignore lint/suspicious/noExplicitAny: skip
function sampleRecords(): Array<Record<string, any>> {
@@ -64,7 +67,11 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
tableFromIPC,
DataType,
Dictionary,
RecordBatch: ArrowRecordBatch,
Table: ArrowTable,
Uint8: ArrowUint8,
makeData: arrowMakeData,
vectorFromArray,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} = <any>arrow;
type Schema = ApacheArrow["Schema"];
@@ -197,6 +204,35 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
expect(table.getChild("d")?.toJSON()).toEqual([9n, 10n, null]);
});
it("will use a provided FixedSizeList schema with typed array values", function () {
const schema = new Schema([
new Field("text", new Utf8(), false),
new Field(
"vector",
new FixedSizeList(3, new Field("item", new Float32(), false)),
false,
),
]);
const table = makeArrowTable(
[
{
text: "foo",
vector: new Float32Array([1, 2, 3]),
},
],
{ schema },
);
expect(table.getChild("text")?.toJSON()).toEqual(["foo"]);
expect(
table
.getChild("vector")
?.toJSON()
.map((value) => value.toJSON()),
).toEqual([[1, 2, 3]]);
});
it("will assume the column `vector` is FixedSizeList<Float32> by default", async function () {
const schema = new Schema([
new Field("a", new Float(Precision.DOUBLE), true),
@@ -1025,6 +1061,114 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
});
describe("when using two versions of arrow", function () {
it("preserves a dictionary shared by multiple fields", async function () {
const values = ["alpha", "beta", "alpha"];
const dictionaryVector = vectorFromArray(values);
const batch = new ArrowRecordBatch({
first: dictionaryVector.data[0],
second: dictionaryVector.data[0],
});
const table = new ArrowTable([batch]);
const sanitized = sanitizeTable(table);
expect([...sanitized.getChild("first")!]).toEqual(values);
expect([...sanitized.getChild("second")!]).toEqual(values);
const firstType = sanitized.schema.fields[0].type as {
dictionary: unknown;
};
const secondType = sanitized.schema.fields[1].type as {
dictionary: unknown;
};
expect(secondType.dictionary).toBe(firstType.dictionary);
expect(sanitized.batches[0].data.children[1].dictionary).toBe(
sanitized.batches[0].data.children[0].dictionary,
);
const buf = await fromDataToBuffer(table);
const actual = currentTableFromIPC(buf);
expect([...actual.getChild("first")!]).toEqual(values);
expect([...actual.getChild("second")!]).toEqual(values);
});
it("preserves shared dictionary data from another Arrow version", async function () {
const values = ["alpha", "beta", "alpha"];
const dictionaryVector = vectorFromArray(values);
const firstBatch = new ArrowRecordBatch({
label: dictionaryVector.slice(0, 2).data[0],
});
const secondBatch = new ArrowRecordBatch({
label: dictionaryVector.slice(2).data[0],
});
const table = new ArrowTable([firstBatch, secondBatch]);
const sanitized = sanitizeTable(table);
expect([...sanitized.getChild("label")!]).toEqual(values);
const dictionaries = sanitized.batches.map(
(batch) => batch.data.children[0].dictionary,
);
expect(dictionaries[0]).toBeInstanceOf(CurrentVector);
expect(dictionaries[1]).toBe(dictionaries[0]);
const buf = await fromDataToBuffer(table);
const actual = currentTableFromIPC(buf);
expect([...actual.getChild("label")!]).toEqual(values);
});
it("preserves shared chunks in growing dictionaries", async function () {
const type = new Dictionary(new Utf8(), new Int32(), 42, false);
const firstDictionary = vectorFromArray(["alpha", "beta"], new Utf8());
const secondDictionary = firstDictionary.concat(
vectorFromArray(["gamma"], new Utf8()),
);
const firstData = arrowMakeData({
type,
data: Int32Array.from([0, 1]),
dictionary: firstDictionary,
});
const secondData = arrowMakeData({
type,
data: Int32Array.from([2]),
dictionary: secondDictionary,
});
const table = new ArrowTable([
new ArrowRecordBatch({ label: firstData }),
new ArrowRecordBatch({ label: secondData }),
]);
const sanitized = sanitizeTable(table);
const expected = ["alpha", "beta", "gamma"];
expect([...sanitized.getChild("label")!]).toEqual(expected);
const firstLocalDictionary =
sanitized.batches[0].data.children[0].dictionary!;
const secondLocalDictionary =
sanitized.batches[1].data.children[0].dictionary!;
expect(secondLocalDictionary.data[0]).toBe(
firstLocalDictionary.data[0],
);
const buf = await fromTableToBuffer(sanitized);
const actual = currentTableFromIPC(buf);
expect([...actual.getChild("label")!]).toEqual(expected);
});
it("can serialize list data from another Arrow version", async function () {
const values = [["anime", "action"], [], null];
const vector = vectorFromArray(
values,
new List(new Field("item", new Utf8(), true)),
);
const table = new ArrowTable({ tags: vector });
const buf = await fromDataToBuffer(table);
const actual = currentTableFromIPC(buf);
const actualTags = actual.getChild("tags");
expect(actualTags?.get(0)?.toJSON()).toEqual(values[0]);
expect(actualTags?.get(1)?.toJSON()).toEqual(values[1]);
expect(actualTags?.get(2)).toBeNull();
});
it("can still import data", async function () {
const schema = new arrow15.Schema([
new arrow15.Field("id", new arrow15.Int32()),
+67 -1
View File
@@ -4,7 +4,13 @@
import { readdirSync } from "fs";
import { Field, Float64, Schema } from "apache-arrow";
import * as tmp from "tmp";
import { Connection, Table, connect, connectNamespace } from "../lancedb";
import {
Connection,
ListTablesResponse,
Table,
connect,
connectNamespace,
} from "../lancedb";
import { LocalTable } from "../lancedb/table";
describe("when connecting", () => {
@@ -89,6 +95,16 @@ describe("given a connection", () => {
await db.createTable("test4", [{ id: 1 }, { id: 2 }]);
});
it("should return a completed job when dropping a local table", async () => {
await db.createTable("async-drop", [{ id: 1 }]);
const job = await db.dropTableAsync("async-drop");
expect(job.id).toBeNull();
await expect(job.status()).resolves.toBe("finished");
await job.wait();
await expect(db.tableNames()).resolves.toEqual([]);
});
it("should fail if creating table twice, unless overwrite is true", async () => {
let tbl = await db.createTable("test", [{ id: 1 }, { id: 2 }]);
await expect(tbl.countRows()).resolves.toBe(2);
@@ -119,6 +135,56 @@ describe("given a connection", () => {
expect(tables).toEqual(["b", "c"]);
});
it("should list tables with a page token", async () => {
const db = await connect(tmpDir.name);
await db.createTable("b", [{ id: 1 }]);
await db.createTable("a", [{ id: 1 }]);
await db.createTable("c", [{ id: 1 }]);
const all = await db.listTables();
expect(all.tables).toEqual(["a", "b", "c"]);
expect(all.pageToken).toBeUndefined();
const first = await db.listTables({ limit: 1 });
expect(first.tables).toEqual(["a"]);
expect(first.pageToken).toBeDefined();
const second = await db.listTables({
limit: 1,
pageToken: first.pageToken,
});
expect(second.tables).toEqual(["b"]);
});
it("should visit every table exactly once when paging", async () => {
const db = await connect(tmpDir.name);
const created = ["a", "b", "c", "d", "e"];
for (const name of created) {
await db.createTable(name, [{ id: 1 }]);
}
const seen: string[] = [];
let pageToken: string | undefined = undefined;
do {
const page: ListTablesResponse = await db.listTables({
limit: 2,
pageToken,
});
seen.push(...page.tables);
pageToken = page.pageToken;
} while (pageToken);
expect(seen.sort()).toEqual(created);
});
it("should reject listTables on a closed connection", async () => {
const db = await connect(tmpDir.name);
db.close();
await expect(db.listTables()).rejects.toThrow("Connection is closed");
});
it("should create tables in v2 mode", async () => {
const db = await connect(tmpDir.name);
const data = [...Array(10000).keys()].map((i) => ({ id: i }));
+60
View File
@@ -11,8 +11,11 @@ import {
Float16,
Float32,
Float64,
Int32,
Schema,
Utf8,
fromDataToBuffer,
tableFromIPC,
} from "../lancedb/arrow";
import { EmbeddingFunction, LanceSchema } from "../lancedb/embedding";
import { getRegistry, register } from "../lancedb/embedding/registry";
@@ -184,6 +187,63 @@ describe("embedding functions", () => {
const vector0 = JSON.parse(JSON.stringify(arr[0].vector));
expect(vector0).toEqual([1, 2, 3]);
});
it("should append generated vectors to a non-nullable schema", async () => {
@register("non_nullable_schema_test")
class MockEmbeddingFunction extends EmbeddingFunction<string> {
ndims() {
return 3;
}
embeddingDataType(): Float {
return new Float64();
}
async computeSourceEmbeddings(data: string[]) {
return data.map(() => [1, 2, 3]);
}
}
const schema = new Schema([
new Field("id", new Int32()),
new Field("text", new Utf8()),
new Field("type", new Utf8()),
new Field(
"vector",
new FixedSizeList(3, new Field("item", new Float64())),
),
]);
const func = new MockEmbeddingFunction();
const db = await connect(tmpDir.name);
const table = await db.createEmptyTable("test_non_nullable", schema, {
embeddingFunction: {
function: func,
sourceColumn: "text",
},
});
const data = [
{ id: 1, text: "Carrot", type: "vegetable" },
{ id: 2, text: "Apple", type: "fruit" },
];
const buffer = await fromDataToBuffer(
data,
undefined,
await table.schema(),
);
const generatedTable = tableFromIPC(buffer);
const vectorField = generatedTable.schema.fields.find(
(field) => field.name === "vector",
);
expect(vectorField?.nullable).toBe(false);
await table.add(data);
const rows = await table.query().toArray();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect([...row.vector]).toEqual([1, 2, 3]);
}
});
it("should error when appending to a table with an unregistered embedding function", async () => {
@register("mock")
class MockEmbeddingFunction extends EmbeddingFunction<string> {
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import packageJson = require("../package.json");
describe("package metadata", () => {
it("requires Node.js type declarations compatible with the runtime", () => {
expect(packageJson.engines.node).toBe(">= 18");
expect(packageJson.peerDependencies["@types/node"]).toBe(">=18");
expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({
optional: true,
});
});
});
+75
View File
@@ -110,6 +110,81 @@ describe("Query outputSchema", () => {
});
});
describe("Search pagination", () => {
let tmpDir: tmp.DirResult;
let table: Table;
beforeEach(async () => {
tmpDir = tmp.dirSync({ unsafeCleanup: true });
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), false),
new Field("text", new Utf8(), false),
new Field(
"vector",
new FixedSizeList(2, new Field("item", new Float32())),
false,
),
]);
const data = makeArrowTable(
[
{ id: 1n, text: "common", vector: [0, 0] },
{ id: 2n, text: "common common", vector: [1, 1] },
{ id: 3n, text: "common common common", vector: [2, 2] },
{ id: 4n, text: "common common common common", vector: [3, 3] },
],
{ schema },
);
table = await db.createTable("test", data);
});
afterEach(() => {
tmpDir.removeCallback();
});
it("applies offset after the vector search limit", async () => {
const allResults = await table
.vectorSearch([0, 0])
.select(["id"])
.limit(4)
.toArray();
const secondPage = await table
.vectorSearch([0, 0])
.select(["id"])
.limit(2)
.offset(2)
.toArray();
expect(allResults).toHaveLength(4);
expect(secondPage).toHaveLength(2);
expect(secondPage.map((row) => row.id)).toEqual(
allResults.slice(2, 4).map((row) => row.id),
);
});
it("applies offset after the full-text search limit", async () => {
await table.createIndex("text", { config: Index.fts() });
const allResults = await table
.search("common", "fts")
.select(["id"])
.limit(4)
.toArray();
const secondPage = await table
.search("common", "fts")
.select(["id"])
.limit(2)
.offset(2)
.toArray();
expect(allResults).toHaveLength(4);
expect(secondPage).toHaveLength(2);
expect(secondPage.map((row) => row.id)).toEqual(
allResults.slice(2, 4).map((row) => row.id),
);
});
});
describe("Query orderBy", () => {
let tmpDir: tmp.DirResult;
let table: Table;
+125
View File
@@ -170,6 +170,38 @@ describe("remote connection", () => {
);
});
it("surfaces JSON server errors from remote table operations", async () => {
await withMockDatabase(
(req, res) => {
const path = req.url ?? "";
if (path.endsWith("/describe/")) {
res.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
name: "broken_table",
version: 1,
schema: { fields: [] },
}),
);
return;
}
if (path.endsWith("/count_rows/")) {
res
.writeHead(400, { "Content-Type": "application/json" })
.end(JSON.stringify({ error: "count rows failed" }));
return;
}
res.writeHead(404).end();
},
async (db) => {
const table = await db.openTable("broken_table");
await expect(table.countRows()).rejects.toThrow("count rows failed");
},
);
});
it("should pass on requested extra headers", async () => {
await withMockDatabase(
(req, res) => {
@@ -877,3 +909,96 @@ describe("remote connection", () => {
});
});
});
describe("remote connection jobs surface", () => {
it("lists, describes, cancels, and reads history", async () => {
const { tableFromArrays, tableToIPC } = await import("apache-arrow");
const eventsTable = tableFromArrays({ state: ["created", "succeeded"] });
const eventsBody = Buffer.from(tableToIPC(eventsTable, "stream"));
await withMockDatabase(
(req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const payload = body.length > 0 ? JSON.parse(body) : {};
if (req.url === "/v1/jobs/list") {
if (payload["page_token"] === undefined) {
res
.writeHead(200, { "Content-Type": "application/json" })
.end(
'{"jobs": [{"job_id": "job-1", "table": "t1", ' +
'"job_type": "create_index", "state": "in_progress", ' +
'"created_at_millis": 1000}], "page_token": "next"}',
);
} else {
res
.writeHead(200, { "Content-Type": "application/json" })
.end(
'{"jobs": [{"job_id": "job-2", "table": "t2", ' +
'"job_type": "create_index", "state": "succeeded", ' +
'"created_at_millis": 2000}]}',
);
}
} else if (req.url === "/v1/jobs/describe") {
if (payload["job_id"] !== "job-1") {
res.writeHead(404).end("no such job");
return;
}
res
.writeHead(200, { "Content-Type": "application/json" })
.end(
'{"job_id": "job-1", "job_type": "create_index", ' +
'"job_state": "FAILED", "creation_ms": 1000, ' +
'"spec": {"column": "vec"}, "failure": {"phase": "execute", ' +
'"message": "worker died", "retryable": true}}',
);
} else if (req.url === "/v1/jobs/cancel") {
if (payload["job_id"] !== "job-1") {
res.writeHead(404).end("no such job");
return;
}
res
.writeHead(200, { "Content-Type": "application/json" })
.end('{"job_id": "job-1"}');
} else if (req.url === "/v1/jobs/query_events") {
res
.writeHead(200, {
"Content-Type": "application/vnd.apache.arrow.stream",
})
.end(eventsBody);
} else {
res.writeHead(404).end();
}
});
},
async (db) => {
const jobs = await db.listJobs();
expect(jobs.map((job) => job.jobId)).toEqual(["job-1", "job-2"]);
expect(jobs[0].state).toEqual("running");
expect(jobs[1].state).toEqual("finished");
const description = await db.getJob("job-1");
expect(description?.state).toEqual("failed");
expect(JSON.parse(description?.specJson ?? "")).toEqual({
column: "vec",
});
expect(description?.failure?.message).toEqual("worker died");
expect(await db.getJob("missing")).toBeNull();
expect(await db.cancelJob("job-1")).toBe(true);
expect(await db.cancelJob("missing")).toBe(false);
const history = await db.jobHistory("job-1");
expect(history.numRows).toEqual(2);
const job = db.job("job-1");
expect(job.id).toEqual("job-1");
expect(await job.status()).toEqual("failed");
await expect(job.wait()).rejects.toThrow("worker died");
},
);
});
});
+169 -2
View File
@@ -86,6 +86,44 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
await expect(table.countRows()).resolves.toBe(3);
});
it("should support a foreign Float64 vector schema end to end", async () => {
const conn = await connect(tmpDir.name);
const schema = new arrow.Schema([
new arrow.Field("resource_id", new arrow.Int32(), false),
new arrow.Field(
"vector",
new arrow.FixedSizeList(
3,
new arrow.Field("value", new arrow.Float64(), true),
),
false,
),
]);
const data = [
{
// biome-ignore lint/style/useNamingConvention: matches the reported schema
resource_id: 0,
vector: [0.1, 0.1, 0.1],
},
];
const resources = await conn.createTable("resources", data, { schema });
const existing = await resources
.query()
.where("resource_id = 0")
.limit(1)
.toArray();
expect(existing).toHaveLength(1);
const matched = await resources
.search(Float64Array.from(data[0].vector))
.limit(1)
.toArray();
expect(matched).toHaveLength(1);
expect(matched[0]["resource_id"]).toBe(0);
});
it("should support branches", async () => {
await table.add([{ id: 1 }]);
expect(await table.countRows()).toBe(1);
@@ -239,8 +277,16 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
},
numIndices: 0,
numRows: 3,
totalBytes: 44,
// Full on-disk size of the two data files, footers and metadata included.
totalBytes: 684,
});
// Index files count toward totalBytes too (only deletion files and
// manifests are excluded).
await table.createIndex("id", { config: Index.btree() });
const statsWithIndex = await table.stats();
expect(statsWithIndex.numIndices).toBe(1);
expect(statsWithIndex.totalBytes).toBeGreaterThan(684);
});
it("should overwrite data if asked", async () => {
@@ -851,7 +897,11 @@ describe("When creating an index", () => {
afterEach(() => tmpDir.removeCallback());
it("should create a vector index on vector columns", async () => {
await tbl.createIndex("vec");
const job = await tbl.createIndexAsync("vec");
expect(job.id).toBeNull();
await job.wait();
// Cancelling a job that already finished succeeds and does nothing.
await job.cancel();
// check index directory
const indexDir = path.join(tmpDir.name, "test.lance", "_indices");
@@ -3290,3 +3340,120 @@ describe("LSM merge insert", () => {
await expect(table.query().useLsm(true).toArray()).rejects.toThrow();
});
});
describe("LSM convergence and stats", () => {
let tmpDir: tmp.DirResult;
beforeEach(() => {
tmpDir = tmp.dirSync({ unsafeCleanup: true });
});
afterEach(() => tmpDir.removeCallback());
async function lsmTable(conn: Connection): Promise<Table> {
const table = await conn.createEmptyTable(
"t",
new arrow.Schema([new arrow.Field("id", new arrow.Utf8(), false)]),
);
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({ specType: "unsharded" });
return table;
}
// These four route through the server that owns the MemWAL, so a local table
// rejects them rather than answering. What is asserted here is that the
// bindings reach the core at all; the behavior against a real endpoint is
// covered by the mocked endpoint tests in rust/lancedb/src/remote/table.rs.
it("rejects flushLsm on a local table", async () => {
const conn = await connect(tmpDir.name);
const table = await lsmTable(conn);
await expect(table.flushLsm()).rejects.toThrow(/not supported/i);
});
it("rejects compactLsm on a local table", async () => {
const conn = await connect(tmpDir.name);
const table = await lsmTable(conn);
await expect(table.compactLsm()).rejects.toThrow(/not supported/i);
});
it("rejects getLsmStats on a local table", async () => {
const conn = await connect(tmpDir.name);
const table = await lsmTable(conn);
await expect(table.getLsmStats()).rejects.toThrow(/not supported/i);
await expect(table.getLsmStats(true)).rejects.toThrow(/not supported/i);
});
it("rejects checkpointLsm on a local table", async () => {
const conn = await connect(tmpDir.name);
const table = await lsmTable(conn);
// checkpointLsm seals first, so it surfaces flushLsm's rejection.
await expect(table.checkpointLsm()).rejects.toThrow(/not supported/i);
});
});
describe("computed columns", () => {
let tmpDir: tmp.DirResult;
beforeEach(() => {
tmpDir = tmp.dirSync({ unsafeCleanup: true });
});
afterEach(() => tmpDir.removeCallback());
it("declares a column and fills it on refresh", async () => {
const db = await connect(tmpDir.name);
const table = await db.createTable("computed", [{ x: 1 }, { x: 2 }]);
await table.addColumns({
computed: [{ name: "doubled", valueSql: "x * 2" }],
});
let rows = await table.query().toArray();
expect(rows.map((r) => r.doubled)).toEqual([null, null]);
const result = await table.refreshColumn("doubled");
expect(result.rowsFilled).toBe(2);
rows = await table.query().toArray();
expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]);
});
it("returns a job handle from refreshColumnAsync", async () => {
const db = await connect(tmpDir.name);
const table = await db.createTable("computed_job", [{ x: 1 }, { x: 2 }]);
await table.addColumns({
computed: [{ name: "doubled", valueSql: "x * 2" }],
});
const job = await table.refreshColumnAsync("doubled");
expect(job.id).toBeNull();
await job.wait();
expect(await job.status()).toBe("finished");
const rows = await table.query().toArray();
expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]);
// Bad input rejects at the call, not through the job.
await expect(table.refreshColumnAsync("x")).rejects.toThrow(
"not a computed column",
);
});
it("fills rows added since the last refresh", async () => {
const db = await connect(tmpDir.name);
const table = await db.createTable("computed_append", [{ x: 1 }]);
await table.addColumns({
computed: [{ name: "doubled", valueSql: "x * 2" }],
});
await table.refreshColumn("doubled");
await table.add([{ x: 5 }]);
const result = await table.refreshColumn("doubled");
expect(result.rowsFilled).toBe(1);
const rows = await table.query().toArray();
expect(rows.map((r) => r.doubled).sort()).toEqual([10, 2]);
});
});
+163
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { tableFromIPC } from "apache-arrow";
import {
Data,
SchemaLike,
@@ -20,13 +21,18 @@ import type {
CreateNamespaceResponse,
DescribeNamespaceResponse,
DropNamespaceResponse,
Job,
JobDescription,
JobInfo,
ListNamespacesResponse,
ListTablesResponse,
} from "./native";
export type {
CreateNamespaceResponse,
DescribeNamespaceResponse,
DropNamespaceResponse,
ListNamespacesResponse,
ListTablesResponse,
};
import { sanitizeTable } from "./sanitize";
import { LocalTable, Table } from "./table";
@@ -124,6 +130,10 @@ export interface OpenTableOptions {
indexCacheSize?: number;
}
/**
* @deprecated Use {@link ListTablesOptions} with {@link Connection.listTables}
* instead.
*/
export interface TableNamesOptions {
/**
* If present, only return names that come lexicographically after the
@@ -137,6 +147,23 @@ export interface TableNamesOptions {
limit?: number;
}
export interface ListTablesOptions {
/**
* Token from a previous response for pagination.
*
* The token is opaque: it carries whatever the database needs to resume, and
* callers should not construct or interpret one.
*/
pageToken?: string;
/**
* An upper bound on how many tables to return.
*
* A page may hold fewer than this and still not be the last one, so continue
* while the response carries a page token rather than while pages are full.
*/
limit?: number;
}
export interface ListNamespacesOptions {
/** Token from a previous response for pagination. */
pageToken?: string;
@@ -221,6 +248,7 @@ export abstract class Connection {
* @param {Partial<TableNamesOptions>} options - options to control the
* paging / start point (backwards compatibility)
*
* @deprecated Use {@link Connection.listTables} instead.
*/
abstract tableNames(options?: Partial<TableNamesOptions>): Promise<string[]>;
/**
@@ -231,12 +259,54 @@ export abstract class Connection {
* @param {Partial<TableNamesOptions>} options - options to control the
* paging / start point
*
* @deprecated Use {@link Connection.listTables} instead.
*/
abstract tableNames(
namespacePath?: string[],
options?: Partial<TableNamesOptions>,
): Promise<string[]>;
/**
* List a page of tables in this database.
*
* Results may be paginated. To retrieve subsequent pages, pass the
* `pageToken` returned by a previous call. A page may be shorter than
* `limit` without being the last one, so walk until the response carries no
* page token:
*
* ```ts
* const names = [];
* let pageToken = undefined;
* do {
* const page = await conn.listTables({ pageToken, limit: 100 });
* names.push(...page.tables);
* pageToken = page.pageToken;
* } while (pageToken);
* ```
*
* @param {Partial<ListTablesOptions>} options - Pagination options
* (`pageToken`, `limit`).
* @returns {Promise<ListTablesResponse>} Table names and an optional token
* for fetching the next page.
*/
abstract listTables(
options?: Partial<ListTablesOptions>,
): Promise<ListTablesResponse>;
/**
* List a page of tables in this database.
*
* @param {string[]} namespacePath - The namespace path to list tables from
* (defaults to root namespace)
* @param {Partial<ListTablesOptions>} options - Pagination options
* (`pageToken`, `limit`).
* @returns {Promise<ListTablesResponse>} Table names and an optional token
* for fetching the next page.
*/
abstract listTables(
namespacePath?: string[],
options?: Partial<ListTablesOptions>,
): Promise<ListTablesResponse>;
/**
* Open a table in the database.
* @param {string} name - The name of the table
@@ -323,6 +393,14 @@ export abstract class Connection {
*/
abstract dropTable(name: string, namespacePath?: string[]): Promise<void>;
/**
* Start dropping a table and return its cleanup job.
*
* The table may become unavailable before its data files are removed. Wait
* on the returned job to know when cleanup has finished.
*/
abstract dropTableAsync(name: string, namespacePath?: string[]): Promise<Job>;
/**
* Drop all tables in the database.
* @param {string[]} namespacePath The namespace path to drop tables from (defaults to root namespace).
@@ -436,6 +514,40 @@ export abstract class Connection {
newName: string,
options?: RenameTableOptions,
): Promise<void>;
/**
* A {@link Job} handle for a server-side job by id.
*
* The handle is constructed without a server round trip; an unknown id
* surfaces when the handle is used. Dropping the handle has no effect on
* the job itself.
*/
abstract job(jobId: string): Job;
/** List server-side jobs across the database's tables. */
abstract listJobs(): Promise<JobInfo[]>;
/**
* Describe a single server-side job by id.
*
* Resolves to `null` when the server has no such job.
*/
abstract getJob(jobId: string): Promise<JobDescription | null>;
/**
* Request cancellation of a server-side job by id.
*
* Resolves to true if the server accepted the cancellation, false if no
* such job exists. Cancelling an already-terminal job is a no-op success.
*/
abstract cancelJob(jobId: string): Promise<boolean>;
/**
* The lifecycle event history of a server-side job, as an Arrow table.
*
* Lists history across all jobs when `jobId` is omitted.
*/
abstract jobHistory(jobId?: string): Promise<ArrowTable>;
}
/** @hideconstructor */
@@ -485,6 +597,29 @@ export class LocalConnection extends Connection {
);
}
async listTables(
namespacePathOrOptions?: string[] | Partial<ListTablesOptions>,
options?: Partial<ListTablesOptions>,
): Promise<ListTablesResponse> {
// Detect if first argument is namespacePath array or options object
let namespacePath: string[] | undefined;
let listTablesOptions: Partial<ListTablesOptions> | undefined;
if (Array.isArray(namespacePathOrOptions)) {
namespacePath = namespacePathOrOptions;
listTablesOptions = options;
} else {
namespacePath = undefined;
listTablesOptions = namespacePathOrOptions;
}
return this.inner.listTables(
namespacePath ?? [],
listTablesOptions?.pageToken,
listTablesOptions?.limit,
);
}
async openTable(
name: string,
namespacePath?: string[],
@@ -667,6 +802,10 @@ export class LocalConnection extends Connection {
return this.inner.dropTable(name, namespacePath ?? []);
}
async dropTableAsync(name: string, namespacePath?: string[]): Promise<Job> {
return this.inner.dropTableAsync(name, namespacePath ?? []);
}
async dropAllTables(namespacePath?: string[]): Promise<void> {
return this.inner.dropAllTables(namespacePath ?? []);
}
@@ -722,6 +861,30 @@ export class LocalConnection extends Connection {
options?.newNamespacePath,
);
}
job(jobId: string): Job {
return this.inner.job(jobId);
}
async listJobs(): Promise<JobInfo[]> {
return this.inner.listJobs();
}
async getJob(jobId: string): Promise<JobDescription | null> {
return this.inner.getJob(jobId);
}
async cancelJob(jobId: string): Promise<boolean> {
return this.inner.cancelJob(jobId);
}
async jobHistory(jobId?: string): Promise<ArrowTable> {
const buf = await this.inner.jobHistory(jobId);
if (buf.length === 0) {
return new ArrowTable();
}
return tableFromIPC(buf);
}
}
/**
+14 -1
View File
@@ -50,6 +50,7 @@ export {
MergeResult,
AddResult,
AddColumnsResult,
RefreshColumnResult,
AlterColumnsResult,
UpdateFieldMetadataResult,
DeleteResult,
@@ -74,18 +75,26 @@ export {
Connection,
CreateTableOptions,
TableNamesOptions,
ListTablesOptions,
OpenTableOptions,
ListNamespacesOptions,
CreateNamespaceOptions,
DropNamespaceOptions,
ListNamespacesResponse,
ListTablesResponse,
CreateNamespaceResponse,
DropNamespaceResponse,
DescribeNamespaceResponse,
RenameTableOptions,
} from "./connection";
export { Session } from "./native.js";
export {
Job,
JobDescription,
JobFailureInfo,
JobInfo,
Session,
} from "./native.js";
export {
ExecutableQuery,
@@ -140,6 +149,10 @@ export {
FtsToken,
TokenizeTableOptions,
LsmWriteSpec,
LsmStats,
BucketStats,
GenerationStats,
MemtableStats,
ColumnAlteration,
FieldMetadataUpdate,
} from "./table";
+174 -29
View File
@@ -9,7 +9,7 @@
// comes from the exact same library instance. This is not always the case
// and so we must sanitize the input to ensure that it is compatible.
import { BufferType, Data } from "apache-arrow";
import { BufferType, Data, Vector } from "apache-arrow";
import type { IntBitWidth, TKeys, TimeBitWidth } from "apache-arrow/type";
import {
Binary,
@@ -74,6 +74,20 @@ import {
Utf8,
} from "./arrow";
type SanitizationContext = {
types: WeakMap<object, DataType>;
vectors: WeakMap<object, Vector>;
data: WeakMap<object, Data<DataType>>;
};
function createSanitizationContext(): SanitizationContext {
return {
types: new WeakMap(),
vectors: new WeakMap(),
data: new WeakMap(),
};
}
export function sanitizeMetadata(
metadataLike?: unknown,
): Map<string, string> | undefined {
@@ -186,6 +200,13 @@ export function sanitizeInterval(typeLike: object) {
}
export function sanitizeList(typeLike: object) {
return sanitizeListWithContext(typeLike, createSanitizationContext());
}
function sanitizeListWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (!("children" in typeLike) || !Array.isArray(typeLike.children)) {
throw Error(
"Expected a List type to have an array-like `children` property",
@@ -194,19 +215,35 @@ export function sanitizeList(typeLike: object) {
if (typeLike.children.length !== 1) {
throw Error("Expected a List type to have exactly one child");
}
return new List(sanitizeField(typeLike.children[0]));
return new List(sanitizeFieldWithContext(typeLike.children[0], context));
}
export function sanitizeStruct(typeLike: object) {
return sanitizeStructWithContext(typeLike, createSanitizationContext());
}
function sanitizeStructWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (!("children" in typeLike) || !Array.isArray(typeLike.children)) {
throw Error(
"Expected a Struct type to have an array-like `children` property",
);
}
return new Struct(typeLike.children.map((child) => sanitizeField(child)));
return new Struct(
typeLike.children.map((child) => sanitizeFieldWithContext(child, context)),
);
}
export function sanitizeUnion(typeLike: object) {
return sanitizeUnionWithContext(typeLike, createSanitizationContext());
}
function sanitizeUnionWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (
!("typeIds" in typeLike) ||
!("mode" in typeLike) ||
@@ -226,7 +263,7 @@ export function sanitizeUnion(typeLike: object) {
typeLike.mode,
// biome-ignore lint/suspicious/noExplicitAny: skip
typeLike.typeIds as any,
typeLike.children.map((child) => sanitizeField(child)),
typeLike.children.map((child) => sanitizeFieldWithContext(child, context)),
);
}
@@ -234,6 +271,19 @@ export function sanitizeTypedUnion(
typeLike: object,
// eslint-disable-next-line @typescript-eslint/naming-convention
UnionType: typeof DenseUnion | typeof SparseUnion,
) {
return sanitizeTypedUnionWithContext(
typeLike,
UnionType,
createSanitizationContext(),
);
}
function sanitizeTypedUnionWithContext(
typeLike: object,
// eslint-disable-next-line @typescript-eslint/naming-convention
UnionType: typeof DenseUnion | typeof SparseUnion,
context: SanitizationContext,
) {
if (!("typeIds" in typeLike)) {
throw Error(
@@ -248,7 +298,7 @@ export function sanitizeTypedUnion(
return new UnionType(
typeLike.typeIds as Int32Array | number[],
typeLike.children.map((child) => sanitizeField(child)),
typeLike.children.map((child) => sanitizeFieldWithContext(child, context)),
);
}
@@ -262,6 +312,16 @@ export function sanitizeFixedSizeBinary(typeLike: object) {
}
export function sanitizeFixedSizeList(typeLike: object) {
return sanitizeFixedSizeListWithContext(
typeLike,
createSanitizationContext(),
);
}
function sanitizeFixedSizeListWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (!("listSize" in typeLike) || typeof typeLike.listSize !== "number") {
throw Error("Expected a FixedSizeList type to have a `listSize` property");
}
@@ -275,11 +335,18 @@ export function sanitizeFixedSizeList(typeLike: object) {
}
return new FixedSizeList(
typeLike.listSize,
sanitizeField(typeLike.children[0]),
sanitizeFieldWithContext(typeLike.children[0], context),
);
}
export function sanitizeMap(typeLike: object) {
return sanitizeMapWithContext(typeLike, createSanitizationContext());
}
function sanitizeMapWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (!("children" in typeLike) || !Array.isArray(typeLike.children)) {
throw Error(
"Expected a Map type to have an array-like `children` property",
@@ -292,7 +359,10 @@ export function sanitizeMap(typeLike: object) {
throw Error("Expected a Map type to have exactly one child");
}
return new Map_(sanitizeField(typeLike.children[0]), typeLike.keysSorted);
return new Map_(
sanitizeFieldWithContext(typeLike.children[0], context),
typeLike.keysSorted,
);
}
export function sanitizeDuration(typeLike: object) {
@@ -303,6 +373,13 @@ export function sanitizeDuration(typeLike: object) {
}
export function sanitizeDictionary(typeLike: object) {
return sanitizeDictionaryWithContext(typeLike, createSanitizationContext());
}
function sanitizeDictionaryWithContext(
typeLike: object,
context: SanitizationContext,
) {
if (!("id" in typeLike) || typeof typeLike.id !== "number") {
throw Error("Expected a Dictionary type to have an `id` property");
}
@@ -316,8 +393,8 @@ export function sanitizeDictionary(typeLike: object) {
throw Error("Expected a Dictionary type to have an `isOrdered` property");
}
return new Dictionary(
sanitizeType(typeLike.dictionary),
sanitizeType(typeLike.indices) as TKeys,
sanitizeTypeWithContext(typeLike.dictionary, context),
sanitizeTypeWithContext(typeLike.indices, context) as TKeys,
typeLike.id,
typeLike.isOrdered,
);
@@ -325,12 +402,23 @@ export function sanitizeDictionary(typeLike: object) {
// biome-ignore lint/suspicious/noExplicitAny: skip
export function sanitizeType(typeLike: unknown): DataType<any> {
return sanitizeTypeWithContext(typeLike, createSanitizationContext());
}
function sanitizeTypeWithContext(
typeLike: unknown,
context: SanitizationContext,
): DataType {
if (typeof typeLike === "string") {
return dataTypeFromName(typeLike);
}
if (typeof typeLike !== "object" || typeLike === null) {
throw Error("Expected a Type but object was null/undefined");
}
const cached = context.types.get(typeLike);
if (cached !== undefined) {
return cached;
}
if (
!("typeId" in typeLike) ||
!(
@@ -349,6 +437,16 @@ export function sanitizeType(typeLike: unknown): DataType<any> {
throw Error("Type's typeId property was not a function or number");
}
const type = sanitizeTypeById(typeLike, typeId, context);
context.types.set(typeLike, type);
return type;
}
function sanitizeTypeById(
typeLike: object,
typeId: Type,
context: SanitizationContext,
): DataType {
switch (typeId) {
case Type.NONE:
throw Error("Received a Type with a typeId of NONE");
@@ -375,21 +473,21 @@ export function sanitizeType(typeLike: unknown): DataType<any> {
case Type.Interval:
return sanitizeInterval(typeLike);
case Type.List:
return sanitizeList(typeLike);
return sanitizeListWithContext(typeLike, context);
case Type.Struct:
return sanitizeStruct(typeLike);
return sanitizeStructWithContext(typeLike, context);
case Type.Union:
return sanitizeUnion(typeLike);
return sanitizeUnionWithContext(typeLike, context);
case Type.FixedSizeBinary:
return sanitizeFixedSizeBinary(typeLike);
case Type.FixedSizeList:
return sanitizeFixedSizeList(typeLike);
return sanitizeFixedSizeListWithContext(typeLike, context);
case Type.Map:
return sanitizeMap(typeLike);
return sanitizeMapWithContext(typeLike, context);
case Type.Duration:
return sanitizeDuration(typeLike);
case Type.Dictionary:
return sanitizeDictionary(typeLike);
return sanitizeDictionaryWithContext(typeLike, context);
case Type.Int8:
return new Int8();
case Type.Int16:
@@ -433,9 +531,9 @@ export function sanitizeType(typeLike: unknown): DataType<any> {
case Type.TimestampSecond:
return sanitizeTypedTimestamp(typeLike, TimestampSecond);
case Type.DenseUnion:
return sanitizeTypedUnion(typeLike, DenseUnion);
return sanitizeTypedUnionWithContext(typeLike, DenseUnion, context);
case Type.SparseUnion:
return sanitizeTypedUnion(typeLike, SparseUnion);
return sanitizeTypedUnionWithContext(typeLike, SparseUnion, context);
case Type.IntervalDayTime:
return new IntervalDayTime();
case Type.IntervalYearMonth:
@@ -454,6 +552,13 @@ export function sanitizeType(typeLike: unknown): DataType<any> {
}
export function sanitizeField(fieldLike: unknown): Field {
return sanitizeFieldWithContext(fieldLike, createSanitizationContext());
}
function sanitizeFieldWithContext(
fieldLike: unknown,
context: SanitizationContext,
): Field {
if (fieldLike instanceof Field) {
return fieldLike;
}
@@ -471,7 +576,7 @@ export function sanitizeField(fieldLike: unknown): Field {
}
let type: DataType;
try {
type = sanitizeType(fieldLike.type);
type = sanitizeTypeWithContext(fieldLike.type, context);
} catch (error: unknown) {
throw Error(
`Unable to sanitize type for field: ${fieldLike.name} due to error: ${error}`,
@@ -501,6 +606,13 @@ export function sanitizeField(fieldLike: unknown): Field {
* than lancedb is using.
*/
export function sanitizeSchema(schemaLike: SchemaLike): Schema {
return sanitizeSchemaWithContext(schemaLike, createSanitizationContext());
}
function sanitizeSchemaWithContext(
schemaLike: SchemaLike,
context: SanitizationContext,
): Schema {
if (schemaLike instanceof Schema) {
return schemaLike;
}
@@ -522,7 +634,7 @@ export function sanitizeSchema(schemaLike: SchemaLike): Schema {
);
}
const sanitizedFields = schemaLike.fields.map((field) =>
sanitizeField(field),
sanitizeFieldWithContext(field, context),
);
return new Schema(sanitizedFields, metadata);
}
@@ -544,13 +656,18 @@ export function sanitizeTable(tableLike: TableLike): Table {
"The table passed in does not appear to be a table (no 'columns' property)",
);
}
const schema = sanitizeSchema(tableLike.schema);
const batches = tableLike.batches.map(sanitizeRecordBatch);
const context = createSanitizationContext();
const schema = sanitizeSchemaWithContext(tableLike.schema, context);
const batches = tableLike.batches.map((batch) =>
sanitizeRecordBatch(batch, context),
);
return new Table(schema, batches);
}
function sanitizeRecordBatch(batchLike: RecordBatchLike): RecordBatch {
function sanitizeRecordBatch(
batchLike: RecordBatchLike,
context: SanitizationContext,
): RecordBatch {
if (batchLike instanceof RecordBatch) {
return batchLike;
}
@@ -567,19 +684,43 @@ function sanitizeRecordBatch(batchLike: RecordBatchLike): RecordBatch {
"The record batch passed in does not appear to be a record batch (no 'data' property)",
);
}
const schema = sanitizeSchema(batchLike.schema);
const data = sanitizeData(batchLike.data);
const schema = sanitizeSchemaWithContext(batchLike.schema, context);
const data = sanitizeData(batchLike.data, context) as Data<Struct>;
return new RecordBatch(schema, data);
}
type DictionaryVectorLike = {
data: readonly DataLike[];
};
type DictionaryDataLike = DataLike & {
dictionary?: DictionaryVectorLike;
};
function sanitizeData(
dataLike: DataLike,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
): import("apache-arrow").Data<Struct<any>> {
context: SanitizationContext,
): Data<DataType> {
if (dataLike instanceof Data) {
return dataLike;
}
return new Data(
dataLike.type,
const cachedData = context.data.get(dataLike);
if (cachedData !== undefined) {
return cachedData;
}
const dictionaryLike = (dataLike as DictionaryDataLike).dictionary;
let dictionary: Vector | undefined;
if (dictionaryLike !== undefined) {
dictionary = context.vectors.get(dictionaryLike);
if (dictionary === undefined) {
dictionary = new Vector(
dictionaryLike.data.map((data) => sanitizeData(data, context)),
);
context.vectors.set(dictionaryLike, dictionary);
}
}
const data = new Data(
sanitizeTypeWithContext(dataLike.type, context),
dataLike.offset,
dataLike.length,
dataLike.nullCount,
@@ -589,7 +730,11 @@ function sanitizeData(
[BufferType.VALIDITY]: dataLike.nullBitmap,
[BufferType.TYPE]: dataLike.typeIds,
},
dataLike.children.map((child) => sanitizeData(child, context)),
dictionary,
);
context.data.set(dataLike, data);
return data;
}
const constructorsByTypeName = {
+202 -6
View File
@@ -30,8 +30,11 @@ import {
DropColumnsResult,
IndexConfig,
IndexStatistics,
Job,
LsmStats,
Branches as NativeBranches,
OptimizeStats,
RefreshColumnResult,
TableStatistics,
Tags,
UpdateFieldMetadataResult,
@@ -48,6 +51,12 @@ import {
import { sanitizeType } from "./sanitize";
import { IntoSql, toSQL } from "./util";
export { IndexConfig } from "./native";
export {
BucketStats,
GenerationStats,
LsmStats,
MemtableStats,
} from "./native";
/**
* Progress snapshot for a write operation, delivered to the `progress`
@@ -196,7 +205,11 @@ export interface LsmWriteSpec {
column?: string;
/** Bucket variant: the number of buckets, in `[1, 1024]`. */
numBuckets?: number;
/** Names of indexes the MemWAL should keep up to date during writes. */
/**
* Indexes the MemWAL keeps up to date. Omit to maintain every supported
* index, resolved on install — a snapshot, so indexes created later are not
* maintained. Pass `[]` for none.
*/
maintainedIndexes?: string[];
/** Default `ShardWriter` configuration recorded in the MemWAL index. */
writerConfigDefaults?: Record<string, string>;
@@ -358,6 +371,17 @@ export abstract class Table {
options?: Partial<IndexOptions>,
): Promise<void>;
/**
* Create an index, returning a handle to the indexing job.
*
* The job may already be complete when returned; callers must not assume
* the index exists until {@link Job.wait} resolves.
*/
abstract createIndexAsync(
column: string,
options?: Partial<IndexOptions>,
): Promise<Job>;
/**
* Drop an index from the table.
*
@@ -509,18 +533,75 @@ export abstract class Table {
abstract vectorSearch(vector: IntoVector | MultiVector): VectorQuery;
/**
* Add new columns with defined values.
*
* The `{ computed }` form stores the expression rather than evaluating it
* now: the column is committed with no values, and rows get them from
* {@link Table#refreshColumn}. Declaring one therefore costs the same on a
* large table as on an empty one.
*
* A refresh does not revisit rows it has already filled, so mutating an
* input leaves the value computed at fill time; recomputing means dropping
* the column and declaring it again. While a declaration reads a column,
* that column cannot be renamed, retyped or dropped.
*
* On LanceDB Cloud and Enterprise the expression is planned by the
* server, and the refresh runs as a server job -- see
* {@link Table#refreshColumnAsync}.
* @param {AddColumnsSql[] | Field | Field[] | Schema} newColumnTransforms Either:
* - An array of objects with column names and SQL expressions to calculate values
* - A single Arrow Field defining one column with its data type (column will be initialized with null values)
* - An array of Arrow Fields defining columns with their data types (columns will be initialized with null values)
* - An Arrow Schema defining columns with their data types (columns will be initialized with null values)
* - `{ computed }`, declaring columns defined by a SQL expression whose type and inputs are derived from it
* @returns {Promise<AddColumnsResult>} A promise that resolves to an object
* containing the new version number of the table after adding the columns.
* @example
* ```ts
* await table.addColumns({ computed: [{ name: "doubled", valueSql: "x * 2" }] });
* const { rowsFilled } = await table.refreshColumn("doubled");
* ```
*/
abstract addColumns(
newColumnTransforms: AddColumnsSql[] | Field | Field[] | Schema,
newColumnTransforms:
| AddColumnsSql[]
| Field
| Field[]
| Schema
| { computed: AddColumnsSql[] },
): Promise<AddColumnsResult>;
/**
* Fill the rows of a computed column that hold no value yet.
*
* Rows appended since the last refresh are filled by the next one; rows
* already filled are left as they are, so the call is idempotent and does
* not observe a mutated input. Local tables only: a remote refresh runs
* as a server job, through {@link Table#refreshColumnAsync}.
* @param {string} column The name of the computed column to fill.
* @returns {Promise<RefreshColumnResult>} A promise that resolves to the
* number of rows filled and the new version number of the table.
*/
abstract refreshColumn(column: string): Promise<RefreshColumnResult>;
/**
* Like {@link Table#refreshColumn}, but returns a handle to the refresh
* job instead of blocking until it completes.
*
* The job may already be complete when returned; callers must not assume
* the column is filled until {@link Job.wait} resolves. Invalid input --
* an unknown column, or one that is not computed -- rejects here rather
* than failing the job. On local tables the job runs in-process; on
* LanceDB Cloud and Enterprise it is the server's backfill job.
* @param {string} column The name of the computed column to fill.
* @example
* ```ts
* const job = await table.refreshColumnAsync("doubled");
* await job.wait();
* console.log(await job.status()); // "finished"
* ```
*/
abstract refreshColumnAsync(column: string): Promise<Job>;
/**
* Alter the name or nullability of columns.
* @param {ColumnAlteration[]} columnAlterations One or more alterations to
@@ -583,6 +664,11 @@ export abstract class Table {
* All variants require the table to have an unenforced primary key
* ({@link Table#setUnenforcedPrimaryKey}); bucket sharding additionally
* requires it to be the single column being bucketed.
*
* Omitting `maintainedIndexes` maintains every index on the table, resolved
* here, failing if one cannot be maintained — name them to install anyway.
* Naming them pins an exact set, and a still-building index is rejected
* rather than quietly omitted.
* @param {LsmWriteSpec} spec The sharding spec to install.
* @returns {Promise<void>}
* @example
@@ -610,9 +696,10 @@ export abstract class Table {
*
* Resolves to `undefined` when the MemWAL LSM write path is not enabled (no
* spec has been set, or it was removed with {@link Table#unsetLsmWriteSpec}).
* The returned spec — including its `maintainedIndexes` and
* `writerConfigDefaults` — mirrors what was passed to
* {@link Table#setLsmWriteSpec}.
* The returned spec mirrors what was passed to
* {@link Table#setLsmWriteSpec}, except that `maintainedIndexes` always
* reports the concrete list resolved when the spec was set — `undefined`
* never round-trips.
* @returns {Promise<LsmWriteSpec | undefined>}
*/
abstract getLsmWriteSpec(): Promise<LsmWriteSpec | undefined>;
@@ -626,6 +713,59 @@ export abstract class Table {
* @returns {Promise<void>}
*/
abstract closeLsmWriters(): Promise<void>;
/**
* Seal every bucket's active memtable into a new L0 generation.
*
* Returns once the seal is committed. Sealing an empty memtable is a no-op,
* so this is safe to call repeatedly.
* @returns {Promise<void>}
*/
abstract flushLsm(): Promise<void>;
/**
* Trigger a background L0 → base compaction pass per bucket.
*
* Returns once the passes are *dispatched*, not once they finish — watch
* {@link Table#getLsmStats} for progress, or use
* {@link Table#checkpointLsm} to wait for convergence.
* @returns {Promise<void>}
*/
abstract compactLsm(): Promise<void>;
/**
* Converge this table's LSM write path into its base table.
*
* Seals once, then triggers compaction and polls until the L0 that existed
* at the start is gone. The target set is fixed at the start, so
* generations created *during* the checkpoint are ignored — that is what
* lets it terminate under write load, and what makes it best-effort: it
* converges the fresh tier as of some instant. Idempotent, abandonable at
* any point, and safe to run on a cadence.
*
* There is no liveness bound — the compactor pool is shared across tables,
* so a checkpoint queued behind unrelated work looks exactly like one that
* is merging. The caller owns the deadline.
* @returns {Promise<void>}
* @example
* ```ts
* const before = await table.getLsmStats();
* await table.checkpointLsm();
* const after = await table.getLsmStats();
* ```
*/
abstract checkpointLsm(): Promise<void>;
/**
* Read live per-bucket LSM state.
*
* Answers "how far behind is my fresh tier", "which bucket is hot", and
* "why is my fresh-tier vector search brute-force". Mutates no table state.
*
* Resolves to `undefined` only when the LSM write path is not enabled.
* @param {boolean} includeGenerationRows Also count rows per L0 generation.
* Off by default because each count opens an uncached Lance dataset.
* @returns {Promise<LsmStats | undefined>}
*/
abstract getLsmStats(
includeGenerationRows?: boolean,
): Promise<LsmStats | undefined>;
/** Retrieve the version of the table */
abstract version(): Promise<number>;
@@ -940,6 +1080,22 @@ export class LocalTable extends Table {
);
}
async createIndexAsync(
column: string,
options?: Partial<IndexOptions>,
): Promise<Job> {
// biome-ignore lint/suspicious/noExplicitAny: skip
const nativeIndex = (options?.config as any)?.inner;
return await this.inner.createIndexAsync(
nativeIndex,
column,
options?.replace,
options?.waitTimeoutSeconds,
options?.name,
options?.train,
);
}
async dropIndex(name: string): Promise<void> {
await this.inner.dropIndex(name);
}
@@ -1050,8 +1206,22 @@ export class LocalTable extends Table {
// TODO: Support BatchUDF
async addColumns(
newColumnTransforms: AddColumnsSql[] | Field | Field[] | Schema,
newColumnTransforms:
| AddColumnsSql[]
| Field
| Field[]
| Schema
| { computed: AddColumnsSql[] },
): Promise<AddColumnsResult> {
// Columns defined by an expression are declared, not materialized here.
if (
typeof newColumnTransforms === "object" &&
!Array.isArray(newColumnTransforms) &&
"computed" in newColumnTransforms
) {
return await this.inner.addComputedColumns(newColumnTransforms.computed);
}
// Handle single Field -> convert to array of Fields
if (newColumnTransforms instanceof Field) {
newColumnTransforms = [newColumnTransforms];
@@ -1086,6 +1256,14 @@ export class LocalTable extends Table {
throw new Error("Invalid input type for addColumns");
}
async refreshColumn(column: string): Promise<RefreshColumnResult> {
return await this.inner.refreshColumn(column);
}
async refreshColumnAsync(column: string): Promise<Job> {
return await this.inner.refreshColumnAsync(column);
}
async alterColumns(
columnAlterations: ColumnAlteration[],
): Promise<AlterColumnsResult> {
@@ -1148,6 +1326,24 @@ export class LocalTable extends Table {
return await this.inner.closeLsmWriters();
}
async flushLsm(): Promise<void> {
return await this.inner.flushLsm();
}
async compactLsm(): Promise<void> {
return await this.inner.compactLsm();
}
async checkpointLsm(): Promise<void> {
return await this.inner.checkpointLsm();
}
async getLsmStats(
includeGenerationRows: boolean = false,
): Promise<LsmStats | undefined> {
return (await this.inner.getLsmStats(includeGenerationRows)) ?? undefined;
}
async version(): Promise<number> {
return await this.inner.version();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-darwin-arm64",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["darwin"],
"cpu": ["arm64"],
"main": "lancedb.darwin-arm64.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-arm64-gnu",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["linux"],
"cpu": ["arm64"],
"main": "lancedb.linux-arm64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-arm64-musl",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["linux"],
"cpu": ["arm64"],
"main": "lancedb.linux-arm64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-x64-gnu",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["linux"],
"cpu": ["x64"],
"main": "lancedb.linux-x64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-x64-musl",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["linux"],
"cpu": ["x64"],
"main": "lancedb.linux-x64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-win32-arm64-msvc",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": [
"win32"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-win32-x64-msvc",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"os": ["win32"],
"cpu": ["x64"],
"main": "lancedb.win32-x64-msvc.node",
+8 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lancedb/lancedb",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lancedb/lancedb",
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"cpu": [
"x64",
"arm64"
@@ -55,7 +55,13 @@
"openai": "4.29.2"
},
"peerDependencies": {
"@types/node": ">=18",
"apache-arrow": ">=15.0.0 <=18.1.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@aws-crypto/crc32": {
+7 -1
View File
@@ -11,7 +11,7 @@
"ann"
],
"private": false,
"version": "0.37.1-beta.0",
"version": "0.38.0-beta.2",
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
@@ -101,6 +101,12 @@
"openai": "4.29.2"
},
"peerDependencies": {
"@types/node": ">=18",
"apache-arrow": ">=15.0.0 <=18.1.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
}
+110
View File
@@ -36,6 +36,12 @@ pub struct ListNamespacesResponse {
pub page_token: Option<String>,
}
#[napi(object)]
pub struct ListTablesResponse {
pub tables: Vec<String>,
pub page_token: Option<String>,
}
#[napi(object)]
pub struct CreateNamespaceResponse {
pub properties: Option<HashMap<String, String>>,
@@ -189,6 +195,8 @@ impl Connection {
/// List all tables in the dataset.
#[napi(catch_unwind)]
// Deprecated in favour of `list_tables`, but still exposed to JavaScript.
#[allow(deprecated)]
pub async fn table_names(
&self,
namespace_path: Option<Vec<String>>,
@@ -206,6 +214,29 @@ impl Connection {
op.execute().await.default_error()
}
/// List a page of tables in the database.
#[napi(catch_unwind)]
pub async fn list_tables(
&self,
namespace_path: Option<Vec<String>>,
page_token: Option<String>,
limit: Option<u32>,
) -> napi::Result<ListTablesResponse> {
let mut op = self.get_inner()?.list_tables();
op = op.namespace(namespace_path.unwrap_or_default());
if let Some(page_token) = page_token {
op = op.page_token(page_token);
}
if let Some(limit) = limit {
op = op.limit(limit);
}
let resp = op.execute().await.default_error()?;
Ok(ListTablesResponse {
tables: resp.tables,
page_token: resp.page_token,
})
}
/// Create table from a Apache Arrow IPC (file) buffer.
///
/// Parameters:
@@ -334,12 +365,91 @@ impl Connection {
.default_error()
}
/// Start dropping a table and return its cleanup job.
#[napi(catch_unwind)]
pub async fn drop_table_async(
&self,
name: String,
namespace_path: Option<Vec<String>>,
) -> napi::Result<crate::job::Job> {
let ns = namespace_path.unwrap_or_default();
let job = self
.get_inner()?
.drop_table_async(&name, &ns)
.await
.default_error()?;
Ok(crate::job::Job::new(job))
}
#[napi(catch_unwind)]
pub async fn drop_all_tables(&self, namespace_path: Option<Vec<String>>) -> napi::Result<()> {
let ns = namespace_path.unwrap_or_default();
self.get_inner()?.drop_all_tables(&ns).await.default_error()
}
/// A `Job` handle for a server-side job by id.
///
/// The handle is constructed without a server round trip; an unknown id
/// surfaces when the handle is used.
#[napi]
pub fn job(&self, job_id: String) -> napi::Result<crate::job::Job> {
let job = self.get_inner()?.job(job_id).default_error()?;
Ok(crate::job::Job::new(job))
}
/// List server-side jobs across the database's tables.
#[napi(catch_unwind)]
pub async fn list_jobs(&self) -> napi::Result<Vec<crate::job::JobInfo>> {
let jobs = self.get_inner()?.list_jobs().await.default_error()?;
Ok(jobs.into_iter().map(Into::into).collect())
}
/// Describe a single server-side job by id. `null` when the server has
/// no such job.
#[napi(catch_unwind)]
pub async fn get_job(
&self,
job_id: String,
) -> napi::Result<Option<crate::job::JobDescription>> {
let description = self.get_inner()?.get_job(&job_id).await.default_error()?;
Ok(description.map(Into::into))
}
/// Request cancellation of a server-side job by id. Returns true if the
/// server accepted the cancellation, false if no such job exists.
#[napi(catch_unwind)]
pub async fn cancel_job(&self, job_id: String) -> napi::Result<bool> {
self.get_inner()?.cancel_job(&job_id).await.default_error()
}
/// The lifecycle event history of a server-side job (all jobs when
/// `job_id` is null), as an Arrow IPC stream buffer. Empty when there is
/// no history.
#[napi(catch_unwind)]
pub async fn job_history(&self, job_id: Option<String>) -> napi::Result<Buffer> {
let batches = self
.get_inner()?
.job_history(job_id.as_deref())
.await
.default_error()?;
let Some(first) = batches.first() else {
return Ok(Buffer::from(Vec::<u8>::new()));
};
let mut out = Vec::new();
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema())
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
for batch in &batches {
writer
.write(batch)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
}
writer
.finish()
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
drop(writer);
Ok(Buffer::from(out))
}
#[napi(catch_unwind)]
/// Describe a namespace and return its properties.
pub async fn describe_namespace(
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use std::sync::Arc;
use napi_derive::napi;
use crate::error::NapiErrorExt;
/// A handle to an operation that may still be running.
#[napi]
pub struct Job {
inner: Arc<lancedb::Job>,
}
impl Job {
pub(crate) fn new(inner: lancedb::Job) -> Self {
Self {
inner: Arc::new(inner),
}
}
}
#[napi]
impl Job {
/// Identifies the operation on the server that is running it. Operations
/// that run in this process have no server id. The value is opaque.
#[napi(getter)]
pub fn id(&self) -> Option<String> {
self.inner.id().map(str::to_string)
}
/// The operation's current lifecycle state: "running", "finished",
/// "failed", or "cancelled".
///
/// A point snapshot; unlike {@link Job.wait} it does not block or reject
/// on a terminal failure state. States a newer server reports that this
/// client version does not know pass through as-is.
#[napi(catch_unwind)]
pub async fn status(&self) -> napi::Result<String> {
self.inner.status().await.default_error()
}
/// Wait until the operation reaches a terminal state.
#[napi(catch_unwind)]
pub async fn wait(&self) -> napi::Result<()> {
self.inner.wait().await.default_error()
}
/// Request cancellation. Cancelling a finished operation is a no-op.
#[napi(catch_unwind)]
pub async fn cancel(&self) -> napi::Result<()> {
self.inner.cancel().await.default_error()
}
}
/// A row from `Connection.listJobs`: one server-side job.
#[napi(object)]
pub struct JobInfo {
/// The job id -- what `Connection.getJob` and `Connection.cancelJob`
/// accept.
pub job_id: String,
/// The table the job runs against, without URI or namespace.
pub table: String,
pub job_type: String,
/// Lifecycle state: "running", "finished", "failed", or "cancelled".
pub state: String,
/// When the job was created, in milliseconds since the epoch.
pub created_at_millis: i64,
}
impl From<lancedb::database::JobInfo> for JobInfo {
fn from(info: lancedb::database::JobInfo) -> Self {
Self {
job_id: info.job_id,
table: info.table,
job_type: info.job_type,
state: info.state,
created_at_millis: info.created_at_millis,
}
}
}
/// The server's account of why a job failed.
#[napi(object)]
pub struct JobFailureInfo {
pub phase: Option<String>,
pub message: Option<String>,
pub retryable: Option<bool>,
}
/// A described job from `Connection.getJob`.
#[napi(object)]
pub struct JobDescription {
pub job_id: String,
pub job_type: String,
/// Lifecycle state: "running", "finished", "failed", or "cancelled".
pub state: String,
/// When the job was created, in milliseconds since the epoch.
pub creation_ms: i64,
/// The job-type-specific specification as a JSON string, when present.
pub spec_json: Option<String>,
/// Why the job failed, when the job is failed and the server reports a
/// reason.
pub failure: Option<JobFailureInfo>,
}
impl From<lancedb::database::JobDescription> for JobDescription {
fn from(description: lancedb::database::JobDescription) -> Self {
Self {
job_id: description.job_id,
job_type: description.job_type,
state: description.state,
creation_ms: description.creation_ms,
spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()),
failure: description.failure.map(|failure| JobFailureInfo {
phase: failure.phase,
message: failure.message,
retryable: failure.retryable,
}),
}
}
}
+1
View File
@@ -11,6 +11,7 @@ mod error;
mod header;
mod index;
mod iterator;
mod job;
pub mod merge;
pub mod otel;
pub mod permutation;
+249 -9
View File
@@ -168,6 +168,39 @@ impl Table {
builder.execute().await.default_error()
}
#[napi(catch_unwind)]
pub async fn create_index_async(
&self,
index: Option<&Index>,
column: String,
replace: Option<bool>,
wait_timeout_s: Option<i64>,
name: Option<String>,
train: Option<bool>,
) -> napi::Result<crate::job::Job> {
let lancedb_index = if let Some(index) = index {
index.consume()?
} else {
lancedb::index::Index::Auto
};
let mut builder = self.inner_ref()?.create_index(&[column], lancedb_index);
if let Some(replace) = replace {
builder = builder.replace(replace);
}
if let Some(timeout) = wait_timeout_s {
builder =
builder.wait_timeout(std::time::Duration::from_secs(timeout.try_into().unwrap()));
}
if let Some(name) = name {
builder = builder.name(name);
}
if let Some(train) = train {
builder = builder.train(train);
}
let job = builder.execute_async().await.default_error()?;
Ok(crate::job::Job::new(job))
}
#[napi(catch_unwind)]
pub async fn drop_index(&self, index_name: String) -> napi::Result<()> {
self.inner_ref()?
@@ -306,12 +339,48 @@ impl Table {
let transforms = NewColumnTransform::SqlExpressions(transforms);
let res = self
.inner_ref()?
.add_columns(transforms, None)
.add_columns()
.transform(transforms)
.execute()
.await
.default_error()?;
Ok(res.into())
}
#[napi(catch_unwind)]
pub async fn add_computed_columns(
&self,
columns: Vec<AddColumnsSql>,
) -> napi::Result<AddColumnsResult> {
let table = self.inner_ref()?;
let mut builder = table.add_columns();
for column in columns {
builder = builder.computed(column.name, column.value_sql);
}
let res = builder.execute().await.default_error()?;
Ok(res.into())
}
#[napi(catch_unwind)]
pub async fn refresh_column(&self, column: String) -> napi::Result<RefreshColumnResult> {
let res = self
.inner_ref()?
.refresh_column(column)
.await
.default_error()?;
Ok(res.into())
}
#[napi(catch_unwind)]
pub async fn refresh_column_async(&self, column: String) -> napi::Result<crate::job::Job> {
let job = self
.inner_ref()?
.refresh_column_async(column)
.await
.default_error()?;
Ok(crate::job::Job::new(job))
}
#[napi(catch_unwind)]
pub async fn add_columns_with_schema(
&self,
@@ -323,7 +392,9 @@ impl Table {
let transforms = NewColumnTransform::AllNulls(schema);
let res = self
.inner_ref()?
.add_columns(transforms, None)
.add_columns()
.transform(transforms)
.execute()
.await
.default_error()?;
Ok(res.into())
@@ -426,6 +497,34 @@ impl Table {
self.inner_ref()?.close_lsm_writers().await.default_error()
}
#[napi(catch_unwind)]
pub async fn flush_lsm(&self) -> napi::Result<()> {
self.inner_ref()?.flush_lsm().await.default_error()
}
#[napi(catch_unwind)]
pub async fn compact_lsm(&self) -> napi::Result<()> {
self.inner_ref()?.compact_lsm().await.default_error()
}
#[napi(catch_unwind)]
pub async fn checkpoint_lsm(&self) -> napi::Result<()> {
self.inner_ref()?.checkpoint_lsm().await.default_error()
}
#[napi(catch_unwind)]
pub async fn get_lsm_stats(
&self,
include_generation_rows: bool,
) -> napi::Result<Option<LsmStats>> {
let stats = self
.inner_ref()?
.get_lsm_stats(include_generation_rows)
.await
.default_error()?;
Ok(stats.map(LsmStats::from))
}
#[napi(catch_unwind)]
pub async fn version(&self) -> napi::Result<i64> {
self.inner_ref()?
@@ -735,7 +834,8 @@ pub struct LsmWriteSpec {
pub column: Option<String>,
/// Bucket variant: the number of buckets, in `[1, 1024]`.
pub num_buckets: Option<u32>,
/// Names of indexes the MemWAL should keep up to date during writes.
/// Indexes the MemWAL keeps up to date. Omitted resolves every
/// maintainable index on install; an empty array means none.
pub maintained_indexes: Option<Vec<String>>,
/// Default `ShardWriter` configuration recorded in the MemWAL index.
pub writer_config_defaults: Option<HashMap<String, String>>,
@@ -745,7 +845,6 @@ impl TryFrom<LsmWriteSpec> for lancedb::table::LsmWriteSpec {
type Error = napi::Error;
fn try_from(value: LsmWriteSpec) -> napi::Result<Self> {
let maintained = value.maintained_indexes.unwrap_or_default();
let writer_config_defaults = value.writer_config_defaults.unwrap_or_default();
let spec = match value.spec_type.as_str() {
"bucket" => {
@@ -772,7 +871,7 @@ impl TryFrom<LsmWriteSpec> for lancedb::table::LsmWriteSpec {
}
};
Ok(spec
.with_maintained_indexes(maintained)
.with_maintained_indexes(value.maintained_indexes)
.with_writer_config_defaults(writer_config_defaults))
}
}
@@ -790,7 +889,7 @@ impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
spec_type: "bucket".to_string(),
column: Some(column),
num_buckets: Some(num_buckets),
maintained_indexes: Some(maintained_indexes),
maintained_indexes,
writer_config_defaults: Some(writer_config_defaults),
},
Native::Identity {
@@ -801,7 +900,7 @@ impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
spec_type: "identity".to_string(),
column: Some(column),
num_buckets: None,
maintained_indexes: Some(maintained_indexes),
maintained_indexes,
writer_config_defaults: Some(writer_config_defaults),
},
Native::Unsharded {
@@ -811,13 +910,136 @@ impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
spec_type: "unsharded".to_string(),
column: None,
num_buckets: None,
maintained_indexes: Some(maintained_indexes),
maintained_indexes,
writer_config_defaults: Some(writer_config_defaults),
},
}
}
}
/// One flushed L0 generation.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct GenerationStats {
/// The generation number. Increases as memtables are sealed into L0.
pub generation: i64,
/// On-disk size of the generation.
pub bytes: i64,
/// Present only when `includeGenerationRows` was requested. Off by default
/// because each count opens an uncached Lance dataset.
pub rows: Option<i64>,
}
impl From<lancedb::table::GenerationStats> for GenerationStats {
fn from(g: lancedb::table::GenerationStats) -> Self {
Self {
generation: g.generation as i64,
bytes: g.bytes as i64,
rows: g.rows.map(|r| r as i64),
}
}
}
/// One in-memory memtable.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct MemtableStats {
/// The generation this memtable will become once sealed.
pub generation: i64,
/// Rows currently buffered.
pub rows: i64,
/// Estimated in-memory size.
pub bytes: i64,
/// Record batches currently buffered.
pub batches: i64,
/// Names of the indexes this memtable carries. An absent name is the whole
/// answer to "why is my fresh-tier search on that column brute-force".
pub indexes: Vec<String>,
}
impl From<lancedb::table::MemtableStats> for MemtableStats {
fn from(m: lancedb::table::MemtableStats) -> Self {
Self {
generation: m.generation as i64,
rows: m.rows as i64,
bytes: m.bytes as i64,
batches: m.batches as i64,
indexes: m.indexes,
}
}
}
/// Live state of one bucket. A table is N buckets on one node; flattening to a
/// single number hides the one hot bucket that is usually why someone opened
/// this endpoint.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct BucketStats {
/// The shard this bucket writes.
pub shard_id: String,
/// `"Active"` or `"Sealed"` (drop-table 2PC in flight).
pub status: String,
/// Epoch of the writer that currently owns the shard.
pub writer_epoch: i64,
/// Version of the shard manifest these numbers were read from.
pub manifest_version: i64,
/// The generation the active memtable will become.
pub current_generation: i64,
/// WAL position replay resumes from.
pub replay_after_wal_entry_position: i64,
/// Highest WAL position the writer has seen. The difference against
/// `replayAfterWalEntryPosition` is the WAL lag.
pub wal_entry_position_last_seen: i64,
/// Flushed L0 generations not yet merged into the base table.
pub generations: Vec<GenerationStats>,
/// Whether a pass owns this bucket's compaction latch right now. Says *a*
/// driver is running, not *whose*, and the latch is held from dispatch —
/// including while the pass queues for a pod-wide compactor permit. Read it
/// as "do not pile on", never as "mine is progressing".
pub compacting: bool,
/// Oldest first, active last. Absent for a `"Sealed"` bucket, whose
/// in-memory state is torn down.
pub memtables: Option<Vec<MemtableStats>>,
}
impl From<lancedb::table::BucketStats> for BucketStats {
fn from(b: lancedb::table::BucketStats) -> Self {
Self {
shard_id: b.shard_id,
status: b.status,
writer_epoch: b.writer_epoch as i64,
manifest_version: b.manifest_version as i64,
current_generation: b.current_generation as i64,
replay_after_wal_entry_position: b.replay_after_wal_entry_position as i64,
wal_entry_position_last_seen: b.wal_entry_position_last_seen as i64,
generations: b.generations.into_iter().map(Into::into).collect(),
compacting: b.compacting,
memtables: b
.memtables
.map(|ms| ms.into_iter().map(Into::into).collect()),
}
}
}
/// Live per-bucket LSM state, as returned by `Table#getLsmStats`.
///
/// Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are
/// the caller's to compute.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct LsmStats {
/// One entry per bucket backing this table.
pub buckets: Vec<BucketStats>,
}
impl From<lancedb::table::LsmStats> for LsmStats {
fn from(stats: lancedb::table::LsmStats) -> Self {
Self {
buckets: stats.buckets.into_iter().map(Into::into).collect(),
}
}
}
/// Statistics about a compaction operation.
#[napi(object)]
#[derive(Clone, Debug)]
@@ -1006,7 +1228,10 @@ impl From<lancedb::index::IndexStatistics> for IndexStatistics {
#[napi(object)]
pub struct TableStatistics {
/// The total number of bytes in the table
/// The total size, in bytes, of the table's data files, index files, and
/// overlay files
///
/// Read from the manifest, so this excludes deletion files and manifests.
pub total_bytes: i64,
/// The number of rows in the table
@@ -1156,6 +1381,21 @@ pub struct AddColumnsResult {
pub version: i64,
}
#[napi(object)]
pub struct RefreshColumnResult {
pub rows_filled: i64,
pub version: i64,
}
impl From<lancedb::table::RefreshColumnResult> for RefreshColumnResult {
fn from(value: lancedb::table::RefreshColumnResult) -> Self {
Self {
rows_filled: value.rows_filled as i64,
version: value.version as i64,
}
}
}
impl From<lancedb::table::AddColumnsResult> for AddColumnsResult {
fn from(value: lancedb::table::AddColumnsResult) -> Self {
Self {
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "lancedb-python"
version = "0.37.1-beta.0"
version = "0.38.0-beta.2"
publish = false
edition.workspace = true
description = "Python bindings for LanceDB"
@@ -26,7 +26,7 @@ lance-namespace-impls.workspace = true
lance-io.workspace = true
env_logger.workspace = true
log.workspace = true
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39", "chrono"] }
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310", "chrono"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
pyo3-async-runtimes = { version = "0.28", features = [
"attributes",
@@ -43,7 +43,7 @@ libc = "0.2"
[build-dependencies]
pyo3-build-config = { version = "0.28", features = [
"extension-module",
"abi3-py39",
"abi3-py310",
] }
[features]
+2 -1
View File
@@ -60,7 +60,7 @@ tests = [
"pytest-asyncio>=0.21",
"duckdb>=0.9.0",
"pytz>=2023.3",
"polars>=0.19, <=1.3.0",
"polars>=0.19, <=1.32.3",
"pyarrow<25",
"pyarrow-stubs>=16.0",
"pylance==9.0.0rc1",
@@ -140,6 +140,7 @@ include = [
"python/lancedb/remote/errors.py",
"python/lancedb/embeddings/__init__.py",
"python/lancedb/_lancedb.pyi",
"python/type_tests/connect.py",
]
exclude = ["python/tests/"]
pythonVersion = "3.13"
+5
View File
@@ -12,6 +12,7 @@ __version__ = importlib.metadata.version("lancedb")
from ._lancedb import connect as lancedb_connect
from ._lancedb import FtsToken
from ._lancedb import LsmWriteSpec
from ._lancedb import tokenize as _tokenize
from .common import URI, sanitize_uri
from urllib.parse import urlparse
@@ -20,6 +21,7 @@ from .remote import ClientConfig
from .remote.db import RemoteDBConnection
from .expr import Expr, col, lit, func
from .schema import blob, vector, BlobType
from .job import AsyncJob, Job
from .table import AsyncTable, Table
from .types import BaseTokenizerType
from ._lancedb import Session
@@ -500,6 +502,7 @@ __all__ = [
"connect_namespace",
"connect_namespace_async",
"AsyncConnection",
"AsyncJob",
"AsyncLanceNamespaceDBConnection",
"AsyncTable",
"FtsToken",
@@ -513,8 +516,10 @@ __all__ = [
"BlobType",
"vector",
"DBConnection",
"Job",
"LanceDBConnection",
"LanceNamespaceDBConnection",
"LsmWriteSpec",
"RemoteDBConnection",
"Session",
"Table",
+6 -23
View File
@@ -14,14 +14,10 @@ import pyarrow as pa
from .expr import Expr
from .schema import blob_v2_column_paths
from .types import BlobMode, QueryProjection, QueryProjectionSpec
from .util import get_uri_scheme
if TYPE_CHECKING:
from _typeshed import WriteableBuffer
from .remote.table import RemoteTable
from .table import AsyncTable, Table
BLOB_MODE_TO_HANDLING = {
"lazy": "blobs_descriptions",
"bytes": "all_binary",
@@ -104,22 +100,6 @@ def validate_blob_mode(blob_mode: BlobMode) -> None:
raise ValueError(f"blob_mode must be one of {modes}, got {blob_mode!r}")
def supports_blob_auto_row_id(table: Table | AsyncTable | RemoteTable) -> bool:
"""Blob auto row-id applies to native tables, not LanceDB Cloud."""
from .remote.table import RemoteTable
if isinstance(table, RemoteTable):
return False
inner = getattr(table, "_inner", None)
if inner is not None:
uri = inner.database().uri
if isinstance(uri, str) and get_uri_scheme(uri) == "db":
return False
return True
def projection_includes_blob_column(
projection: QueryProjection,
blob_columns: Iterable[str],
@@ -164,16 +144,14 @@ def v2_projection_needs_row_id(
def blob_auto_row_id_for_scan(
table: Table | AsyncTable | RemoteTable,
schema: pa.Schema,
projection: QueryProjection,
*,
with_row_id: bool | None,
) -> bool:
"""Auto row-id only applies when the caller said nothing about row ids."""
if with_row_id is not None:
return False
if not supports_blob_auto_row_id(table):
return False
return v2_projection_needs_row_id(schema, projection, with_row_id=False)
@@ -186,6 +164,11 @@ def finalize_blob_query_table(
) -> pa.Table:
if user_requested_row_id or not blob_auto_row_id:
return tbl
if "_rowid" not in tbl.column_names:
# A backend that ignores the row-id request leaves nothing to stash. Hand
# back the projection as-is so fetch_blobs raises the error that names the
# ways to supply row ids, rather than failing here about a hidden column.
return tbl
return stash_auto_row_ids(tbl, blob_paths)
+93 -4
View File
@@ -146,6 +146,13 @@ class Connection(object):
start_after: Optional[str],
limit: Optional[int],
) -> list[str]: ... # Deprecated: Use list_tables instead
def job(self, job_id: str) -> Job: ...
async def list_jobs(self) -> List[JobInfo]: ...
async def get_job(self, job_id: str) -> Optional[JobDescription]: ...
async def cancel_job(self, job_id: str) -> bool: ...
async def job_history(
self, job_id: Optional[str] = None
) -> List[pa.RecordBatch]: ...
async def create_table(
self,
name: str,
@@ -191,6 +198,9 @@ class Connection(object):
async def drop_table(
self, name: str, namespace_path: Optional[List[str]] = None
) -> None: ...
async def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job: ...
async def drop_all_tables(
self, namespace_path: Optional[List[str]] = None
) -> None: ...
@@ -209,6 +219,47 @@ class BlobFile:
def read_range(self, offset: int, length: int) -> bytes: ...
def read_up_to(self, length: int) -> bytes: ...
class Job:
@property
def id(self) -> Optional[str]: ...
async def status(self) -> str: ...
async def wait(self) -> None: ...
async def cancel(self) -> None: ...
class JobInfo:
@property
def job_id(self) -> str: ...
@property
def table(self) -> str: ...
@property
def job_type(self) -> str: ...
@property
def state(self) -> str: ...
@property
def created_at_millis(self) -> int: ...
class JobFailureInfo:
@property
def phase(self) -> Optional[str]: ...
@property
def message(self) -> Optional[str]: ...
@property
def retryable(self) -> Optional[bool]: ...
class JobDescription:
@property
def job_id(self) -> str: ...
@property
def job_type(self) -> str: ...
@property
def state(self) -> str: ...
@property
def creation_ms(self) -> int: ...
@property
def spec_json(self) -> Optional[str]: ...
@property
def failure(self) -> Optional[JobFailureInfo]: ...
class Table:
def name(self) -> str: ...
def __repr__(self) -> str: ...
@@ -248,6 +299,28 @@ class Table:
name: Optional[str],
train: Optional[bool],
): ...
async def create_index_async(
self,
column: str,
index: Union[
IvfFlat,
IvfSq,
IvfPq,
HnswPq,
HnswSq,
HnswFlat,
BTree,
Bitmap,
LabelList,
Fm,
FTS,
],
replace: Optional[bool],
wait_timeout: Optional[object],
*,
name: Optional[str],
train: Optional[bool],
) -> Job: ...
async def list_versions(self) -> List[Dict[str, Any]]: ...
async def version(self) -> int: ...
async def checkout(self, version: Union[int, str]): ...
@@ -265,6 +338,11 @@ class Table:
) -> list[FtsToken]: ...
async def delete(self, filter: Union[str, PyExpr]) -> DeleteResult: ...
async def add_columns(self, columns: list[tuple[str, str]]) -> AddColumnsResult: ...
async def add_computed_columns(
self, columns: list[tuple[str, str]]
) -> AddColumnsResult: ...
async def refresh_column(self, column: str) -> RefreshColumnResult: ...
async def refresh_column_async(self, column: str) -> Job: ...
async def add_columns_with_schema(self, schema: pa.Schema) -> AddColumnsResult: ...
async def alter_columns(
self, columns: list[dict[str, Any]]
@@ -285,6 +363,10 @@ class Table:
async def set_lsm_write_spec(self, spec: LsmWriteSpec) -> None: ...
async def unset_lsm_write_spec(self) -> None: ...
async def get_lsm_write_spec(self) -> Optional[LsmWriteSpec]: ...
async def checkpoint_lsm(self) -> None: ...
async def flush_lsm(self) -> None: ...
async def compact_lsm(self) -> None: ...
async def get_lsm_stats(self, include_generation_rows: bool) -> Optional[dict]: ...
async def close_lsm_writers(self) -> None: ...
@property
def tags(self) -> Tags: ...
@@ -579,9 +661,10 @@ class LsmWriteSpec:
def identity(column: str) -> "LsmWriteSpec": ...
@staticmethod
def unsharded() -> "LsmWriteSpec": ...
def with_maintained_indexes(self, indexes: List[str]) -> "LsmWriteSpec":
"""Return a copy of this spec asking the MemWAL to keep the named
indexes up to date as rows are appended."""
def with_maintained_indexes(self, indexes: Optional[List[str]]) -> "LsmWriteSpec":
"""Set which indexes the MemWAL keeps up to date. None resolves every
index on the table at install, failing if one cannot be maintained;
a list is verbatim, empty means none."""
...
def with_writer_config_defaults(self, defaults: Dict[str, str]) -> "LsmWriteSpec":
"""Return a copy of this spec recording the given default
@@ -596,13 +679,19 @@ class LsmWriteSpec:
@property
def num_buckets(self) -> Optional[int]: ...
@property
def maintained_indexes(self) -> List[str]: ...
def maintained_indexes(self) -> Optional[List[str]]:
"""Indexes the MemWAL keeps up to date, or None for every supported one."""
...
@property
def writer_config_defaults(self) -> Dict[str, str]: ...
class AddColumnsResult:
version: int
class RefreshColumnResult:
rows_filled: int
version: int
class AlterColumnsResult:
version: int
+220 -8
View File
@@ -45,6 +45,7 @@ from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from . import __version__
from ._lancedb import connect as lancedb_connect # type: ignore
from .job import AsyncJob, Job
from .table import (
AsyncTable,
LanceTable,
@@ -63,6 +64,7 @@ if TYPE_CHECKING:
from .pydantic import LanceModel
from ._lancedb import Connection as LanceDbConnection
from ._lancedb import JobDescription, JobInfo
from .common import DATA, URI
from .embeddings import EmbeddingFunctionConfig
from ._lancedb import Session
@@ -178,6 +180,51 @@ class DBConnection(EnforceOverrides):
"Namespace operations are not supported for this connection type"
)
def namespace_exists(self, namespace_id: List[str]) -> bool:
"""Check if a namespace exists.
Parameters
----------
namespace_id: List[str]
The namespace identifier to check.
Returns
-------
bool
True if the namespace exists, False otherwise.
Raises
------
NotImplementedError
If the connection type does not support namespace operations.
"""
raise NotImplementedError(
"Namespace operations are not supported for this connection type"
)
def table_exists(self, table_id: List[str]) -> bool:
"""Check if a table exists.
Parameters
----------
table_id: List[str]
The table identifier to check (full path including namespace
segments and table name).
Returns
-------
bool
True if the table exists, False otherwise.
Raises
------
NotImplementedError
If the connection type does not support namespace operations.
"""
raise NotImplementedError(
"Namespace operations are not supported for this connection type"
)
def list_tables(
self,
namespace_path: Optional[List[str]] = None,
@@ -477,6 +524,12 @@ class DBConnection(EnforceOverrides):
namespace_path = []
raise NotImplementedError
def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job:
"""Start dropping a table and return its cleanup job."""
raise NotImplementedError
def rename_table(
self,
cur_name: str,
@@ -563,6 +616,46 @@ class DBConnection(EnforceOverrides):
"""
raise NotImplementedError("serialize is not supported for this connection type")
def job(self, job_id: str) -> Job:
"""A [Job][lancedb.job.Job] handle for a server-side job by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
"""
raise NotImplementedError("job is not supported for this connection type")
def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables."""
raise NotImplementedError("list_jobs is not supported for this connection type")
def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
raise NotImplementedError("get_job is not supported for this connection type")
def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id.
Returns True if the server accepted the cancellation, False if no
such job exists. Cancelling an already-terminal job is a no-op
success.
"""
raise NotImplementedError(
"cancel_job is not supported for this connection type"
)
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]:
"""The lifecycle event history of a server-side job, as Arrow batches.
Lists history across all jobs when `job_id` is None.
"""
raise NotImplementedError(
"job_history is not supported for this connection type"
)
class LanceDBConnection(DBConnection):
"""
@@ -620,6 +713,9 @@ class LanceDBConnection(DBConnection):
self._namespace_client_properties = namespace_client_properties
if _inner is not None:
self._conn = _inner
# Native-derived wrappers resolve this in their async reconstruction
# path so construction never synchronously re-enters LOOP.
self._read_consistency_interval = read_consistency_interval
self._cached_namespace_client = None
return
@@ -669,11 +765,14 @@ class LanceDBConnection(DBConnection):
# storage_options. Also, this class really shouldn't be holding any state
# beyond _conn.
self._conn = AsyncConnection(LOOP.run(do_connect()))
# Keep property access synchronous so debugger introspection cannot wait on
# the background loop while that thread is suspended at a breakpoint.
self._read_consistency_interval = read_consistency_interval
self._cached_namespace_client: Optional[LanceNamespace] = None
@property
def read_consistency_interval(self) -> Optional[timedelta]:
return LOOP.run(self._conn.get_read_consistency_interval())
return self._read_consistency_interval
@property
def session(self) -> Optional[Session]:
@@ -684,15 +783,19 @@ class LanceDBConnection(DBConnection):
return self._conn.uri
@classmethod
def from_inner(cls, inner: LanceDbConnection):
return cls(None, _inner=inner)
def from_inner(
cls,
inner: LanceDbConnection,
read_consistency_interval: Optional[timedelta],
):
return cls(
None,
read_consistency_interval=read_consistency_interval,
_inner=inner,
)
def __repr__(self) -> str:
val = f"{self.__class__.__name__}(uri={self._conn.uri!r}"
if self.read_consistency_interval is not None:
val += f", read_consistency_interval={repr(self.read_consistency_interval)}"
val += ")"
return val
return f"{self.__class__.__name__}(uri={self._conn.uri!r})"
@override
def serialize(self) -> str:
@@ -1089,6 +1192,20 @@ class LanceDBConnection(DBConnection):
)
)
@override
def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job:
"""Start dropping a table and return its cleanup job.
The table may become unavailable before its data files are removed.
Call :meth:`Job.wait` to wait for cleanup to finish.
"""
if namespace_path is None:
namespace_path = []
job = LOOP.run(self._conn.drop_table_async(name, namespace_path=namespace_path))
return Job(job if isinstance(job, AsyncJob) else AsyncJob(job))
@override
def drop_all_tables(self, namespace_path: Optional[List[str]] = None):
if namespace_path is None:
@@ -1129,6 +1246,47 @@ class LanceDBConnection(DBConnection):
)
)
@override
def job(self, job_id: str) -> Job:
"""A [Job][lancedb.job.Job] handle for a server-side job by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
"""
return Job(self._conn.job(job_id))
@override
def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables."""
return LOOP.run(self._conn.list_jobs())
@override
def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return LOOP.run(self._conn.get_job(job_id))
@override
def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id.
Returns True if the server accepted the cancellation, False if no
such job exists. Cancelling an already-terminal job is a no-op
success.
"""
return LOOP.run(self._conn.cancel_job(job_id))
@override
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]:
"""The lifecycle event history of a server-side job, as Arrow batches.
Lists history across all jobs when `job_id` is None.
"""
return LOOP.run(self._conn.job_history(job_id))
@override
def namespace_client(self) -> LanceNamespace:
"""Get the equivalent namespace client for this connection.
@@ -1825,6 +1983,23 @@ class AsyncConnection(object):
if f"Table '{name}' was not found" not in str(e):
raise e
async def drop_table_async(
self,
name: str,
*,
namespace_path: Optional[List[str]] = None,
) -> AsyncJob:
"""Start dropping a table and return its cleanup job.
The table may become unavailable before its data files are removed.
Await :meth:`AsyncJob.wait` to wait for cleanup to finish.
"""
if namespace_path is None:
namespace_path = []
return AsyncJob(
await self._inner.drop_table_async(name, namespace_path=namespace_path)
)
async def drop_all_tables(self, namespace_path: Optional[List[str]] = None):
"""Drop all tables from the database.
@@ -1838,6 +2013,43 @@ class AsyncConnection(object):
namespace_path = []
await self._inner.drop_all_tables(namespace_path=namespace_path)
def job(self, job_id: str) -> AsyncJob:
"""An [AsyncJob][lancedb.job.AsyncJob] handle for a server-side job
by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
"""
return AsyncJob(self._inner.job(job_id))
async def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables."""
return await self._inner.list_jobs()
async def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return await self._inner.get_job(job_id)
async def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id.
Returns True if the server accepted the cancellation, False if no
such job exists. Cancelling an already-terminal job is a no-op
success.
"""
return await self._inner.cancel_job(job_id)
async def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]:
"""The lifecycle event history of a server-side job, as Arrow batches.
Lists history across all jobs when `job_id` is None.
"""
return await self._inner.job_history(job_id)
async def namespace_client(self) -> LanceNamespace:
"""Get the equivalent namespace client for this connection.
@@ -101,8 +101,7 @@ class InstructorEmbeddingFunction(TextEmbeddingFunction):
@weak_lru(maxsize=1)
def ndims(self):
model = self.get_model()
return model.encode("foo").shape[0]
return len(self.generate_embeddings([[self.source_instruction, "foo"]])[0])
def compute_query_embeddings(self, query: str, *args, **kwargs) -> List[np.array]:
return self.generate_embeddings([[self.query_instruction, query]])
+4 -3
View File
@@ -87,12 +87,13 @@ class JinaEmbeddings(EmbeddingFunction):
if isinstance(image, bytes):
image_dict = {"image": base64.b64encode(image).decode("utf-8")}
elif isinstance(image, (str, Path)):
parsed = urlparse.urlparse(image)
# TODO handle drive letter on windows.
parsed = urlparse(str(image))
PIL_Image = attempt_import_or_raise("PIL.Image", "pillow")
if parsed.scheme == "file":
pil_image = PIL_Image.open(parsed.path)
elif parsed.scheme == "":
elif parsed.scheme == "" or (os.name == "nt" and len(parsed.scheme) == 1):
# A Windows drive letter parses as a one-character scheme
# ("C:\\img.png" -> scheme="c"), so treat it as a local path.
pil_image = PIL_Image.open(image if os.name == "nt" else parsed.path)
elif parsed.scheme.startswith("http"):
pil_image = PIL_Image.open(io.BytesIO(url_retrieve(image)))
+12
View File
@@ -23,3 +23,15 @@ class MissingColumnError(KeyError):
return (
f"Error: Column '{self.column_name}' does not exist in the DataFrame object"
)
class JobFailedError(RuntimeError):
"""Exception raised when an asynchronous job reaches the failed state."""
pass
class JobCancelledError(RuntimeError):
"""Exception raised when an asynchronous job was cancelled."""
pass
+105
View File
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Handles to operations a server may run asynchronously."""
import asyncio
from datetime import timedelta
from typing import Optional
from lancedb.background_loop import LOOP
from . import _lancedb
class AsyncJob:
"""A handle to an operation that may still be running.
The operation may already be complete when the handle is created.
"""
def __init__(self, inner: Optional["_lancedb.Job"]):
self._inner = inner
@property
def id(self) -> Optional[str]:
"""Identifies the operation on the server that is running it.
Returned for correlating with server logs or the jobs API. Operations
that run in this process have no server id and return `None`. The value
is opaque: parsing it or storing it to resume the job later is not
supported.
"""
return self._inner.id if self._inner is not None else None
async def status(self) -> str:
"""The operation's current lifecycle state: "running", "finished",
"failed", or "cancelled".
A point snapshot; unlike `wait` it does not block or raise on a
terminal failure state. States a newer server reports that this
client version does not know pass through as-is.
"""
if self._inner is None:
return "finished"
return await self._inner.status()
async def wait(self, timeout: Optional[timedelta] = None):
"""Wait until the operation reaches a terminal state.
Raises `JobFailedError` if the operation failed, `JobCancelledError`
if it was cancelled, and `TimeoutError` if `timeout` elapses first.
"""
if self._inner is None:
return
if timeout is None:
await self._inner.wait()
else:
await asyncio.wait_for(self._inner.wait(), timeout.total_seconds())
async def cancel(self):
"""Request cancellation. Cancelling a finished operation is a no-op."""
if self._inner is None:
return
await self._inner.cancel()
class Job:
"""Synchronous counterpart of `AsyncJob`."""
def __init__(self, inner: Optional[AsyncJob]):
self._inner = inner
@property
def id(self) -> Optional[str]:
"""Identifies the operation on the server that is running it.
See :attr:`AsyncJob.id`.
"""
return self._inner.id if self._inner is not None else None
def status(self) -> str:
"""The operation's current lifecycle state: "running", "finished",
"failed", or "cancelled".
See :meth:`AsyncJob.status`.
"""
if self._inner is None:
return "finished"
return LOOP.run(self._inner.status())
def wait(self, timeout: Optional[timedelta] = None):
"""Block until the operation reaches a terminal state.
Raises `JobFailedError` if the operation failed, `JobCancelledError`
if it was cancelled, and `TimeoutError` if `timeout` elapses first.
"""
if self._inner is None:
return
LOOP.run(self._inner.wait(timeout))
def cancel(self):
"""Request cancellation. Cancelling a finished operation is a no-op."""
if self._inner is None:
return
LOOP.run(self._inner.cancel())
+3 -1
View File
@@ -92,8 +92,10 @@ class LanceMergeInsertBuilder(object):
self._when_not_matched_by_source_delete = True
if isinstance(condition, Expr):
self._when_not_matched_by_source_condition_expr = condition._inner
elif condition is not None:
self._when_not_matched_by_source_condition = None
else:
self._when_not_matched_by_source_condition = condition
self._when_not_matched_by_source_condition_expr = None
return self
def use_index(self, use_index: bool) -> LanceMergeInsertBuilder:
+116 -1
View File
@@ -38,13 +38,18 @@ from lance_namespace_urllib3_client.models.query_table_request_vector import (
QueryTableRequestVector,
)
from lance_namespace_urllib3_client.models.string_fts_query import StringFtsQuery
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from lance_namespace.errors import (
NamespaceNotEmptyError,
NamespaceNotFoundError,
TableNotFoundError,
)
from lancedb._lancedb import (
connect_namespace as _connect_namespace,
connect_namespace_client as _connect_namespace_client,
)
from lancedb.background_loop import LOOP
from lancedb.db import AsyncConnection, DBConnection
from lancedb.job import AsyncJob, Job
from lance_namespace import (
LanceNamespace,
connect as namespace_connect,
@@ -53,6 +58,8 @@ from lance_namespace import (
DropNamespaceResponse,
ListNamespacesResponse,
ListTablesResponse,
NamespaceExistsRequest,
TableExistsRequest,
)
from lancedb.table import AsyncTable, LanceTable, Table
from lancedb.util import validate_table_name
@@ -618,6 +625,18 @@ class LanceNamespaceDBConnection(DBConnection):
namespace_path = []
LOOP.run(self._inner.drop_table(name, namespace_path=namespace_path))
@override
def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job:
"""Start dropping a table and return its cleanup job."""
if namespace_path is None:
namespace_path = []
job = LOOP.run(
self._inner.drop_table_async(name, namespace_path=namespace_path)
)
return Job(job if isinstance(job, AsyncJob) else AsyncJob(job))
@override
def rename_table(
self,
@@ -780,6 +799,51 @@ class LanceNamespaceDBConnection(DBConnection):
"""
return LOOP.run(self._inner.describe_namespace(namespace_path))
@override
def namespace_exists(self, namespace_id: List[str]) -> bool:
"""
Check if a namespace exists.
Parameters
----------
namespace_id : List[str]
The namespace identifier to check.
Returns
-------
bool
True if the namespace exists, False otherwise.
"""
request = NamespaceExistsRequest(id=namespace_id)
try:
self._namespace_client.namespace_exists(request)
return True
except NamespaceNotFoundError:
return False
@override
def table_exists(self, table_id: List[str]) -> bool:
"""
Check if a table exists.
Parameters
----------
table_id : List[str]
The table identifier to check (full path including namespace
segments and table name).
Returns
-------
bool
True if the table exists, False otherwise.
"""
request = TableExistsRequest(id=table_id)
try:
self._namespace_client.table_exists(request)
return True
except TableNotFoundError:
return False
@override
def list_tables(
self,
@@ -1083,6 +1147,14 @@ class AsyncLanceNamespaceDBConnection:
namespace_path = []
await self._inner.drop_table(name, namespace_path=namespace_path)
async def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> AsyncJob:
"""Start dropping a table and return its cleanup job."""
if namespace_path is None:
namespace_path = []
return await self._inner.drop_table_async(name, namespace_path=namespace_path)
async def rename_table(
self,
cur_name: str,
@@ -1233,6 +1305,49 @@ class AsyncLanceNamespaceDBConnection:
"""
return await self._inner.describe_namespace(namespace_path)
async def namespace_exists(self, namespace_id: List[str]) -> bool:
"""
Check if a namespace exists.
Parameters
----------
namespace_id : List[str]
The namespace identifier to check.
Returns
-------
bool
True if the namespace exists, False otherwise.
"""
request = NamespaceExistsRequest(id=namespace_id)
try:
self._namespace_client.namespace_exists(request)
return True
except NamespaceNotFoundError:
return False
async def table_exists(self, table_id: List[str]) -> bool:
"""
Check if a table exists.
Parameters
----------
table_id : List[str]
The table identifier to check (full path including namespace
segments and table name).
Returns
-------
bool
True if the table exists, False otherwise.
"""
request = TableExistsRequest(id=table_id)
try:
self._namespace_client.table_exists(request)
return True
except TableNotFoundError:
return False
async def list_tables(
self,
namespace_path: Optional[List[str]] = None,
+1 -1
View File
@@ -226,7 +226,7 @@ class PermutationBuilder:
async def do_execute():
inner_tbl = await self._async.execute()
return LanceTable.from_inner(inner_tbl)
return await LanceTable.from_inner(inner_tbl)
return LOOP.run(do_execute())
+1
View File
@@ -0,0 +1 @@
+10
View File
@@ -153,6 +153,16 @@ def Vector(
return FixedSizeList
def _raise_bare_vector_error(*_args):
raise TypeError("Vector must be parameterized with a dimension, e.g. Vector(128).")
# Pydantic v1 and v2 otherwise treat the bare Vector factory as a field validator
# and inspect its signature, which produces misleading errors about internal types.
setattr(Vector, "__get_validators__", _raise_bare_vector_error)
setattr(Vector, "__get_pydantic_core_schema__", _raise_bare_vector_error)
def MultiVector(
dim: int, value_type: pa.DataType = pa.float32(), nullable: bool = True
) -> Type:
+12 -13
View File
@@ -52,7 +52,6 @@ from ._blob import (
finalize_blob_query_table,
replace_v2_blob_columns_with_bytes,
replace_v2_blob_columns_with_bytes_sync,
supports_blob_auto_row_id,
validate_blob_mode,
)
from .types import BlobMode, QueryProjection
@@ -1280,10 +1279,7 @@ class LanceQueryBuilder(ABC):
return self._with_row_id is True
def _blob_auto_row_id_enabled(self) -> bool:
if not supports_blob_auto_row_id(self._table):
return False
return blob_auto_row_id_for_scan(
self._table,
self._table.schema,
self._columns,
with_row_id=self._with_row_id,
@@ -2239,6 +2235,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
reranker=self._reranker,
limit=self._limit,
with_row_ids=True,
offset=self._offset,
)
return self._finish_hybrid_results(results)
@@ -2260,6 +2257,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
reranker,
limit: int,
with_row_ids: bool,
offset: Optional[int] = None,
) -> pa.Table:
if norm == "rank":
vector_results = LanceHybridQueryBuilder._rank(vector_results, "_distance")
@@ -2336,7 +2334,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
score_i = results.column_names.index("_score")
results = results.set_column(score_i, "_score", original_scores)
results = results.slice(length=limit)
results = results.slice(offset=offset or 0, length=limit)
if not with_row_ids:
results = results.drop(["_rowid"])
@@ -2683,8 +2681,12 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
# Apply common configurations
if self._limit:
self._vector_query.limit(self._limit)
self._fts_query.limit(self._limit)
# The final offset/limit window is sliced out of the combined,
# reranked results, so each sub-query must fetch enough rows to
# cover the skipped prefix as well as the window itself.
sub_query_limit = self._limit + (self._offset or 0)
self._vector_query.limit(sub_query_limit)
self._fts_query.limit(sub_query_limit)
if self._columns:
self._vector_query.select(self._columns)
self._fts_query.select(self._columns)
@@ -2701,7 +2703,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
self._fts_query.phrase_query(True)
if self._distance_type:
self._vector_query.metric(self._distance_type)
if self._minimum_nprobes:
if self._minimum_nprobes is not None:
self._vector_query.minimum_nprobes(self._minimum_nprobes)
if self._maximum_nprobes is not None:
self._vector_query.maximum_nprobes(self._maximum_nprobes)
@@ -2774,7 +2776,7 @@ class AsyncQueryBase(object):
)
async def _maybe_add_blob_row_id(self) -> None:
if self._table is None or not supports_blob_auto_row_id(self._table):
if self._table is None:
self._blob_auto_row_id = False
self._blob_paths = ()
return
@@ -2782,7 +2784,6 @@ class AsyncQueryBase(object):
req = self._inner.to_query_request()
schema = await self._table.schema()
self._blob_auto_row_id = blob_auto_row_id_for_scan(
self._table,
schema,
req.select,
with_row_id=self._with_row_id,
@@ -3034,7 +3035,6 @@ class AsyncQueryBase(object):
schema = await self._table.schema()
blob_auto_row_id = blob_auto_row_id_for_scan(
self._table,
schema,
query.columns,
with_row_id=self._with_row_id,
@@ -3880,10 +3880,9 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
req = fts_query._inner.to_query_request()
blob_auto_row_id = False
blob_paths: tuple[str, ...] = ()
if self._table is not None and supports_blob_auto_row_id(self._table):
if self._table is not None:
schema = await self._table.schema()
blob_auto_row_id = blob_auto_row_id_for_scan(
self._table,
schema,
req.select,
with_row_id=self._with_row_id,
+61 -1
View File
@@ -7,7 +7,7 @@ import json
import logging
from concurrent.futures import ThreadPoolExecutor
import sys
from typing import Any, Dict, Iterable, List, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlparse
import warnings
@@ -23,6 +23,10 @@ import pyarrow as pa
from ..common import DATA
from ..db import DBConnection, LOOP
from ..job import AsyncJob, Job
if TYPE_CHECKING:
from .._lancedb import JobDescription, JobInfo
from ..embeddings import EmbeddingFunctionConfig
from lance_namespace import (
LanceNamespace,
@@ -415,6 +419,11 @@ class RemoteDBConnection(DBConnection):
if namespace_path is None:
namespace_path = []
if storage_options is not None:
logging.info(
"storage_options is ignored in LanceDb Cloud"
" (storage is managed; set storage_options on connect() instead)"
)
if index_cache_size is not None:
logging.info(
"index_cache_size is ignored in LanceDb Cloud"
@@ -654,6 +663,16 @@ class RemoteDBConnection(DBConnection):
namespace_path = []
LOOP.run(self._conn.drop_table(name, namespace_path=namespace_path))
@override
def drop_table_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job:
"""Start dropping a table and return its cleanup job."""
if namespace_path is None:
namespace_path = []
job = LOOP.run(self._conn.drop_table_async(name, namespace_path=namespace_path))
return Job(job if isinstance(job, AsyncJob) else AsyncJob(job))
@override
def rename_table(
self,
@@ -684,6 +703,47 @@ class RemoteDBConnection(DBConnection):
)
)
@override
def job(self, job_id: str) -> Job:
"""A [Job][lancedb.job.Job] handle for a server-side job by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
"""
return Job(self._conn.job(job_id))
@override
def list_jobs(self) -> List["JobInfo"]:
"""List server-side jobs across the database's tables."""
return LOOP.run(self._conn.list_jobs())
@override
def get_job(self, job_id: str) -> Optional["JobDescription"]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return LOOP.run(self._conn.get_job(job_id))
@override
def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id.
Returns True if the server accepted the cancellation, False if no
such job exists. Cancelling an already-terminal job is a no-op
success.
"""
return LOOP.run(self._conn.cancel_job(job_id))
@override
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]:
"""The lifecycle event history of a server-side job, as Arrow batches.
Lists history across all jobs when `job_id` is None.
"""
return LOOP.run(self._conn.job_history(job_id))
@override
def namespace_client(self) -> LanceNamespace:
"""Get the equivalent namespace client for this connection.
+76 -13
View File
@@ -20,6 +20,7 @@ from typing import (
import warnings
from lancedb import __version__
from lancedb._blob import BlobFile
from lancedb._lancedb import (
AddColumnsResult,
@@ -47,6 +48,7 @@ from lancedb.index import (
IvfSq,
LabelList,
)
from lancedb.job import Job
from lancedb.remote.db import LOOP
from lancedb.table import IndexConfigType, KNOWN_METRICS
import pyarrow as pa
@@ -540,6 +542,34 @@ class RemoteTable(Table):
)
)
def create_index_async(
self,
column: str,
*,
config: IndexConfigType,
replace: Optional[bool] = None,
wait_timeout: Optional[timedelta] = None,
name: Optional[str] = None,
train: bool = True,
) -> Job:
"""Create an index, returning a handle to the indexing job.
The job may already be complete when returned; callers must not assume
the index exists until :meth:`Job.wait` returns.
"""
return Job(
LOOP.run(
self._table.create_index_async(
column,
replace=replace,
config=config,
wait_timeout=wait_timeout,
name=name,
train=train,
)
)
)
def _is_legacy_create_index_call(
self,
first_arg: str,
@@ -928,8 +958,19 @@ class RemoteTable(Table):
def count_rows(self, filter: Optional[str] = None) -> int:
return LOOP.run(self._table.count_rows(filter))
def add_columns(self, transforms: Dict[str, str]) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms))
def add_columns(
self,
transforms: Dict[str, str] | None = None,
*,
computed: Dict[str, str] | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms, computed=computed))
def refresh_column(self, column: str):
return LOOP.run(self._table.refresh_column(column))
def refresh_column_async(self, column: str) -> Job:
return Job(LOOP.run(self._table.refresh_column_async(column)))
def alter_columns(
self, *alterations: Iterable[Dict[str, str]]
@@ -949,17 +990,39 @@ class RemoteTable(Table):
return LOOP.run(self._table.set_unenforced_primary_key(columns))
def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
"""Not supported on LanceDB Cloud."""
"""Install an LsmWriteSpec."""
return LOOP.run(self._table.set_lsm_write_spec(spec))
def unset_lsm_write_spec(self) -> None:
"""Not supported on LanceDB Cloud."""
"""Remove the LsmWriteSpec."""
return LOOP.run(self._table.unset_lsm_write_spec())
def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]:
"""Read the installed LsmWriteSpec, or ``None``."""
return LOOP.run(self._table.get_lsm_write_spec())
def checkpoint_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.checkpoint_lsm`][lancedb.AsyncTable.checkpoint_lsm]."""
return LOOP.run(self._table.checkpoint_lsm())
def flush_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.flush_lsm`][lancedb.AsyncTable.flush_lsm]."""
return LOOP.run(self._table.flush_lsm())
def compact_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
)
def close_lsm_writers(self) -> None:
"""No-op on LanceDB Cloud (no local shard writers)."""
return LOOP.run(self._table.close_lsm_writers())
@@ -1039,22 +1102,22 @@ class RemoteTable(Table):
)
def blob_columns(self) -> list[str]:
raise NotImplementedError(
"blob_columns() is not yet supported on the LanceDB Cloud"
)
return LOOP.run(self._table.blob_columns())
def fetch_blobs(self, column: str, row_ids) -> pa.LargeBinaryArray:
raise NotImplementedError("fetch_blobs() is not supported on LanceDB Cloud")
def fetch_blobs(
self, column: str, row_ids: Union[list[int], pa.Table]
) -> pa.LargeBinaryArray:
return LOOP.run(self._table.fetch_blobs(column, row_ids))
def fetch_blob_ranges(self, column: str, requests) -> pa.LargeBinaryArray:
raise NotImplementedError(
"fetch_blob_ranges() is not supported on LanceDB Cloud"
)
def fetch_blob_files(self, column: str, row_ids):
raise NotImplementedError(
"fetch_blob_files() is not supported on LanceDB Cloud"
)
def fetch_blob_files(
self, column: str, row_ids: Union[list[int], pa.Table]
) -> "list[Optional[BlobFile]]":
return LOOP.run(self._table.fetch_blob_files(column, row_ids))
def head(self, n=5) -> pa.Table:
"""
+315 -27
View File
@@ -11,6 +11,11 @@ Provides StreamingDataset, a PyTorch IterableDataset that guarantees:
- **Resumability**: state_dict / load_state_dict capture per-split consumption
counts so training can resume from an exact mid-epoch position even when the
distributed topology changes between runs.
Transform failures on bad rows (e.g. nulls or NaNs from incomplete data) can
be tolerated with ``on_transform_error="skip"``; see the parameter
documentation on StreamingDataset for how this interacts with the guarantees
above.
"""
import ctypes
@@ -22,7 +27,7 @@ import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import RawArray
from typing import Any, Callable, Iterator, Optional
from typing import Any, Callable, Iterator, Optional, Union
from torch.utils.data import IterableDataset, get_worker_info
@@ -127,6 +132,49 @@ class StreamingDataset(IterableDataset):
Maximum number of transforms to run concurrently. Must be greater
than zero. When ``None`` (the default), uses ``os.cpu_count()`` or 1
when the CPU count is unavailable.
on_transform_error:
What to do when the transform raises an exception:
- ``"raise"`` (the default): the exception propagates and iteration
aborts.
- ``"skip"``: the failing rows are dropped and iteration continues.
- ``"warn"``: like ``"skip"``, but a warning is logged for each
failing batch.
- a callable ``handler(exc) -> bool``: called with the exception;
return ``True`` to skip the failing rows or ``False`` to re-raise.
Useful to skip only expected error types (compatible with
``webdataset.handlers`` style handlers).
When a batch fails, the transform is re-invoked on each single-row
slice of the batch so that only the rows that actually fail are
dropped. Transforms should therefore be deterministic and accept
batches of any size (including one row). Skipped rows are counted in
``rows_skipped``.
Skipping weakens the elastic-determinism guarantee at the end of the
epoch: splits that lose more rows than others run dry earlier, and
each rank's iterator ends at the last cycle where every split *it
owns* still has a row. Because bad rows are not distributed evenly
across splits, this means one rank's iterator can yield noticeably
fewer or more steps than another rank's *in the same run* — there is
no cross-rank coordination that stops every rank at the same global
step. This is generally safe for asynchronous or single-rank use,
but synchronous distributed training (e.g. ranks that call
``all_reduce`` every step) can hang or deadlock if one rank's
iterator is exhausted while others are still stepping; callers doing
synchronous multi-rank training with ``on_transform_error != "raise"``
are responsible for their own cross-rank stopping mechanism (e.g.
broadcasting a stop signal on ``StopIteration``). The final few
global steps can also differ across topologies (bounded by the skew
in bad-row counts across splits). The sequence of samples yielded
from each split remains deterministic. Mid-epoch
checkpoints remain exact provided the transform fails
deterministically; in multi-rank training each rank must save its
own ``state_dict`` and the states must be combined with
``merge_state_dicts`` before resuming on a different topology.
Prefer the ``filter`` parameter when bad rows can be expressed as a
SQL predicate (e.g. ``"col IS NOT NULL"``) filtering happens before
splits are built, so every guarantee is fully preserved.
worker_info_override:
If set, used in place of ``torch.utils.data.get_worker_info()`` to
determine the DataLoader worker assignment. Intended for unit tests
@@ -152,6 +200,7 @@ class StreamingDataset(IterableDataset):
filter: Optional[str] = None,
transform: Optional[Callable] = None,
transform_parallelism: Optional[int] = None,
on_transform_error: Union[str, Callable[[Exception], bool]] = "raise",
connection_factory: Optional[Callable[[str], Any]] = None,
worker_info_override=None,
):
@@ -167,6 +216,13 @@ class StreamingDataset(IterableDataset):
)
if transform_parallelism is not None and transform_parallelism <= 0:
raise ValueError("transform_parallelism must be greater than 0")
if on_transform_error not in ("raise", "skip", "warn") and not callable(
on_transform_error
):
raise ValueError(
"on_transform_error must be 'raise', 'skip', 'warn', or a "
f"callable, got {on_transform_error!r}"
)
self._table = table
self._num_splits = num_splits
@@ -182,6 +238,7 @@ class StreamingDataset(IterableDataset):
self._filter = filter
self._transform = transform
self._transform_parallelism = transform_parallelism
self._on_transform_error = on_transform_error
self._connection_factory = connection_factory
self._worker_info_override = worker_info_override
@@ -199,19 +256,28 @@ class StreamingDataset(IterableDataset):
# in the main process. RawArray is picklable via the forkserver
# reduction protocol so it survives the dataset pickle round-trip.
# Layout: [unscanned_rows, raw_rows, cooked_rows, consumed_rows,
# bytes_loaded, fetch_time_us, transform_time_us]
self._worker_stats: RawArray = RawArray(ctypes.c_int64, 7)
# bytes_loaded, fetch_time_us, transform_time_us,
# rows_skipped]
self._worker_stats: RawArray = RawArray(ctypes.c_int64, 8)
# Cumulative bytes of Arrow buffer data fetched across all iterations.
self._bytes_loaded: int = 0
# Cumulative seconds spent in LanceDB I/O and in transform functions.
self._fetch_time: float = 0.0
self._transform_time: float = 0.0
# Cumulative rows dropped by on_transform_error across all iterations.
self._rows_skipped: int = 0
# Number of samples each split has already been consumed. At global
# step boundaries all splits have consumed this many samples, so a
# single scalar captures the topology-independent checkpoint state.
self._resume_offset: int = 0
# Permutation position each split has consumed through, keyed by
# global split index. Equal to _resume_offset for every split unless
# on_transform_error skipped rows, in which case skipped positions
# push the watermark of the affected splits further ahead. Splits
# this instance has never iterated have no entry.
self._resume_positions: dict[int, int] = {}
# Build the permutation table once, deterministically.
builder = permutation_builder(table)
@@ -275,6 +341,7 @@ class StreamingDataset(IterableDataset):
# Set identity transform on each Permutation so __getitems__ returns
# the raw RecordBatch. Stage 2 applies the real transform.
permutations: list[Permutation] = []
initial_positions: list[int] = []
for split_idx in my_splits:
perm = Permutation.from_tables(
self._table, self._perm_table, split=split_idx
@@ -282,14 +349,20 @@ class StreamingDataset(IterableDataset):
if self._columns is not None:
perm = perm.select_columns(self._columns)
perm = perm.with_transform(lambda batch: batch)
if self._resume_offset > 0:
perm = perm.with_skip(self._resume_offset)
start_pos = self._resume_positions.get(split_idx, self._resume_offset)
if start_pos > 0:
perm = perm.with_skip(start_pos)
initial_positions.append(start_pos)
permutations.append(perm)
n = len(permutations)
split_sizes = [perm.num_rows for perm in permutations]
initial_offset = self._resume_offset
local_consumed = [0] * n
# Permutation position each split has consumed through (absolute,
# i.e. counted from the start of the unskipped split). Runs ahead of
# initial + local_consumed when rows are skipped.
pos_consumed = list(initial_positions)
batch_size = self._read_batch_size
max_prefetch = self._prefetch_batches
@@ -302,12 +375,14 @@ class StreamingDataset(IterableDataset):
self._transform if self._transform is not None else Transforms.arrow2python
)
# Per-split pipeline state.
# Per-split pipeline state. Batches are paired with the absolute
# permutation position of their first row so that skipped rows can be
# accounted for in pos_consumed.
fetch_head = [0] * n
io_pending = [deque() for _ in range(n)] # Future[RecordBatch]
raw_batches = [deque() for _ in range(n)] # RecordBatch — fetched, awaiting tx
tx_pending = [deque() for _ in range(n)] # Future[list[Any]]
cooked = [deque() for _ in range(n)] # rows ready to yield
io_pending = [deque() for _ in range(n)] # (abs_start, Future[RecordBatch])
raw_batches = [deque() for _ in range(n)] # (abs_start, RecordBatch)
tx_pending = [deque() for _ in range(n)] # Future[list[(abs_pos, row)]]
cooked = [deque() for _ in range(n)] # (abs_pos, row) ready to yield
# Limit simultaneous transforms to transform_workers across all splits.
tx_semaphore = threading.Semaphore(transform_workers)
@@ -330,7 +405,8 @@ class StreamingDataset(IterableDataset):
fetch_head[i] += fetch
perm_i = permutations[i]
indices = list(range(start, start + fetch))
io_pending[i].append(io_pool.submit(_io_call, perm_i, indices))
abs_start = initial_positions[i] + start
io_pending[i].append((abs_start, io_pool.submit(_io_call, perm_i, indices)))
def _fill_io(i: int) -> None:
while len(io_pending[i]) < max_prefetch and fetch_head[i] < split_sizes[i]:
@@ -338,15 +414,72 @@ class StreamingDataset(IterableDataset):
def _drain_io(i: int) -> None:
"""Move completed I/O futures into raw_batches non-blockingly."""
while io_pending[i] and io_pending[i][0].done():
raw_batches[i].append(io_pending[i].popleft().result())
while io_pending[i] and io_pending[i][0][1].done():
abs_start, fut = io_pending[i].popleft()
raw_batches[i].append((abs_start, fut.result()))
# ── Stage 2 helpers ───────────────────────────────────────────────────
def _tx_call_guarded(batch):
on_error = self._on_transform_error
def _should_skip(exc: Exception) -> bool:
if on_error == "raise":
return False
if callable(on_error):
return bool(on_error(exc))
return True # "skip" or "warn"
def _check_row_count(rows: list, num_rows: int) -> None:
if len(rows) != num_rows:
raise ValueError(
f"transform returned {len(rows)} rows for a batch of "
f"{num_rows}; transforms must return exactly one output "
"row per input row. To drop bad rows, raise inside the "
"transform and pass on_transform_error='skip'."
)
def _transform_isolated(abs_start, batch, batch_exc):
"""Re-run the transform on single-row slices, dropping failures."""
out = []
skipped = 0
first_exc = None
for j in range(batch.num_rows):
try:
rows = list(final_transform(batch.slice(j, 1)))
except Exception as exc:
if not _should_skip(exc):
raise
skipped += 1
if first_exc is None:
first_exc = exc
continue
_check_row_count(rows, 1)
out.append((abs_start + j, rows[0]))
self._rows_skipped += skipped
if skipped and on_error == "warn":
logger.warning(
"Skipped %d of %d rows whose transform failed (first error: %r)",
skipped,
batch.num_rows,
first_exc if first_exc is not None else batch_exc,
)
return out
def _transform_batch(abs_start, batch):
"""Apply the transform, returning [(abs_pos, row), ...]."""
try:
rows = list(final_transform(batch))
except Exception as exc:
if not _should_skip(exc):
raise
return _transform_isolated(abs_start, batch, exc)
_check_row_count(rows, batch.num_rows)
return [(abs_start + j, row) for j, row in enumerate(rows)]
def _tx_call_guarded(abs_start, batch):
try:
t0 = time.perf_counter()
result = final_transform(batch)
result = _transform_batch(abs_start, batch)
self._transform_time += time.perf_counter() - t0
return result
finally:
@@ -355,8 +488,8 @@ class StreamingDataset(IterableDataset):
def _try_submit_tx(i: int) -> None:
"""Submit transforms for raw_batches[i] up to available capacity."""
while raw_batches[i] and tx_semaphore.acquire(blocking=False):
batch = raw_batches[i].popleft()
tx_pending[i].append(tx_pool.submit(_tx_call_guarded, batch))
abs_start, batch = raw_batches[i].popleft()
tx_pending[i].append(tx_pool.submit(_tx_call_guarded, abs_start, batch))
def _drain_tx(i: int) -> None:
"""Move completed transform futures into cooked non-blockingly."""
@@ -384,11 +517,14 @@ class StreamingDataset(IterableDataset):
# Acquire a transform slot (may block briefly if all
# transform_workers are busy with other splits).
tx_semaphore.acquire()
batch = raw_batches[i].popleft()
tx_pending[i].append(tx_pool.submit(_tx_call_guarded, batch))
abs_start, batch = raw_batches[i].popleft()
tx_pending[i].append(
tx_pool.submit(_tx_call_guarded, abs_start, batch)
)
elif io_pending[i]:
# Block on the oldest in-flight I/O fetch.
raw_batches[i].append(io_pending[i].popleft().result())
abs_start, fut = io_pending[i].popleft()
raw_batches[i].append((abs_start, fut.result()))
_advance(i)
else:
break # split exhausted
@@ -407,15 +543,28 @@ class StreamingDataset(IterableDataset):
_fill_io(i)
while True:
# Stop when any split is exhausted (all exhaust
# simultaneously: equal split sizes + round-robin).
if any(local_consumed[i] >= split_sizes[i] for i in range(n)):
# A cycle only runs if every split can still produce a
# row. Without skips all splits exhaust simultaneously
# (equal split sizes + round-robin); when
# on_transform_error drops rows a split can run dry
# early, ending the epoch at the last complete cycle.
# This check only sees splits owned by this rank/worker
# (my_splits) — there is no cross-rank coordination, so
# a different rank with fewer skipped rows keeps going;
# see the on_transform_error docstring.
exhausted = False
for i in range(n):
_ensure_cooked(i)
if not cooked[i]:
exhausted = True
break
if exhausted:
break
for i in range(n):
_ensure_cooked(i)
row = cooked[i].popleft()
pos, row = cooked[i].popleft()
local_consumed[i] += 1
pos_consumed[i] = pos + 1
_advance(i)
# After the last split in each cycle: update the
@@ -424,21 +573,39 @@ class StreamingDataset(IterableDataset):
# even when __iter__ runs in a worker process.
if i == n - 1:
self._resume_offset = initial_offset + local_consumed[i]
for j, split_idx in enumerate(my_splits):
self._resume_positions[split_idx] = pos_consumed[j]
ws = self._worker_stats
ws[0] = sum(
split_sizes[j] - fetch_head[j] for j in range(n)
)
ws[1] = sum(
batch.num_rows for q in raw_batches for batch in q
batch.num_rows
for q in raw_batches
for _, batch in q
)
ws[2] = sum(len(q) for q in cooked)
ws[3] = sum(local_consumed)
ws[4] = self._bytes_loaded
ws[5] = int(self._fetch_time * 1_000_000)
ws[6] = int(self._transform_time * 1_000_000)
ws[7] = self._rows_skipped
yield row
finally:
# Final stats flush: the per-cycle write above never runs
# when iteration ends mid-cycle (e.g. a split whose rows
# were all skipped before completing a single cycle), so
# counters like rows_skipped would otherwise be stale.
ws = self._worker_stats
ws[0] = sum(split_sizes[j] - fetch_head[j] for j in range(n))
ws[1] = 0 # queue-depth properties document 0 when idle
ws[2] = 0
ws[3] = sum(local_consumed)
ws[4] = self._bytes_loaded
ws[5] = int(self._fetch_time * 1_000_000)
ws[6] = int(self._transform_time * 1_000_000)
ws[7] = self._rows_skipped
self._raw_batches_ref = None
self._cooked_ref = None
self._fetch_head_ref = None
@@ -492,7 +659,7 @@ class StreamingDataset(IterableDataset):
batches. Returns 0 when not iterating.
"""
if self._raw_batches_ref is not None:
return sum(batch.num_rows for q in self._raw_batches_ref for batch in q)
return sum(batch.num_rows for q in self._raw_batches_ref for _, batch in q)
return int(self._worker_stats[1])
@property
@@ -522,6 +689,19 @@ class StreamingDataset(IterableDataset):
)
return int(self._worker_stats[0])
@property
def rows_skipped(self) -> int:
"""Number of rows dropped because their transform raised an exception.
Only ever non-zero when ``on_transform_error`` is set to ``"skip"``,
``"warn"``, or a callable that returned ``True``. Accumulates across
multiple iterations of the same dataset instance and is never reset
automatically.
"""
if self._raw_batches_ref is not None:
return self._rows_skipped
return int(self._worker_stats[7])
@property
def consumed_rows(self) -> int:
"""Number of rows already yielded to the caller across all splits.
@@ -587,12 +767,27 @@ class StreamingDataset(IterableDataset):
every split has been consumed the same number of times (by the
round-robin design), so the per-split count is a single uniform value
that is identical across all ranks and DataLoader workers.
``positions_consumed_per_split`` records how far into each split's
permutation iteration has advanced. It only differs from
``samples_consumed_per_split`` when ``on_transform_error`` skipped
rows, in which case entries are exact for the splits this instance
iterated and a lower bound (the sample count) for splits owned by
other ranks or workers. Combine the state dicts from all ranks with
[merge_state_dicts][lancedb.streaming.StreamingDataset.merge_state_dicts]
to recover the exact value for every split before resuming on a
different topology.
"""
positions = [
self._resume_positions.get(split, self._resume_offset)
for split in range(self._num_splits)
]
return {
"shuffle_seed": self._shuffle_seed,
"num_splits": self._num_splits,
"epoch": self._epoch,
"samples_consumed_per_split": [self._resume_offset] * self._num_splits,
"positions_consumed_per_split": positions,
}
def load_state_dict(self, state: dict) -> None:
@@ -618,3 +813,96 @@ class StreamingDataset(IterableDataset):
self._resume_offset = consumed[0] if consumed else 0
else:
self._resume_offset = int(consumed)
# Older checkpoints predate positions_consumed_per_split; without
# skipped rows positions equal sample counts, so falling back to
# _resume_offset (the .get default in __iter__) is exact.
positions = state.get("positions_consumed_per_split")
if positions is None:
self._resume_positions = {}
else:
self._resume_positions = {
split: int(pos) for split, pos in enumerate(positions)
}
@staticmethod
def merge_state_dicts(states: list[dict]) -> dict:
"""Merge state dicts saved by different ranks into one exact state.
Only needed when ``on_transform_error`` skips rows in multi-rank
training: each rank then knows the exact permutation position only for
its own splits, and records a lower bound for the rest. Because
exactly one rank owns each split, the elementwise maximum across all
ranks' ``positions_consumed_per_split`` recovers the exact position of
every split. Without skipped rows every rank's state is already
identical and merging is a no-op.
Raises ``ValueError`` if the states are empty or were not produced by
the same run (mismatched seed, split count, epoch, or sample counts).
The merge is always all-to-all and topology-agnostic: collect the
``state_dict()`` from every rank of the *previous* run into one list,
merge that whole list, and hand the identical merged result to every
rank of the *next* run regardless of whether the rank count grew,
shrank, or stayed the same. There is no pairwise or subset merging
step, because each split's exact position is only known to whichever
rank owned that split, and the elementwise maximum needs every rank's
contribution to be correct.
For example, checkpointing 8 ranks and resuming on 4 (the same
pattern applies when growing, e.g. 4 ranks resuming on 8)::
states = [ds.state_dict() for ds in previous_run_datasets] # 8
merged = StreamingDataset.merge_state_dicts(states)
for ds in resumed_datasets: # now only 4 ranks
ds.load_state_dict(merged) # same dict on every rank
The rank count on either side never affects the merge itself, since
``merge_state_dicts`` only cares about the list of states it is
given. Each split's position is recovered by elementwise maximum;
here rank 0 owned split 0 (and skipped two rows there) while rank 1
owned split 1 (and skipped one row):
>>> rank0 = {
... "shuffle_seed": 0, "num_splits": 2, "epoch": 0,
... "samples_consumed_per_split": [3, 3],
... "positions_consumed_per_split": [5, 3],
... }
>>> rank1 = {
... "shuffle_seed": 0, "num_splits": 2, "epoch": 0,
... "samples_consumed_per_split": [3, 3],
... "positions_consumed_per_split": [3, 4],
... }
>>> merged = StreamingDataset.merge_state_dicts([rank0, rank1])
>>> merged["positions_consumed_per_split"]
[5, 4]
"""
if not states:
raise ValueError("merge_state_dicts requires at least one state dict")
first = states[0]
for state in states[1:]:
for key in ("shuffle_seed", "num_splits", "epoch"):
if state[key] != first[key]:
raise ValueError(
f"{key} mismatch across state dicts: "
f"{state[key]} != {first[key]}"
)
if (
state["samples_consumed_per_split"]
!= first["samples_consumed_per_split"]
):
raise ValueError(
"samples_consumed_per_split mismatch across state dicts; "
"state_dict() must be called at the same global step "
"boundary on every rank"
)
merged = dict(first)
all_positions = [
state.get(
"positions_consumed_per_split", state["samples_consumed_per_split"]
)
for state in states
]
merged["positions_consumed_per_split"] = [
max(per_split) for per_split in zip(*all_positions)
]
return merged
+417 -26
View File
@@ -40,6 +40,7 @@ from ._blob import (
from .types import BlobMode
from lancedb.arrow import peek_reader
from lancedb.background_loop import LOOP, embedding_executor
from lancedb.job import AsyncJob, Job
from .dependencies import (
_check_for_hugging_face,
_check_for_lance,
@@ -107,6 +108,11 @@ def _should_push_down_query_table(
return namespace_client is not None and "QueryTable" in pushdown_operations
def _polars_predicate_pushdown_barrier(frame: Any) -> Any:
"""Return a Polars frame unchanged while blocking predicate pushdown."""
return frame
_MODEL_BACKED_TOKENIZER_PREFIXES = ("jieba", "lindera")
_MODEL_BACKED_TOKENIZER_ERRORS = (
"unknown base tokenizer",
@@ -170,6 +176,7 @@ if TYPE_CHECKING:
CompactionStats,
Tag,
AddColumnsResult,
RefreshColumnResult,
AddResult,
AlterColumnsResult,
UpdateFieldMetadataResult,
@@ -863,12 +870,18 @@ class Table(ABC):
"""
raise NotImplementedError
def to_polars(self, **kwargs) -> "pl.DataFrame":
"""Return the table as a polars.DataFrame.
def to_polars(self, **kwargs) -> "pl.LazyFrame":
"""Return the table as a Polars LazyFrame.
Note
----
The Polars streaming engine is not supported because it does not currently
implement Python PyArrow dataset scans. Use the default engine when collecting
this LazyFrame.
Returns
-------
polars.DataFrame
polars.LazyFrame
"""
raise NotImplementedError
@@ -977,6 +990,24 @@ class Table(ABC):
"""
raise NotImplementedError
def create_index_async(
self,
column: str,
*,
config: IndexConfigType,
replace: Optional[bool] = None,
wait_timeout: Optional[timedelta] = None,
name: Optional[str] = None,
train: bool = True,
) -> Job:
"""Create an index, returning a handle to the indexing job.
Takes the same arguments as :meth:`create_index`. The job may already
be complete when returned; callers must not assume the index exists
until :meth:`Job.wait` returns.
"""
raise NotImplementedError
def drop_index(self, name: str) -> None:
"""
Drop an index from the table.
@@ -1574,8 +1605,10 @@ class Table(ABC):
"""Open lazy, seekable :class:`~lancedb._blob.BlobFile` handles.
Prefer this over :meth:`fetch_blobs` for large payloads. ``row_ids`` is
a ``list[int]`` or query ``pyarrow.Table`` with ``_rowid`` (or stashed
row-id metadata). Null rows are ``None``. Local tables only.
a ``list[int]`` or a query ``pyarrow.Table`` carrying row identity via
``_rowid`` or a ``_lance_row_id`` field on the blob descriptor. Null
rows are ``None``. Remote tables require LanceDB Cloud server 0.5.0 or
newer.
"""
@abstractmethod
@@ -1884,7 +1917,14 @@ class Table(ABC):
@abstractmethod
def add_columns(
self, transforms: Dict[str, str] | pa.Field | List[pa.Field] | pa.Schema
self,
transforms: Dict[str, str]
| pa.Field
| List[pa.Field]
| pa.Schema
| None = None,
*,
computed: Dict[str, str] | None = None,
):
"""
Add new columns with defined values.
@@ -1898,11 +1938,95 @@ class Table(ABC):
Alternatively, a pyarrow Field or Schema can be provided to add
new columns with the specified data types. The new columns will
be initialized with null values.
computed: Dict[str, str], optional
A map of column name to a SQL expression defining the column. The
column's type and inputs are derived from the expression, so no
data type is supplied.
Unlike ``transforms``, the expression is stored rather than
evaluated now: the column is committed with no values, and rows get
them from [`refresh_column`][lancedb.table.Table.refresh_column].
Declaring one therefore costs the same on a large table as on an
empty one.
A refresh does not revisit rows it has already filled, so mutating
an input leaves the value computed at fill time; recomputing means
dropping the column and declaring it again. While a declaration
reads a column, that column cannot be renamed, retyped or dropped.
On LanceDB Cloud and Enterprise the expression is planned by the
server, and the refresh runs as a server job -- see
[`refresh_column_async`][lancedb.table.Table.refresh_column_async].
Cannot be combined with ``transforms``.
Returns
-------
AddColumnsResult
version: the new version number of the table after adding columns.
Examples
--------
>>> import lancedb
>>> db = lancedb.connect("./.lancedb")
>>> table = db.create_table("computed_demo", [{"x": 1}, {"x": 2}])
>>> table.add_columns(computed={"doubled": "x * 2"})
AddColumnsResult(version=2)
>>> table.refresh_column("doubled")
RefreshColumnResult(rows_filled=2, version=3)
>>> table.to_arrow().sort_by("x").to_pandas()
x doubled
0 1 2
1 2 4
"""
@abstractmethod
def refresh_column(self, column: str) -> "RefreshColumnResult":
"""
Fill the rows of a computed column that hold no value yet.
Declared with ``add_columns(computed=...)``, a column starts empty and
gets its values here. Rows appended since the last refresh are filled
by the next one; rows already filled are left as they are, so the call
is idempotent and does not observe a mutated input.
Local tables only: a remote refresh runs as a server job, through
[`refresh_column_async`][lancedb.table.Table.refresh_column_async].
Parameters
----------
column: str
The name of the computed column to fill.
Returns
-------
RefreshColumnResult
rows_filled: the number of rows given a value.
version: the new version number of the table.
"""
@abstractmethod
def refresh_column_async(self, column: str) -> Job:
"""
Like :meth:`refresh_column`, but returns a handle to the refresh job
instead of blocking until it completes.
The job may already be complete when returned; callers must not assume
the column is filled until :meth:`Job.wait` returns. Invalid input --
an unknown column, or one that is not computed -- raises here rather
than failing the job. On local tables the job runs in-process; on
LanceDB Cloud and Enterprise it is the server's backfill job.
Examples
--------
>>> import lancedb
>>> db = lancedb.connect("./.lancedb")
>>> table = db.create_table("computed_job_demo", [{"x": 1}, {"x": 2}])
>>> table.add_columns(computed={"doubled": "x * 2"})
AddColumnsResult(version=2)
>>> job = table.refresh_column_async("doubled")
>>> job.wait()
>>> job.status()
'finished'
"""
@abstractmethod
@@ -2161,11 +2285,15 @@ class LanceTable(Table):
return self.name
@classmethod
def from_inner(cls, tbl: LanceDBTable):
from .db import LanceDBConnection
async def from_inner(cls, tbl: LanceDBTable):
from .db import AsyncConnection, LanceDBConnection
async_tbl = AsyncTable(tbl)
conn = LanceDBConnection.from_inner(tbl.database())
inner_conn = tbl.database()
read_consistency_interval = await AsyncConnection(
inner_conn
).get_read_consistency_interval()
conn = LanceDBConnection.from_inner(inner_conn, read_consistency_interval)
return cls(
conn,
async_tbl.name,
@@ -2469,13 +2597,7 @@ class LanceTable(Table):
return LOOP.run(self._table.count_rows(filter))
def __repr__(self) -> str:
val = f"{self.__class__.__name__}(name={self.name!r}"
if self._conn.read_consistency_interval is not None:
val += ", read_consistency_interval={!r}".format(
self._conn.read_consistency_interval
)
val += f", _conn={self._conn!r})"
return val
return f"{self.__class__.__name__}(name={self.name!r}, _conn={self._conn!r})"
def __str__(self) -> str:
return self.__repr__()
@@ -2550,6 +2672,9 @@ class LanceTable(Table):
2. Currently we've disabled push-down of the filters from polars
because polars pushdown into pyarrow uses pyarrow compute
expressions rather than SQl strings (which LanceDB supports)
3. The Polars streaming engine is not supported because it does not
currently implement Python PyArrow dataset scans. Use the default
engine when collecting this LazyFrame.
Returns
-------
@@ -2558,8 +2683,12 @@ class LanceTable(Table):
from lancedb.integrations.pyarrow import PyarrowDatasetAdapter
dataset = PyarrowDatasetAdapter(self)
return pl.scan_pyarrow_dataset(
dataset, allow_pyarrow_filter=False, batch_size=batch_size
# Polars 1.32's non-PyArrow callback path passes batch_size twice. Keep
# the compatible PyArrow path, but block predicates because this adapter
# cannot translate PyArrow expressions into LanceDB filters.
return pl.scan_pyarrow_dataset(dataset, batch_size=batch_size).map_batches(
_polars_predicate_pushdown_barrier,
predicate_pushdown=False,
)
# New unified API overload
@@ -2784,6 +2913,34 @@ class LanceTable(Table):
)
)
def create_index_async(
self,
column: str,
*,
config: IndexConfigType,
replace: Optional[bool] = None,
wait_timeout: Optional[timedelta] = None,
name: Optional[str] = None,
train: bool = True,
) -> Job:
"""Create an index, returning a handle to the indexing job.
The job may already be complete when returned; callers must not assume
the index exists until :meth:`Job.wait` returns.
"""
return Job(
LOOP.run(
self._table.create_index_async(
column,
replace=replace,
config=config,
wait_timeout=wait_timeout,
name=name,
train=train,
)
)
)
def _is_legacy_create_index_call(
self,
first_arg: str,
@@ -3874,9 +4031,28 @@ class LanceTable(Table):
return LOOP.run(self._table.index_stats(index_name))
def add_columns(
self, transforms: Dict[str, str] | pa.field | List[pa.field] | pa.Schema
self,
transforms: Dict[str, str]
| pa.field
| List[pa.field]
| pa.Schema
| None = None,
*,
computed: Dict[str, str] | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms))
return LOOP.run(self._table.add_columns(transforms, computed=computed))
def refresh_column(self, column: str) -> "RefreshColumnResult":
"""Fill a computed column's unfilled rows. See
[`AsyncTable.refresh_column`][lancedb.AsyncTable.refresh_column]."""
return LOOP.run(self._table.refresh_column(column))
def refresh_column_async(self, column: str) -> Job:
"""Fill a computed column's unfilled rows, returning a handle to the
refresh job. See
[`Table.refresh_column_async`][lancedb.table.Table.refresh_column_async].
"""
return Job(LOOP.run(self._table.refresh_column_async(column)))
def alter_columns(
self, *alterations: Iterable[Dict[str, str]]
@@ -3911,6 +4087,28 @@ class LanceTable(Table):
[`AsyncTable.get_lsm_write_spec`][lancedb.AsyncTable.get_lsm_write_spec]."""
return LOOP.run(self._table.get_lsm_write_spec())
def checkpoint_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.checkpoint_lsm`][lancedb.AsyncTable.checkpoint_lsm]."""
return LOOP.run(self._table.checkpoint_lsm())
def flush_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.flush_lsm`][lancedb.AsyncTable.flush_lsm]."""
return LOOP.run(self._table.flush_lsm())
def compact_lsm(self) -> None:
"""Synchronous version of
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
)
def close_lsm_writers(self) -> None:
"""Close cached MemWAL shard writers. See
[`AsyncTable.close_lsm_writers`][lancedb.AsyncTable.close_lsm_writers]."""
@@ -4589,6 +4787,13 @@ class AsyncTable:
via [`set_unenforced_primary_key`]; bucket sharding additionally
requires it to be the single column being bucketed.
By default the MemWAL maintains every index on the table, resolved
here a snapshot, so an index created afterwards needs the spec unset
and set again. This fails if one cannot be maintained; name the set
with ``with_maintained_indexes`` to install anyway. That pins an exact
set (a still-building index is rejected, not omitted); ``[]`` maintains
none.
Parameters
----------
spec : LsmWriteSpec
@@ -4596,7 +4801,7 @@ class AsyncTable:
Examples
--------
>>> from lancedb._lancedb import LsmWriteSpec
>>> from lancedb import LsmWriteSpec
>>> # table.set_unenforced_primary_key("id")
>>> # table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 16))
"""
@@ -4615,12 +4820,73 @@ class AsyncTable:
Returns ``None`` when the MemWAL LSM write path is not enabled (no
spec has been set, or it was removed with `unset_lsm_write_spec`).
The returned spec including its ``maintained_indexes`` and
``writer_config_defaults`` mirrors what was passed to
`set_lsm_write_spec`.
The returned spec mirrors what was passed to `set_lsm_write_spec`,
except that ``maintained_indexes`` always reports the concrete list
resolved when the spec was set ``None`` never round-trips.
"""
return await self._inner.get_lsm_write_spec()
async def checkpoint_lsm(self) -> None:
"""Converge this table's LSM write path into its base table.
One flush, sealing every memtable into L0, then compaction triggers
until every generation that existed at that moment has reached base.
The loop runs client-side, reading progress from ``get_lsm_stats``.
Best-effort: generations created *while* it runs are deliberately not
waited on, which is what lets it terminate on a table taking writes.
Idempotent and safe on a cadence.
There is no deadline, and the caller owns that. It returns when the
target generations are gone, raises on a terminal server fault, and
otherwise waits however long the server takes. A slow table and a
stuck one are the same picture from the client: the compactor pool is
shared across every table on the node, so a checkpoint queued behind
unrelated work looks exactly like one that is merging. Wrap this in
``asyncio.wait_for`` for a wall-clock bound; abandoning it partway
costs nothing.
"""
await self._inner.checkpoint_lsm()
async def flush_lsm(self) -> None:
"""Seal every bucket's active memtable into L0.
Does not touch the base table moving L0 into base is
`compact_lsm`. On a node that has not claimed this table, this claims
it and replays its WAL log first.
"""
await self._inner.flush_lsm()
async def compact_lsm(self) -> None:
"""Trigger a background L0 to base compaction pass per bucket.
Returns once the passes are dispatched, not once they finish: watch
``get_lsm_stats`` for progress, or use ``checkpoint_lsm`` to loop
until the current L0 has reached base.
"""
await self._inner.compact_lsm()
async def get_lsm_stats(
self, *, include_generation_rows: bool = False
) -> Optional[dict]:
"""Read live per-bucket LSM state.
Answers "how far behind is my fresh tier", "which bucket is hot", and
"why is my fresh-tier vector search brute-force". Mutates no table
state, though on a node that has not claimed this table it claims it,
exactly as a read would.
Returns ``None`` only when the LSM write path is not enabled.
Parameters
----------
include_generation_rows
Report a row count per L0 generation. Off by default: each count
opens an uncached Lance dataset, and ``checkpoint_lsm`` polls this
needing only generation numbers.
"""
return await self._inner.get_lsm_stats(include_generation_rows)
async def close_lsm_writers(self) -> None:
"""Drain and close any cached MemWAL shard writers for this table.
@@ -4871,6 +5137,46 @@ class AsyncTable:
)
raise e
async def create_index_async(
self,
column: str,
*,
replace: Optional[bool] = None,
config: Optional[
Union[
IvfFlat,
IvfPq,
IvfRq,
HnswPq,
HnswSq,
HnswFlat,
BTree,
Bitmap,
LabelList,
Fm,
FTS,
]
] = None,
wait_timeout: Optional[timedelta] = None,
name: Optional[str] = None,
train: bool = True,
) -> AsyncJob:
"""Create an index, returning a handle to the indexing job.
Takes the same arguments as :meth:`create_index`. The job may already
be complete when returned; callers must not assume the index exists
until :meth:`AsyncJob.wait` resolves.
"""
job = await self._inner.create_index_async(
column,
index=config,
replace=replace,
wait_timeout=wait_timeout,
name=name,
train=train,
)
return AsyncJob(job)
async def drop_index(self, name: str) -> None:
"""
Drop an index from the table.
@@ -5661,7 +5967,14 @@ class AsyncTable:
return await self._inner.update(updates_sql, where)
async def add_columns(
self, transforms: dict[str, str] | pa.field | List[pa.field] | pa.Schema
self,
transforms: dict[str, str]
| pa.field
| List[pa.field]
| pa.Schema
| None = None,
*,
computed: dict[str, str] | None = None,
) -> AddColumnsResult:
"""
Add new columns with defined values.
@@ -5674,6 +5987,22 @@ class AsyncTable:
each row in the table, and can reference existing columns.
Alternatively, you can pass a pyarrow field or schema to add
new columns with NULLs.
computed: Dict[str, str], optional
A map of column name to a SQL expression defining the column. The
column's type and inputs are derived from the expression.
Unlike ``transforms``, the expression is stored rather than
evaluated now: the column is committed with no values, and rows get
them from
[`refresh_column`][lancedb.table.AsyncTable.refresh_column].
A refresh does not revisit rows it has already filled, so mutating
an input leaves the value computed at fill time. While a
declaration reads a column, that column cannot be renamed, retyped
or dropped.
On LanceDB Cloud and Enterprise the expression is planned by
the server. Cannot be combined with ``transforms``.
Returns
-------
@@ -5687,11 +6016,71 @@ class AsyncTable:
{isinstance(f, pa.Field) for f in transforms}
):
transforms = pa.schema(transforms)
if computed:
if transforms:
raise ValueError(
"add_columns cannot take both transforms and computed columns"
)
return await self._inner.add_computed_columns(list(computed.items()))
if transforms is None:
raise ValueError("add_columns requires transforms or computed columns")
if isinstance(transforms, pa.Schema):
return await self._inner.add_columns_with_schema(transforms)
else:
return await self._inner.add_columns(list(transforms.items()))
async def refresh_column(self, column: str) -> RefreshColumnResult:
"""
Fill the rows of a computed column that hold no value yet.
Declared with ``add_columns(computed=...)``, a column starts empty and
gets its values here. Rows appended since the last refresh are filled
by the next one; rows already filled are left as they are, so the call
is idempotent and does not observe a mutated input.
Local tables only: a remote refresh runs as a server job, through
[`refresh_column_async`][lancedb.table.Table.refresh_column_async].
Parameters
----------
column: str
The name of the computed column to fill.
Returns
-------
RefreshColumnResult
The number of rows filled and the new version of the table.
"""
return await self._inner.refresh_column(column)
async def refresh_column_async(self, column: str) -> AsyncJob:
"""
Like :meth:`refresh_column`, but returns a handle to the refresh job
instead of blocking until it completes.
The job may already be complete when returned; callers must not assume
the column is filled until :meth:`AsyncJob.wait` resolves. Invalid
input -- an unknown column, or one that is not computed -- raises here
rather than failing the job. On local tables the job runs
in-process; on LanceDB Cloud and Enterprise it is the server's
backfill job.
Examples
--------
>>> import asyncio
>>> import lancedb
>>> async def refresh_in_background():
... db = await lancedb.connect_async("./.lancedb")
... table = await db.create_table("computed_job_async_demo", [{"x": 1}])
... await table.add_columns(computed={"doubled": "x * 2"})
... job = await table.refresh_column_async("doubled")
... await job.wait()
... return await job.status()
>>> asyncio.run(refresh_in_background())
'finished'
"""
return AsyncJob(await self._inner.refresh_column_async(column))
async def alter_columns(
self, *alterations: Iterable[dict[str, Any]]
) -> AlterColumnsResult:
@@ -6146,7 +6535,9 @@ class TableStatistics:
Attributes
----------
total_bytes: int
The total number of bytes in the table.
The total size, in bytes, of the table's data files, index files, and
overlay files. Read from the manifest, so this excludes deletion files
and manifests.
num_rows: int
The total number of rows in the table.
num_indices: int
+5
View File
@@ -395,6 +395,11 @@ def _(value: dict):
)
@value_to_sql.register(pa.Scalar)
def _(value: pa.Scalar):
return value_to_sql(value.as_py())
@value_to_sql.register(np.ndarray)
def _(value: np.ndarray):
return value_to_sql(value.tolist())
+3 -3
View File
@@ -226,13 +226,13 @@ def test_fetch_blob_ranges_validates_requests():
table = _blob_table("range_validation", [{"id": 1, "image": b"abc"}])
row_id = _row_ids_by_id(table)[1]
with pytest.raises(RuntimeError, match="exceeds blob size"):
with pytest.raises(ValueError, match="exceeds blob size"):
table.fetch_blob_ranges("image", [(row_id, 2, 2)])
with pytest.raises(RuntimeError, match="offset \\+ length overflowed"):
with pytest.raises(ValueError, match="offset \\+ length overflowed"):
table.fetch_blob_ranges("image", [(row_id, 2**64 - 1, 1)])
with pytest.raises(ValueError, match="row ids"):
with pytest.raises(ValueError, match="row IDs"):
table.fetch_blob_ranges("image", [(2**64 - 1, 0, 1)])
+60 -3
View File
@@ -2,9 +2,11 @@
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import inspect
import re
import sys
from datetime import timedelta
from importlib import resources
import os
from types import SimpleNamespace
@@ -17,6 +19,10 @@ from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from lancedb.pydantic import LanceModel, Vector
def test_package_includes_pep_561_marker():
assert resources.files(lancedb).joinpath("py.typed").is_file()
def test_basic(tmp_path):
db = lancedb.connect(tmp_path)
@@ -62,6 +68,44 @@ def test_basic(tmp_path):
assert db.open_table("test").name == db["test"].name
def test_sync_debugger_inspection_does_not_use_background_loop(tmp_path, monkeypatch):
from lancedb.background_loop import LOOP
db = lancedb.connect(tmp_path)
table = db.create_table("test", data=[{"id": 1}])
def fail_run(*args, **kwargs):
raise AssertionError("debugger inspection should not use the background loop")
monkeypatch.setattr(LOOP, "run", fail_run)
# Debuggers enumerate and evaluate every exposed attribute when expanding a
# variable. This must remain safe while their breakpoint suspends LOOP's thread.
members = dict(inspect.getmembers(db))
assert members["uri"] == str(tmp_path)
assert members["read_consistency_interval"] is None
assert repr(db) == f"LanceDBConnection(uri={str(tmp_path)!r})"
assert repr(table) == f"LanceTable(name='test', _conn={db!r})"
def test_read_consistency_interval_does_not_use_background_loop(tmp_path, monkeypatch):
from lancedb.background_loop import LOOP
from lancedb.db import LanceDBConnection
consistency_interval = timedelta(seconds=5)
db = lancedb.connect(tmp_path, read_consistency_interval=consistency_interval)
db_from_inner = LanceDBConnection.from_inner(db._inner, consistency_interval)
def fail_run(*args, **kwargs):
raise AssertionError("properties should not use the Python background loop")
monkeypatch.setattr(LOOP, "run", fail_run)
assert db.read_consistency_interval == consistency_interval
assert db_from_inner.read_consistency_interval == consistency_interval
def test_ingest_pd(tmp_path):
db = lancedb.connect(tmp_path)
@@ -711,8 +755,7 @@ def test_delete_table(tmp_db: lancedb.DBConnection):
assert tmp_db.table_names() == []
@pytest.mark.asyncio
async def test_delete_table_async(tmp_db: lancedb.DBConnection):
def test_drop_table_async(tmp_db: lancedb.DBConnection):
data = pd.DataFrame(
{
"vector": [[3.1, 4.1], [5.9, 26.5]],
@@ -728,7 +771,10 @@ async def test_delete_table_async(tmp_db: lancedb.DBConnection):
assert tmp_db.table_names() == ["test"]
tmp_db.drop_table("test")
job = tmp_db.drop_table_async("test")
assert job.id is None
assert job.status() == "finished"
job.wait()
assert tmp_db.table_names() == []
tmp_db.create_table("test", data=data)
@@ -737,6 +783,17 @@ async def test_delete_table_async(tmp_db: lancedb.DBConnection):
tmp_db.drop_table("does_not_exist", ignore_missing=True)
@pytest.mark.asyncio
async def test_drop_table_async_connection(tmp_db_async: lancedb.AsyncConnection):
await tmp_db_async.create_table("test", data=pa.table({"id": [1, 2]}))
job = await tmp_db_async.drop_table_async("test")
assert job.id is None
assert await job.status() == "finished"
await job.wait()
assert await tmp_db_async.table_names() == []
def test_drop_database(tmp_db: lancedb.DBConnection):
data = pd.DataFrame(
{
@@ -1456,6 +1456,408 @@ def test_shuffle_clump_size_yields_all_rows(lance_table):
)
# ---------------------------------------------------------------------------
# on_transform_error tests
# ---------------------------------------------------------------------------
class BadRowError(ValueError):
"""Raised by the failing transforms below when a batch contains a bad id."""
def _failing_transform(bad_ids: set):
"""A transform that raises BadRowError whenever the batch has a bad id.
Raises on the full batch and on any single-row slice containing a bad id,
so per-row isolation drops exactly the bad rows.
"""
def transform(batch: pa.RecordBatch) -> list:
ids = batch.column("id").to_pylist()
bad = sorted(set(ids) & bad_ids)
if bad:
raise BadRowError(f"bad ids in batch: {bad}")
return [{"id": i} for i in ids]
return transform
def _sequential_split_members(table) -> list[list[int]]:
"""Return each split's ids in yield order for shuffle=False.
With a single rank and no workers the round-robin yields one row per split
per cycle, so item k of a clean run belongs to split k % NUM_SPLITS.
"""
ds = StreamingDataset(table, num_splits=NUM_SPLITS, shuffle=False)
members: list[list[int]] = [[] for _ in range(NUM_SPLITS)]
for k, row in enumerate(ds):
members[k % NUM_SPLITS].append(row["id"])
return members
def test_on_transform_error_default_raises(lance_table):
"""By default a transform exception propagates and aborts iteration."""
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle_seed=SHUFFLE_SEED,
transform=_failing_transform({7}),
)
with pytest.raises(BadRowError):
list(ds)
def test_on_transform_error_invalid_value(lance_table):
with pytest.raises(ValueError, match="on_transform_error"):
StreamingDataset(lance_table, num_splits=NUM_SPLITS, on_transform_error="bogus")
def test_on_transform_error_skip_drops_bad_rows(lance_table):
"""With one bad row per split, 'skip' yields every good row exactly once
and counts the dropped rows in rows_skipped."""
members = _sequential_split_members(lance_table)
bad_ids = {members[i][4] for i in range(NUM_SPLITS)}
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
assert ds.rows_skipped == 0
ids = [row["id"] for row in ds]
assert sorted(ids) == sorted(set(range(NUM_ROWS)) - bad_ids)
assert ds.rows_skipped == NUM_SPLITS
def test_on_transform_error_skip_uneven_ends_at_last_complete_cycle(lance_table):
"""When one split loses more rows than the others, the epoch ends at the
last cycle where every split still has a row no crash, no bad rows, and
every step remains one sample per split."""
members = _sequential_split_members(lance_table)
bad_ids = set(members[0][:3]) # all 3 bad rows in split 0
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
items = [row["id"] for row in ds]
rows_per_split = NUM_ROWS // NUM_SPLITS
expected_cycles = rows_per_split - len(bad_ids)
assert len(items) == expected_cycles * NUM_SPLITS
assert len(set(items)) == len(items), "duplicate samples yielded"
assert not set(items) & bad_ids, "a bad row was yielded"
# Split 0 contributed exactly its surviving rows, in order, one per cycle.
survivors = [i for i in members[0] if i not in bad_ids]
assert items[0::NUM_SPLITS] == survivors[:expected_cycles]
def test_on_transform_error_warn_logs(lance_table, caplog):
"""'warn' skips like 'skip' but logs a warning for the failing batch."""
members = _sequential_split_members(lance_table)
bad_ids = {members[i][3] for i in range(NUM_SPLITS)}
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="warn",
)
with caplog.at_level(logging.WARNING, logger="lancedb.streaming"):
items = list(ds)
assert len(items) == NUM_ROWS - NUM_SPLITS
assert ds.rows_skipped == NUM_SPLITS
assert "Skipped" in caplog.text
assert "BadRowError" in caplog.text
def test_on_transform_error_callable_selective(lance_table):
"""A callable handler can skip expected errors and re-raise the rest."""
members = _sequential_split_members(lance_table)
bad_ids = {members[i][0] for i in range(NUM_SPLITS)}
handled: list[Exception] = []
def handler(exc: Exception) -> bool:
handled.append(exc)
return isinstance(exc, BadRowError)
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error=handler,
)
items = list(ds)
assert len(items) == NUM_ROWS - NUM_SPLITS
assert handled and all(isinstance(exc, BadRowError) for exc in handled)
def broken_transform(batch: pa.RecordBatch) -> list:
raise TypeError("boom")
ds2 = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=broken_transform,
on_transform_error=handler,
)
with pytest.raises(TypeError, match="boom"):
list(ds2)
def test_transform_wrong_row_count_raises(lance_table):
"""A transform that returns the wrong number of rows is an error even with
on_transform_error='skip' silent shrinkage would corrupt accounting."""
def drops_rows(batch: pa.RecordBatch) -> list:
return batch.column("id").to_pylist()[:-1]
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle_seed=SHUFFLE_SEED,
transform=drops_rows,
on_transform_error="skip",
)
with pytest.raises(ValueError, match="one output row per input row"):
list(ds)
def test_skip_deterministic_across_runs(lance_table):
"""With a fixed seed, skipping produces the identical sample sequence on
every run skips are data-dependent, not run-dependent."""
bad_ids = {5, 17, 46}
def run() -> tuple[list[int], int]:
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle_seed=SHUFFLE_SEED,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
return [row["id"] for row in ds], ds.rows_skipped
ids_a, skipped_a = run()
ids_b, skipped_b = run()
assert ids_a == ids_b
assert skipped_a == skipped_b
assert not set(ids_a) & bad_ids
def test_skip_elastic_det_across_world_sizes(lance_table):
"""With equal bad-row counts per split, skipping preserves the full
elastic-determinism guarantee: identical global batches at every step for
every compatible world_size."""
members = _sequential_split_members(lance_table)
bad_ids = {members[i][6] for i in range(NUM_SPLITS)}
def collect(world_size: int) -> list[frozenset[int]]:
micro = GLOBAL_BATCH_SIZE // world_size
iters = [
iter(
StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
rank=rank,
world_size=world_size,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
)
for rank in range(world_size)
]
_STOP = object()
batches: list[frozenset[int]] = []
while True:
step_samples: set[int] = set()
exhausted = 0
for it in iters:
for _ in range(micro):
val = next(it, _STOP)
if val is _STOP:
exhausted += 1
break
step_samples.add(val["id"])
if exhausted == len(iters):
break
assert exhausted == 0, (
"Rank iterators exhausted at different steps despite equal "
"bad-row counts per split"
)
batches.append(frozenset(step_samples))
return batches
reference = collect(1)
assert len(reference) == NUM_ROWS // NUM_SPLITS - 1
for ws in (2, 3, 4):
assert collect(ws) == reference, f"world_size={ws} diverged"
def test_resumability_with_skips_same_topology(lance_table):
"""Checkpointing mid-epoch with skipped rows resumes exactly: no sample
repeated, no sample lost, skipped rows stay skipped."""
members = _sequential_split_members(lance_table)
# Uneven skips: positions diverge across splits (2 bad in split 0, 1 in
# split 5), which only a position-based checkpoint can resume exactly.
bad_ids = {members[0][2], members[0][3], members[5][7]}
kwargs = dict(
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)]
rows_per_split = NUM_ROWS // NUM_SPLITS
assert len(reference) == (rows_per_split - 2) * NUM_SPLITS
steps = 3
ds = StreamingDataset(lance_table, **kwargs)
it = iter(ds)
consumed = [next(it)["id"] for _ in range(steps * NUM_SPLITS)]
checkpoint = ds.state_dict()
it.close()
# Split 0 skipped positions 2 and 3 within its first 3 yields; split 5's
# bad row is beyond the checkpoint. Everything else is at 3 = the sample
# count.
positions = checkpoint["positions_consumed_per_split"]
assert positions[0] == 5
assert positions[1:] == [3] * (NUM_SPLITS - 1)
assert checkpoint["samples_consumed_per_split"] == [3] * NUM_SPLITS
ds2 = StreamingDataset(lance_table, **kwargs)
ds2.load_state_dict(checkpoint)
resumed = [row["id"] for row in ds2]
assert consumed == reference[: steps * NUM_SPLITS]
assert resumed == reference[steps * NUM_SPLITS :]
def test_resumability_with_skips_elastic_merge(lance_table):
"""Elastic resume with skips: each rank's checkpoint knows exact positions
only for its own splits; merge_state_dicts recovers the global state, and
a run on a different world_size continues exactly."""
members = _sequential_split_members(lance_table)
# Bad rows early in split 0 (rank 0) and split 6 (rank 1 of a ws=2 run) so
# both ranks' position vectors diverge before the checkpoint.
bad_ids = {members[0][0], members[0][2], members[6][1]}
kwargs = dict(
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
reference = [row["id"] for row in StreamingDataset(lance_table, **kwargs)]
steps = 3
world_size = 2
micro = GLOBAL_BATCH_SIZE // world_size
datasets = [
StreamingDataset(lance_table, rank=rank, world_size=world_size, **kwargs)
for rank in range(world_size)
]
iters = [iter(ds) for ds in datasets]
seen: list[frozenset[int]] = []
for _ in range(steps):
step_samples = set()
for it in iters:
for _ in range(micro):
step_samples.add(next(it)["id"])
seen.append(frozenset(step_samples))
states = [ds.state_dict() for ds in datasets]
for it in iters:
it.close()
merged = StreamingDataset.merge_state_dicts(states)
expected_positions = [3] * NUM_SPLITS
expected_positions[0] = 5 # skipped positions 0 and 2
expected_positions[6] = 4 # skipped position 1
assert merged["positions_consumed_per_split"] == expected_positions
# The first 3 global batches match the world_size=1 reference.
ref_batches = [
frozenset(reference[s * NUM_SPLITS : (s + 1) * NUM_SPLITS])
for s in range(len(reference) // NUM_SPLITS)
]
assert seen == ref_batches[:steps]
# Resume on world_size=1 from the merged state.
ds_resume = StreamingDataset(lance_table, **kwargs)
ds_resume.load_state_dict(merged)
resumed = [row["id"] for row in ds_resume]
assert resumed == reference[steps * NUM_SPLITS :]
def test_rows_skipped_flushed_when_split_entirely_bad(lance_table):
"""A split whose rows all fail never completes a cycle, so the epoch ends
immediately but rows_skipped must still report the drops after the
iterator exits (the shared-memory counter is flushed on exhaustion)."""
members = _sequential_split_members(lance_table)
bad_ids = set(members[0]) # every row of split 0 is bad
ds = StreamingDataset(
lance_table,
num_splits=NUM_SPLITS,
shuffle=False,
transform=_failing_transform(bad_ids),
on_transform_error="skip",
)
assert list(ds) == []
assert ds.rows_skipped == len(bad_ids)
def test_merge_state_dicts_validates_consistency(lance_table):
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
state = ds.state_dict()
other = dict(state, shuffle_seed=SHUFFLE_SEED + 1)
with pytest.raises(ValueError, match="shuffle_seed mismatch"):
StreamingDataset.merge_state_dicts([state, other])
with pytest.raises(ValueError, match="at least one"):
StreamingDataset.merge_state_dicts([])
def test_load_state_dict_without_positions_key(lance_table):
"""Checkpoints from before positions_consumed_per_split existed still
resume exactly (positions equal sample counts when nothing is skipped)."""
reference = [
row["id"]
for row in StreamingDataset(
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
)
]
steps = 4
ds = StreamingDataset(lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED)
it = iter(ds)
for _ in range(steps * NUM_SPLITS):
next(it)
checkpoint = ds.state_dict()
it.close()
del checkpoint["positions_consumed_per_split"]
ds2 = StreamingDataset(
lance_table, num_splits=NUM_SPLITS, shuffle_seed=SHUFFLE_SEED
)
ds2.load_state_dict(checkpoint)
resumed = [row["id"] for row in ds2]
assert resumed == reference[steps * NUM_SPLITS :]
def test_num_splits_defaults_to_world_size(lance_table):
"""Omitting num_splits gives world_size splits (one per rank)."""
ds = StreamingDataset(
+51 -27
View File
@@ -64,6 +64,23 @@ def test_embedding_function(tmp_path):
assert np.allclose(actual, expected)
def test_instructor_ndims_uses_instruction():
instructor = get_registry().get("instructor").create()
model = MagicMock()
model.encode.return_value = np.zeros((1, 384))
with patch.object(type(instructor), "get_model", return_value=model):
assert instructor.ndims() == 384
model.encode.assert_called_once_with(
[[instructor.source_instruction, "foo"]],
batch_size=instructor.batch_size,
show_progress_bar=instructor.show_progress_bar,
normalize_embeddings=instructor.normalize_embeddings,
device=instructor.device,
)
def test_embedding_function_variables():
@register("variable-testing")
class VariableTestingFunction(TextEmbeddingFunction):
@@ -115,34 +132,16 @@ def test_embedding_function_variables():
assert func.safe_model_dump()["secret_key"] == "$var:secret"
def test_parse_functions_with_variables():
@register("variable-parsing-test")
class VariableParsingFunction(TextEmbeddingFunction):
api_key: str
base_url: Optional[str] = None
@staticmethod
def sensitive_keys():
return ["api_key"]
def ndims(self):
return 10
def generate_embeddings(self, texts):
# Mock implementation that just returns random embeddings
# In real usage, this would use the api_key to call an API
return [np.random.rand(self.ndims()).tolist() for _ in texts]
def test_openai_variables_survive_metadata_round_trip():
registry = EmbeddingFunctionRegistry.get_instance()
registry.set_var("test_api_key", "sk-test-key-12345")
registry.set_var("test_base_url", "https://api.example.com")
conf = EmbeddingFunctionConfig(
source_column="text",
vector_column="vector",
function=registry.get("variable-parsing-test").create(
api_key="$var:test_api_key", base_url="$var:test_base_url"
function=registry.get("openai").create(
api_key="$var:test_api_key", base_url="https://api.example.com"
),
)
@@ -150,7 +149,10 @@ def test_parse_functions_with_variables():
# Create a mock arrow table with the metadata
schema = pa.schema(
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
[
pa.field("text", pa.string()),
pa.field("vector", pa.list_(pa.float32(), 1536)),
]
)
table = pa.table({"text": [], "vector": []}, schema=schema)
table = table.replace_schema_metadata(metadata)
@@ -164,13 +166,15 @@ def test_parse_functions_with_variables():
assert parsed_func.api_key == "sk-test-key-12345"
assert parsed_func.base_url == "https://api.example.com"
embeddings = parsed_func.generate_embeddings(["test text"])
assert len(embeddings) == 1
assert len(embeddings[0]) == 10
assert parsed_func.safe_model_dump()["api_key"] == "$var:test_api_key"
with patch("lancedb.embeddings.openai.attempt_import_or_raise") as import_openai:
parsed_func._openai_client
import_openai.return_value.OpenAI.assert_called_once_with(
api_key="sk-test-key-12345", base_url="https://api.example.com"
)
def test_embedding_with_bad_results(tmp_path):
@register("null-embedding")
@@ -627,3 +631,23 @@ def test_url_retrieve_downloads_image():
image_bytes = url_retrieve(image_url)
img = Image.open(io.BytesIO(image_bytes))
assert img.size[0] > 0 and img.size[1] > 0
def test_jina_generate_image_input_dict_local_path(tmp_path):
"""
JinaEmbeddings._generate_image_input_dict must accept a local image path
(str or Path), not just bytes. Previously it crashed with
`AttributeError: 'function' object has no attribute 'urlparse'` on any
str/Path input because it called `urlparse.urlparse(image)` instead of
`urlparse(image)` (urlparse was imported as a function, not a module).
"""
Image = pytest.importorskip("PIL.Image")
from lancedb.embeddings.jinaai import JinaEmbeddings
image_path = tmp_path / "test.png"
Image.new("RGB", (4, 4), color="red").save(image_path, format="PNG")
for image in (str(image_path), image_path):
image_dict = JinaEmbeddings._generate_image_input_dict(image)
assert "image" in image_dict
assert isinstance(image_dict["image"], str) and len(image_dict["image"]) > 0
+98
View File
@@ -632,3 +632,101 @@ class TestExprBytesIntegration:
.to_arrow()
)
assert result.num_rows == 2
# ── datetime / timezone integration for lit() (issue #3262) ──────────────────
class TestExprDatetimeTimezoneIntegration:
"""Integration coverage for lit(datetime) against table timestamp columns.
PyArrow stores naive timestamps as UTC wall-clock microseconds. Python's
datetime.timestamp() treats naive values as *local* time, which used to
shift lit(naive) by the host UTC offset and break equality filters on
non-UTC machines. These cases lock the expected semantics.
"""
def test_both_naive_match(self, tmp_path):
"""Table naive + lit naive with the same wall clock must match."""
db = lancedb.connect(str(tmp_path / "naive"))
ts = datetime(2024, 7, 1, 10, 0, 0)
table = db.create_table(
"t", [{"id": 1, "ts": ts}, {"id": 2, "ts": datetime(2024, 7, 2, 10, 0, 0)}]
)
result = table.search().where(col("ts") == lit(ts)).to_list()
assert len(result) == 1
assert result[0]["id"] == 1
def test_both_same_timezone_match(self, tmp_path):
"""Table UTC + lit UTC for the same instant must match."""
db = lancedb.connect(str(tmp_path / "utc"))
ts = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
table = db.create_table(
"t",
pa.table(
{
"id": [1, 2],
"ts": pa.array(
[ts, datetime(2024, 7, 2, 10, 0, 0, tzinfo=timezone.utc)],
type=pa.timestamp("us", tz="UTC"),
),
}
),
)
result = table.search().where(col("ts") == lit(ts)).to_list()
assert len(result) == 1
assert result[0]["id"] == 1
def test_different_timezones_same_instant(self, tmp_path):
"""UTC table row equals lit of the same instant in a different zone."""
db = lancedb.connect(str(tmp_path / "diff_tz"))
ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
# Same instant as 06:00 in UTC-4
ts_est = datetime(2024, 7, 1, 6, 0, 0, tzinfo=timezone(timedelta(hours=-4)))
table = db.create_table(
"t",
pa.table(
{
"id": [1],
"ts": pa.array([ts_utc], type=pa.timestamp("us", tz="UTC")),
}
),
)
result = table.search().where(col("ts") == lit(ts_est)).to_list()
assert len(result) == 1
assert result[0]["id"] == 1
def test_table_tz_literal_naive(self, tmp_path):
"""UTC table + naive lit uses wall-clock equality (10:00 == 10:00 UTC)."""
db = lancedb.connect(str(tmp_path / "tz_naive"))
ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
ts_naive = datetime(2024, 7, 1, 10, 0, 0)
table = db.create_table(
"t",
pa.table(
{
"id": [1],
"ts": pa.array([ts_utc], type=pa.timestamp("us", tz="UTC")),
}
),
)
result = table.search().where(col("ts") == lit(ts_naive)).to_list()
assert len(result) == 1
assert result[0]["id"] == 1
def test_table_naive_literal_aware(self, tmp_path):
"""Naive table + UTC lit with the same wall clock must match."""
db = lancedb.connect(str(tmp_path / "naive_aware"))
ts_naive = datetime(2024, 7, 1, 10, 0, 0)
ts_utc = datetime(2024, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
table = db.create_table("t", [{"id": 1, "ts": ts_naive}])
result = table.search().where(col("ts") == lit(ts_utc)).to_list()
assert len(result) == 1
assert result[0]["id"] == 1
def test_naive_lit_sql_is_wall_clock_not_local_shifted(self):
"""Regression: naive lit must not apply the host local UTC offset."""
ts = datetime(2024, 7, 1, 10, 0, 0)
sql = lit(ts).to_sql()
# Must encode 10:00 wall clock, not 10:00+local_offset.
assert "2024-07-01 10:00:00" in sql
+119 -1
View File
@@ -12,7 +12,7 @@ import pyarrow.compute as pc
import pytest
import pytest_asyncio
from lancedb.index import FTS
from lancedb.index import BTree, FTS, IvfPq
from lancedb.table import AsyncTable, Table
@@ -99,6 +99,86 @@ async def test_async_hybrid_query_filters(table: AsyncTable):
assert result["text"].to_pylist() == ["cat", "b"]
@pytest.mark.asyncio
async def test_hybrid_query_with_stale_fixed_size_binary_prefilter(
tmpdir_factory,
):
tmp_path = str(tmpdir_factory.mktemp("stale_scalar_prefilter"))
db = await lancedb.connect_async(tmp_path)
def fixed_size_binary(value: int) -> bytes:
return value.to_bytes(16, byteorder="big")
num_rows = 1000
data = pa.table(
{
"space_id": pa.array(
[fixed_size_binary(i) for i in range(num_rows)],
type=pa.binary(16),
),
"text": ["book"] * num_rows,
"vector": pa.array(
[[float(i), float(i)] for i in range(num_rows)],
type=pa.list_(pa.float32(), 2),
),
}
)
table = await db.create_table("test", data)
await table.create_index(
"vector", config=IvfPq(num_partitions=4, num_sub_vectors=2)
)
await table.create_index("space_id", config=BTree())
await table.create_index("text", config=FTS(with_position=False))
# Advance the search indices without advancing the scalar index. This is the
# state that previously let hybrid search use an incomplete scalar prefilter.
await table.add(data)
lance_dataset = await table.to_lance()
lance_dataset.optimize.optimize_indices(index_names=["vector_idx", "text_idx"])
await table.checkout_latest()
scalar_stats = await table.index_stats("space_id_idx")
assert scalar_stats is not None
assert scalar_stats.num_indexed_rows == num_rows
assert scalar_stats.num_unindexed_rows == num_rows
for index_name in ["vector_idx", "text_idx"]:
search_stats = await table.index_stats(index_name)
assert search_stats is not None
assert search_stats.num_indexed_rows == num_rows * 2
assert search_stats.num_unindexed_rows == 0
matching_ids = [5, 10, 15, 20, 25, 30]
literals = [
f"arrow_cast(0x{fixed_size_binary(i).hex()}, 'FixedSizeBinary(16)')"
for i in matching_ids
]
predicate = f"space_id IN ({', '.join(literals)})"
expected_ids = sorted(fixed_size_binary(i) for i in matching_ids for _ in range(2))
vector_query = (
table.query().where(predicate).nearest_to([5.0, 5.0]).limit(num_rows * 2)
)
vector_results = await vector_query.to_arrow()
assert sorted(vector_results["space_id"].to_pylist()) == expected_ids
fts_query = (
table.query().where(predicate).nearest_to_text("book").limit(num_rows * 2)
)
fts_results = await fts_query.to_arrow()
assert sorted(fts_results["space_id"].to_pylist()) == expected_ids
hybrid_results = await (
table.query()
.where(predicate)
.nearest_to([5.0, 5.0])
.nearest_to_text("book")
.limit(num_rows * 2)
.to_arrow()
)
assert sorted(hybrid_results["space_id"].to_pylist()) == expected_ids
@pytest.mark.asyncio
async def test_async_hybrid_query_default_limit(table: AsyncTable):
# add 10 new rows
@@ -123,6 +203,44 @@ async def test_async_hybrid_query_default_limit(table: AsyncTable):
assert texts.count("a") == 1
def test_hybrid_query_offset(sync_table: Table):
# The offset window of a hybrid query must be a suffix of the same query
# run without an offset -- it must not be silently ignored.
full = (
sync_table.search(query_type="hybrid")
.vector([0.0, 0.4])
.text("dog")
.limit(4)
.with_row_id(True)
.to_arrow()
)
assert len(full) == 4
offset_result = (
sync_table.search(query_type="hybrid")
.vector([0.0, 0.4])
.text("dog")
.offset(2)
.limit(2)
.with_row_id(True)
.to_arrow()
)
assert offset_result["_rowid"].to_pylist() == full["_rowid"].to_pylist()[2:]
def test_hybrid_query_minimum_nprobes_zero_raises(sync_table: Table):
# minimum_nprobes(0) must raise the same validation error a plain vector
# query raises, not silently no-op because 0 is falsy.
with pytest.raises(ValueError, match="minimum_nprobes must be greater than 0"):
(
sync_table.search(query_type="hybrid")
.vector([0.0, 0.4])
.text("dog")
.minimum_nprobes(0)
.to_arrow()
)
def test_hybrid_query_distance_range(sync_table: Table):
reranker = RRFReranker(return_score="all")
result = (
+33
View File
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import re
import shutil
import subprocess
import sys
import lancedb._lancedb as _lancedb
import pytest
@pytest.mark.skipif(sys.platform != "linux", reason="ldd is Linux-specific")
def test_native_extension_does_not_link_openssl():
"""OpenSSL-linked wheels abort when imported on RHEL hosts in FIPS mode."""
ldd = shutil.which("ldd")
if ldd is None:
pytest.skip("ldd is not installed")
result = subprocess.run(
[ldd, _lancedb.__file__],
check=True,
capture_output=True,
text=True,
)
openssl_libraries = re.findall(
r"^\s*(lib(?:crypto|ssl)\S*)\s+=>", result.stdout, flags=re.MULTILINE
)
assert not openssl_libraries, (
"the LanceDB native extension must use rustls instead of linking OpenSSL: "
f"{openssl_libraries}"
)
+34
View File
@@ -84,6 +84,15 @@ async def binary_table(db_async):
)
@pytest.mark.asyncio
async def test_create_index_async_returns_done_job(some_table: AsyncTable):
job = await some_table.create_index_async("id", config=BTree())
assert job.id is None
await job.wait()
assert len(await some_table.list_indices()) == 1
await job.cancel()
@pytest.mark.asyncio
async def test_create_scalar_index(some_table: AsyncTable):
# Can create
@@ -363,6 +372,31 @@ async def test_create_vector_index(some_table: AsyncTable):
assert stats.num_indices == 1
@pytest.mark.asyncio
async def test_create_ivf_index_reports_unsplittable_partitions(db_async):
dim = 8
num_partitions = 300 # More than 256 selects hierarchical k-means.
base_vectors = [[float(row == column) for column in range(dim)] for row in range(5)]
vectors = pa.array(base_vectors * 200, pa.list_(pa.float32(), dim))
table = await db_async.create_table(
"unsplittable_partitions",
pa.table({"vector": vectors}),
)
error_pattern = (
rf"Cannot create {num_partitions} IVF partitions: k-means could only form"
)
with pytest.raises(RuntimeError, match=error_pattern):
await table.create_index(
"vector",
config=IvfFlat(
distance_type="dot",
num_partitions=num_partitions,
max_iterations=10,
),
)
@pytest.mark.asyncio
async def test_create_4bit_ivfpq_index(some_table: AsyncTable):
# Can create
+11 -4
View File
@@ -83,7 +83,9 @@ def test_lsm_write_spec_repr():
assert s.spec_type == "bucket"
assert s.column == "id"
assert s.num_buckets == 4
assert s.maintained_indexes == []
# A fresh spec defers its maintained set to install time.
assert s.maintained_indexes is None
assert s.with_maintained_indexes([]).maintained_indexes == []
assert "bucket" in repr(s)
assert "id" in repr(s)
assert "4" in repr(s)
@@ -169,18 +171,23 @@ def test_get_lsm_write_spec(tmp_path):
table.unset_lsm_write_spec()
assert table.get_lsm_write_spec() is None
# Identity round-trips (column recovered from the schema).
# Identity round-trips (column recovered from the schema). Leaving the
# maintained set to be inferred picks up the index on the table, so the
# spec reads back naming it rather than as "infer".
table.set_lsm_write_spec(LsmWriteSpec.identity("id"))
spec = table.get_lsm_write_spec()
assert spec.spec_type == "identity"
assert spec.column == "id"
assert spec.maintained_indexes == [idx_name]
table.unset_lsm_write_spec()
# Unsharded round-trips (no routing column).
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
# Unsharded round-trips (no routing column). Opting out is distinct from
# the inferred default.
table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([]))
spec = table.get_lsm_write_spec()
assert spec.spec_type == "unsharded"
assert spec.column is None
assert spec.maintained_indexes == []
@pytest.mark.asyncio
+2 -2
View File
@@ -544,7 +544,7 @@ def test_lsm_read_fts_unmaintained_index_errors(tmp_path):
table.create_index("text", config=FTS())
# No maintained indexes: the active memtable FTS arm cannot serve un-compacted
# docs, so the search would silently omit them — reject instead.
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([]))
with pytest.raises(Exception, match="maintained"):
table.search("fox", query_type="fts", fts_columns="text").to_arrow()
@@ -631,7 +631,7 @@ def test_lsm_read_vector_unmaintained_index_errors(tmp_path):
)
# Spec with NO maintained indexes: the base vector index's catch-up is untracked,
# so the scanner rejects rather than risk dropping compacted-but-unindexed rows.
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([]))
with pytest.raises(Exception, match="maintained"):
table.search([1.0] * VECTOR_DIM).to_arrow()
@@ -18,6 +18,7 @@ Tests verify:
"""
import copy
import os
import shutil
import sys
import tempfile
@@ -239,7 +240,7 @@ def create_tracking_namespace(
dir_props = {f"storage.{k}": v for k, v in storage_options_with_refresh.items()}
if bucket_name.startswith("/") or bucket_name.startswith("file://"):
if os.path.isabs(bucket_name) or bucket_name.startswith("file://"):
dir_props["root"] = f"{bucket_name}/namespace_root"
else:
dir_props["root"] = f"s3://{bucket_name}/namespace_root"
@@ -767,3 +768,70 @@ def test_namespace_with_schema_only(s3_bucket: str, use_custom: bool):
# Verify data was added
assert table.count_rows() == 2
@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"])
def test_namespace_exists(use_custom: bool):
"""
Test namespace_exists returns True for existing and False for non-existent.
"""
temp_dir = tempfile.mkdtemp()
try:
ns_client, _ = create_tracking_namespace(
bucket_name=temp_dir,
storage_options={},
credential_expires_in_seconds=3600,
use_custom=use_custom,
)
db = LanceNamespaceDBConnection(ns_client)
namespace_name = f"test_ns_{uuid.uuid4().hex[:8]}"
db.create_namespace([namespace_name])
# Existing namespace should return True
assert db.namespace_exists(namespace_id=[namespace_name]) is True
# Non-existent namespace should return False
assert db.namespace_exists(namespace_id=["nonexistent_ns"]) is False
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"])
def test_table_exists(use_custom: bool):
"""
Test table_exists returns True for existing table and False for non-existent.
"""
temp_dir = tempfile.mkdtemp()
try:
ns_client, _ = create_tracking_namespace(
bucket_name=temp_dir,
storage_options={},
credential_expires_in_seconds=3600,
use_custom=use_custom,
)
db = LanceNamespaceDBConnection(ns_client)
namespace_name = f"test_ns_{uuid.uuid4().hex[:8]}"
db.create_namespace([namespace_name])
table_name = f"test_table_{uuid.uuid4().hex}"
namespace_path = [namespace_name]
schema = pa.schema(
[
pa.field("id", pa.int64()),
pa.field("vector", pa.list_(pa.float32(), 2)),
pa.field("text", pa.string()),
]
)
db.create_table(table_name, schema=schema, namespace_path=namespace_path)
# Existing table should return True
table_id = namespace_path + [table_name]
assert db.table_exists(table_id=table_id) is True
# Non-existent table should return False
assert db.table_exists(table_id=namespace_path + ["nonexistent_table"]) is False
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import importlib
import re
import sys
from pathlib import Path
import pytest
def test_pyo3_abi_matches_minimum_supported_python():
project_dir = Path(__file__).parents[2]
pyproject = (project_dir / "pyproject.toml").read_text()
cargo_manifest = (project_dir / "Cargo.toml").read_text()
minimum_python = re.search(
r'^requires-python\s*=\s*">=(\d+)\.(\d+)"$', pyproject, re.MULTILINE
)
assert minimum_python is not None
major, minor = minimum_python.groups()
expected_abi = f"abi3-py{major}{minor}"
configured_abis = re.findall(r'"(abi3-py\d+)"', cargo_manifest)
assert configured_abis == [expected_abi, expected_abi], (
"the pyo3 runtime and build ABI features must both match requires-python"
)
@pytest.mark.skipif(sys.platform != "win32", reason="Windows wheel regression test")
def test_windows_wheel_tag_and_native_import():
project_dir = Path(__file__).parents[2]
wheels = list((project_dir.parent / "target" / "wheels").glob("lancedb-*.whl"))
if not wheels:
pytest.skip("no wheel artifact is available in this development environment")
assert len(wheels) == 1
assert wheels[0].name.endswith("-cp310-abi3-win_amd64.whl")
native_module = importlib.import_module("lancedb._lancedb")
assert Path(native_module.__file__).suffix == ".pyd"
+20
View File
@@ -6,6 +6,7 @@ import math
import pytest
from lancedb import DBConnection, Table, connect
from lancedb.background_loop import LOOP
from lancedb.permutation import Permutation, Permutations, permutation_builder
@@ -31,6 +32,25 @@ def test_split_random_ratios(mem_db):
assert 65 <= split_1_count <= 75 # ~70% ± tolerance
def test_execute_does_not_reenter_background_loop(tmp_path, monkeypatch):
import threading
db = connect(tmp_path)
tbl = db.create_table("test_table", pa.table({"x": range(10)}))
original_run = LOOP.run
def fail_on_reentry(future):
assert threading.current_thread() is not LOOP.thread
return original_run(future)
monkeypatch.setattr(LOOP, "run", fail_on_reentry)
permutation_tbl = permutation_builder(tbl).execute()
assert permutation_tbl.count_rows() == 10
assert permutation_tbl._conn.read_consistency_interval is None
def test_split_random_counts(mem_db):
"""Test random splitting with absolute counts."""
tbl = mem_db.create_table(
+11
View File
@@ -415,6 +415,17 @@ def test_nullable_vector():
assert schema == pa.schema([pa.field("vec", pa.list_(pa.float32(), 16), True)])
def test_bare_vector_raises_clear_error():
namespace = {
"__name__": "test_model_without_pyarrow",
"LanceModel": LanceModel,
"Vector": Vector,
}
with pytest.raises(TypeError, match=r"Vector must be parameterized.*Vector\(128\)"):
exec("class TestModel(LanceModel):\n vector: Vector", namespace)
def test_fixed_size_list_field():
class TestModel(pydantic.BaseModel):
vec: Vector(16)

Some files were not shown because too many files have changed in this diff Show More