refactor!: rename LSM APIs to MemWAL and align SSTable/table shard naming (#4075)

## What

Renames the MemWAL surface in two passes, across Rust, Python,
TypeScript, and Java, including the wire format.

### 1. MemWAL APIs drop "LSM"

The write path these APIs drive is Lance's MemWAL; "LSM" described its
shape rather than naming it. Turning it on and off now reads as enabling
a feature rather than installing a spec.

| Before | After |
|---|---|
| `set_lsm_write_spec` | `enable_memwal` |
| `unset_lsm_write_spec` | `disable_memwal` |
| `get_lsm_write_spec` | `get_memwal_spec` |
| `flush_lsm` / `compact_lsm` / `checkpoint_lsm` | `flush_memwal` /
`compact_memwal` / `checkpoint_memwal` |
| `get_lsm_stats` | `get_memwal_stats` |
| `close_lsm_writers` | `close_memwal_writers` |
| `use_lsm` (query, merge insert) | `use_memwal` |
| `LsmWriteSpec` / `LsmStats` | `MemWalWriteSpec` / `MemWalStats` |
| Java `LanceDbTableLsm` | `LanceDbTableMemWal` |

TypeScript and Java use camelCase (`enableMemWal`, `getMemWalStats`,
...); the napi methods set `js_name` so the generated names keep the
`MemWal` casing. flush/compact/checkpoint keep a `_memwal` suffix so
`compact_memwal` is not confused with `compact_files` / `optimize`,
which compact the base table.

Internal names (`LsmMode`, `LsmScanner`, the `merge::lsm` / `query::lsm`
modules) are unchanged.

### 2. Stats use SSTable and table shard

Aligns the stats surface with the MemWAL naming settled upstream in
lance-format/lance#7943 and #7957, where the persisted unit became an
**SSTable**.

**Generation → SSTable.** A `GenerationStats` describes one flushed
MemTable, which is an SSTable. The generation *number* is kept — an
SSTable is identified by its generation — so only the noun moved.

**Bucket → table shard.** Each entry is one MemWAL shard. "Bucket" names
only the hash sharding *transform*, so it was already wrong for
`identity` and `year` sharding, which produce shards and no buckets at
all.

| Before | After |
|---|---|
| `GenerationStats` | `SsTableStats` |
| `BucketStats` | `TableShardStats` |
| `stats.buckets` | `stats.table_shards` |
| `BucketStats.generations` | `TableShardStats.sstables` |
| `include_generation_rows` | `include_sstable_rows` |
| `newest_generation` | `newest_sstable_generation` |
| `outstanding_generations` | `outstanding_sstables` |

Docs and comments follow suit: "seal" → "freeze", "L0" / "flushed
generations" → SSTables, "bucket" (meaning a shard) → table shard.

`MemWalWriteSpec::bucket`, `num_buckets`, and the `"Sealed"` shard
status value are **unchanged** — those name the sharding transform or a
server value, not the shard.

## Compatibility

Breaking, for an experimental API. The wire format changes with the
SDKs, so this must roll out together with the matching server change:

- Routes: `/set_lsm_write_spec/` → `/enable_memwal/`,
`/unset_lsm_write_spec/` → `/disable_memwal/`, `/get_lsm_write_spec/` →
`/get_memwal_spec/`, `/flush_lsm/` → `/flush_memwal/`, `/compact_lsm/` →
`/compact_memwal/`, `/get_lsm_stats/` → `/get_memwal_stats/`
- Response keys: `lsm_write_spec` → `memwal_spec`, `lsm_stats` →
`memwal_stats`, `buckets` → `table_shards`, `generations` → `sstables`
- Request fields: `include_generation_rows` → `include_sstable_rows`;
`use_lsm` → `use_memwal` on query and merge-insert bodies

## Validation

- Rust: `cargo test --features remote -p lancedb --lib --tests` — 1467
lib tests and all integration test targets pass; `cargo clippy` clean
- Python: rebuilt the extension; `test_remote_db`, `test_memwal_spec`,
`test_merge_insert_lsm`, `test_query`, `test_permutation`, `test_table`
— 655 passed; `ruff format` / `ruff check` clean
- TypeScript: `pnpm build`; `table`, `merge`, `query` tests — 593
passed; `biome check` clean; `docs/src/js` regenerated with `npm run
docs`
- Java: `mvn test` — 37 passed (Spotless applied with JDK 11; the pinned
google-java-format does not run on JDK 21)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Rammer
2026-10-16 13:33:57 -05:00
committed by GitHub
co-authored by Claude Opus 5
parent 0968c21f4c
commit fe34eb3549
73 changed files with 1853 additions and 1856 deletions
+6 -6
View File
@@ -425,19 +425,19 @@ Empty results retain the query's output schema.
***
### useLsm()
### useMemWal()
```ts
useLsm(enable): this
useMemWal(enable): this
```
Control MemWAL read routing for this query.
By default (unset), when the table carries a MemWAL write spec (see
[Table#setLsmWriteSpec](Table.md#setlsmwritespec)), reads are routed through the LSM scanner so
[Table#enableMemWal](Table.md#enablememwal)), reads are routed through the LSM scanner so
they also return data written via the `mergeInsert` LSM path that has not yet
been compacted into the base table (the active/frozen in-memory memtables and
the flushed generations), deduplicated by primary key; a table without a spec
the SSTables), deduplicated by primary key; a table without a spec
reads the base table.
#### Parameters
@@ -448,7 +448,7 @@ reads the base table.
even when a spec is present.
Note: the LSM scanner does not support every query shape (e.g. reranking,
hybrid search, `orderBy`). On a MemWAL table those shapes error unless
`useLsm(false)` is set, because a base-only read would silently exclude
`useMemWal(false)` is set, because a base-only read would silently exclude
un-compacted MemWAL data.
#### Returns
@@ -457,7 +457,7 @@ reads the base table.
#### Inherited from
`StandardQueryBase.useLsm`
`StandardQueryBase.useMemWal`
***
+6 -6
View File
@@ -76,15 +76,15 @@ the query optimizer chooses a suboptimal path.
***
### useLsm()
### useMemWal()
```ts
useLsm(enable): MergeInsertBuilder
useMemWal(enable): MergeInsertBuilder
```
Control MemWAL routing for this merge.
By default (unset), a `mergeInsert` on a table with an LSM write spec is
By default (unset), a `mergeInsert` on a table with a MemWAL write spec is
routed through Lance's MemWAL shard writer, and a table without one uses the
standard path.
@@ -92,7 +92,7 @@ standard path.
* **enable**: `boolean`
`true` forces MemWAL routing and errors if the table has no
LSM write spec. `false` forces the standard write path even when a spec is set.
MemWAL write spec. `false` forces the standard write path even when a spec is set.
#### Returns
@@ -108,12 +108,12 @@ validateSingleShard(validateSingleShard): MergeInsertBuilder
Controls how an LSM merge checks that its input targets a single shard.
When a table has an LSM write spec, every row in a `mergeInsert` call must
When a table has a MemWAL write spec, every row in a `mergeInsert` call must
route to the same shard. When `true` (the default), every row is inspected
to verify this. When `false`, only the first row is inspected and the
shard it routes to is used for the whole input — a faster path for callers
that have already pre-sharded their input. Has no effect on tables without
an LSM write spec.
a MemWAL write spec.
#### Parameters
+6 -6
View File
@@ -499,19 +499,19 @@ Empty results retain the query's output schema.
***
### useLsm()
### useMemWal()
```ts
useLsm(enable): this
useMemWal(enable): this
```
Control MemWAL read routing for this query.
By default (unset), when the table carries a MemWAL write spec (see
[Table#setLsmWriteSpec](Table.md#setlsmwritespec)), reads are routed through the LSM scanner so
[Table#enableMemWal](Table.md#enablememwal)), reads are routed through the LSM scanner so
they also return data written via the `mergeInsert` LSM path that has not yet
been compacted into the base table (the active/frozen in-memory memtables and
the flushed generations), deduplicated by primary key; a table without a spec
the SSTables), deduplicated by primary key; a table without a spec
reads the base table.
#### Parameters
@@ -522,7 +522,7 @@ reads the base table.
even when a spec is present.
Note: the LSM scanner does not support every query shape (e.g. reranking,
hybrid search, `orderBy`). On a MemWAL table those shapes error unless
`useLsm(false)` is set, because a base-only read would silently exclude
`useMemWal(false)` is set, because a base-only read would silently exclude
un-compacted MemWAL data.
#### Returns
@@ -531,7 +531,7 @@ reads the base table.
#### Inherited from
`StandardQueryBase.useLsm`
`StandardQueryBase.useMemWal`
***
+116 -116
View File
@@ -228,17 +228,17 @@ version of the table.
***
### checkpointLsm()
### checkpointMemWal()
```ts
abstract checkpointLsm(): Promise<void>
abstract checkpointMemWal(): Promise<void>
```
Converge this table's LSM write path into its base table.
Converge this table's MemWAL write path into its base table.
Seals once, then triggers compaction and polls until the L0 that existed
Freezes once, then triggers compaction and polls until the SSTables 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
SSTables 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.
@@ -254,9 +254,9 @@ is merging. The caller owns the deadline.
#### Example
```ts
const before = await table.getLsmStats();
await table.checkpointLsm();
const after = await table.getLsmStats();
const before = await table.getMemWalStats();
await table.checkpointMemWal();
const after = await table.getMemWalStats();
```
***
@@ -279,15 +279,15 @@ Any attempt to use the table after it is closed will result in an error.
***
### closeLsmWriters()
### closeMemWalWriters()
```ts
abstract closeLsmWriters(): Promise<void>
abstract closeMemWalWriters(): Promise<void>
```
Drain and close any cached MemWAL shard writers held for this table.
When an [LsmWriteSpec](../interfaces/LsmWriteSpec.md) is installed, `mergeInsert` opens MemWAL
When an [MemWalWriteSpec](../interfaces/MemWalWriteSpec.md) is installed, `mergeInsert` opens MemWAL
shard writers and caches them for reuse across calls. This closes them,
flushing pending data; writers reopen lazily on the next `mergeInsert`.
It is a no-op when no writers are cached.
@@ -298,17 +298,17 @@ It is a no-op when no writers are cached.
***
### compactLsm()
### compactMemWal()
```ts
abstract compactLsm(): Promise<void>
abstract compactMemWal(): Promise<void>
```
Trigger a background L0 → base compaction pass per bucket.
Trigger a background SSTable compaction pass per table shard.
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.
[Table#getMemWalStats](Table.md#getmemwalstats) for progress, or use
[Table#checkpointMemWal](Table.md#checkpointmemwal) to wait for convergence.
#### Returns
@@ -449,6 +449,23 @@ containing the new version number of the table
***
### disableMemWal()
```ts
abstract disableMemWal(): Promise<void>
```
Remove the [MemWalWriteSpec](../interfaces/MemWalWriteSpec.md) from this table, reverting to the standard
`mergeInsert` write path.
Errors if no spec is currently set.
#### Returns
`Promise`&lt;`void`&gt;
***
### display()
```ts
@@ -514,6 +531,54 @@ Drop an index from the table.
***
### enableMemWal()
```ts
abstract enableMemWal(spec): Promise<void>
```
Install an [MemWalWriteSpec](../interfaces/MemWalWriteSpec.md) on this table, selecting Lance's MemWAL
LSM-style write path for future `mergeInsert` calls.
`MemWalWriteSpec` chooses one of three sharding strategies via `specType`:
- `"bucket"` — hash-bucket writes by the single-column unenforced primary
key (`column` and `numBuckets` required).
- `"identity"` — shard by the raw value of a scalar `column`.
- `"unsharded"` — route every write to a single shard.
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 the table has,
including ones created later, and skips a kind the MemWAL cannot maintain.
Naming them pins an exact set, and a still-building index is rejected
rather than quietly omitted.
#### Parameters
* **spec**: [`MemWalWriteSpec`](../interfaces/MemWalWriteSpec.md)
The sharding spec to install.
#### Returns
`Promise`&lt;`void`&gt;
#### Example
```ts
await table.setUnenforcedPrimaryKey("id");
await table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 16,
maintainedIndexes: ["id_idx"],
});
```
***
### fetchBlobFiles()
```ts
@@ -582,15 +647,15 @@ memory; use [Table.fetchBlobFiles](Table.md#fetchblobfiles) for large values.
***
### flushLsm()
### flushMemWal()
```ts
abstract flushLsm(): Promise<void>
abstract flushMemWal(): Promise<void>
```
Seal every bucket's active memtable into a new L0 generation.
Freeze every table shard's active memtable into a new SSTable.
Returns once the seal is committed. Sealing an empty memtable is a no-op,
Returns once the freeze is committed. Freezing an empty memtable is a no-op,
so this is safe to call repeatedly.
#### Returns
@@ -634,48 +699,48 @@ const { records, truncated } = await table.functionErrors({ column: "embedding"
***
### getLsmStats()
### getMemWalSpec()
```ts
abstract getLsmStats(includeGenerationRows?): Promise<undefined | LsmStats>
abstract getMemWalSpec(): Promise<undefined | MemWalWriteSpec>
```
Read live per-bucket LSM state.
Read the [MemWalWriteSpec](../interfaces/MemWalWriteSpec.md) currently installed on this table.
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
abstract getLsmWriteSpec(): Promise<undefined | LsmWriteSpec>
```
Read the [LsmWriteSpec](../interfaces/LsmWriteSpec.md) currently installed on this table.
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)).
Resolves to `undefined` when the MemWAL write path is not enabled (no
spec has been set, or it was removed with [Table#disableMemWal](Table.md#disablememwal)).
The spec is the one installed, including its maintained-index selection:
an absent `maintainedIndexes` for every index the table has, an empty
array for none.
#### Returns
`Promise`&lt;`undefined` \| [`LsmWriteSpec`](../interfaces/LsmWriteSpec.md)&gt;
`Promise`&lt;`undefined` \| [`MemWalWriteSpec`](../interfaces/MemWalWriteSpec.md)&gt;
***
### getMemWalStats()
```ts
abstract getMemWalStats(includeSstableRows?): Promise<undefined | MemWalStats>
```
Read live per-table-shard MemWAL state.
Answers "how far behind is my fresh tier", "which table shard is hot", and
"why is my fresh-tier vector search brute-force". Mutates no table state.
Resolves to `undefined` only when the MemWAL write path is not enabled.
#### Parameters
* **includeSstableRows?**: `boolean`
Also count rows per SSTable.
Off by default because each count opens an uncached Lance dataset.
#### Returns
`Promise`&lt;`undefined` \| [`MemWalStats`](../interfaces/MemWalStats.md)&gt;
***
@@ -1093,54 +1158,6 @@ of the given query
***
### setLsmWriteSpec()
```ts
abstract setLsmWriteSpec(spec): Promise<void>
```
Install an [LsmWriteSpec](../interfaces/LsmWriteSpec.md) on this table, selecting Lance's MemWAL
LSM-style write path for future `mergeInsert` calls.
`LsmWriteSpec` chooses one of three sharding strategies via `specType`:
- `"bucket"` — hash-bucket writes by the single-column unenforced primary
key (`column` and `numBuckets` required).
- `"identity"` — shard by the raw value of a scalar `column`.
- `"unsharded"` — route every write to a single shard.
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 the table has,
including ones created later, and skips a kind the MemWAL cannot maintain.
Naming them pins an exact set, and a still-building index is rejected
rather than quietly omitted.
#### Parameters
* **spec**: [`LsmWriteSpec`](../interfaces/LsmWriteSpec.md)
The sharding spec to install.
#### Returns
`Promise`&lt;`void`&gt;
#### Example
```ts
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({
specType: "bucket",
column: "id",
numBuckets: 16,
maintainedIndexes: ["id_idx"],
});
```
***
### setUnenforcedPrimaryKey()
```ts
@@ -1295,23 +1312,6 @@ same tokenizer model files must also exist locally.
***
### unsetLsmWriteSpec()
```ts
abstract unsetLsmWriteSpec(): Promise<void>
```
Remove the [LsmWriteSpec](../interfaces/LsmWriteSpec.md) from this table, reverting to the standard
`mergeInsert` write path.
Errors if no spec is currently set.
#### Returns
`Promise`&lt;`void`&gt;
***
### update()
#### update(opts)
+2 -2
View File
@@ -275,10 +275,10 @@ Empty results retain the query's output schema.
***
### useLsm()
### useMemWal()
```ts
useLsm(enable): this
useMemWal(enable): this
```
Control MemWAL read routing for this take query.
+6 -6
View File
@@ -781,19 +781,19 @@ Empty results retain the query's output schema.
***
### useLsm()
### useMemWal()
```ts
useLsm(enable): this
useMemWal(enable): this
```
Control MemWAL read routing for this query.
By default (unset), when the table carries a MemWAL write spec (see
[Table#setLsmWriteSpec](Table.md#setlsmwritespec)), reads are routed through the LSM scanner so
[Table#enableMemWal](Table.md#enablememwal)), reads are routed through the LSM scanner so
they also return data written via the `mergeInsert` LSM path that has not yet
been compacted into the base table (the active/frozen in-memory memtables and
the flushed generations), deduplicated by primary key; a table without a spec
the SSTables), deduplicated by primary key; a table without a spec
reads the base table.
#### Parameters
@@ -804,7 +804,7 @@ reads the base table.
even when a spec is present.
Note: the LSM scanner does not support every query shape (e.g. reranking,
hybrid search, `orderBy`). On a MemWAL table those shapes error unless
`useLsm(false)` is set, because a base-only read would silently exclude
`useMemWal(false)` is set, because a base-only read would silently exclude
un-compacted MemWAL data.
#### Returns
@@ -813,7 +813,7 @@ reads the base table.
#### Inherited from
`StandardQueryBase.useLsm`
`StandardQueryBase.useMemWal`
***
+4 -4
View File
@@ -66,7 +66,6 @@
- [BranchDiff](interfaces/BranchDiff.md)
- [BranchIndexSummary](interfaces/BranchIndexSummary.md)
- [BranchRowCountSummary](interfaces/BranchRowCountSummary.md)
- [BucketStats](interfaces/BucketStats.md)
- [CatalogOptions](interfaces/CatalogOptions.md)
- [CherryPickError](interfaces/CherryPickError.md)
- [CherryPickPreview](interfaces/CherryPickPreview.md)
@@ -98,7 +97,6 @@
- [FunctionErrorRecord](interfaces/FunctionErrorRecord.md)
- [FunctionErrors](interfaces/FunctionErrors.md)
- [FunctionErrorsOptions](interfaces/FunctionErrorsOptions.md)
- [GenerationStats](interfaces/GenerationStats.md)
- [HnswPqOptions](interfaces/HnswPqOptions.md)
- [HnswSqOptions](interfaces/HnswSqOptions.md)
- [IndexConfig](interfaces/IndexConfig.md)
@@ -115,9 +113,9 @@
- [ListTablesOptions](interfaces/ListTablesOptions.md)
- [ListTablesResponse](interfaces/ListTablesResponse.md)
- [ListingOptions](interfaces/ListingOptions.md)
- [LsmStats](interfaces/LsmStats.md)
- [LsmWriteSpec](interfaces/LsmWriteSpec.md)
- [MaterializedViewDefinition](interfaces/MaterializedViewDefinition.md)
- [MemWalStats](interfaces/MemWalStats.md)
- [MemWalWriteSpec](interfaces/MemWalWriteSpec.md)
- [MemtableStats](interfaces/MemtableStats.md)
- [MergeResult](interfaces/MergeResult.md)
- [NativeOAuthConfig](interfaces/NativeOAuthConfig.md)
@@ -140,7 +138,9 @@
- [SplitHashOptions](interfaces/SplitHashOptions.md)
- [SplitRandomOptions](interfaces/SplitRandomOptions.md)
- [SplitSequentialOptions](interfaces/SplitSequentialOptions.md)
- [SsTableStats](interfaces/SsTableStats.md)
- [TableNamesOptions](interfaces/TableNamesOptions.md)
- [TableShardStats](interfaces/TableShardStats.md)
- [TableStatistics](interfaces/TableStatistics.md)
- [TimeoutConfig](interfaces/TimeoutConfig.md)
- [TlsConfig](interfaces/TlsConfig.md)
-40
View File
@@ -1,40 +0,0 @@
[**@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.
-22
View File
@@ -1,22 +0,0 @@
[**@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.
+22
View File
@@ -0,0 +1,22 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / MemWalStats
# Interface: MemWalStats
Live per-table-shard MemWAL state, as returned by `Table#getMemWalStats`.
Nothing here is derived: sums and differences (total SSTable bytes, WAL lag) are
the caller's to compute.
## Properties
### tableShards
```ts
tableShards: TableShardStats[];
```
One entry per table shard backing this table.
@@ -2,9 +2,9 @@
***
[@lancedb/lancedb](../globals.md) / LsmWriteSpec
[@lancedb/lancedb](../globals.md) / MemWalWriteSpec
# Interface: LsmWriteSpec
# Interface: MemWalWriteSpec
Specification selecting Lance's MemWAL LSM-style write path for
`mergeInsert`.
+1 -1
View File
@@ -36,7 +36,7 @@ Estimated in-memory size.
generation: number;
```
The generation this memtable will become once sealed.
The generation this memtable will become once frozen.
***
+40
View File
@@ -0,0 +1,40 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / SsTableStats
# Interface: SsTableStats
One SSTable.
## Properties
### bytes
```ts
bytes: number;
```
On-disk size of the SSTable.
***
### generation
```ts
generation: number;
```
The generation number. Increases as memtables are frozen into SSTables.
***
### rows?
```ts
optional rows: number;
```
Present only when `includeSstableRows` was requested. Off by default
because each count opens an uncached Lance dataset.
@@ -2,12 +2,12 @@
***
[@lancedb/lancedb](../globals.md) / BucketStats
[@lancedb/lancedb](../globals.md) / TableShardStats
# Interface: BucketStats
# Interface: TableShardStats
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
Live state of one table shard. A table is N table shards on one node; flattening to a
single number hides the one hot table shard that is usually why someone opened
this endpoint.
## Properties
@@ -18,7 +18,7 @@ this endpoint.
compacting: boolean;
```
Whether a pass owns this bucket's compaction latch right now. Says *a*
Whether a pass owns this table shard'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".
@@ -35,16 +35,6 @@ The generation the active memtable will become.
***
### generations
```ts
generations: GenerationStats[];
```
Flushed L0 generations not yet merged into the base table.
***
### manifestVersion
```ts
@@ -61,7 +51,7 @@ Version of the shard manifest these numbers were read from.
optional memtables: MemtableStats[];
```
Oldest first, active last. Absent for a `"Sealed"` bucket, whose
Oldest first, active last. Absent for a `"Sealed"` table shard, whose
in-memory state is torn down.
***
@@ -82,7 +72,17 @@ WAL position replay resumes from.
shardId: string;
```
The shard this bucket writes.
The id of this table shard.
***
### sstables
```ts
sstables: SsTableStats[];
```
SSTables not yet merged into the base table.
***
+1 -1
View File
@@ -297,7 +297,7 @@ listing a storage directory.
::: lancedb.table.Branches
::: lancedb.LsmWriteSpec
::: lancedb.MemWalWriteSpec
## Functions and Jobs
@@ -52,7 +52,7 @@ In Python SDK 0.40.0, create the source with
above does. Stable row IDs let the view track source rows across updates and
compaction. Set this option when creating the table; it cannot be added later.
A source with an LSM write specification or retained, uncompacted write-ahead-log
A source with a MemWAL write spec or retained, uncompacted write-ahead-log
rows is not accepted by the local materialized-view implementation.
## Refresh behavior {#incremental}
+1 -1
View File
@@ -32,7 +32,7 @@ log from growing without bound:
| Job type | What it does |
| --- | --- |
| `sstable_compaction` | Folds a WAL bucket's SSTables into the base table by primary key. Distinct from `compact`, which merges base-table fragments -- the two are unrelated. |
| `sstable_compaction` | Folds a table shard's SSTables into the base table by primary key. Distinct from `compact`, which merges base-table fragments -- the two are unrelated. |
| `wal_index_catchup` | Extends a base-table index over the rows `sstable_compaction` just folded in, and records how far the index has caught up. Nothing else advances that record. |
## Index maintenance {#index}
+4 -4
View File
@@ -102,10 +102,10 @@ duplicates impossible. It tells the planner what to assume. The column must be
declared `NOT NULL`.
```sql
ALTER TABLE t SET WAL
ALTER TABLE t SET WAL WITH (sharding = 'bucket', column = 'id', buckets = 4)
ALTER TABLE t SET WAL WITH (maintained_indexes = '')
ALTER TABLE t UNSET WAL
ALTER TABLE t SET MEMWAL
ALTER TABLE t SET MEMWAL WITH (sharding = 'bucket', column = 'id', buckets = 4)
ALTER TABLE t SET MEMWAL WITH (maintained_indexes = '')
ALTER TABLE t UNSET MEMWAL
```
A write-ahead log makes small frequent writes cheap by batching them out of the
+9 -9
View File
@@ -29,7 +29,7 @@ LanceNamespace namespaceClient = LanceDbNamespaceClientBuilder.newBuilder()
.build();
```
## MemWAL LSM write path
## MemWAL 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
@@ -37,27 +37,27 @@ of that specification, so they are issued through a separate client:
```java
import com.lancedb.LanceDbRestClient;
import com.lancedb.LanceDbTableLsm;
import com.lancedb.LsmWriteSpec;
import com.lancedb.LanceDbTableMemWal;
import com.lancedb.MemWalWriteSpec;
LanceDbRestClient client = LanceDbNamespaceClientBuilder.newBuilder()
.apiKey("your_lancedb_cloud_api_key")
.database("your_database_name")
.buildRestClient();
LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
LanceDbTableMemWal memWal = new LanceDbTableMemWal(client, "my_table");
// Route future merge_insert upserts through the MemWAL, hash-bucketed by `id`.
lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
memWal.enableMemWal(MemWalWriteSpec.bucket("id", 16));
// ... merge_insert traffic ...
// Converge the fresh tier into the base table.
lsm.checkpointLsm();
memWal.checkpointMemWal();
// Inspect live per-bucket state.
lsm.getLsmStats().ifPresent(stats -> stats.buckets().forEach(bucket ->
System.out.println(bucket.shardId() + ": " + bucket.generations().size() + " L0 generations")));
// Inspect live per-table-shard state.
memWal.getMemWalStats().ifPresent(stats -> stats.tableShards().forEach(tableShard ->
System.out.println(tableShard.shardId() + ": " + tableShard.sstables().size() + " SSTables")));
client.close();
```
@@ -20,12 +20,12 @@ import com.fasterxml.jackson.databind.JsonNode;
*
* <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
* {@code rust/lancedb/src/table/memwal_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
* array or a zero — is unsafe here because {@link LanceDbTableMemWal#checkpointMemWal()} decides
* convergence from these numbers. A defaulted {@code sstables} array is indistinguishable from a
* drained one, so a malformed response would report a checkpoint that never happened.
*/
final class JsonFields {
@@ -151,8 +151,8 @@ public class LanceDbNamespaceClientBuilder {
* 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()}.
* MemWAL write path, reached through {@link LanceDbTableMemWal}. 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.
*
@@ -31,9 +31,9 @@ import java.io.UncheckedIOException;
* 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
* is generated from the namespace spec. A handful of routes — the MemWAL 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}.
* directly here. See {@link LanceDbTableMemWal}.
*
* <p>Obtain one from {@link LanceDbNamespaceClientBuilder#buildRestClient()}.
*/
@@ -50,8 +50,8 @@ public class LanceDbRestClient implements Closeable {
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
// exactly the two statuses LanceDbTableMemWal.checkpointMemWal() acts on — which would
// silently double its explicit retry budget and would also retry compact_memwal 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();
@@ -98,8 +98,9 @@ public class LanceDbRestClient implements Closeable {
/**
* 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.
* <p>The status is exposed because callers act on it: {@link
* LanceDbTableMemWal#checkpointMemWal()} 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;
@@ -22,11 +22,11 @@ import java.util.Optional;
import java.util.OptionalLong;
/**
* The MemWAL LSM write path for one LanceDB Cloud or Enterprise table.
* The MemWAL 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>Installing an {@link MemWalWriteSpec} 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, freeze into SSTables, 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}.
@@ -37,23 +37,23 @@ import java.util.OptionalLong;
* .database("your_database_name")
* .buildRestClient();
*
* LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
* lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
* LanceDbTableMemWal memWal = new LanceDbTableMemWal(client, "my_table");
* memWal.enableMemWal(MemWalWriteSpec.bucket("id", 16));
* // ... merge_insert traffic ...
* lsm.checkpointLsm();
* memWal.checkpointMemWal();
* }</pre>
*/
public class LanceDbTableLsm {
public class LanceDbTableMemWal {
/**
* 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.
* Interval between {@code get_memwal_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.
* Cap on re-issues from {@code flushMemWal} 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.
@@ -79,7 +79,7 @@ public class LanceDbTableLsm {
* @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) {
public LanceDbTableMemWal(LanceDbRestClient client, String tableIdentifier) {
if (client == null) {
throw new IllegalArgumentException("Client cannot be null");
}
@@ -91,140 +91,140 @@ public class LanceDbTableLsm {
}
/**
* Install an {@link LsmWriteSpec} on this table, selecting the MemWAL LSM write path for future
* {@code mergeInsert} calls.
* Install an {@link MemWalWriteSpec} on this table, selecting the MemWAL 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) {
public void enableMemWal(MemWalWriteSpec spec) {
if (spec == null) {
throw new IllegalArgumentException("Spec cannot be null");
}
client.post(route("set_lsm_write_spec"), spec.toRequestBody());
client.post(route("enable_memwal"), spec.toRequestBody());
}
/**
* Remove the {@link LsmWriteSpec} from this table, reverting to the standard {@code mergeInsert}
* write path.
* Remove the {@link MemWalWriteSpec} 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);
public void disableMemWal() {
client.post(route("disable_memwal"), null);
}
/**
* Read the {@link LsmWriteSpec} currently installed on this table.
* Read the {@link MemWalWriteSpec} currently installed on this table.
*
* <p>Empty when the LSM write path is not enabled at all — no spec has been set, or one was
* removed with {@link #unsetLsmWriteSpec()}. That is a different answer from a present spec
* whose {@link LsmWriteSpec#maintainedIndexes()} is null, which selects indexes automatically.
* <p>Empty when the MemWAL write path is not enabled at all — no spec has been set, or one was
* removed with {@link #disableMemWal()}. That is a different answer from a present spec whose
* {@link MemWalWriteSpec#maintainedIndexes()} is null, which selects indexes automatically.
*
* <p>The spec read back is the one that was installed, selection included: null maintains every
* supported index the table has now or gains later, an empty list maintains none, and a
* non-empty list maintains exactly those. All three round-trip.
* supported index the table has now or gains later, an empty list maintains none, and a non-empty
* list maintains exactly those. All three round-trip.
*/
public Optional<LsmWriteSpec> getLsmWriteSpec() {
JsonNode response = client.post(route("get_lsm_write_spec"), null);
if (response == null || !response.hasNonNull("lsm_write_spec")) {
public Optional<MemWalWriteSpec> getMemWalSpec() {
JsonNode response = client.post(route("get_memwal_spec"), null);
if (response == null || !response.hasNonNull("memwal_spec")) {
return Optional.empty();
}
return Optional.of(LsmWriteSpec.fromJson(response.get("lsm_write_spec")));
return Optional.of(MemWalWriteSpec.fromJson(response.get("memwal_spec")));
}
/**
* Seal every bucket's active memtable into a new L0 generation.
* Freeze every table shard's active memtable into a new SSTable.
*
* <p>Returns once the seal is committed. Sealing an empty memtable is a no-op, so this is safe to
* call repeatedly.
* <p>Returns once the freeze is committed. Freezing an empty memtable is a no-op, so this is safe
* to call repeatedly.
*/
public void flushLsm() {
client.post(route("flush_lsm"), null);
public void flushMemWal() {
client.post(route("flush_memwal"), null);
}
/**
* Trigger a background L0 → base compaction pass per bucket.
* Trigger a background SSTable compaction pass per table shard.
*
* <p>Returns once the passes are <em>dispatched</em>, not once they finish — watch {@link
* #getLsmStats}, or use {@link #checkpointLsm} to wait for convergence.
* #getMemWalStats}, or use {@link #checkpointMemWal} to wait for convergence.
*/
public void compactLsm() {
client.post(route("compact_lsm"), null);
public void compactMemWal() {
client.post(route("compact_memwal"), null);
}
/**
* Read live per-bucket LSM state.
* Read live per-table-shard MemWAL 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>Answers "how far behind is my fresh tier", "which table shard 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.
* <p>Empty only when the MemWAL write path is not enabled — that is, when the server sends an
* absent or null {@code memwal_stats}. A stats object that is present is decoded strictly, and a
* malformed one throws rather than decoding to something empty, because {@link #checkpointMemWal}
* 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.
* @param includeSstableRows Also count rows per SSTable. 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) {
public Optional<MemWalStats> getMemWalStats(boolean includeSstableRows) {
Map<String, Object> body = new LinkedHashMap<String, Object>();
body.put("include_generation_rows", includeGenerationRows);
JsonNode response = client.post(route("get_lsm_stats"), body);
body.put("include_sstable_rows", includeSstableRows);
JsonNode response = client.post(route("get_memwal_stats"), body);
if (response == null) {
throw new IllegalStateException("get_lsm_stats returned an empty response body");
throw new IllegalStateException("get_memwal_stats returned an empty response body");
}
JsonNode stats = response.get("lsm_stats");
JsonNode stats = response.get("memwal_stats");
if (stats == null || stats.isNull()) {
return Optional.empty();
}
return Optional.of(LsmStats.fromJson(stats));
return Optional.of(MemWalStats.fromJson(stats));
}
/** Equivalent to {@code getLsmStats(false)}. */
public Optional<LsmStats> getLsmStats() {
return getLsmStats(false);
/** Equivalent to {@code getMemWalStats(false)}. */
public Optional<MemWalStats> getMemWalStats() {
return getMemWalStats(false);
}
/**
* Converge this table's LSM write path into its base table.
* Converge this table's MemWAL 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>Freezes once, fixes a target watermark from the resulting SSTables, then triggers compaction
* and polls until those SSTables are gone. The target set is fixed at the start, so sstables
* 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.
* <p>The loop runs here, not on the server: {@link #compactMemWal} 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() {
public void checkpointMemWal() {
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)) {
// The freeze turns everything written before this call into an SSTable, so the
// watermark has to be read after it. Idempotent: freezing an empty memtable is a
// no-op, so a re-issue does not churn empty sstables.
if (issueVoid(this::flushMemWal)) {
backoff(reissue);
continue;
}
Attempt<Optional<LsmStats>> stats = issue(() -> getLsmStats(false));
Attempt<Optional<MemWalStats>> stats = issue(() -> getMemWalStats(false));
if (stats.lostClaim) {
backoff(reissue);
continue;
}
if (!stats.value.isPresent()) {
// Not WAL-backed; flushLsm would have errored first but for a race.
// Not WAL-backed; flushMemWal would have errored first but for a race.
return;
}
Map<String, Long> targets = newestGenerations(stats.value.get());
Map<String, Long> targets = newestSstableGenerations(stats.value.get());
if (targets.isEmpty()) {
return;
}
@@ -235,18 +235,18 @@ public class LanceDbTableLsm {
backoff(reissue);
}
throw new IllegalStateException(
"checkpointLsm: the owning node kept losing its claim; re-issued from flush the maximum "
"checkpointMemWal: 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.
* Trigger and poll until no table shard holds an SSTable 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));
Attempt<Optional<MemWalStats>> stats = issue(() -> getMemWalStats(false));
if (stats.lostClaim) {
return false;
}
@@ -254,21 +254,21 @@ public class LanceDbTableLsm {
return true;
}
// `compacting` is the bucket's compaction latch, held from dispatch until the pass
// `compacting` is the table shard'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
// only: do not pile on. Table shards 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());
for (TableShardStats tableShard : stats.value.get().tableShards()) {
Long target = targets.get(tableShard.shardId());
if (target == null) {
continue;
}
long remaining = bucket.outstandingGenerations(target);
long remaining = tableShard.outstandingSstables(target);
if (remaining > 0) {
outstanding += remaining;
allCompacting &= bucket.compacting();
allCompacting &= tableShard.compacting();
}
}
if (outstanding == 0) {
@@ -277,7 +277,7 @@ public class LanceDbTableLsm {
if (!allCompacting) {
try {
compactLsm();
compactMemWal();
} catch (LanceDbRestClient.HttpException e) {
if (isLostClaim(e)) {
return false;
@@ -285,7 +285,7 @@ public class LanceDbTableLsm {
if (!isRetryable(e)) {
throw e;
}
// A 429 here means the server could latch no bucket at all, which the poll
// A 429 here means the server could latch no table shard 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.
@@ -295,13 +295,13 @@ public class LanceDbTableLsm {
}
}
/** The newest generation held by each bucket, skipping buckets holding none. */
private static Map<String, Long> newestGenerations(LsmStats stats) {
/** The newest SSTable generation held by each table shard, skipping table shards holding none. */
private static Map<String, Long> newestSstableGenerations(MemWalStats stats) {
Map<String, Long> targets = new HashMap<String, Long>();
for (BucketStats bucket : stats.buckets()) {
OptionalLong newest = bucket.newestGeneration();
for (TableShardStats tableShard : stats.tableShards()) {
OptionalLong newest = tableShard.newestSstableGeneration();
if (newest.isPresent()) {
targets.put(bucket.shardId(), newest.getAsLong());
targets.put(tableShard.shardId(), newest.getAsLong());
}
}
return targets;
@@ -1,56 +0,0 @@
/*
* 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,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-table-shard MemWAL state, as returned by {@link LanceDbTableMemWal#getMemWalStats()}.
*
* <p>Nothing here is derived: sums and differences (total SSTable 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 MemWalStats {
private static final String CONTEXT = "memwal stats";
private final List<TableShardStats> tableShards;
MemWalStats(List<TableShardStats> tableShards) {
this.tableShards = Collections.unmodifiableList(tableShards);
}
/** One entry per table shard. */
public List<TableShardStats> tableShards() {
return tableShards;
}
static MemWalStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
List<TableShardStats> tableShards = new ArrayList<TableShardStats>();
for (JsonNode tableShard : JsonFields.requiredArray(node, "table_shards", CONTEXT)) {
tableShards.add(TableShardStats.fromJson(tableShard));
}
return new MemWalStats(tableShards);
}
@Override
public String toString() {
return "MemWalStats{tableShards=" + tableShards + "}";
}
}
@@ -27,16 +27,16 @@ import java.util.Map;
*
* <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}.
* LanceDbTableMemWal#enableMemWal} and remove it with {@link LanceDbTableMemWal#disableMemWal}.
*
* <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 {
public class MemWalWriteSpec {
private static final String CONTEXT = "get_lsm_write_spec response";
private static final String CONTEXT = "get_memwal_spec response";
/** How writes are routed to MemWAL shards. */
public enum Sharding {
@@ -73,7 +73,7 @@ public class LsmWriteSpec {
private final List<String> maintainedIndexes;
private final Map<String, String> writerConfigDefaults;
private LsmWriteSpec(
private MemWalWriteSpec(
Sharding sharding,
String column,
Integer numBuckets,
@@ -95,11 +95,11 @@ public class LsmWriteSpec {
* @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) {
public static MemWalWriteSpec bucket(String column, int numBuckets) {
if (column == null || column.trim().isEmpty()) {
throw new IllegalArgumentException("Column cannot be null or empty");
}
return new LsmWriteSpec(
return new MemWalWriteSpec(
Sharding.BUCKET, column, numBuckets, null, new HashMap<String, String>());
}
@@ -111,16 +111,17 @@ public class LsmWriteSpec {
* 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) {
public static MemWalWriteSpec 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>());
return new MemWalWriteSpec(
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>());
public static MemWalWriteSpec unsharded() {
return new MemWalWriteSpec(Sharding.UNSHARDED, null, null, null, new HashMap<String, String>());
}
/**
@@ -131,8 +132,8 @@ public class LsmWriteSpec {
*
* <p>Note that {@code null} and the empty list mean opposite things here.
*/
public LsmWriteSpec withMaintainedIndexes(List<String> maintainedIndexes) {
return new LsmWriteSpec(
public MemWalWriteSpec withMaintainedIndexes(List<String> maintainedIndexes) {
return new MemWalWriteSpec(
sharding,
column,
numBuckets,
@@ -149,11 +150,11 @@ public class LsmWriteSpec {
* 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) {
public MemWalWriteSpec withWriterConfigDefaults(Map<String, String> writerConfigDefaults) {
if (writerConfigDefaults == null) {
throw new IllegalArgumentException("writerConfigDefaults cannot be null");
}
return new LsmWriteSpec(
return new MemWalWriteSpec(
sharding,
column,
numBuckets,
@@ -191,7 +192,7 @@ public class LsmWriteSpec {
return Collections.unmodifiableMap(writerConfigDefaults);
}
/** Render this spec as the {@code set_lsm_write_spec} request body. */
/** Render this spec as the {@code enable_memwal} request body. */
Map<String, Object> toRequestBody() {
Map<String, Object> shardingBody = new LinkedHashMap<String, Object>();
shardingBody.put("mode", sharding.wireName());
@@ -211,15 +212,15 @@ public class LsmWriteSpec {
}
/**
* Rebuild a spec from a {@code get_lsm_write_spec} response body.
* Rebuild a spec from a {@code get_memwal_spec} response body.
*
* <p>A null {@code maintained_indexes} selects every index the table has; an empty list selects
* none.
*/
static LsmWriteSpec fromJson(JsonNode node) {
static MemWalWriteSpec 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");
throw new IllegalStateException("get_memwal_spec response has no sharding mode");
}
Sharding sharding = Sharding.fromWireName(shardingNode.get("mode").asText());
@@ -252,12 +253,12 @@ public class LsmWriteSpec {
.forEachRemaining(name -> defaults.put(name, defaultsNode.get(name).asText()));
}
return new LsmWriteSpec(sharding, column, numBuckets, maintainedIndexes, defaults);
return new MemWalWriteSpec(sharding, column, numBuckets, maintainedIndexes, defaults);
}
@Override
public String toString() {
return "LsmWriteSpec{sharding="
return "MemWalWriteSpec{sharding="
+ sharding
+ ", column="
+ column
@@ -37,7 +37,7 @@ public class MemtableStats {
this.indexes = Collections.unmodifiableList(indexes);
}
/** The generation this memtable will become once sealed. */
/** The generation this memtable will become once frozen. */
public long generation() {
return generation;
}
@@ -17,21 +17,21 @@ import com.fasterxml.jackson.databind.JsonNode;
import java.util.OptionalLong;
/** One flushed L0 generation. */
public class GenerationStats {
/** One SSTable. */
public class SsTableStats {
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) {
SsTableStats(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. */
/** The generation number. Increases as memtables are frozen into SSTables. */
public long generation() {
return generation;
}
@@ -42,16 +42,16 @@ public class GenerationStats {
}
/**
* Rows in this generation, present only when {@code includeGenerationRows} was requested. Off by
* Rows in this generation, present only when {@code includeSstableRows} 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) {
static SsTableStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
return new GenerationStats(
return new SsTableStats(
JsonFields.requiredLong(node, "generation", CONTEXT),
JsonFields.requiredLong(node, "bytes", CONTEXT),
JsonFields.optionalLong(node, "rows", CONTEXT));
@@ -59,6 +59,6 @@ public class GenerationStats {
@Override
public String toString() {
return "GenerationStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}";
return "SsTableStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}";
}
}
@@ -22,11 +22,11 @@ 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.
* Live state of one table shard. A table is N table shards on one node; flattening to a single
* number hides the one hot table shard that is usually why someone opened this endpoint.
*/
public class BucketStats {
private static final String CONTEXT = "bucket stats";
public class TableShardStats {
private static final String CONTEXT = "tableShard stats";
private final String shardId;
private final String status;
@@ -35,11 +35,11 @@ public class BucketStats {
private final long currentGeneration;
private final long replayAfterWalEntryPosition;
private final long walEntryPositionLastSeen;
private final List<GenerationStats> generations;
private final List<SsTableStats> sstables;
private final boolean compacting;
private final List<MemtableStats> memtables;
BucketStats(
TableShardStats(
String shardId,
String status,
long writerEpoch,
@@ -47,7 +47,7 @@ public class BucketStats {
long currentGeneration,
long replayAfterWalEntryPosition,
long walEntryPositionLastSeen,
List<GenerationStats> generations,
List<SsTableStats> sstables,
boolean compacting,
List<MemtableStats> memtables) {
this.shardId = shardId;
@@ -57,12 +57,12 @@ public class BucketStats {
this.currentGeneration = currentGeneration;
this.replayAfterWalEntryPosition = replayAfterWalEntryPosition;
this.walEntryPositionLastSeen = walEntryPositionLastSeen;
this.generations = Collections.unmodifiableList(generations);
this.sstables = Collections.unmodifiableList(sstables);
this.compacting = compacting;
this.memtables = memtables == null ? null : Collections.unmodifiableList(memtables);
}
/** The shard this bucket writes. */
/** The id of this table shard. */
public String shardId() {
return shardId;
}
@@ -100,13 +100,13 @@ public class BucketStats {
return walEntryPositionLastSeen;
}
/** Flushed L0 generations not yet merged into the base table. */
public List<GenerationStats> generations() {
return generations;
/** SSTables not yet merged into the base table. */
public List<SsTableStats> sstables() {
return sstables;
}
/**
* Whether a pass owns this bucket's compaction latch right now. Says <em>a</em> driver is
* Whether a pass owns this table shard'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".
@@ -115,15 +115,17 @@ public class BucketStats {
return compacting;
}
/** Oldest first, active last. Empty for a {@code "Sealed"} bucket, whose state is torn down. */
/**
* Oldest first, active last. Empty for a {@code "Sealed"} table shard, 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() {
/** The newest SSTable generation, or empty when the tier is empty. */
OptionalLong newestSstableGeneration() {
OptionalLong newest = OptionalLong.empty();
for (GenerationStats generation : generations) {
for (SsTableStats generation : sstables) {
if (!newest.isPresent() || generation.generation() > newest.getAsLong()) {
newest = OptionalLong.of(generation.generation());
}
@@ -132,15 +134,15 @@ public class BucketStats {
}
/**
* How many generations at or below {@code target} are still in L0.
* How many SSTables at or below {@code target} are still uncompacted.
*
* <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 outstandingSstables(long target) {
long count = 0;
for (GenerationStats generation : generations) {
for (SsTableStats generation : sstables) {
if (generation.generation() <= target) {
count++;
}
@@ -148,11 +150,11 @@ public class BucketStats {
return count;
}
static BucketStats fromJson(JsonNode node) {
static TableShardStats 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));
List<SsTableStats> sstables = new ArrayList<SsTableStats>();
for (JsonNode generation : JsonFields.requiredArray(node, "sstables", CONTEXT)) {
sstables.add(SsTableStats.fromJson(generation));
}
JsonNode memtablesNode = JsonFields.optionalArray(node, "memtables", CONTEXT);
@@ -164,7 +166,7 @@ public class BucketStats {
}
}
return new BucketStats(
return new TableShardStats(
JsonFields.requiredText(node, "shard_id", CONTEXT),
JsonFields.requiredText(node, "status", CONTEXT),
JsonFields.requiredLong(node, "writer_epoch", CONTEXT),
@@ -172,21 +174,21 @@ public class BucketStats {
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,
sstables,
JsonFields.requiredBoolean(node, "compacting", CONTEXT),
memtables);
}
@Override
public String toString() {
return "BucketStats{shardId="
return "TableShardStats{shardId="
+ shardId
+ ", status="
+ status
+ ", currentGeneration="
+ currentGeneration
+ ", generations="
+ generations
+ ", sstables="
+ sstables
+ ", compacting="
+ compacting
+ "}";
@@ -46,12 +46,12 @@ import static org.junit.jupiter.api.Assertions.*;
* <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 {
public class LanceDbTableMemWalTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private HttpServer server;
private LanceDbRestClient client;
private LanceDbTableLsm lsm;
private LanceDbTableMemWal memWal;
private final List<String> requestPaths = Collections.synchronizedList(new ArrayList<String>());
private final List<String> requestBodies = Collections.synchronizedList(new ArrayList<String>());
@@ -101,7 +101,7 @@ public class LanceDbTableLsmTest {
.database("test-db")
.endpoint("http://127.0.0.1:" + server.getAddress().getPort())
.buildRestClient();
lsm = new LanceDbTableLsm(client, "my_table");
memWal = new LanceDbTableMemWal(client, "my_table");
}
@AfterEach
@@ -115,12 +115,12 @@ public class LanceDbTableLsmTest {
// ===========================================================================
@Test
public void testSetLsmWriteSpecUnsharded() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
public void testEnableMemWalUnsharded() throws Exception {
enqueue("enable_memwal", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded());
memWal.enableMemWal(MemWalWriteSpec.unsharded());
assertEquals("/v1/table/my_table/set_lsm_write_spec/", requestPaths.get(0));
assertEquals("/v1/table/my_table/enable_memwal/", requestPaths.get(0));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("unsharded", body.get("sharding").get("mode").asText());
assertFalse(body.get("sharding").has("column"));
@@ -128,11 +128,11 @@ public class LanceDbTableLsmTest {
}
@Test
public void testSetLsmWriteSpecBucket() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
public void testEnableMemWalBucket() throws Exception {
enqueue("enable_memwal", 200, "");
lsm.setLsmWriteSpec(
LsmWriteSpec.bucket("id", 16).withMaintainedIndexes(Arrays.asList("id_idx")));
memWal.enableMemWal(
MemWalWriteSpec.bucket("id", 16).withMaintainedIndexes(Arrays.asList("id_idx")));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("bucket", body.get("sharding").get("mode").asText());
@@ -143,10 +143,10 @@ public class LanceDbTableLsmTest {
}
@Test
public void testSetLsmWriteSpecIdentity() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
public void testEnableMemWalIdentity() throws Exception {
enqueue("enable_memwal", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.identity("tenant"));
memWal.enableMemWal(MemWalWriteSpec.identity("tenant"));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("identity", body.get("sharding").get("mode").asText());
@@ -160,55 +160,55 @@ public class LanceDbTableLsmTest {
*/
@Test
public void testMaintainedIndexesNullAndEmptyAreDistinctOnTheWire() throws Exception {
enqueue("set_lsm_write_spec", 200, "");
enqueue("enable_memwal", 200, "");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded());
memWal.enableMemWal(MemWalWriteSpec.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()));
memWal.enableMemWal(
MemWalWriteSpec.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, "");
public void testEnableMemWalWriterConfigDefaults() throws Exception {
enqueue("enable_memwal", 200, "");
Map<String, String> defaults = new HashMap<String, String>();
defaults.put("max_memtable_rows", "50000");
lsm.setLsmWriteSpec(LsmWriteSpec.unsharded().withWriterConfigDefaults(defaults));
memWal.enableMemWal(MemWalWriteSpec.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, "");
public void testDisableMemWal() {
enqueue("disable_memwal", 200, "");
lsm.unsetLsmWriteSpec();
memWal.disableMemWal();
assertEquals("/v1/table/my_table/unset_lsm_write_spec/", requestPaths.get(0));
assertEquals("/v1/table/my_table/disable_memwal/", requestPaths.get(0));
assertEquals("", requestBodies.get(0));
}
@Test
public void testGetLsmWriteSpec() {
public void testGetMemWalSpec() {
enqueue(
"get_lsm_write_spec",
"get_memwal_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"bucket\",\"column\":\"id\","
"{\"memwal_spec\":{\"sharding\":{\"mode\":\"bucket\",\"column\":\"id\","
+ "\"num_buckets\":16},\"maintained_indexes\":[\"id_idx\"],"
+ "\"writer_config_defaults\":{\"durable_write\":\"true\"}}}");
Optional<LsmWriteSpec> spec = lsm.getLsmWriteSpec();
Optional<MemWalWriteSpec> spec = memWal.getMemWalSpec();
assertTrue(spec.isPresent());
assertEquals(LsmWriteSpec.Sharding.BUCKET, spec.get().sharding());
assertEquals(MemWalWriteSpec.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());
@@ -217,28 +217,28 @@ public class LanceDbTableLsmTest {
/** A null selection, every index the table has, reads back as null rather than an empty list. */
@Test
public void testGetLsmWriteSpecRoundTripsEveryIndexSelection() {
public void testGetMemWalSpecRoundTripsEveryIndexSelection() {
enqueue(
"get_lsm_write_spec",
"get_memwal_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
"{\"memwal_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
+ "\"maintained_indexes\":null,\"writer_config_defaults\":{}}}");
Optional<LsmWriteSpec> spec = lsm.getLsmWriteSpec();
Optional<MemWalWriteSpec> spec = memWal.getMemWalSpec();
assertTrue(spec.isPresent());
assertNull(spec.get().maintainedIndexes());
}
@Test
public void testGetLsmWriteSpecRoundTripsNoIndexSelection() {
public void testGetMemWalSpecRoundTripsNoIndexSelection() {
enqueue(
"get_lsm_write_spec",
"get_memwal_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
"{\"memwal_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
+ "\"maintained_indexes\":[],\"writer_config_defaults\":{}}}");
Optional<LsmWriteSpec> spec = lsm.getLsmWriteSpec();
Optional<MemWalWriteSpec> spec = memWal.getMemWalSpec();
assertTrue(spec.isPresent());
assertEquals(Collections.emptyList(), spec.get().maintainedIndexes());
@@ -249,32 +249,32 @@ public class LanceDbTableLsmTest {
* rejected: reading a string or an object as null would turn a malformed response into a policy.
*/
@Test
public void testGetLsmWriteSpecRejectsANonArrayMaintainedIndexes() {
public void testGetMemWalSpecRejectsANonArrayMaintainedIndexes() {
enqueue(
"get_lsm_write_spec",
"get_memwal_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
"{\"memwal_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
+ "\"maintained_indexes\":\"id_idx\",\"writer_config_defaults\":{}}}");
assertThrows(IllegalStateException.class, () -> lsm.getLsmWriteSpec());
assertThrows(IllegalStateException.class, () -> memWal.getMemWalSpec());
}
@Test
public void testGetLsmWriteSpecRejectsNonStringIndexNames() {
public void testGetMemWalSpecRejectsNonStringIndexNames() {
enqueue(
"get_lsm_write_spec",
"get_memwal_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
"{\"memwal_spec\":{\"sharding\":{\"mode\":\"unsharded\"},"
+ "\"maintained_indexes\":[7],\"writer_config_defaults\":{}}}");
assertThrows(IllegalStateException.class, () -> lsm.getLsmWriteSpec());
assertThrows(IllegalStateException.class, () -> memWal.getMemWalSpec());
}
@Test
public void testGetLsmWriteSpecAbsent() {
enqueue("get_lsm_write_spec", 200, "{\"lsm_write_spec\":null}");
public void testGetMemWalSpecAbsent() {
enqueue("get_memwal_spec", 200, "{\"memwal_spec\":null}");
assertFalse(lsm.getLsmWriteSpec().isPresent());
assertFalse(memWal.getMemWalSpec().isPresent());
}
// ===========================================================================
@@ -282,15 +282,15 @@ public class LanceDbTableLsmTest {
// ===========================================================================
@Test
public void testGetLsmStats() throws Exception {
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
public void testGetMemWalStats() throws Exception {
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false, 7L, 8L)));
Optional<LsmStats> got = lsm.getLsmStats(true);
Optional<MemWalStats> got = memWal.getMemWalStats(true);
assertEquals("/v1/table/my_table/get_lsm_stats/", requestPaths.get(0));
assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
assertEquals("/v1/table/my_table/get_memwal_stats/", requestPaths.get(0));
assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_sstable_rows").asBoolean());
assertTrue(got.isPresent());
BucketStats decoded = got.get().buckets().get(0);
TableShardStats decoded = got.get().tableShards().get(0);
assertEquals("shard-0", decoded.shardId());
assertEquals("Active", decoded.status());
assertEquals(1, decoded.writerEpoch());
@@ -298,30 +298,30 @@ public class LanceDbTableLsmTest {
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");
assertEquals(1024, decoded.sstables().get(0).bytes());
assertFalse(decoded.sstables().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() {
public void testGetMemWalStatsDecodesOptionalFields() {
enqueue(
"get_lsm_stats",
"get_memwal_stats",
200,
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
"{\"memwal_stats\":{\"table_shards\":[{\"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}],"
+ "\"sstables\":[{\"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);
TableShardStats decoded = memWal.getMemWalStats(true).get().tableShards().get(0);
assertEquals(3, decoded.replayAfterWalEntryPosition());
assertEquals(11, decoded.walEntryPositionLastSeen());
assertTrue(decoded.compacting());
assertEquals(42, decoded.generations().get(0).rows().getAsLong());
assertEquals(42, decoded.sstables().get(0).rows().getAsLong());
assertTrue(decoded.memtables().isPresent());
MemtableStats memtable = decoded.memtables().get().get(0);
assertEquals(8, memtable.generation());
@@ -332,19 +332,19 @@ public class LanceDbTableLsmTest {
}
@Test
public void testGetLsmStatsAbsentWhenLsmDisabled() {
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
public void testGetMemWalStatsAbsentWhenMemWalDisabled() {
enqueue("get_memwal_stats", 200, "{\"memwal_stats\":null}");
assertFalse(lsm.getLsmStats().isPresent());
assertFalse(memWal.getMemWalStats().isPresent());
}
@Test
public void testGetLsmStatsDefaultsToExcludingGenerationRows() throws Exception {
enqueue("get_lsm_stats", 200, stats());
public void testGetMemWalStatsDefaultsToExcludingGenerationRows() throws Exception {
enqueue("get_memwal_stats", 200, stats());
lsm.getLsmStats();
memWal.getMemWalStats();
assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_sstable_rows").asBoolean());
}
// ===========================================================================
@@ -353,22 +353,22 @@ public class LanceDbTableLsmTest {
@Test
public void testFlushAndCompactRoutes() {
enqueue("flush_lsm", 200, "");
enqueue("compact_lsm", 200, "");
enqueue("flush_memwal", 200, "");
enqueue("compact_memwal", 200, "");
lsm.flushLsm();
lsm.compactLsm();
memWal.flushMemWal();
memWal.compactMemWal();
assertEquals("/v1/table/my_table/flush_lsm/", requestPaths.get(0));
assertEquals("/v1/table/my_table/compact_lsm/", requestPaths.get(1));
assertEquals("/v1/table/my_table/flush_memwal/", requestPaths.get(0));
assertEquals("/v1/table/my_table/compact_memwal/", requestPaths.get(1));
}
@Test
public void testHttpErrorCarriesStatus() {
enqueue("flush_lsm", 404, "no such table");
enqueue("flush_memwal", 404, "no such table");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.flushLsm());
assertThrows(LanceDbRestClient.HttpException.class, () -> memWal.flushMemWal());
assertEquals(404, e.statusCode());
}
@@ -377,97 +377,98 @@ public class LanceDbTableLsmTest {
// ===========================================================================
@Test
public void testCheckpointReturnsWhenLsmDisabled() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
public void testCheckpointReturnsWhenMemWalDisabled() {
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 200, "{\"memwal_stats\":null}");
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(0, countCalls("compact_lsm"), "nothing to compact when the LSM path is off");
assertEquals(0, countCalls("compact_memwal"), "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)));
enqueue("flush_memwal", 200, "");
// A table shard with no SSTables yields no target, so the drain never starts.
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false)));
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(0, countCalls("compact_lsm"));
assertEquals(0, countCalls("compact_memwal"));
}
@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)));
enqueue("flush_memwal", 200, "");
// Watermark read: shard-0 holds sstables 7 and 8, so target = 8.
enqueue("get_memwal_stats", 200, stats(tableShard("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)));
enqueue("get_memwal_stats", 200, stats(tableShard("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, "");
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false, 9L)));
enqueue("compact_memwal", 200, "");
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(1, countCalls("compact_lsm"), "one pass dispatched");
assertEquals(3, countCalls("get_lsm_stats"), "watermark read plus two drain polls");
assertEquals(1, countCalls("compact_memwal"), "one pass dispatched");
assertEquals(3, countCalls("get_memwal_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)));
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 200, stats(tableShard("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)));
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", true, 4L)));
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false, 5L)));
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(0, countCalls("compact_lsm"), "a latched bucket is left alone");
assertEquals(0, countCalls("compact_memwal"), "a latched table shard 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)));
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 421, "no claim");
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false)));
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(2, countCalls("flush_lsm"), "re-issued from flush");
assertEquals(2, countCalls("flush_memwal"), "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)));
enqueue("flush_memwal", 429, "latch held");
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 200, stats(tableShard("shard-0", false)));
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(2, countCalls("flush_lsm"), "429 retried in place, not re-issued");
assertEquals(2, countCalls("flush_memwal"), "429 retried in place, not re-issued");
}
@Test
public void testCheckpointPropagatesTerminalStatus() {
enqueue("flush_lsm", 400, "bad request");
enqueue("flush_memwal", 400, "bad request");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm());
assertThrows(LanceDbRestClient.HttpException.class, () -> memWal.checkpointMemWal());
assertEquals(400, e.statusCode());
assertEquals(1, countCalls("flush_lsm"), "a terminal status is not retried");
assertEquals(1, countCalls("flush_memwal"), "a terminal status is not retried");
}
@Test
public void testCheckpointGivesUpAfterRepeatedLostClaims() {
enqueue("flush_lsm", 421, "no claim");
enqueue("flush_memwal", 421, "no claim");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> lsm.checkpointLsm());
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> memWal.checkpointMemWal());
assertTrue(e.getMessage().contains("kept losing its claim"), e.getMessage());
assertEquals(4, countCalls("flush_lsm"), "the initial attempt plus MAX_REISSUES");
assertEquals(4, countCalls("flush_memwal"), "the initial attempt plus MAX_REISSUES");
}
// ===========================================================================
@@ -476,50 +477,51 @@ public class LanceDbTableLsmTest {
/**
* 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.
* read as "no table shards", which is indistinguishable from a drained table, so {@code
* checkpointMemWal} 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("stats object with no table shards", "{\"memwal_stats\":{}}");
malformed.put(
"bucket missing generations",
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
"table shard missing its required fields", "{\"memwal_stats\":{\"table_shards\":[{}]}}");
malformed.put(
"table shard missing sstables",
"{\"memwal_stats\":{\"table_shards\":[{\"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\","
"{\"memwal_stats\":{\"table_shards\":[{\"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}],"
+ "\"sstables\":[{\"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());
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 200, each.getValue());
assertThrows(
IllegalStateException.class,
() -> lsm.checkpointLsm(),
() -> memWal.checkpointMemWal(),
each.getKey() + " must not report convergence");
}
}
/** The one shape that legitimately means "this table has no LSM write path". */
/** The one shape that legitimately means "this table has no MemWAL write path". */
@Test
public void testCheckpointTreatsNullStatsAsNotWalBacked() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, "{\"lsm_stats\":null}");
enqueue("flush_memwal", 200, "");
enqueue("get_memwal_stats", 200, "{\"memwal_stats\":null}");
lsm.checkpointLsm();
memWal.checkpointMemWal();
assertEquals(1, countCalls("get_lsm_stats"));
assertEquals(1, countCalls("get_memwal_stats"));
}
// ===========================================================================
@@ -529,40 +531,41 @@ public class LanceDbTableLsmTest {
/**
* 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
* doubled every budget here and also retried {@code compact_memwal} 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");
enqueue("flush_memwal", 429, "latch held");
LanceDbRestClient.HttpException e =
assertThrows(LanceDbRestClient.HttpException.class, () -> lsm.checkpointLsm());
assertThrows(LanceDbRestClient.HttpException.class, () -> memWal.checkpointMemWal());
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");
assertEquals(
9, countCalls("flush_memwal"), "the initial request plus MAX_RETRIES, and no more");
}
// ===========================================================================
// harness
// ===========================================================================
private static List<Long> generationNumbers(BucketStats bucket) {
private static List<Long> generationNumbers(TableShardStats tableShard) {
List<Long> numbers = new ArrayList<Long>();
for (GenerationStats generation : bucket.generations()) {
for (SsTableStats generation : tableShard.sstables()) {
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) + "]}}";
/** Build a {@code memwal_stats} response body from table shard fragments. */
private static String stats(String... tableShards) {
return "{\"memwal_stats\":{\"table_shards\":[" + String.join(",", tableShards) + "]}}";
}
private static String bucket(String shardId, boolean compacting, Long... generations) {
private static String tableShard(String shardId, boolean compacting, Long... sstables) {
StringBuilder gens = new StringBuilder();
for (Long generation : generations) {
for (Long generation : sstables) {
if (gens.length() > 0) {
gens.append(",");
}
@@ -572,7 +575,7 @@ public class LanceDbTableLsmTest {
+ 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\":["
+ "\"wal_entry_position_last_seen\":0,\"sstables\":["
+ gens
+ "],\"compacting\":"
+ compacting
@@ -597,7 +600,7 @@ public class LanceDbTableLsmTest {
return requestPaths.stream().filter(path -> operationOf(path).equals(operation)).count();
}
/** {@code /v1/table/my_table/flush_lsm/} -> {@code flush_lsm}. */
/** {@code /v1/table/my_table/flush_memwal/} -> {@code flush_memwal}. */
private static String operationOf(String path) {
String[] segments = path.split("/");
return segments.length == 0 ? "" : segments[segments.length - 1];
+50 -48
View File
@@ -640,11 +640,11 @@ describe.each(arrowVersions)(
);
});
it("should expose useLsm on takeRowIds as the base-only escape hatch", async () => {
it("should expose useMemWal on takeRowIds as the base-only escape hatch", async () => {
await table.add([{ id: 1 }, { id: 2 }, { id: 3 }]);
// useLsm(false) is reachable on TakeQuery (the escape hatch for MemWAL tables,
// useMemWal(false) is reachable on TakeQuery (the escape hatch for MemWAL tables,
// where take-by-row-id auto-routes to the LSM scanner and is rejected).
const res = await table.takeRowIds([0, 2]).useLsm(false).toArray();
const res = await table.takeRowIds([0, 2]).useMemWal(false).toArray();
expect(res.map((r) => r.id)).toEqual([1, 3]);
});
@@ -4628,7 +4628,7 @@ describe("setUnenforcedPrimaryKey", () => {
});
});
describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
describe("enableMemWal / disableMemWal", () => {
let tmpDir: tmp.DirResult;
beforeEach(() => {
@@ -4648,16 +4648,16 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
const table = await makeTable(conn);
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({
await table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 4,
});
await table.unsetLsmWriteSpec();
await table.disableMemWal();
// A second unset errors — there is no spec left to remove.
await expect(table.unsetLsmWriteSpec()).rejects.toThrow();
await expect(table.disableMemWal()).rejects.toThrow();
// A fresh spec can be installed after unset.
await table.setLsmWriteSpec({
await table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 8,
@@ -4669,8 +4669,8 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
const table = await makeTable(conn);
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({ specType: "unsharded" });
await table.unsetLsmWriteSpec();
await table.enableMemWal({ specType: "unsharded" });
await table.disableMemWal();
});
it("installs an identity spec", async () => {
@@ -4678,8 +4678,8 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
const table = await makeTable(conn);
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({ specType: "identity", column: "id" });
await table.unsetLsmWriteSpec();
await table.enableMemWal({ specType: "identity", column: "id" });
await table.disableMemWal();
});
it("rejects an invalid spec", async () => {
@@ -4689,7 +4689,7 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
await table.setUnenforcedPrimaryKey("id");
// num_buckets out of range.
await expect(
table.setLsmWriteSpec({
table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 0,
@@ -4697,7 +4697,7 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
).rejects.toThrow();
// Column mismatch.
await expect(
table.setLsmWriteSpec({
table.enableMemWal({
specType: "bucket",
column: "missing",
numBuckets: 4,
@@ -4705,13 +4705,13 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
).rejects.toThrow();
});
it("reads back the installed spec via getLsmWriteSpec", async () => {
it("reads back the installed spec via getMemWalSpec", async () => {
const conn = await connect(tmpDir.name);
const table = await makeTable(conn);
await table.setUnenforcedPrimaryKey("id");
// Nothing installed yet.
expect(await table.getLsmWriteSpec()).toBeUndefined();
expect(await table.getMemWalSpec()).toBeUndefined();
// A real scalar index is needed to name it as a maintained index.
await table.add([{ id: 1 }, { id: 2 }, { id: 3 }]);
@@ -4722,14 +4722,14 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
// defaults. Lance writer-config keys are canonically snake_case.
// biome-ignore lint/style/useNamingConvention: Lance writer-config keys are snake_case
const writerConfigDefaults = { durable_write: "false" };
await table.setLsmWriteSpec({
await table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 4,
maintainedIndexes: [indexName],
writerConfigDefaults,
});
const spec = await table.getLsmWriteSpec();
const spec = await table.getMemWalSpec();
expect(spec).toBeDefined();
expect(spec?.specType).toBe("bucket");
expect(spec?.column).toBe("id");
@@ -4738,19 +4738,19 @@ describe("setLsmWriteSpec / unsetLsmWriteSpec", () => {
expect(spec?.writerConfigDefaults).toEqual(writerConfigDefaults);
// After unset, undefined again.
await table.unsetLsmWriteSpec();
expect(await table.getLsmWriteSpec()).toBeUndefined();
await table.disableMemWal();
expect(await table.getMemWalSpec()).toBeUndefined();
// Identity round-trips (column recovered from the schema).
await table.setLsmWriteSpec({ specType: "identity", column: "id" });
const identity = await table.getLsmWriteSpec();
await table.enableMemWal({ specType: "identity", column: "id" });
const identity = await table.getMemWalSpec();
expect(identity?.specType).toBe("identity");
expect(identity?.column).toBe("id");
await table.unsetLsmWriteSpec();
await table.disableMemWal();
// Unsharded round-trips (no routing column).
await table.setLsmWriteSpec({ specType: "unsharded" });
const unsharded = await table.getLsmWriteSpec();
await table.enableMemWal({ specType: "unsharded" });
const unsharded = await table.getMemWalSpec();
expect(unsharded?.specType).toBe("unsharded");
expect(unsharded?.column).toBeFalsy();
});
@@ -4779,7 +4779,7 @@ describe("LSM merge insert", () => {
]);
await table.setUnenforcedPrimaryKey("id");
// numBuckets = 1: every row routes to the single bucket.
await table.setLsmWriteSpec({
await table.enableMemWal({
specType: "bucket",
column: "id",
numBuckets: 1,
@@ -4804,17 +4804,17 @@ describe("LSM merge insert", () => {
expect(res.version).toBe(0);
expect(res.numInsertedRows).toBe(0);
await table.closeLsmWriters();
await table.closeMemWalWriters();
});
it("falls back to the standard path with useLsm(false)", async () => {
it("falls back to the standard path with useMemWal(false)", async () => {
const conn = await connect(tmpDir.name);
const table = await bucketTable(conn);
const res = await table
.mergeInsert("id")
.whenNotMatchedInsertAll()
.useLsm(false)
.useMemWal(false)
.execute([
{ id: "b", value: 9 },
{ id: "e", value: 5 },
@@ -4837,7 +4837,7 @@ describe("LSM merge insert", () => {
expect(res.numRows).toBe(1);
});
it("rejects a non-upsert merge under an LSM spec", async () => {
it("rejects a non-upsert merge under a MemWAL spec", async () => {
const conn = await connect(tmpDir.name);
const table = await bucketTable(conn);
@@ -4863,22 +4863,24 @@ describe("LSM merge insert", () => {
const lsm = await table.query().toArray();
expect(lsm.map((r) => r.id).sort()).toEqual(["a", "b", "c"]);
// useLsm(false) bypasses the MemWAL and reads the base table only.
const baseOnly = await table.query().useLsm(false).toArray();
// useMemWal(false) bypasses the MemWAL and reads the base table only.
const baseOnly = await table.query().useMemWal(false).toArray();
expect(baseOnly.map((r) => r.id).sort()).toEqual(["a", "b"]);
});
it("reads the base table when no LSM spec is installed", async () => {
it("reads the base table when no MemWAL spec is installed", async () => {
const conn = await connect(tmpDir.name);
const table = await conn.createEmptyTable(
"plain",
new arrow.Schema([new arrow.Field("id", new arrow.Utf8(), false)]),
);
// No spec: default read and useLsm(false) both succeed against the base table.
// No spec: default read and useMemWal(false) both succeed against the base table.
await expect(table.query().toArray()).resolves.toBeDefined();
await expect(table.query().useLsm(false).toArray()).resolves.toBeDefined();
// useLsm(true) demands MemWAL routing; without a spec it errors.
await expect(table.query().useLsm(true).toArray()).rejects.toThrow();
await expect(
table.query().useMemWal(false).toArray(),
).resolves.toBeDefined();
// useMemWal(true) demands MemWAL routing; without a spec it errors.
await expect(table.query().useMemWal(true).toArray()).rejects.toThrow();
});
});
@@ -4896,7 +4898,7 @@ describe("LSM convergence and stats", () => {
new arrow.Schema([new arrow.Field("id", new arrow.Utf8(), false)]),
);
await table.setUnenforcedPrimaryKey("id");
await table.setLsmWriteSpec({ specType: "unsharded" });
await table.enableMemWal({ specType: "unsharded" });
return table;
}
@@ -4904,34 +4906,34 @@ describe("LSM convergence and stats", () => {
// 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 () => {
it("rejects flushMemWal 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);
await expect(table.flushMemWal()).rejects.toThrow(/not supported/i);
});
it("rejects compactLsm on a local table", async () => {
it("rejects compactMemWal 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);
await expect(table.compactMemWal()).rejects.toThrow(/not supported/i);
});
it("rejects getLsmStats on a local table", async () => {
it("rejects getMemWalStats 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);
await expect(table.getMemWalStats()).rejects.toThrow(/not supported/i);
await expect(table.getMemWalStats(true)).rejects.toThrow(/not supported/i);
});
it("rejects checkpointLsm on a local table", async () => {
it("rejects checkpointMemWal 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);
// checkpointMemWal freezes first, so it surfaces flushMemWal's rejection.
await expect(table.checkpointMemWal()).rejects.toThrow(/not supported/i);
});
});
+4 -4
View File
@@ -167,10 +167,10 @@ export {
WriteProgress,
FtsToken,
TokenizeTableOptions,
LsmWriteSpec,
LsmStats,
BucketStats,
GenerationStats,
MemWalWriteSpec,
MemWalStats,
TableShardStats,
SsTableStats,
MemtableStats,
ColumnAlteration,
FieldMetadataUpdate,
+6 -6
View File
@@ -90,25 +90,25 @@ export class MergeInsertBuilder {
/**
* Control MemWAL routing for this merge.
*
* By default (unset), a `mergeInsert` on a table with an LSM write spec is
* By default (unset), a `mergeInsert` on a table with a MemWAL write spec is
* routed through Lance's MemWAL shard writer, and a table without one uses the
* standard path.
*
* @param enable - `true` forces MemWAL routing and errors if the table has no
* LSM write spec. `false` forces the standard write path even when a spec is set.
* MemWAL write spec. `false` forces the standard write path even when a spec is set.
*/
useLsm(enable: boolean): MergeInsertBuilder {
return new MergeInsertBuilder(this.#native.useLsm(enable), this.#schema);
useMemWal(enable: boolean): MergeInsertBuilder {
return new MergeInsertBuilder(this.#native.useMemWal(enable), this.#schema);
}
/**
* Controls how an LSM merge checks that its input targets a single shard.
*
* When a table has an LSM write spec, every row in a `mergeInsert` call must
* When a table has a MemWAL write spec, every row in a `mergeInsert` call must
* route to the same shard. When `true` (the default), every row is inspected
* to verify this. When `false`, only the first row is inspected and the
* shard it routes to is used for the whole input — a faster path for callers
* that have already pre-sharded their input. Has no effect on tables without
* an LSM write spec.
* a MemWAL write spec.
*
* @param validateSingleShard - Whether to check every row routes to one shard. Defaults to `true`.
*/
+7 -7
View File
@@ -464,10 +464,10 @@ export class StandardQueryBase<
* Control MemWAL read routing for this query.
*
* By default (unset), when the table carries a MemWAL write spec (see
* {@link Table#setLsmWriteSpec}), reads are routed through the LSM scanner so
* {@link Table#enableMemWal}), reads are routed through the LSM scanner so
* they also return data written via the `mergeInsert` LSM path that has not yet
* been compacted into the base table (the active/frozen in-memory memtables and
* the flushed generations), deduplicated by primary key; a table without a spec
* the SSTables), deduplicated by primary key; a table without a spec
* reads the base table.
*
* @param enable - `true` forces the LSM scanner and errors if the table has no
@@ -476,11 +476,11 @@ export class StandardQueryBase<
*
* Note: the LSM scanner does not support every query shape (e.g. reranking,
* hybrid search, `orderBy`). On a MemWAL table those shapes error unless
* `useLsm(false)` is set, because a base-only read would silently exclude
* `useMemWal(false)` is set, because a base-only read would silently exclude
* un-compacted MemWAL data.
*/
useLsm(enable: boolean): this {
this.doCall((inner: NativeQueryType) => inner.useLsm(enable));
useMemWal(enable: boolean): this {
this.doCall((inner: NativeQueryType) => inner.useMemWal(enable));
return this;
}
}
@@ -871,8 +871,8 @@ export class TakeQuery extends QueryBase<NativeTakeQuery> {
*
* @param enable - `false` reads the base table only.
*/
useLsm(enable: boolean): this {
this.doCall((inner: NativeTakeQuery) => inner.useLsm(enable));
useMemWal(enable: boolean): this {
this.doCall((inner: NativeTakeQuery) => inner.useMemWal(enable));
return this;
}
}
+60 -60
View File
@@ -35,7 +35,7 @@ import {
FunctionErrorsOptions,
IndexConfig,
IndexStatistics,
LsmStats,
MemWalStats,
Branches as NativeBranches,
OptimizeStats,
RefreshColumnResult,
@@ -59,9 +59,9 @@ import { sanitizeType } from "./sanitize";
import { IntoSql, toSQL } from "./util";
export { IndexConfig } from "./native";
export {
BucketStats,
GenerationStats,
LsmStats,
TableShardStats,
SsTableStats,
MemWalStats,
MemtableStats,
} from "./native";
@@ -217,7 +217,7 @@ export type TokenizeTableOptions =
* `column` value, or upserts of that key can land in different shards and a
* stale version can win).
*/
export interface LsmWriteSpec {
export interface MemWalWriteSpec {
/** One of `"bucket"`, `"identity"`, or `"unsharded"`. */
specType: "bucket" | "identity" | "unsharded";
/** Bucket and identity variants: the sharding column. */
@@ -758,10 +758,10 @@ export abstract class Table {
*/
abstract setUnenforcedPrimaryKey(columns: string | string[]): Promise<void>;
/**
* Install an {@link LsmWriteSpec} on this table, selecting Lance's MemWAL
* Install an {@link MemWalWriteSpec} on this table, selecting Lance's MemWAL
* LSM-style write path for future `mergeInsert` calls.
*
* `LsmWriteSpec` chooses one of three sharding strategies via `specType`:
* `MemWalWriteSpec` chooses one of three sharding strategies via `specType`:
*
* - `"bucket"` — hash-bucket writes by the single-column unenforced primary
* key (`column` and `numBuckets` required).
@@ -776,12 +776,12 @@ export abstract class Table {
* including ones created later, and skips a kind the MemWAL cannot maintain.
* 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.
* @param {MemWalWriteSpec} spec The sharding spec to install.
* @returns {Promise<void>}
* @example
* ```ts
* await table.setUnenforcedPrimaryKey("id");
* await table.setLsmWriteSpec({
* await table.enableMemWal({
* specType: "bucket",
* column: "id",
* numBuckets: 16,
@@ -789,59 +789,59 @@ export abstract class Table {
* });
* ```
*/
abstract setLsmWriteSpec(spec: LsmWriteSpec): Promise<void>;
abstract enableMemWal(spec: MemWalWriteSpec): Promise<void>;
/**
* Remove the {@link LsmWriteSpec} from this table, reverting to the standard
* Remove the {@link MemWalWriteSpec} from this table, reverting to the standard
* `mergeInsert` write path.
*
* Errors if no spec is currently set.
* @returns {Promise<void>}
*/
abstract unsetLsmWriteSpec(): Promise<void>;
abstract disableMemWal(): Promise<void>;
/**
* Read the {@link LsmWriteSpec} currently installed on this table.
* Read the {@link MemWalWriteSpec} currently installed on this 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}).
* Resolves to `undefined` when the MemWAL write path is not enabled (no
* spec has been set, or it was removed with {@link Table#disableMemWal}).
* The spec is the one installed, including its maintained-index selection:
* an absent `maintainedIndexes` for every index the table has, an empty
* array for none.
* @returns {Promise<LsmWriteSpec | undefined>}
* @returns {Promise<MemWalWriteSpec | undefined>}
*/
abstract getLsmWriteSpec(): Promise<LsmWriteSpec | undefined>;
abstract getMemWalSpec(): Promise<MemWalWriteSpec | undefined>;
/**
* Drain and close any cached MemWAL shard writers held for this table.
*
* When an {@link LsmWriteSpec} is installed, `mergeInsert` opens MemWAL
* When an {@link MemWalWriteSpec} is installed, `mergeInsert` opens MemWAL
* shard writers and caches them for reuse across calls. This closes them,
* flushing pending data; writers reopen lazily on the next `mergeInsert`.
* It is a no-op when no writers are cached.
* @returns {Promise<void>}
*/
abstract closeLsmWriters(): Promise<void>;
abstract closeMemWalWriters(): Promise<void>;
/**
* Seal every bucket's active memtable into a new L0 generation.
* Freeze every table shard's active memtable into a new SSTable.
*
* Returns once the seal is committed. Sealing an empty memtable is a no-op,
* Returns once the freeze is committed. Freezing an empty memtable is a no-op,
* so this is safe to call repeatedly.
* @returns {Promise<void>}
*/
abstract flushLsm(): Promise<void>;
abstract flushMemWal(): Promise<void>;
/**
* Trigger a background L0 → base compaction pass per bucket.
* Trigger a background SSTable compaction pass per table shard.
*
* 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.
* {@link Table#getMemWalStats} for progress, or use
* {@link Table#checkpointMemWal} to wait for convergence.
* @returns {Promise<void>}
*/
abstract compactLsm(): Promise<void>;
abstract compactMemWal(): Promise<void>;
/**
* Converge this table's LSM write path into its base table.
* Converge this table's MemWAL write path into its base table.
*
* Seals once, then triggers compaction and polls until the L0 that existed
* Freezes once, then triggers compaction and polls until the SSTables 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
* SSTables 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.
@@ -852,26 +852,26 @@ export abstract class Table {
* @returns {Promise<void>}
* @example
* ```ts
* const before = await table.getLsmStats();
* await table.checkpointLsm();
* const after = await table.getLsmStats();
* const before = await table.getMemWalStats();
* await table.checkpointMemWal();
* const after = await table.getMemWalStats();
* ```
*/
abstract checkpointLsm(): Promise<void>;
abstract checkpointMemWal(): Promise<void>;
/**
* Read live per-bucket LSM state.
* Read live per-table-shard MemWAL state.
*
* Answers "how far behind is my fresh tier", "which bucket is hot", and
* Answers "how far behind is my fresh tier", "which table shard 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.
* Resolves to `undefined` only when the MemWAL write path is not enabled.
* @param {boolean} includeSstableRows Also count rows per SSTable.
* Off by default because each count opens an uncached Lance dataset.
* @returns {Promise<LsmStats | undefined>}
* @returns {Promise<MemWalStats | undefined>}
*/
abstract getLsmStats(
includeGenerationRows?: boolean,
): Promise<LsmStats | undefined>;
abstract getMemWalStats(
includeSstableRows?: boolean,
): Promise<MemWalStats | undefined>;
/** Retrieve the version of the table */
abstract version(): Promise<number>;
@@ -1474,43 +1474,43 @@ export class LocalTable extends Table {
return await this.inner.setUnenforcedPrimaryKey(cols);
}
async setLsmWriteSpec(spec: LsmWriteSpec): Promise<void> {
return await this.inner.setLsmWriteSpec(spec);
async enableMemWal(spec: MemWalWriteSpec): Promise<void> {
return await this.inner.enableMemWal(spec);
}
async unsetLsmWriteSpec(): Promise<void> {
return await this.inner.unsetLsmWriteSpec();
async disableMemWal(): Promise<void> {
return await this.inner.disableMemWal();
}
async getLsmWriteSpec(): Promise<LsmWriteSpec | undefined> {
async getMemWalSpec(): Promise<MemWalWriteSpec | undefined> {
// The native binding types `specType` as a plain `string`; narrow it back
// to the public union. The Rust `From` impl only ever emits one of the
// three valid values, so the cast is safe.
return ((await this.inner.getLsmWriteSpec()) ?? undefined) as
| LsmWriteSpec
return ((await this.inner.getMemWalSpec()) ?? undefined) as
| MemWalWriteSpec
| undefined;
}
async closeLsmWriters(): Promise<void> {
return await this.inner.closeLsmWriters();
async closeMemWalWriters(): Promise<void> {
return await this.inner.closeMemWalWriters();
}
async flushLsm(): Promise<void> {
return await this.inner.flushLsm();
async flushMemWal(): Promise<void> {
return await this.inner.flushMemWal();
}
async compactLsm(): Promise<void> {
return await this.inner.compactLsm();
async compactMemWal(): Promise<void> {
return await this.inner.compactMemWal();
}
async checkpointLsm(): Promise<void> {
return await this.inner.checkpointLsm();
async checkpointMemWal(): Promise<void> {
return await this.inner.checkpointMemWal();
}
async getLsmStats(
includeGenerationRows: boolean = false,
): Promise<LsmStats | undefined> {
return (await this.inner.getLsmStats(includeGenerationRows)) ?? undefined;
async getMemWalStats(
includeSstableRows: boolean = false,
): Promise<MemWalStats | undefined> {
return (await this.inner.getMemWalStats(includeSstableRows)) ?? undefined;
}
async version(): Promise<number> {
+3 -3
View File
@@ -50,10 +50,10 @@ impl NativeMergeInsertBuilder {
this
}
#[napi]
pub fn use_lsm(&self, enable: bool) -> Self {
#[napi(js_name = "useMemWal")]
pub fn use_memwal(&self, enable: bool) -> Self {
let mut this = self.clone();
this.inner.use_lsm(enable);
this.inner.use_memwal(enable);
this
}
+9 -9
View File
@@ -168,9 +168,9 @@ impl Query {
self.inner = self.inner.clone().with_row_id();
}
#[napi]
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
#[napi(js_name = "useMemWal")]
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
#[napi]
@@ -394,9 +394,9 @@ impl VectorQuery {
self.inner = self.inner.clone().with_row_id();
}
#[napi]
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
#[napi(js_name = "useMemWal")]
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
#[napi]
@@ -504,9 +504,9 @@ impl TakeQuery {
self.inner = self.inner.clone().with_row_id();
}
#[napi]
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
#[napi(js_name = "useMemWal")]
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
#[napi(catch_unwind)]
+70 -74
View File
@@ -580,64 +580,60 @@ impl Table {
.default_error()
}
#[napi(catch_unwind)]
pub async fn set_lsm_write_spec(&self, spec: LsmWriteSpec) -> napi::Result<()> {
let native_spec = lancedb::table::LsmWriteSpec::try_from(spec)?;
#[napi(catch_unwind, js_name = "enableMemWal")]
pub async fn enable_memwal(&self, spec: MemWalWriteSpec) -> napi::Result<()> {
let native_spec = lancedb::table::MemWalWriteSpec::try_from(spec)?;
self.inner_ref()?
.set_lsm_write_spec(native_spec)
.enable_memwal(native_spec)
.await
.default_error()
}
#[napi(catch_unwind)]
pub async fn unset_lsm_write_spec(&self) -> napi::Result<()> {
#[napi(catch_unwind, js_name = "disableMemWal")]
pub async fn disable_memwal(&self) -> napi::Result<()> {
self.inner_ref()?.disable_memwal().await.default_error()
}
#[napi(catch_unwind, js_name = "getMemWalSpec")]
pub async fn get_memwal_spec(&self) -> napi::Result<Option<MemWalWriteSpec>> {
let spec = self.inner_ref()?.get_memwal_spec().await.default_error()?;
Ok(spec.map(MemWalWriteSpec::from))
}
#[napi(catch_unwind, js_name = "closeMemWalWriters")]
pub async fn close_memwal_writers(&self) -> napi::Result<()> {
self.inner_ref()?
.unset_lsm_write_spec()
.close_memwal_writers()
.await
.default_error()
}
#[napi(catch_unwind)]
pub async fn get_lsm_write_spec(&self) -> napi::Result<Option<LsmWriteSpec>> {
let spec = self
.inner_ref()?
.get_lsm_write_spec()
.await
.default_error()?;
Ok(spec.map(LsmWriteSpec::from))
#[napi(catch_unwind, js_name = "flushMemWal")]
pub async fn flush_memwal(&self) -> napi::Result<()> {
self.inner_ref()?.flush_memwal().await.default_error()
}
#[napi(catch_unwind)]
pub async fn close_lsm_writers(&self) -> napi::Result<()> {
self.inner_ref()?.close_lsm_writers().await.default_error()
#[napi(catch_unwind, js_name = "compactMemWal")]
pub async fn compact_memwal(&self) -> napi::Result<()> {
self.inner_ref()?.compact_memwal().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, js_name = "checkpointMemWal")]
pub async fn checkpoint_memwal(&self) -> napi::Result<()> {
self.inner_ref()?.checkpoint_memwal().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(
#[napi(catch_unwind, js_name = "getMemWalStats")]
pub async fn get_memwal_stats(
&self,
include_generation_rows: bool,
) -> napi::Result<Option<LsmStats>> {
include_sstable_rows: bool,
) -> napi::Result<Option<MemWalStats>> {
let stats = self
.inner_ref()?
.get_lsm_stats(include_generation_rows)
.get_memwal_stats(include_sstable_rows)
.await
.default_error()?;
Ok(stats.map(LsmStats::from))
Ok(stats.map(MemWalStats::from))
}
#[napi(catch_unwind)]
@@ -958,7 +954,7 @@ impl From<LanceDbFtsToken> for FtsToken {
/// `column` is required.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct LsmWriteSpec {
pub struct MemWalWriteSpec {
/// One of `"bucket"`, `"identity"`, or `"unsharded"`.
pub spec_type: String,
/// Bucket and identity variants: the sharding column.
@@ -972,31 +968,31 @@ pub struct LsmWriteSpec {
pub writer_config_defaults: Option<HashMap<String, String>>,
}
impl TryFrom<LsmWriteSpec> for lancedb::table::LsmWriteSpec {
impl TryFrom<MemWalWriteSpec> for lancedb::table::MemWalWriteSpec {
type Error = napi::Error;
fn try_from(value: LsmWriteSpec) -> napi::Result<Self> {
fn try_from(value: MemWalWriteSpec) -> napi::Result<Self> {
let writer_config_defaults = value.writer_config_defaults.unwrap_or_default();
let spec = match value.spec_type.as_str() {
"bucket" => {
let column = value.column.ok_or_else(|| {
napi::Error::from_reason("LsmWriteSpec bucket requires `column`")
napi::Error::from_reason("MemWalWriteSpec bucket requires `column`")
})?;
let num_buckets = value.num_buckets.ok_or_else(|| {
napi::Error::from_reason("LsmWriteSpec bucket requires `numBuckets`")
napi::Error::from_reason("MemWalWriteSpec bucket requires `numBuckets`")
})?;
Self::bucket(column, num_buckets)
}
"identity" => {
let column = value.column.ok_or_else(|| {
napi::Error::from_reason("LsmWriteSpec identity requires `column`")
napi::Error::from_reason("MemWalWriteSpec identity requires `column`")
})?;
Self::identity(column)
}
"unsharded" => Self::unsharded(),
other => {
return Err(napi::Error::from_reason(format!(
"LsmWriteSpec `specType` must be 'bucket', 'identity', or 'unsharded', got '{}'",
"MemWalWriteSpec `specType` must be 'bucket', 'identity', or 'unsharded', got '{}'",
other
)));
}
@@ -1007,9 +1003,9 @@ impl TryFrom<LsmWriteSpec> for lancedb::table::LsmWriteSpec {
}
}
impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
fn from(spec: lancedb::table::LsmWriteSpec) -> Self {
use lancedb::table::LsmWriteSpec as Native;
impl From<lancedb::table::MemWalWriteSpec> for MemWalWriteSpec {
fn from(spec: lancedb::table::MemWalWriteSpec) -> Self {
use lancedb::table::MemWalWriteSpec as Native;
match spec {
Native::Bucket {
column,
@@ -1048,21 +1044,21 @@ impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
}
}
/// One flushed L0 generation.
/// One SSTable.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct GenerationStats {
/// The generation number. Increases as memtables are sealed into L0.
pub struct SsTableStats {
/// The generation number. Increases as memtables are frozen into SSTables.
pub generation: i64,
/// On-disk size of the generation.
/// On-disk size of the SSTable.
pub bytes: i64,
/// Present only when `includeGenerationRows` was requested. Off by default
/// Present only when `includeSstableRows` 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 {
impl From<lancedb::table::SsTableStats> for SsTableStats {
fn from(g: lancedb::table::SsTableStats) -> Self {
Self {
generation: g.generation as i64,
bytes: g.bytes as i64,
@@ -1075,7 +1071,7 @@ impl From<lancedb::table::GenerationStats> for GenerationStats {
#[napi(object)]
#[derive(Clone, Debug)]
pub struct MemtableStats {
/// The generation this memtable will become once sealed.
/// The generation this memtable will become once frozen.
pub generation: i64,
/// Rows currently buffered.
pub rows: i64,
@@ -1100,13 +1096,13 @@ impl From<lancedb::table::MemtableStats> for MemtableStats {
}
}
/// 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
/// Live state of one table shard. A table is N table shards on one node; flattening to a
/// single number hides the one hot table shard that is usually why someone opened
/// this endpoint.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct BucketStats {
/// The shard this bucket writes.
pub struct TableShardStats {
/// The id of this table shard.
pub shard_id: String,
/// `"Active"` or `"Sealed"` (drop-table 2PC in flight).
pub status: String,
@@ -1121,20 +1117,20 @@ pub struct BucketStats {
/// 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*
/// SSTables not yet merged into the base table.
pub sstables: Vec<SsTableStats>,
/// Whether a pass owns this table shard'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
/// Oldest first, active last. Absent for a `"Sealed"` table shard, 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 {
impl From<lancedb::table::TableShardStats> for TableShardStats {
fn from(b: lancedb::table::TableShardStats) -> Self {
Self {
shard_id: b.shard_id,
status: b.status,
@@ -1143,7 +1139,7 @@ impl From<lancedb::table::BucketStats> for BucketStats {
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(),
sstables: b.sstables.into_iter().map(Into::into).collect(),
compacting: b.compacting,
memtables: b
.memtables
@@ -1152,21 +1148,21 @@ impl From<lancedb::table::BucketStats> for BucketStats {
}
}
/// Live per-bucket LSM state, as returned by `Table#getLsmStats`.
/// Live per-table-shard MemWAL state, as returned by `Table#getMemWalStats`.
///
/// Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are
/// Nothing here is derived: sums and differences (total SSTable 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>,
pub struct MemWalStats {
/// One entry per table shard backing this table.
pub table_shards: Vec<TableShardStats>,
}
impl From<lancedb::table::LsmStats> for LsmStats {
fn from(stats: lancedb::table::LsmStats) -> Self {
impl From<lancedb::table::MemWalStats> for MemWalStats {
fn from(stats: lancedb::table::MemWalStats) -> Self {
Self {
buckets: stats.buckets.into_iter().map(Into::into).collect(),
table_shards: stats.table_shards.into_iter().map(Into::into).collect(),
}
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ from ._lancedb import FtsToken
from ._lancedb import FunctionErrorFragment as FunctionErrorFragment
from ._lancedb import FunctionErrorRecord as FunctionErrorRecord
from ._lancedb import FunctionErrors as FunctionErrors
from ._lancedb import LsmWriteSpec
from ._lancedb import MemWalWriteSpec
from ._lancedb import tokenize as _tokenize
from .common import URI, sanitize_uri
from urllib.parse import urlparse
@@ -610,7 +610,7 @@ __all__ = [
"SqlQuery",
"LanceDBConnection",
"LanceNamespaceDBConnection",
"LsmWriteSpec",
"MemWalWriteSpec",
"RemoteDBConnection",
"Session",
"Table",
+24 -20
View File
@@ -538,17 +538,17 @@ class Table:
async def initial_storage_options(self) -> Optional[Dict[str, str]]: ...
async def latest_storage_options(self) -> Optional[Dict[str, str]]: ...
async def set_unenforced_primary_key(self, columns: List[str]) -> None: ...
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 enable_memwal(self, spec: MemWalWriteSpec) -> None: ...
async def disable_memwal(self) -> None: ...
async def get_memwal_spec(self) -> Optional[MemWalWriteSpec]: ...
# WAL-PK-FUSION: delete both.
def hybrid_pk_fusion_learned(self) -> bool: ...
def note_hybrid_pk_fusion(self) -> None: ...
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: ...
async def checkpoint_memwal(self) -> None: ...
async def flush_memwal(self) -> None: ...
async def compact_memwal(self) -> None: ...
async def get_memwal_stats(self, include_sstable_rows: bool) -> Optional[dict]: ...
async def close_memwal_writers(self) -> None: ...
@property
def tags(self) -> Tags: ...
@property
@@ -661,7 +661,7 @@ class Query:
def fast_search(self): ...
def with_row_id(self): ...
def postfilter(self): ...
def use_lsm(self, enable: bool): ...
def use_memwal(self, enable: bool): ...
def nearest_to(self, query_vec: pa.Array) -> VectorQuery: ...
def nearest_to_text(self, query: dict) -> FTSQuery: ...
def order_by(self, ordering: Optional[List[ColumnOrdering]]): ...
@@ -678,7 +678,7 @@ class Query:
class TakeQuery:
def select(self, columns: List[str]): ...
def with_row_id(self): ...
def use_lsm(self, enable: bool): ...
def use_memwal(self, enable: bool): ...
async def output_schema(self) -> pa.Schema: ...
async def execute(self) -> RecordBatchStream: ...
async def explain_plan(self, verbose: Optional[bool]) -> str: ...
@@ -697,7 +697,7 @@ class FTSQuery:
def fast_search(self): ...
def with_row_id(self): ...
def postfilter(self): ...
def use_lsm(self, enable: bool): ...
def use_memwal(self, enable: bool): ...
def get_query(self) -> str: ...
def add_query_vector(self, query_vec: pa.Array) -> None: ...
def nearest_to(self, query_vec: pa.Array) -> HybridQuery: ...
@@ -725,7 +725,7 @@ class VectorQuery:
def column(self, column: str): ...
def distance_type(self, distance_type: str): ...
def postfilter(self): ...
def use_lsm(self, enable: bool): ...
def use_memwal(self, enable: bool): ...
def refine_factor(self, refine_factor: int): ...
def nprobes(self, nprobes: int): ...
def search_effort(self, search_effort: Optional[float]): ...
@@ -750,7 +750,7 @@ class HybridQuery:
def fast_search(self): ...
def with_row_id(self): ...
def postfilter(self): ...
def use_lsm(self, enable: bool): ...
def use_memwal(self, enable: bool): ...
def distance_type(self, distance_type: str): ...
def refine_factor(self, refine_factor: int): ...
def nprobes(self, nprobes: int): ...
@@ -778,7 +778,7 @@ class PyQueryRequest:
select_source_columns: Optional[Dict[str, str]]
fast_search: Optional[bool]
with_row_id: Optional[bool]
use_lsm: Optional[bool]
use_memwal: Optional[bool]
column: Optional[str]
query_vector: Optional[List[pa.Array]]
nprobes: Optional[int]
@@ -839,22 +839,26 @@ class FtsToken:
text: str
position: int
class LsmWriteSpec:
class MemWalWriteSpec:
"""Specification selecting Lance's MemWAL LSM-style write path for
`merge_insert`."""
@staticmethod
def bucket(column: str, num_buckets: int) -> "LsmWriteSpec": ...
def bucket(column: str, num_buckets: int) -> "MemWalWriteSpec": ...
@staticmethod
def identity(column: str) -> "LsmWriteSpec": ...
def identity(column: str) -> "MemWalWriteSpec": ...
@staticmethod
def unsharded() -> "LsmWriteSpec": ...
def with_maintained_indexes(self, indexes: Optional[List[str]]) -> "LsmWriteSpec":
def unsharded() -> "MemWalWriteSpec": ...
def with_maintained_indexes(
self, indexes: Optional[List[str]]
) -> "MemWalWriteSpec":
"""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":
def with_writer_config_defaults(
self, defaults: Dict[str, str]
) -> "MemWalWriteSpec":
"""Return a copy of this spec recording the given default
`ShardWriter` configuration in the MemWAL index."""
...
+4 -4
View File
@@ -76,7 +76,7 @@ _WAL_ROW_ID_REFUSAL = "does not support with_row_id"
WAL_ROW_ID_UNSUPPORTED = (
"hybrid search on a MemWAL table cannot return _rowid: the fresh tier has "
"no stable row id, and the ids the fusion joins on are synthesized from "
"the primary key. Set use_lsm(False) to read the base table only (results "
"the primary key. Set use_memwal(False) to read the base table only (results "
"will exclude un-compacted MemWAL data)"
)
@@ -151,13 +151,13 @@ def _stamp_surrogate_row_ids(
return stamped
def pk_fusion_learned(table, use_lsm: Optional[bool]) -> bool:
def pk_fusion_learned(table, use_memwal: Optional[bool]) -> bool:
"""Whether a previous hybrid query on `table` was refused `_rowid`, so this
one should go straight to the primary key.
Learned from a refusal, never probed, so asking is free.
"""
if table is None or use_lsm is False:
if table is None or use_memwal is False:
return False
# Only an unambiguous True forks to the key: a table type that answers the
# question loosely keeps the `_rowid` path.
@@ -192,7 +192,7 @@ class PkFusion:
raise NotImplementedError(
"hybrid search cannot project a blob column on a MemWAL table: "
"fetching blobs needs a real _rowid, and the fresh tier has no "
"stable one. Set use_lsm(False) to read the base table only "
"stable one. Set use_memwal(False) to read the base table only "
"(results will exclude un-compacted MemWAL data)"
)
return cls(_pk_columns(schema))
+7 -7
View File
@@ -37,7 +37,7 @@ class LanceMergeInsertBuilder(object):
self._when_not_matched_by_source_condition_expr = None
self._timeout = None
self._use_index = True
self._use_lsm = None
self._use_memwal = None
self._validate_single_shard = None
def when_matched_update_all(
@@ -115,22 +115,22 @@ class LanceMergeInsertBuilder(object):
self._use_index = use_index
return self
def use_lsm(self, enable: bool) -> LanceMergeInsertBuilder:
def use_memwal(self, enable: bool) -> LanceMergeInsertBuilder:
"""
Control MemWAL routing for this merge.
By default (unset), a `merge_insert` on a table with an LSM write spec is
By default (unset), a `merge_insert` on a table with a MemWAL write spec is
routed through Lance's MemWAL shard writer, and a table without one uses
the standard path.
Parameters
----------
enable: bool
``True`` forces MemWAL routing and errors if the table has no LSM
``True`` forces MemWAL routing and errors if the table has no MemWAL
write spec. ``False`` forces the standard write path even when a spec
is set.
"""
self._use_lsm = enable
self._use_memwal = enable
return self
def validate_single_shard(
@@ -139,13 +139,13 @@ class LanceMergeInsertBuilder(object):
"""
Controls how an LSM merge checks that its input targets a single shard.
When a table has an LSM write spec, every row in a `merge_insert` call
When a table has a MemWAL write spec, every row in a `merge_insert` call
must route to the same shard. When `True` (the default), every row is
inspected to verify this. When `False`, only the first row is inspected
and the shard it routes to is used for the whole input — a faster path
for callers that have already pre-sharded their input.
Has no effect on tables without an LSM write spec.
Has no effect on tables without a MemWAL write spec.
Parameters
----------
+1 -1
View File
@@ -48,7 +48,7 @@ class PermutationBuilder:
By default, the permutation builder will create a single split that contains all
rows in the same order as the base table.
Tables with an LSM write spec are rejected: unflushed rows have no row id.
Tables with a MemWAL write spec are rejected: unflushed rows have no row id.
"""
self._async = async_permutation_builder(table)
+27 -27
View File
@@ -890,7 +890,7 @@ class Query(pydantic.BaseModel):
# MemWAL LSM read routing: None auto-routes when the table carries a write
# spec, True forces the LSM scanner (errors without a spec), False reads the
# base table only
use_lsm: Optional[bool] = None
use_memwal: Optional[bool] = None
# size of the nearest neighbor list maintained during HNSW search
ef: Optional[int] = None
@@ -910,9 +910,9 @@ class Query(pydantic.BaseModel):
query.full_text_query = req.full_text_search
query.columns = req.select
query.with_row_id = req.with_row_id
# use_lsm is a genuine tri-state (None / True / False); preserve it as-is
# use_memwal is a genuine tri-state (None / True / False); preserve it as-is
# so a round-tripped query keeps an explicit False.
query.use_lsm = req.use_lsm
query.use_memwal = req.use_memwal
query.vector_column = req.column
query.vector = req.query_vector
query.distance_type = req.distance_type
@@ -1085,7 +1085,7 @@ class LanceQueryBuilder(ABC):
self._with_row_address = None
self._fragments = None
self._fragment_ids = None
self._use_lsm = None
self._use_memwal = None
self._vector = None
self._text = None
self._ef = None
@@ -1520,19 +1520,19 @@ class LanceQueryBuilder(ABC):
self._fragment_ids = fragment_ids
return self
def use_lsm(self, enable: bool) -> Self:
def use_memwal(self, enable: bool) -> Self:
"""Control MemWAL LSM read routing for this query.
By default (unset), a query against a table with an LSM write spec is
By default (unset), a query against a table with a MemWAL write spec is
routed through the LSM scanner so it also returns data written via the
``merge_insert`` LSM path that has not yet been compacted into the base
table (active/frozen memtables + flushed generations); a table without a
table (active/frozen memtables + SSTables); a table without a
spec reads the base table.
Parameters
----------
enable : bool
``True`` forces the LSM scanner and errors if the table has no LSM
``True`` forces the LSM scanner and errors if the table has no MemWAL
write spec. ``False`` bypasses the MemWAL and reads the base table
only, even when a spec is present.
@@ -1541,7 +1541,7 @@ class LanceQueryBuilder(ABC):
LanceQueryBuilder
The LanceQueryBuilder object.
"""
self._use_lsm = enable
self._use_memwal = enable
return self
def explain_plan(self, verbose: Optional[bool] = False) -> str:
@@ -2055,7 +2055,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
with_row_address=self._with_row_address,
fragments=self._fragments,
fragment_ids=self._fragment_ids,
use_lsm=self._use_lsm,
use_memwal=self._use_memwal,
offset=self._offset,
fast_search=self._fast_search,
ef=self._ef,
@@ -2280,7 +2280,7 @@ class LanceFtsQueryBuilder(LanceQueryBuilder):
with_row_address=self._with_row_address,
fragments=self._fragments,
fragment_ids=self._fragment_ids,
use_lsm=self._use_lsm,
use_memwal=self._use_memwal,
full_text_query=FullTextSearchQuery(
query=self._query_with_phrase_semantics(), columns=self._fts_columns
),
@@ -2362,7 +2362,7 @@ class LanceEmptyQueryBuilder(LanceQueryBuilder):
with_row_address=self._with_row_address,
fragments=self._fragments,
fragment_ids=self._fragment_ids,
use_lsm=self._use_lsm,
use_memwal=self._use_memwal,
offset=self._offset,
order_by=self._order_by,
)
@@ -3040,7 +3040,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
# WAL-PK-FUSION: without the fallback, select `self._columns` as is.
self._pk_fusion = None
columns = self._columns
if _wal_hybrid.pk_fusion_learned(self._table, self._use_lsm):
if _wal_hybrid.pk_fusion_learned(self._table, self._use_memwal):
self._pk_fusion = _wal_hybrid.PkFusion.for_query(
self._table.schema,
self._columns,
@@ -3057,9 +3057,9 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
if self._with_row_id and self._pk_fusion is None:
self._vector_query.with_row_id(True)
self._fts_query.with_row_id(True)
if self._use_lsm is not None:
self._vector_query.use_lsm(self._use_lsm)
self._fts_query.use_lsm(self._use_lsm)
if self._use_memwal is not None:
self._vector_query.use_memwal(self._use_memwal)
self._fts_query.use_memwal(self._use_memwal)
if self._phrase_query:
self._fts_query.phrase_query(True)
if self._distance_type:
@@ -3723,25 +3723,25 @@ class AsyncStandardQuery(AsyncQueryBase):
self._inner.fast_search()
return self
def use_lsm(self, enable: bool) -> Self:
def use_memwal(self, enable: bool) -> Self:
"""
Control MemWAL LSM read routing for this query.
By default (unset), a query against a table with an LSM write spec (see
[AsyncTable.set_lsm_write_spec][lancedb.table.AsyncTable.set_lsm_write_spec])
By default (unset), a query against a table with a MemWAL write spec (see
[AsyncTable.enable_memwal][lancedb.table.AsyncTable.enable_memwal])
is routed through the LSM scanner so it also returns data written via the
``merge_insert`` LSM path that has not yet been compacted into the base
table (the active/frozen in-memory memtables and the flushed generations),
table (the active/frozen in-memory memtables and the SSTables),
deduplicated by primary key; a table without a spec reads the base table.
Parameters
----------
enable : bool
``True`` forces the LSM scanner and errors if the table has no LSM
``True`` forces the LSM scanner and errors if the table has no MemWAL
write spec. ``False`` bypasses the MemWAL and reads the base table
only, even when a spec is present.
"""
self._inner.use_lsm(enable)
self._inner.use_memwal(enable)
return self
def postfilter(self) -> Self:
@@ -4388,7 +4388,7 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
# WAL-PK-FUSION: without the fallback, keep only the two `with_row_id`
# calls.
self._pk_fusion = None
if _wal_hybrid.pk_fusion_learned(self._table, fts_req.use_lsm):
if _wal_hybrid.pk_fusion_learned(self._table, fts_req.use_memwal):
self._pk_fusion = _wal_hybrid.PkFusion.for_query(
await self._table.schema(),
_query_request_projection(fts_req),
@@ -4577,13 +4577,13 @@ class AsyncTakeQuery(AsyncQueryBase):
def __init__(self, inner: LanceTakeQuery, table: Optional["AsyncTable"] = None):
super().__init__(inner, table)
def use_lsm(self, enable: bool) -> "AsyncTakeQuery":
def use_memwal(self, enable: bool) -> "AsyncTakeQuery":
"""Control MemWAL LSM read routing for this take query.
``False`` bypasses the MemWAL and reads the base table only — the escape
hatch, since take-by-row-id/offset is not supported on the LSM scanner.
"""
self._inner.use_lsm(enable)
self._inner.use_memwal(enable)
return self
async def _plain_scan_to_pandas(
@@ -4644,14 +4644,14 @@ class BaseQueryBuilder(object):
self._inner.with_row_id()
return self
def use_lsm(self, enable: bool) -> Self:
def use_memwal(self, enable: bool) -> Self:
"""
Control MemWAL LSM read routing for this query.
``False`` bypasses the MemWAL and reads the base table only, the escape
hatch for shapes the LSM scanner cannot honor (e.g. take-by-row-id).
"""
self._inner.use_lsm(enable)
self._inner.use_memwal(enable)
return self
def with_row_address(self, with_row_address: bool = True) -> Self:
+24 -24
View File
@@ -31,7 +31,7 @@ from lancedb._lancedb import (
DropColumnsResult,
FtsToken,
IndexConfig,
LsmWriteSpec,
MemWalWriteSpec,
MergeResult,
UpdateResult,
)
@@ -1037,17 +1037,17 @@ class RemoteTable(Table):
"""Set the unenforced primary key for this table to a single column."""
return LOOP.run(self._table.set_unenforced_primary_key(columns))
def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
"""Install an LsmWriteSpec."""
return LOOP.run(self._table.set_lsm_write_spec(spec))
def enable_memwal(self, spec: "MemWalWriteSpec") -> None:
"""Install an MemWalWriteSpec."""
return LOOP.run(self._table.enable_memwal(spec))
def unset_lsm_write_spec(self) -> None:
"""Remove the LsmWriteSpec."""
return LOOP.run(self._table.unset_lsm_write_spec())
def disable_memwal(self) -> None:
"""Remove the MemWalWriteSpec."""
return LOOP.run(self._table.disable_memwal())
def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]:
"""Read the installed LsmWriteSpec, or ``None``."""
return LOOP.run(self._table.get_lsm_write_spec())
def get_memwal_spec(self) -> Optional["MemWalWriteSpec"]:
"""Read the installed MemWalWriteSpec, or ``None``."""
return LOOP.run(self._table.get_memwal_spec())
# WAL-PK-FUSION: delete both hooks.
def _hybrid_pk_fusion_learned(self) -> bool:
@@ -1057,31 +1057,31 @@ class RemoteTable(Table):
def _note_hybrid_pk_fusion(self) -> None:
self._table._note_hybrid_pk_fusion()
def checkpoint_lsm(self) -> None:
def checkpoint_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.checkpoint_lsm`][lancedb.AsyncTable.checkpoint_lsm]."""
return LOOP.run(self._table.checkpoint_lsm())
[`AsyncTable.checkpoint_memwal`][lancedb.AsyncTable.checkpoint_memwal]."""
return LOOP.run(self._table.checkpoint_memwal())
def flush_lsm(self) -> None:
def flush_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.flush_lsm`][lancedb.AsyncTable.flush_lsm]."""
return LOOP.run(self._table.flush_lsm())
[`AsyncTable.flush_memwal`][lancedb.AsyncTable.flush_memwal]."""
return LOOP.run(self._table.flush_memwal())
def compact_lsm(self) -> None:
def compact_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
[`AsyncTable.compact_memwal`][lancedb.AsyncTable.compact_memwal]."""
return LOOP.run(self._table.compact_memwal())
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
def get_memwal_stats(self, *, include_sstable_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
[`AsyncTable.get_memwal_stats`][lancedb.AsyncTable.get_memwal_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
self._table.get_memwal_stats(include_sstable_rows=include_sstable_rows)
)
def close_lsm_writers(self) -> None:
def close_memwal_writers(self) -> None:
"""No-op for remote tables (no local shard writers)."""
return LOOP.run(self._table.close_lsm_writers())
return LOOP.run(self._table.close_memwal_writers())
def drop_index(self, index_name: str):
return LOOP.run(self._table.drop_index(index_name))
+81 -81
View File
@@ -223,7 +223,7 @@ if TYPE_CHECKING:
DeleteResult,
DropColumnsResult,
FtsToken,
LsmWriteSpec,
MemWalWriteSpec,
MergeResult,
UpdateResult,
)
@@ -4827,47 +4827,47 @@ class LanceTable(Table):
[`AsyncTable.set_unenforced_primary_key`][lancedb.AsyncTable.set_unenforced_primary_key]."""
return LOOP.run(self._table.set_unenforced_primary_key(columns))
def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
"""Install an LsmWriteSpec. See
[`AsyncTable.set_lsm_write_spec`][lancedb.AsyncTable.set_lsm_write_spec]."""
return LOOP.run(self._table.set_lsm_write_spec(spec))
def enable_memwal(self, spec: "MemWalWriteSpec") -> None:
"""Install an MemWalWriteSpec. See
[`AsyncTable.enable_memwal`][lancedb.AsyncTable.enable_memwal]."""
return LOOP.run(self._table.enable_memwal(spec))
def unset_lsm_write_spec(self) -> None:
"""Remove the LsmWriteSpec. See
[`AsyncTable.unset_lsm_write_spec`][lancedb.AsyncTable.unset_lsm_write_spec]."""
return LOOP.run(self._table.unset_lsm_write_spec())
def disable_memwal(self) -> None:
"""Remove the MemWalWriteSpec. See
[`AsyncTable.disable_memwal`][lancedb.AsyncTable.disable_memwal]."""
return LOOP.run(self._table.disable_memwal())
def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]:
"""Read the installed LsmWriteSpec, or ``None``. See
[`AsyncTable.get_lsm_write_spec`][lancedb.AsyncTable.get_lsm_write_spec]."""
return LOOP.run(self._table.get_lsm_write_spec())
def get_memwal_spec(self) -> Optional["MemWalWriteSpec"]:
"""Read the installed MemWalWriteSpec, or ``None``. See
[`AsyncTable.get_memwal_spec`][lancedb.AsyncTable.get_memwal_spec]."""
return LOOP.run(self._table.get_memwal_spec())
def checkpoint_lsm(self) -> None:
def checkpoint_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.checkpoint_lsm`][lancedb.AsyncTable.checkpoint_lsm]."""
return LOOP.run(self._table.checkpoint_lsm())
[`AsyncTable.checkpoint_memwal`][lancedb.AsyncTable.checkpoint_memwal]."""
return LOOP.run(self._table.checkpoint_memwal())
def flush_lsm(self) -> None:
def flush_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.flush_lsm`][lancedb.AsyncTable.flush_lsm]."""
return LOOP.run(self._table.flush_lsm())
[`AsyncTable.flush_memwal`][lancedb.AsyncTable.flush_memwal]."""
return LOOP.run(self._table.flush_memwal())
def compact_lsm(self) -> None:
def compact_memwal(self) -> None:
"""Synchronous version of
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
[`AsyncTable.compact_memwal`][lancedb.AsyncTable.compact_memwal]."""
return LOOP.run(self._table.compact_memwal())
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
def get_memwal_stats(self, *, include_sstable_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
[`AsyncTable.get_memwal_stats`][lancedb.AsyncTable.get_memwal_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
self._table.get_memwal_stats(include_sstable_rows=include_sstable_rows)
)
def close_lsm_writers(self) -> None:
def close_memwal_writers(self) -> None:
"""Close cached MemWAL shard writers. See
[`AsyncTable.close_lsm_writers`][lancedb.AsyncTable.close_lsm_writers]."""
return LOOP.run(self._table.close_lsm_writers())
[`AsyncTable.close_memwal_writers`][lancedb.AsyncTable.close_memwal_writers]."""
return LOOP.run(self._table.close_memwal_writers())
def uses_v2_manifest_paths(self) -> bool:
"""
@@ -5525,18 +5525,18 @@ class AsyncTable:
columns = list(columns)
await self._inner.set_unenforced_primary_key(columns)
async def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
"""Install an LsmWriteSpec on this table.
async def enable_memwal(self, spec: "MemWalWriteSpec") -> None:
"""Install an MemWalWriteSpec on this table.
The spec selects Lance's MemWAL LSM-style write path for future
`merge_insert` calls. ``LsmWriteSpec`` chooses one of three sharding
`merge_insert` calls. ``MemWalWriteSpec`` chooses one of three sharding
strategies:
- ``LsmWriteSpec.bucket(column, num_buckets)`` — hash-bucket writes by
- ``MemWalWriteSpec.bucket(column, num_buckets)`` — hash-bucket writes by
the single-column unenforced primary key.
- ``LsmWriteSpec.identity(column)`` — shard by the raw value of a
- ``MemWalWriteSpec.identity(column)`` — shard by the raw value of a
scalar column.
- ``LsmWriteSpec.unsharded()`` — route every write to a single shard.
- ``MemWalWriteSpec.unsharded()`` — route every write to a single shard.
All variants require the table to have an unenforced primary key set
via [`set_unenforced_primary_key`]; bucket sharding additionally
@@ -5551,30 +5551,30 @@ class AsyncTable:
Parameters
----------
spec : LsmWriteSpec
spec : MemWalWriteSpec
The sharding spec to install.
Examples
--------
>>> from lancedb import LsmWriteSpec
>>> from lancedb import MemWalWriteSpec
>>> # table.set_unenforced_primary_key("id")
>>> # table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 16))
>>> # table.enable_memwal(MemWalWriteSpec.bucket("id", 16))
"""
await self._inner.set_lsm_write_spec(spec)
await self._inner.enable_memwal(spec)
async def unset_lsm_write_spec(self) -> None:
"""Remove the LsmWriteSpec from this table.
async def disable_memwal(self) -> None:
"""Remove the MemWalWriteSpec from this table.
Reverts to the standard `merge_insert` write path. Errors if no spec
is currently set.
"""
await self._inner.unset_lsm_write_spec()
await self._inner.disable_memwal()
async def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]:
"""Read the LsmWriteSpec currently installed on this table.
async def get_memwal_spec(self) -> Optional["MemWalWriteSpec"]:
"""Read the MemWalWriteSpec currently installed on this table.
Returns ``None`` when the LSM write path is not enabled at all — no
spec has been set, or one was removed with `unset_lsm_write_spec`.
Returns ``None`` when the MemWAL write path is not enabled at all — no
spec has been set, or one was removed with `disable_memwal`.
That is a different answer from a spec whose ``maintained_indexes``
is ``None``, which is an installed spec selecting indexes
automatically.
@@ -5584,7 +5584,7 @@ class AsyncTable:
or gains later, ``[]`` maintains none, and a non-empty list maintains
exactly those. All three round-trip.
"""
return await self._inner.get_lsm_write_spec()
return await self._inner.get_memwal_spec()
# WAL-PK-FUSION: delete both hooks.
def _hybrid_pk_fusion_learned(self) -> bool:
@@ -5595,19 +5595,19 @@ class AsyncTable:
"""See [`Table._note_hybrid_pk_fusion`][lancedb.table.Table]."""
self._inner.note_hybrid_pk_fusion()
async def checkpoint_lsm(self) -> None:
"""Converge this table's LSM write path into its base table.
async def checkpoint_memwal(self) -> None:
"""Converge this table's MemWAL 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``.
One flush, freezing every memtable into an SSTable, then compaction triggers
until every SSTable that existed at that moment has reached base.
The loop runs client-side, reading progress from ``get_memwal_stats``.
Best-effort: generations created *while* it runs are deliberately not
Best-effort: SSTables 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
target SSTables 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
@@ -5615,56 +5615,56 @@ class AsyncTable:
``asyncio.wait_for`` for a wall-clock bound; abandoning it partway
costs nothing.
"""
await self._inner.checkpoint_lsm()
await self._inner.checkpoint_memwal()
async def flush_lsm(self) -> None:
"""Seal every bucket's active memtable into L0.
async def flush_memwal(self) -> None:
"""Freeze every table shard's active memtable into an SSTable.
Does not touch the base table — moving L0 into base is
`compact_lsm`. On a node that has not claimed this table, this claims
Does not touch the base table — compacting SSTables into base is
`compact_memwal`. On a node that has not claimed this table, this claims
it and replays its WAL log first.
"""
await self._inner.flush_lsm()
await self._inner.flush_memwal()
async def compact_lsm(self) -> None:
"""Trigger a background L0 to base compaction pass per bucket.
async def compact_memwal(self) -> None:
"""Trigger a background SSTable compaction pass per table shard.
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.
``get_memwal_stats`` for progress, or use ``checkpoint_memwal`` to loop
until the current SSTables have reached base.
"""
await self._inner.compact_lsm()
await self._inner.compact_memwal()
async def get_lsm_stats(
self, *, include_generation_rows: bool = False
async def get_memwal_stats(
self, *, include_sstable_rows: bool = False
) -> Optional[dict]:
"""Read live per-bucket LSM state.
"""Read live per-table-shard MemWAL state.
Answers "how far behind is my fresh tier", "which bucket is hot", and
Answers "how far behind is my fresh tier", "which table shard 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.
Returns ``None`` only when the MemWAL 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
include_sstable_rows
Report a row count per SSTable. Off by default: each count
opens an uncached Lance dataset, and ``checkpoint_memwal`` polls this
needing only generation numbers.
"""
return await self._inner.get_lsm_stats(include_generation_rows)
return await self._inner.get_memwal_stats(include_sstable_rows)
async def close_lsm_writers(self) -> None:
async def close_memwal_writers(self) -> None:
"""Drain and close any cached MemWAL shard writers for this table.
When an LSM write spec is installed, `merge_insert` opens MemWAL shard
When a MemWAL write spec is installed, `merge_insert` opens MemWAL shard
writers and caches them for reuse across calls. This closes them,
flushing pending data; writers reopen lazily on the next
`merge_insert`. It is a no-op when no writers are cached.
"""
await self._inner.close_lsm_writers()
await self._inner.close_memwal_writers()
@property
def name(self) -> str:
@@ -6553,8 +6553,8 @@ class AsyncTable:
take_query = self.take_offsets(query.take_offsets)
if query.columns:
take_query = take_query.select(query.columns)
if query.use_lsm is not None:
take_query = take_query.use_lsm(query.use_lsm)
if query.use_memwal is not None:
take_query = take_query.use_memwal(query.use_memwal)
if query.with_row_id:
take_query = take_query.with_row_id()
return take_query
@@ -6570,8 +6570,8 @@ class AsyncTable:
async_query = async_query.where(query.filter)
if query.fast_search:
async_query = async_query.fast_search()
if query.use_lsm is not None:
async_query = async_query.use_lsm(query.use_lsm)
if query.use_memwal is not None:
async_query = async_query.use_memwal(query.use_memwal)
if query.with_row_id:
async_query = async_query.with_row_id()
if query.order_by:
@@ -6697,7 +6697,7 @@ class AsyncTable:
when_not_matched_by_source_condition_expr=merge._when_not_matched_by_source_condition_expr,
timeout=merge._timeout,
use_index=merge._use_index,
use_lsm=merge._use_lsm,
use_memwal=merge._use_memwal,
validate_single_shard=merge._validate_single_shard,
),
)
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Tests for installing and clearing an LsmWriteSpec via
`Table.set_lsm_write_spec` / `Table.unset_lsm_write_spec`.
"""Tests for installing and clearing an MemWalWriteSpec via
`Table.enable_memwal` / `Table.disable_memwal`.
"""
from datetime import timedelta
@@ -10,7 +10,7 @@ from datetime import timedelta
import lancedb
import pyarrow as pa
import pytest
from lancedb._lancedb import LsmWriteSpec
from lancedb._lancedb import MemWalWriteSpec
from lancedb.index import BTree
SCHEMA = pa.schema(
@@ -21,9 +21,9 @@ SCHEMA = pa.schema(
)
def test_lsm_write_spec_module_metadata():
assert lancedb.LsmWriteSpec is LsmWriteSpec
assert LsmWriteSpec.__module__ == "lancedb._lancedb"
def test_memwal_spec_module_metadata():
assert lancedb.MemWalWriteSpec is MemWalWriteSpec
assert MemWalWriteSpec.__module__ == "lancedb._lancedb"
def _batch(ids, vs):
@@ -43,35 +43,35 @@ def _make_table(tmp_path):
return db, table
def test_set_lsm_write_spec_validates(tmp_path):
def test_enable_memwal_validates(tmp_path):
_db, table = _make_table(tmp_path)
# Out-of-range num_buckets.
with pytest.raises(Exception, match="num_buckets"):
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 0))
table.enable_memwal(MemWalWriteSpec.bucket("id", 0))
with pytest.raises(Exception, match="num_buckets"):
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 1025))
table.enable_memwal(MemWalWriteSpec.bucket("id", 1025))
# Happy path, then a set over the installed spec is refused.
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 4))
table.enable_memwal(MemWalWriteSpec.bucket("id", 4))
with pytest.raises(Exception, match="already set"):
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 8))
table.enable_memwal(MemWalWriteSpec.bucket("id", 8))
def test_unset_lsm_write_spec(tmp_path):
def test_disable_memwal(tmp_path):
_db, table = _make_table(tmp_path)
# unset errors when no spec is set.
with pytest.raises(Exception, match="no LSM write spec"):
table.unset_lsm_write_spec()
with pytest.raises(Exception, match="no MemWAL write spec"):
table.disable_memwal()
# Install a spec, then remove it; afterwards a fresh spec can be set.
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 4))
table.unset_lsm_write_spec()
table.enable_memwal(MemWalWriteSpec.bucket("id", 4))
table.disable_memwal()
# A second unset errors — there is no spec left to remove.
with pytest.raises(Exception, match="no LSM write spec"):
table.unset_lsm_write_spec()
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 8))
with pytest.raises(Exception, match="no MemWAL write spec"):
table.disable_memwal()
table.enable_memwal(MemWalWriteSpec.bucket("id", 8))
def test_set_unsharded_spec(tmp_path):
@@ -79,12 +79,12 @@ def test_set_unsharded_spec(tmp_path):
# Lance MemWAL still requires a primary key on the dataset; Unsharded
# just skips per-row hashing.
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.unset_lsm_write_spec()
table.enable_memwal(MemWalWriteSpec.unsharded())
table.disable_memwal()
def test_lsm_write_spec_repr():
s = LsmWriteSpec.bucket("id", 4)
def test_memwal_spec_repr():
s = MemWalWriteSpec.bucket("id", 4)
assert s.spec_type == "bucket"
assert s.column == "id"
assert s.num_buckets == 4
@@ -95,20 +95,20 @@ def test_lsm_write_spec_repr():
assert "id" in repr(s)
assert "4" in repr(s)
u = LsmWriteSpec.unsharded()
u = MemWalWriteSpec.unsharded()
assert u.spec_type == "unsharded"
assert u.column is None
assert u.num_buckets is None
assert "unsharded" in repr(u)
def test_lsm_write_spec_with_maintained_indexes():
s = LsmWriteSpec.bucket("id", 4).with_maintained_indexes(["idx_a", "idx_b"])
def test_memwal_spec_with_maintained_indexes():
s = MemWalWriteSpec.bucket("id", 4).with_maintained_indexes(["idx_a", "idx_b"])
assert s.maintained_indexes == ["idx_a", "idx_b"]
@pytest.mark.asyncio
async def test_async_set_unset_lsm_write_spec(tmp_path):
async def test_async_set_disable_memwal(tmp_path):
db = await lancedb.connect_async(
tmp_path, read_consistency_interval=timedelta(seconds=0)
)
@@ -118,11 +118,11 @@ async def test_async_set_unset_lsm_write_spec(tmp_path):
)
await table.set_unenforced_primary_key("id")
await table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 4))
await table.unset_lsm_write_spec()
await table.enable_memwal(MemWalWriteSpec.bucket("id", 4))
await table.disable_memwal()
# A second unset errors.
with pytest.raises(Exception, match="no LSM write spec"):
await table.unset_lsm_write_spec()
with pytest.raises(Exception, match="no MemWAL write spec"):
await table.disable_memwal()
def test_set_identity_spec(tmp_path):
@@ -130,12 +130,12 @@ def test_set_identity_spec(tmp_path):
# Identity sharding still requires an unenforced primary key on the
# table; it shards by the raw value of the given column.
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.identity("v"))
table.unset_lsm_write_spec()
table.enable_memwal(MemWalWriteSpec.identity("v"))
table.disable_memwal()
def test_lsm_write_spec_identity_and_writer_config_defaults():
s = LsmWriteSpec.identity("v")
def test_memwal_spec_identity_and_writer_config_defaults():
s = MemWalWriteSpec.identity("v")
assert s.spec_type == "identity"
assert s.column == "v"
assert s.num_buckets is None
@@ -146,12 +146,12 @@ def test_lsm_write_spec_identity_and_writer_config_defaults():
assert "durable_write" in repr(s)
def test_get_lsm_write_spec(tmp_path):
def test_get_memwal_spec(tmp_path):
_db, table = _make_table(tmp_path)
table.set_unenforced_primary_key("id")
# None when nothing is installed.
assert table.get_lsm_write_spec() is None
assert table.get_memwal_spec() is None
# A real scalar index is needed to name it as a maintained index.
table.create_index("id", config=BTree())
@@ -159,12 +159,12 @@ def test_get_lsm_write_spec(tmp_path):
# Bucket spec round-trips, including maintained indexes and writer config
# defaults.
table.set_lsm_write_spec(
LsmWriteSpec.bucket("id", 4)
table.enable_memwal(
MemWalWriteSpec.bucket("id", 4)
.with_maintained_indexes([idx_name])
.with_writer_config_defaults({"durable_write": "false"})
)
spec = table.get_lsm_write_spec()
spec = table.get_memwal_spec()
assert spec is not None
assert spec.spec_type == "bucket"
assert spec.column == "id"
@@ -173,30 +173,30 @@ def test_get_lsm_write_spec(tmp_path):
assert spec.writer_config_defaults == {"durable_write": "false"}
# After unset, None again.
table.unset_lsm_write_spec()
assert table.get_lsm_write_spec() is None
table.disable_memwal()
assert table.get_memwal_spec() is None
# Identity round-trips (column recovered from the schema). An unnamed
# maintained set reads back as None: what is stored is the intent to
# maintain every index, not the set it resolves to now.
table.set_lsm_write_spec(LsmWriteSpec.identity("id"))
spec = table.get_lsm_write_spec()
table.enable_memwal(MemWalWriteSpec.identity("id"))
spec = table.get_memwal_spec()
assert spec.spec_type == "identity"
assert spec.column == "id"
assert spec.maintained_indexes is None
table.unset_lsm_write_spec()
table.disable_memwal()
# Unsharded round-trips (no routing column). Opting out is distinct from
# the unnamed default.
table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([]))
spec = table.get_lsm_write_spec()
table.enable_memwal(MemWalWriteSpec.unsharded().with_maintained_indexes([]))
spec = table.get_memwal_spec()
assert spec.spec_type == "unsharded"
assert spec.column is None
assert spec.maintained_indexes == []
@pytest.mark.asyncio
async def test_async_get_lsm_write_spec(tmp_path):
async def test_async_get_memwal_spec(tmp_path):
db = await lancedb.connect_async(
tmp_path, read_consistency_interval=timedelta(seconds=0)
)
@@ -205,20 +205,20 @@ async def test_async_get_lsm_write_spec(tmp_path):
pa.RecordBatchReader.from_batches(SCHEMA, [_batch(["seed"], [0])]),
)
assert await table.get_lsm_write_spec() is None
assert await table.get_memwal_spec() is None
# A real scalar index is needed to name it as a maintained index.
await table.create_index("id", config=BTree())
idx_name = (await table.list_indices())[0].name
await table.set_lsm_write_spec(
LsmWriteSpec.bucket("id", 8).with_maintained_indexes([idx_name])
await table.enable_memwal(
MemWalWriteSpec.bucket("id", 8).with_maintained_indexes([idx_name])
)
spec = await table.get_lsm_write_spec()
spec = await table.get_memwal_spec()
assert spec is not None
assert spec.spec_type == "bucket"
assert spec.column == "id"
assert spec.num_buckets == 8
assert spec.maintained_indexes == [idx_name]
await table.unset_lsm_write_spec()
assert await table.get_lsm_write_spec() is None
await table.disable_memwal()
assert await table.get_memwal_spec() is None
+50 -52
View File
@@ -8,7 +8,7 @@ from datetime import timedelta
import lancedb
import pyarrow as pa
import pytest
from lancedb._lancedb import LsmWriteSpec
from lancedb._lancedb import MemWalWriteSpec
from lancedb.index import FTS, IvfPq
SCHEMA = pa.schema(
@@ -49,12 +49,12 @@ def _region_reader(rows):
def _bucket_table(tmp_path):
"""A table with ``id`` as the primary key and a single-bucket LSM spec."""
"""A table with ``id`` as the primary key and a single-bucket MemWAL spec."""
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
# num_buckets = 1: every row routes to the single bucket.
table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 1))
table.enable_memwal(MemWalWriteSpec.bucket("id", 1))
return table
@@ -78,7 +78,7 @@ def test_lsm_merge_insert_unsharded(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
result = (
table.merge_insert("id")
.when_matched_update_all()
@@ -92,7 +92,7 @@ def test_lsm_merge_insert_identity(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _region_reader([(1, "us"), (2, "us")]))
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.identity("region"))
table.enable_memwal(MemWalWriteSpec.identity("region"))
# All rows share one identity value, so they route to one shard.
result = (
table.merge_insert([])
@@ -103,31 +103,31 @@ def test_lsm_merge_insert_identity(tmp_path):
assert result.num_rows == 2
def test_lsm_merge_insert_use_lsm_false(tmp_path):
def test_lsm_merge_insert_use_memwal_false(tmp_path):
table = _bucket_table(tmp_path) # rows id = 1, 2, 3
# use_lsm(False) opts out: the standard path runs and commits even with a spec.
# use_memwal(False) opts out: the standard path runs and commits even with a spec.
result = (
table.merge_insert("id")
.when_not_matched_insert_all()
.use_lsm(False)
.use_memwal(False)
.execute(_reader([3, 4, 5]))
)
assert result.num_inserted_rows == 2
assert table.count_rows() == 5
def test_lsm_merge_insert_use_lsm_true_without_spec_errors(tmp_path):
# A table with a primary key but no LSM write spec installed.
def test_lsm_merge_insert_use_memwal_true_without_spec_errors(tmp_path):
# A table with a primary key but no MemWAL write spec installed.
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
# use_lsm(True) demands MemWAL routing; without a spec it errors.
with pytest.raises(Exception, match="use_lsm"):
# use_memwal(True) demands MemWAL routing; without a spec it errors.
with pytest.raises(Exception, match="use_memwal"):
(
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.use_lsm(True)
.use_memwal(True)
.execute(_reader([3, 4, 5]))
)
@@ -145,7 +145,7 @@ def test_lsm_merge_insert_validate_single_shard_off(tmp_path):
def test_lsm_merge_insert_no_spec_uses_standard_path(tmp_path):
# A table with a primary key but no LSM write spec installed.
# A table with a primary key but no MemWAL write spec installed.
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
@@ -186,7 +186,7 @@ def test_lsm_close_writers(tmp_path):
.when_not_matched_insert_all()
.execute(_reader([7, 8]))
)
table.close_lsm_writers()
table.close_memwal_writers()
# The writer reopens lazily on the next merge_insert.
result = (
table.merge_insert([])
@@ -204,14 +204,14 @@ async def test_async_lsm_merge_insert(tmp_path):
)
table = await db.create_table("t", _reader([1, 2, 3]))
await table.set_unenforced_primary_key("id")
await table.set_lsm_write_spec(LsmWriteSpec.bucket("id", 1))
await table.enable_memwal(MemWalWriteSpec.bucket("id", 1))
builder = (
table.merge_insert([]).when_matched_update_all().when_not_matched_insert_all()
)
result = await builder.execute(_reader([3, 4, 5]))
assert result.num_rows == 3
await table.close_lsm_writers()
await table.close_memwal_writers()
def _lsm_upsert(table, ids):
@@ -228,7 +228,7 @@ def test_lsm_read_sees_active_memtable(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3])) # base ids 1,2,3
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
_lsm_upsert(table, [4, 5]) # active memtable only, not committed to base
@@ -236,8 +236,8 @@ def test_lsm_read_sees_active_memtable(tmp_path):
lsm = table.search().to_arrow()
assert sorted(lsm["id"].to_pylist()) == [1, 2, 3, 4, 5]
# use_lsm(False) bypasses the MemWAL and reads the base table only.
base_only = table.search().use_lsm(False).to_arrow()
# use_memwal(False) bypasses the MemWAL and reads the base table only.
base_only = table.search().use_memwal(False).to_arrow()
assert sorted(base_only["id"].to_pylist()) == [1, 2, 3]
@@ -245,7 +245,7 @@ def test_lsm_read_dedup_newest_wins(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3])) # id 2 -> value 1
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
_lsm_upsert(table, [2, 3, 4]) # ids 2,3,4 -> values 0,1,2
@@ -258,22 +258,22 @@ def test_lsm_read_dedup_newest_wins(tmp_path):
def test_lsm_read_without_spec_reads_base(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id") # no LSM write spec
table.set_unenforced_primary_key("id") # no MemWAL write spec
# No spec: default read and use_lsm(False) both read the base table, no error.
# No spec: default read and use_memwal(False) both read the base table, no error.
assert sorted(table.search().to_arrow()["id"].to_pylist()) == [1, 2, 3]
assert sorted(table.search().use_lsm(False).to_arrow()["id"].to_pylist()) == [
assert sorted(table.search().use_memwal(False).to_arrow()["id"].to_pylist()) == [
1,
2,
3,
]
def test_lsm_read_unsupported_shape_errors_without_use_lsm_false(tmp_path):
def test_lsm_read_unsupported_shape_errors_without_use_memwal_false(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
_lsm_upsert(table, [4])
# with_row_id is unsupported by the LSM scanner; on a MemWAL table the default
@@ -281,8 +281,8 @@ def test_lsm_read_unsupported_shape_errors_without_use_lsm_false(tmp_path):
with pytest.raises(Exception):
table.search().with_row_id(True).to_arrow()
# use_lsm(False) is the escape hatch: it reads the base table only.
base = table.search().with_row_id(True).use_lsm(False).to_arrow()
# use_memwal(False) is the escape hatch: it reads the base table only.
base = table.search().with_row_id(True).use_memwal(False).to_arrow()
assert sorted(base["id"].to_pylist()) == [1, 2, 3]
@@ -293,7 +293,7 @@ async def test_async_lsm_read(tmp_path):
)
table = await db.create_table("t", _reader([1, 2, 3]))
await table.set_unenforced_primary_key("id")
await table.set_lsm_write_spec(LsmWriteSpec.unsharded())
await table.enable_memwal(MemWalWriteSpec.unsharded())
builder = (
table.merge_insert([]).when_matched_update_all().when_not_matched_insert_all()
@@ -331,7 +331,7 @@ def _vector_reader(rows):
def _vector_table(tmp_path):
"""Base table whose vector column is indexed so its rows are visible to the LSM
vector scanner (the base arm uses ``fast_search`` — indexed data only), plus an
unsharded LSM spec that maintains that index for the memtable.
unsharded MemWAL spec that maintains that index for the memtable.
Rows 1,2 are category ``a``, row 3 is ``b``, and 4..60 are filler ``c`` that
give the tiny IVF index enough data to train.
@@ -353,8 +353,8 @@ def _vector_table(tmp_path):
"vector", config=IvfPq(num_partitions=1, num_sub_vectors=2, num_bits=4)
)
index_name = table.list_indices()[0].name
table.set_lsm_write_spec(
LsmWriteSpec.unsharded().with_maintained_indexes([index_name])
table.enable_memwal(
MemWalWriteSpec.unsharded().with_maintained_indexes([index_name])
)
return table
@@ -379,9 +379,9 @@ def test_lsm_read_vector_sees_memtable(tmp_path):
assert {1, 2, 3} <= ids # indexed base rows
assert 1000 in ids # in-flight memtable row
# use_lsm(False) bypasses the MemWAL, so the in-flight row is not visible.
# use_memwal(False) bypasses the MemWAL, so the in-flight row is not visible.
base_ids = set(
table.search(query).use_lsm(False).limit(100).to_arrow()["id"].to_pylist()
table.search(query).use_memwal(False).limit(100).to_arrow()["id"].to_pylist()
)
assert {1, 2, 3} <= base_ids
assert 1000 not in base_ids
@@ -453,8 +453,8 @@ def test_lsm_read_fts_sees_memtable(tmp_path):
# Native FTS index (tantivy is not compatible with the LSM memtable index).
table.create_index("text", config=FTS())
index_name = table.list_indices()[0].name
table.set_lsm_write_spec(
LsmWriteSpec.unsharded().with_maintained_indexes([index_name])
table.enable_memwal(
MemWalWriteSpec.unsharded().with_maintained_indexes([index_name])
)
# in-flight doc 4 lands in the memtable's maintained FTS index.
@@ -498,8 +498,8 @@ def test_lsm_read_vector_unsupported_knobs_error(tmp_path):
with pytest.raises(Exception, match="use_index"):
table.search(query).bypass_vector_index().to_arrow()
# use_lsm(False) is the escape hatch: the base-only standard path honors them.
base = table.search(query).distance_range(0.0, 100.0).use_lsm(False).to_arrow()
# use_memwal(False) is the escape hatch: the base-only standard path honors them.
base = table.search(query).distance_range(0.0, 100.0).use_memwal(False).to_arrow()
assert 1000 not in set(base["id"].to_pylist())
@@ -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().with_maintained_indexes([]))
table.enable_memwal(MemWalWriteSpec.unsharded().with_maintained_indexes([]))
with pytest.raises(Exception, match="maintained"):
table.search("fox", query_type="fts", fts_columns="text").to_arrow()
@@ -553,7 +553,7 @@ def test_lsm_read_time_travel_errors(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
pinned = table.version
table.add(_reader([4, 5])) # standard add commits a newer version
table.checkout(pinned) # detached head at the historical version
@@ -562,8 +562,8 @@ def test_lsm_read_time_travel_errors(tmp_path):
# historical version is rejected.
with pytest.raises(Exception, match="time-travel"):
table.search().to_arrow()
# use_lsm(False) reads the base table at the pinned version.
base = table.search().use_lsm(False).to_arrow()
# use_memwal(False) reads the base table at the pinned version.
base = table.search().use_memwal(False).to_arrow()
assert sorted(base["id"].to_pylist()) == [1, 2, 3]
@@ -571,14 +571,14 @@ def test_lsm_read_take_row_ids_errors(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(seconds=0))
table = db.create_table("t", _reader([1, 2, 3]))
table.set_unenforced_primary_key("id")
table.set_lsm_write_spec(LsmWriteSpec.unsharded())
table.enable_memwal(MemWalWriteSpec.unsharded())
_lsm_upsert(table, [4])
# take-by-row-id auto-routes through the LSM scanner, which has no stable _rowid,
# so it hard-errors instead of failing with an opaque column-not-found error.
with pytest.raises(Exception, match="row id"):
table.take_row_ids([0, 1]).to_arrow()
# use_lsm(False) is the escape hatch: it reads the base table.
base = table.take_row_ids([0, 1]).use_lsm(False).to_arrow()
# use_memwal(False) is the escape hatch: it reads the base table.
base = table.take_row_ids([0, 1]).use_memwal(False).to_arrow()
assert base.num_rows == 2
@@ -588,8 +588,8 @@ def test_lsm_read_fts_postfilter_errors(tmp_path):
table.set_unenforced_primary_key("id")
table.create_index("text", config=FTS())
index_name = table.list_indices()[0].name
table.set_lsm_write_spec(
LsmWriteSpec.unsharded().with_maintained_indexes([index_name])
table.enable_memwal(
MemWalWriteSpec.unsharded().with_maintained_indexes([index_name])
)
# The LSM scanner always prefilters; postfilter on FTS changes result semantics,
# so it hard-errors (previously only the vector arm rejected it).
@@ -608,9 +608,7 @@ def test_lsm_read_fts_multiple_same_type_indexes_errors(tmp_path):
table.set_unenforced_primary_key("id")
table.create_index("text", config=FTS(), name="fts_a")
table.create_index("text", config=FTS(), name="fts_b", replace=False)
table.set_lsm_write_spec(
LsmWriteSpec.unsharded().with_maintained_indexes(["fts_a"])
)
table.enable_memwal(MemWalWriteSpec.unsharded().with_maintained_indexes(["fts_a"]))
# Two FTS indexes on the column: the base planner's chosen index is ambiguous, so
# the scanner can't pick a catch-up watermark and rejects rather than risk
# dropping rows the actually-used index has not caught up to.
@@ -631,7 +629,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().with_maintained_indexes([]))
table.enable_memwal(MemWalWriteSpec.unsharded().with_maintained_indexes([]))
with pytest.raises(Exception, match="maintained"):
table.search([1.0] * VECTOR_DIM).to_arrow()
@@ -644,7 +642,7 @@ def test_lsm_read_fts_optimized_index_not_rejected(tmp_path):
table.add(_fts_reader([(i, "lazy fox") for i in range(6, 11)]))
table.optimize() # may split the FTS index into multiple physical segments
name = table.list_indices()[0].name
table.set_lsm_write_spec(LsmWriteSpec.unsharded().with_maintained_indexes([name]))
table.enable_memwal(MemWalWriteSpec.unsharded().with_maintained_indexes([name]))
# Multiple physical segments of one logical index must not be miscounted as
# multiple indexes and rejected.
ids = set(
+27 -27
View File
@@ -1845,10 +1845,10 @@ def lsm_test_table(lsm_handler):
"""A remote table whose LSM routes are served by ``lsm_handler``.
``lsm_handler(request, route)`` is called for ``/v1/table/test/<route>/``
where route is one of flush_lsm, compact_lsm, get_lsm_stats, and is
where route is one of flush_memwal, compact_memwal, get_memwal_stats, and is
responsible for writing the response.
"""
routes = ("flush_lsm", "compact_lsm", "get_lsm_stats")
routes = ("flush_memwal", "compact_memwal", "get_memwal_stats")
def handler(request):
match = re.fullmatch(r"/v1/table/test/(\w+)/", request.path)
@@ -1880,9 +1880,9 @@ def send_json(request, payload, status=200):
request.wfile.write(json.dumps(payload).encode())
def test_get_lsm_stats_sync():
def test_get_memwal_stats_sync():
"""The sync wrapper round-trips the server payload into a dict."""
bucket = {
table_shard = {
"shard_id": "b0",
"status": "Active",
"writer_epoch": 3,
@@ -1890,7 +1890,7 @@ def test_get_lsm_stats_sync():
"current_generation": 6,
"replay_after_wal_entry_position": 40,
"wal_entry_position_last_seen": 42,
"generations": [{"generation": 5, "bytes": 1024, "rows": 7}],
"sstables": [{"generation": 5, "bytes": 1024, "rows": 7}],
"compacting": False,
"memtables": [
{
@@ -1905,29 +1905,29 @@ def test_get_lsm_stats_sync():
seen_bodies = []
def lsm_handler(request, route):
assert route == "get_lsm_stats"
assert route == "get_memwal_stats"
seen_bodies.append(read_json_body(request))
send_json(request, {"lsm_stats": {"buckets": [bucket]}})
send_json(request, {"memwal_stats": {"table_shards": [table_shard]}})
with lsm_test_table(lsm_handler) as table:
assert table.get_lsm_stats() == {"buckets": [bucket]}
assert table.get_memwal_stats() == {"table_shards": [table_shard]}
# Off by default, and forwarded when asked for.
assert seen_bodies == [{"include_generation_rows": False}]
table.get_lsm_stats(include_generation_rows=True)
assert seen_bodies[-1] == {"include_generation_rows": True}
assert seen_bodies == [{"include_sstable_rows": False}]
table.get_memwal_stats(include_sstable_rows=True)
assert seen_bodies[-1] == {"include_sstable_rows": True}
def test_get_lsm_stats_sync_returns_none_when_lsm_disabled():
"""A null envelope means the LSM write path is not enabled, not an error."""
def test_get_memwal_stats_sync_returns_none_when_memwal_disabled():
"""A null envelope means the MemWAL write path is not enabled, not an error."""
def lsm_handler(request, route):
send_json(request, {"lsm_stats": None})
send_json(request, {"memwal_stats": None})
with lsm_test_table(lsm_handler) as table:
assert table.get_lsm_stats() is None
assert table.get_memwal_stats() is None
def test_flush_and_compact_lsm_sync():
def test_flush_and_compact_memwal_sync():
"""Both are one-shot POSTs answered 202 with no body."""
called = []
@@ -1937,13 +1937,13 @@ def test_flush_and_compact_lsm_sync():
request.end_headers()
with lsm_test_table(lsm_handler) as table:
assert table.flush_lsm() is None
assert table.compact_lsm() is None
assert called == ["flush_lsm", "compact_lsm"]
assert table.flush_memwal() is None
assert table.compact_memwal() is None
assert called == ["flush_memwal", "compact_memwal"]
def test_checkpoint_lsm_sync():
"""Seal, read the watermark, and return once L0 holds nothing.
def test_checkpoint_memwal_sync():
"""Freeze, read the watermark, and return once no SSTables remain.
The convergence loop itself is covered in Rust; this pins the sync
binding to the endpoints it drives.
@@ -1952,17 +1952,17 @@ def test_checkpoint_lsm_sync():
def lsm_handler(request, route):
called.append(route)
if route == "get_lsm_stats":
# An empty L0 yields no target watermark, so the loop is done
# after the seal without ever polling compaction.
send_json(request, {"lsm_stats": {"buckets": []}})
if route == "get_memwal_stats":
# An empty SSTable tier yields no target watermark, so the loop is done
# after the freeze without ever polling compaction.
send_json(request, {"memwal_stats": {"table_shards": []}})
else:
request.send_response(202)
request.end_headers()
with lsm_test_table(lsm_handler) as table:
assert table.checkpoint_lsm() is None
assert called == ["flush_lsm", "get_lsm_stats"]
assert table.checkpoint_memwal() is None
assert called == ["flush_memwal", "get_memwal_stats"]
@contextlib.contextmanager
+5 -5
View File
@@ -81,15 +81,15 @@ def test_base_table_hybrid_still_joins_on_row_ids():
assert builder._vector_query._columns is None
def test_wal_hybrid_with_use_lsm_false_joins_on_row_ids():
builder = _wal_hybrid().use_lsm(False)
def test_wal_hybrid_with_use_memwal_false_joins_on_row_ids():
builder = _wal_hybrid().use_memwal(False)
builder._create_query_builders()
assert builder._pk_fusion is None
def test_wal_hybrid_refuses_an_explicit_with_row_id():
builder = _wal_hybrid().with_row_id(True)
with pytest.raises(NotImplementedError, match="use_lsm"):
with pytest.raises(NotImplementedError, match="use_memwal"):
builder._create_query_builders()
@@ -141,7 +141,7 @@ def test_wal_hybrid_refuses_row_id_even_with_return_score_all(row_id_first: bool
else:
builder = builder.rerank(reranker).with_row_id(True)
with pytest.raises(NotImplementedError, match="use_lsm"):
with pytest.raises(NotImplementedError, match="use_memwal"):
builder._create_query_builders()
@@ -199,7 +199,7 @@ def test_fallback_explains_the_refusal_to_a_caller_who_asked_for_row_ids():
def run():
raise _REFUSAL
with pytest.raises(NotImplementedError, match="use_lsm") as info:
with pytest.raises(NotImplementedError, match="use_memwal") as info:
with_pk_fallback(
run, mock.Mock(), fused=lambda: False, caller_requested_row_id=True
)
+3 -3
View File
@@ -73,10 +73,10 @@ class MockPermutationServer:
},
},
)
if path == f"/v1/table/{self.name}/get_lsm_write_spec/":
if path == f"/v1/table/{self.name}/get_memwal_spec/":
self._read_body(request)
# Null spec: this table has no LSM write path.
return self._json(request, {"lsm_write_spec": None})
# Null spec: this table has no MemWAL write path.
return self._json(request, {"memwal_spec": None})
if path == f"/v1/table/{self.name}/count_rows/":
self._read_body(request)
return self._json(request, self.num_rows)
+2 -2
View File
@@ -16,7 +16,7 @@ use query::{FTSQuery, HybridQuery, Query, VectorQuery};
use session::Session;
use table::{
AddColumnsResult, AddResult, AlterColumnsResult, DeleteResult, DropColumnsResult, FtsToken,
FunctionErrorFragment, FunctionErrorRecord, FunctionErrors, LsmWriteSpec, MergeResult,
FunctionErrorFragment, FunctionErrorRecord, FunctionErrors, MemWalWriteSpec, MergeResult,
PyBlobFile, RefreshColumnResult, RefreshMaterializedViewResult, Table,
UpdateFieldMetadataResult, UpdateResult,
};
@@ -104,7 +104,7 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<UpdateFieldMetadataResult>()?;
m.add_class::<AddResult>()?;
m.add_class::<MergeResult>()?;
m.add_class::<LsmWriteSpec>()?;
m.add_class::<MemWalWriteSpec>()?;
m.add_class::<DeleteResult>()?;
m.add_class::<DropColumnsResult>()?;
m.add_class::<UpdateResult>()?;
+14 -14
View File
@@ -333,7 +333,7 @@ pub struct PyQueryRequest {
pub select_source_columns: Option<HashMap<String, String>>,
pub fast_search: Option<bool>,
pub with_row_id: Option<bool>,
pub use_lsm: Option<bool>,
pub use_memwal: Option<bool>,
pub column: Option<String>,
pub query_vector: Option<PyQueryVectors>,
pub nprobes: Option<usize>,
@@ -366,7 +366,7 @@ impl From<AnyQuery> for PyQueryRequest {
select: PySelect(query_request.select),
fast_search: Some(query_request.fast_search),
with_row_id: Some(query_request.with_row_id),
use_lsm: query_request.use_lsm,
use_memwal: query_request.use_memwal,
column: None,
query_vector: None,
nprobes: None,
@@ -395,7 +395,7 @@ impl From<AnyQuery> for PyQueryRequest {
select: PySelect(vector_query.base.select),
fast_search: Some(vector_query.base.fast_search),
with_row_id: Some(vector_query.base.with_row_id),
use_lsm: vector_query.base.use_lsm,
use_memwal: vector_query.base.use_memwal,
column: vector_query.column,
query_vector: Some(PyQueryVectors(vector_query.query_vector)),
nprobes: vector_query.nprobes,
@@ -540,8 +540,8 @@ impl Query {
self.inner = self.inner.clone().fast_search();
}
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
pub fn with_row_id(&mut self) {
@@ -706,8 +706,8 @@ impl TakeQuery {
self.inner = self.inner.clone().with_row_id();
}
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
#[pyo3(signature = ())]
@@ -819,8 +819,8 @@ impl FTSQuery {
self.inner = self.inner.clone().fast_search();
}
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
pub fn with_row_id(&mut self) {
@@ -970,8 +970,8 @@ impl VectorQuery {
self.inner = self.inner.clone().fast_search();
}
pub fn use_lsm(&mut self, enable: bool) {
self.inner = self.inner.clone().use_lsm(enable);
pub fn use_memwal(&mut self, enable: bool) {
self.inner = self.inner.clone().use_memwal(enable);
}
pub fn with_row_id(&mut self) {
@@ -1178,9 +1178,9 @@ impl HybridQuery {
self.inner_fts.postfilter();
}
pub fn use_lsm(&mut self, enable: bool) {
self.inner_vec.use_lsm(enable);
self.inner_fts.use_lsm(enable);
pub fn use_memwal(&mut self, enable: bool) {
self.inner_vec.use_memwal(enable);
self.inner_fts.use_memwal(enable);
}
pub fn add_query_vector(&mut self, vector: Bound<'_, PyAny>) -> PyResult<()> {
+70 -71
View File
@@ -39,16 +39,16 @@ use pyo3::{
mod scannable;
/// Convert `LsmStats` to a Python dict, preserving the per-bucket list.
/// Convert `MemWalStats` to a Python dict, preserving the per-table-shard list.
///
/// Deliberately not flattened to a table-level summary: a table is N
/// buckets on one node, and the per-bucket detail is the reason the
/// endpoint exists — flattening hides the single hot bucket someone opened
/// table shards on one node, and the per-shard detail is the reason the
/// endpoint exists — flattening hides the single hot table shard someone opened
/// it to find.
fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult<Py<PyDict>> {
fn memwal_stats_to_py(py: Python<'_>, stats: &lancedb::table::MemWalStats) -> PyResult<Py<PyDict>> {
let out = PyDict::new(py);
let buckets = PyList::empty(py);
for b in &stats.buckets {
let table_shards = PyList::empty(py);
for b in &stats.table_shards {
let e = PyDict::new(py);
e.set_item("shard_id", &b.shard_id)?;
e.set_item("status", &b.status)?;
@@ -64,15 +64,15 @@ fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult
b.wal_entry_position_last_seen,
)?;
let generations = PyList::empty(py);
for g in &b.generations {
let sstables = PyList::empty(py);
for g in &b.sstables {
let ge = PyDict::new(py);
ge.set_item("generation", g.generation)?;
ge.set_item("bytes", g.bytes)?;
ge.set_item("rows", g.rows)?;
generations.append(ge)?;
sstables.append(ge)?;
}
e.set_item("generations", generations)?;
e.set_item("sstables", sstables)?;
e.set_item("compacting", b.compacting)?;
e.set_item(
@@ -94,9 +94,9 @@ fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult
})
.transpose()?,
)?;
buckets.append(e)?;
table_shards.append(e)?;
}
out.set_item("buckets", buckets)?;
out.set_item("table_shards", table_shards)?;
Ok(out.unbind())
}
@@ -270,17 +270,17 @@ fn fmt_maintained(maintained: &Option<Vec<String>>) -> String {
/// table has, including ones created later.
#[pyclass(module = "lancedb._lancedb", from_py_object)]
#[derive(Clone, Debug)]
pub struct LsmWriteSpec {
inner: lancedb::table::LsmWriteSpec,
pub struct MemWalWriteSpec {
inner: lancedb::table::MemWalWriteSpec,
}
#[pymethods]
impl LsmWriteSpec {
impl MemWalWriteSpec {
/// Hash-bucket sharding by the unenforced primary key column.
#[staticmethod]
pub fn bucket(column: String, num_buckets: u32) -> Self {
Self {
inner: lancedb::table::LsmWriteSpec::bucket(column, num_buckets),
inner: lancedb::table::MemWalWriteSpec::bucket(column, num_buckets),
}
}
@@ -294,7 +294,7 @@ impl LsmWriteSpec {
#[staticmethod]
pub fn identity(column: String) -> Self {
Self {
inner: lancedb::table::LsmWriteSpec::identity(column),
inner: lancedb::table::MemWalWriteSpec::identity(column),
}
}
@@ -303,7 +303,7 @@ impl LsmWriteSpec {
#[staticmethod]
pub fn unsharded() -> Self {
Self {
inner: lancedb::table::LsmWriteSpec::unsharded(),
inner: lancedb::table::MemWalWriteSpec::unsharded(),
}
}
@@ -327,33 +327,33 @@ impl LsmWriteSpec {
pub fn __repr__(&self) -> String {
match &self.inner {
lancedb::table::LsmWriteSpec::Bucket {
lancedb::table::MemWalWriteSpec::Bucket {
column,
num_buckets,
maintained_indexes,
writer_config_defaults,
} => format!(
"LsmWriteSpec.bucket(column={:?}, num_buckets={}, maintained_indexes={}, writer_config_defaults={:?})",
"MemWalWriteSpec.bucket(column={:?}, num_buckets={}, maintained_indexes={}, writer_config_defaults={:?})",
column,
num_buckets,
fmt_maintained(maintained_indexes),
writer_config_defaults,
),
lancedb::table::LsmWriteSpec::Identity {
lancedb::table::MemWalWriteSpec::Identity {
column,
maintained_indexes,
writer_config_defaults,
} => format!(
"LsmWriteSpec.identity(column={:?}, maintained_indexes={}, writer_config_defaults={:?})",
"MemWalWriteSpec.identity(column={:?}, maintained_indexes={}, writer_config_defaults={:?})",
column,
fmt_maintained(maintained_indexes),
writer_config_defaults,
),
lancedb::table::LsmWriteSpec::Unsharded {
lancedb::table::MemWalWriteSpec::Unsharded {
maintained_indexes,
writer_config_defaults,
} => format!(
"LsmWriteSpec.unsharded(maintained_indexes={}, writer_config_defaults={:?})",
"MemWalWriteSpec.unsharded(maintained_indexes={}, writer_config_defaults={:?})",
fmt_maintained(maintained_indexes),
writer_config_defaults,
),
@@ -365,9 +365,9 @@ impl LsmWriteSpec {
#[getter]
pub fn spec_type(&self) -> &'static str {
match &self.inner {
lancedb::table::LsmWriteSpec::Bucket { .. } => "bucket",
lancedb::table::LsmWriteSpec::Identity { .. } => "identity",
lancedb::table::LsmWriteSpec::Unsharded { .. } => "unsharded",
lancedb::table::MemWalWriteSpec::Bucket { .. } => "bucket",
lancedb::table::MemWalWriteSpec::Identity { .. } => "identity",
lancedb::table::MemWalWriteSpec::Unsharded { .. } => "unsharded",
}
}
@@ -375,9 +375,9 @@ impl LsmWriteSpec {
#[getter]
pub fn column(&self) -> Option<String> {
match &self.inner {
lancedb::table::LsmWriteSpec::Bucket { column, .. }
| lancedb::table::LsmWriteSpec::Identity { column, .. } => Some(column.clone()),
lancedb::table::LsmWriteSpec::Unsharded { .. } => None,
lancedb::table::MemWalWriteSpec::Bucket { column, .. }
| lancedb::table::MemWalWriteSpec::Identity { column, .. } => Some(column.clone()),
lancedb::table::MemWalWriteSpec::Unsharded { .. } => None,
}
}
@@ -385,7 +385,7 @@ impl LsmWriteSpec {
#[getter]
pub fn num_buckets(&self) -> Option<u32> {
match &self.inner {
lancedb::table::LsmWriteSpec::Bucket { num_buckets, .. } => Some(*num_buckets),
lancedb::table::MemWalWriteSpec::Bucket { num_buckets, .. } => Some(*num_buckets),
_ => None,
}
}
@@ -403,14 +403,14 @@ impl LsmWriteSpec {
}
}
impl From<LsmWriteSpec> for lancedb::table::LsmWriteSpec {
fn from(spec: LsmWriteSpec) -> Self {
impl From<MemWalWriteSpec> for lancedb::table::MemWalWriteSpec {
fn from(spec: MemWalWriteSpec) -> Self {
spec.inner
}
}
impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
fn from(inner: lancedb::table::LsmWriteSpec) -> Self {
impl From<lancedb::table::MemWalWriteSpec> for MemWalWriteSpec {
fn from(inner: lancedb::table::MemWalWriteSpec) -> Self {
Self { inner }
}
}
@@ -1636,8 +1636,8 @@ impl Table {
if let Some(use_index) = parameters.use_index {
builder.use_index(use_index);
}
if let Some(use_lsm) = parameters.use_lsm {
builder.use_lsm(use_lsm);
if let Some(use_memwal) = parameters.use_memwal {
builder.use_memwal(use_memwal);
}
if let Some(validate_single_shard) = parameters.validate_single_shard {
builder.validate_single_shard(validate_single_shard);
@@ -1662,29 +1662,29 @@ impl Table {
})
}
pub fn set_lsm_write_spec<'a>(
pub fn enable_memwal<'a>(
self_: PyRef<'a, Self>,
spec: LsmWriteSpec,
spec: MemWalWriteSpec,
) -> PyResult<Bound<'a, PyAny>> {
let inner = self_.inner_ref()?.clone();
let native_spec = lancedb::table::LsmWriteSpec::from(spec);
let native_spec = lancedb::table::MemWalWriteSpec::from(spec);
future_into_py(self_.py(), async move {
inner.set_lsm_write_spec(native_spec).await.infer_error()
inner.enable_memwal(native_spec).await.infer_error()
})
}
pub fn unset_lsm_write_spec(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
pub fn disable_memwal(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
inner.unset_lsm_write_spec().await.infer_error()
inner.disable_memwal().await.infer_error()
})
}
pub fn get_lsm_write_spec(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
pub fn get_memwal_spec(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
let spec = inner.get_lsm_write_spec().await.infer_error()?;
Ok(spec.map(LsmWriteSpec::from))
let spec = inner.get_memwal_spec().await.infer_error()?;
Ok(spec.map(MemWalWriteSpec::from))
})
}
@@ -1703,55 +1703,54 @@ impl Table {
Ok(())
}
/// Converge the table's LSM write path into its base table.
/// Converge the table's MemWAL write path into its base table.
///
/// Best-effort: with writes flowing, new rows may land after the last
/// pass. Errors if the table stops making progress.
pub fn checkpoint_lsm(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
pub fn checkpoint_memwal(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
inner.checkpoint_lsm().await.infer_error()
inner.checkpoint_memwal().await.infer_error()
})
}
/// Seal every bucket's active memtable into L0.
pub fn flush_lsm(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(
self_.py(),
async move { inner.flush_lsm().await.infer_error() },
)
}
/// Trigger a background L0 → base pass per bucket. Returns once the
/// passes are dispatched, not once they finish — watch `get_lsm_stats`.
pub fn compact_lsm(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
/// Freeze every table shard's active memtable into an SSTable.
pub fn flush_memwal(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
inner.compact_lsm().await.infer_error()
inner.flush_memwal().await.infer_error()
})
}
/// Live LSM state, or `None` when the LSM write path is not enabled.
#[pyo3(signature = (include_generation_rows=false))]
pub fn get_lsm_stats(
/// Trigger a background SSTable compaction pass per table shard. Returns once the
/// passes are dispatched, not once they finish — watch `get_memwal_stats`.
pub fn compact_memwal(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
inner.compact_memwal().await.infer_error()
})
}
/// Live MemWAL state, or `None` when the MemWAL write path is not enabled.
#[pyo3(signature = (include_sstable_rows=false))]
pub fn get_memwal_stats(
self_: PyRef<'_, Self>,
include_generation_rows: bool,
include_sstable_rows: bool,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
let stats = inner
.get_lsm_stats(include_generation_rows)
.get_memwal_stats(include_sstable_rows)
.await
.infer_error()?;
Python::attach(|py| stats.map(|s| lsm_stats_to_py(py, &s)).transpose())
Python::attach(|py| stats.map(|s| memwal_stats_to_py(py, &s)).transpose())
})
}
pub fn close_lsm_writers(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
pub fn close_memwal_writers(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
inner.close_lsm_writers().await.infer_error()
inner.close_memwal_writers().await.infer_error()
})
}
@@ -2083,7 +2082,7 @@ pub struct MergeInsertParams {
when_not_matched_by_source_condition_expr: Option<PyExpr>,
timeout: Option<std::time::Duration>,
use_index: Option<bool>,
use_lsm: Option<bool>,
use_memwal: Option<bool>,
validate_single_shard: Option<bool>,
}
@@ -250,17 +250,17 @@ impl PermutationBuilder {
}
// Unflushed rows have no row id, so a permutation cannot address them.
match self.base_table.base_table().get_lsm_write_spec().await {
match self.base_table.base_table().get_memwal_spec().await {
Ok(Some(_)) => {
return Err(Error::NotSupported {
message: "the data loader does not support tables with an LSM write \
message: "the data loader does not support tables with a MemWAL write \
spec: rows that have not been flushed to the base table \
have no row id, so a permutation cannot reference them"
.to_string(),
});
}
Ok(None) => {}
// No LSM write path means no spec.
// No MemWAL write path means no spec.
Err(Error::NotSupported { .. }) => {}
Err(err) => return Err(err),
}
@@ -489,9 +489,9 @@ mod tests {
)
.unwrap()
}
"/v1/table/remote_base/get_lsm_write_spec/" => http::Response::builder()
"/v1/table/remote_base/get_memwal_spec/" => http::Response::builder()
.status(200)
.body(br#"{"lsm_write_spec":null}"#.to_vec())
.body(br#"{"memwal_spec":null}"#.to_vec())
.unwrap(),
"/v1/table/remote_base/count_rows/" => {
let body = body.unwrap();
@@ -737,8 +737,8 @@ mod tests {
/// permutation cannot reference them. Reading the base table alone would drop
/// them from training without saying so, so the table is refused instead.
#[tokio::test]
async fn test_permutation_rejects_lsm_write_spec() {
use crate::table::LsmWriteSpec;
async fn test_permutation_rejects_memwal_spec() {
use crate::table::MemWalWriteSpec;
use arrow_array::{Int32Array, RecordBatchIterator};
use arrow_schema::{DataType, Field, Schema};
@@ -766,13 +766,13 @@ mod tests {
table.set_unenforced_primary_key(["idx"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
let err = PermutationBuilder::new(table).build().await.unwrap_err();
assert!(
err.to_string().contains("LSM write spec"),
err.to_string().contains("MemWAL write spec"),
"expected the pre-check to refuse the table, got: {err}"
);
}
+1 -1
View File
@@ -167,7 +167,7 @@
//! # }
//! ```
// The MemWAL LSM read path (`table::query::lsm`) deepens the `create_plan` future's
// The MemWAL read path (`table::query::lsm`) deepens the `create_plan` future's
// type graph enough to overflow the default trait-recursion limit while evaluating
// auto-traits (`Send`) through the Linux io_uring build's moka cache. Raise it.
#![recursion_limit = "256"]
@@ -288,13 +288,15 @@ fn refresh_conflict_filter(dataset: &Dataset) -> Result<KeyExistenceFilter> {
Ok(filter.build())
}
/// Reject MemWAL/LSM state because an ordinary dataset scan cannot see its
/// Reject MemWAL/MemWAL state because an ordinary dataset scan cannot see its
/// un-compacted tiers.
pub(crate) async fn ensure_no_mem_wal(dataset: &Dataset, role: &str, name: &str) -> Result<()> {
let retained = !dataset.list_mem_wal_latest_shard_ids().await?.is_empty();
if retained || dataset.mem_wal_index_details().await?.is_some() {
return Err(Error::NotSupported {
message: format!("{role} '{name}' has an LSM write spec or retained un-compacted rows"),
message: format!(
"{role} '{name}' has a MemWAL write spec or retained un-compacted rows"
),
});
}
Ok(())
+13 -13
View File
@@ -547,22 +547,22 @@ pub trait QueryBase {
/// Control MemWAL read routing for this query.
///
/// By default (unset), when the table carries a MemWAL write spec (see
/// [`crate::Table::set_lsm_write_spec`]), reads are routed through the LSM
/// [`crate::Table::enable_memwal`]), reads are routed through the LSM
/// scanner so they also return data written via the `merge_insert` LSM path
/// that has not yet been compacted into the base table (active/frozen
/// memtables and flushed generations); a table without a spec reads the base
/// memtables and SSTables); a table without a spec reads the base
/// table.
///
/// - `use_lsm(true)` forces LSM routing and errors if the table has no
/// - `use_memwal(true)` forces MemWAL routing and errors if the table has no
/// MemWAL write spec.
/// - `use_lsm(false)` bypasses the MemWAL and reads the base table only,
/// - `use_memwal(false)` bypasses the MemWAL and reads the base table only,
/// even when a spec is present.
///
/// Note: the LSM scanner does not support every query shape (e.g. reranking,
/// hybrid search, `order_by`). On a MemWAL table those shapes error unless
/// `use_lsm(false)` is set, because a base-only read would silently
/// `use_memwal(false)` is set, because a base-only read would silently
/// exclude un-compacted MemWAL data.
fn use_lsm(self, enable: bool) -> Self;
fn use_memwal(self, enable: bool) -> Self;
}
pub trait HasQuery {
@@ -634,8 +634,8 @@ impl<T: HasQuery> QueryBase for T {
self
}
fn use_lsm(mut self, enable: bool) -> Self {
self.mut_query().use_lsm = Some(enable);
fn use_memwal(mut self, enable: bool) -> Self {
self.mut_query().use_memwal = Some(enable);
self
}
}
@@ -900,17 +900,17 @@ pub struct QueryRequest {
/// Controls MemWAL read routing. When unset (the default), a query against a
/// table that carries a MemWAL write spec (see
/// [`crate::Table::set_lsm_write_spec`]) is routed through the LSM scanner so
/// [`crate::Table::enable_memwal`]) is routed through the LSM scanner so
/// it also sees data written via the `merge_insert` LSM path that has not yet
/// been compacted into the base table — the active and frozen in-memory
/// memtables and the flushed (L0) generations, deduplicated by primary key
/// memtables and the SSTables, deduplicated by primary key
/// against the base table (newest generation wins); a table without a spec
/// reads the base table.
///
/// - `Some(true)` forces LSM routing and errors if the table has no MemWAL
/// - `Some(true)` forces MemWAL routing and errors if the table has no MemWAL
/// write spec.
/// - `Some(false)` reads only the base table, bypassing the MemWAL.
pub use_lsm: Option<bool>,
pub use_memwal: Option<bool>,
}
impl Default for QueryRequest {
@@ -930,7 +930,7 @@ impl Default for QueryRequest {
norm: None,
disable_scoring_autoprojection: false,
order_by: None,
use_lsm: None,
use_memwal: None,
}
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ fn wal_row_id_unsupported() -> Error {
Error::NotSupported {
message: "hybrid search on a MemWAL table cannot return _rowid: the fresh tier has no \
stable row id, and the ids the fusion joins on are synthesized from the \
primary key. Set use_lsm(false) to read the base table only (results will \
primary key. Set use_memwal(false) to read the base table only (results will \
exclude un-compacted MemWAL data)"
.to_string(),
}
@@ -80,7 +80,7 @@ where
{
// A previous query on this table was already refused, so skip straight to
// the key.
if query.request.base.use_lsm != Some(false) && query.parent.hybrid_pk_fusion_learned() {
if query.request.base.use_memwal != Some(false) && query.parent.hybrid_pk_fusion_learned() {
return run(Some(PkFusion::resolve(query).await?)).await;
}
match run(None).await {
+145 -167
View File
@@ -30,12 +30,12 @@ use crate::table::BranchDiff;
use crate::table::CherryPickResult;
use crate::table::DeleteResult;
use crate::table::DropColumnsResult;
use crate::table::LsmStats;
use crate::table::LsmWriteSpec;
use crate::table::MemWalStats;
use crate::table::MemWalWriteSpec;
use crate::table::MergeResult;
use crate::table::Tags;
use crate::table::UpdateResult;
use crate::table::lsm_stats::GetLsmStatsResponse;
use crate::table::memwal_stats::GetMemWalStatsResponse;
use crate::table::merge::MergeFilter;
use crate::table::primary_key;
use crate::table::query::create_multi_vector_plan;
@@ -1020,10 +1020,10 @@ impl<S: HttpSend> RemoteTable<S> {
) -> Result<()> {
params.check_filter()?;
body["prefilter"] = params.prefilter.into();
// Only forward use_lsm when explicitly set; a server that predates it
// Only forward use_memwal when explicitly set; a server that predates it
// ignores the field and routes as it would by default.
if let Some(use_lsm) = params.use_lsm {
body["use_lsm"] = serde_json::Value::Bool(use_lsm);
if let Some(use_memwal) = params.use_memwal {
body["use_memwal"] = serde_json::Value::Bool(use_memwal);
}
if let Some(offset) = params.offset {
body["offset"] = serde_json::Value::Number(serde_json::Number::from(offset));
@@ -3227,42 +3227,43 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
Ok(())
}
async fn flush_lsm(&self) -> Result<()> {
async fn flush_memwal(&self) -> Result<()> {
let request = self
.client
.post(&format!("/v1/table/{}/flush_lsm/", self.identifier));
.post(&format!("/v1/table/{}/flush_memwal/", self.identifier));
self.send_lsm_route(request).await?;
Ok(())
}
async fn compact_lsm(&self) -> Result<()> {
async fn compact_memwal(&self) -> Result<()> {
let request = self
.client
.post(&format!("/v1/table/{}/compact_lsm/", self.identifier));
.post(&format!("/v1/table/{}/compact_memwal/", self.identifier));
self.send_lsm_route(request).await?;
Ok(())
}
async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result<Option<LsmStats>> {
// Read-semantics POST, like `get_lsm_write_spec`.
async fn get_memwal_stats(&self, include_sstable_rows: bool) -> Result<Option<MemWalStats>> {
// Read-semantics POST, like `get_memwal_spec`.
let request = self
.client
.post(&format!("/v1/table/{}/get_lsm_stats/", self.identifier))
.post(&format!("/v1/table/{}/get_memwal_stats/", self.identifier))
.json(&serde_json::json!({
"include_generation_rows": include_generation_rows,
"include_sstable_rows": include_sstable_rows,
}));
let (request_id, response) = self.send_lsm_route(request).await?;
let body = response.text().await.err_to_http(request_id.clone())?;
let parsed: GetLsmStatsResponse = serde_json::from_str(&body).map_err(|e| Error::Http {
source: format!("Failed to parse get_lsm_stats response: {e}").into(),
request_id,
status_code: None,
})?;
// `null` — and only — when the table has no LSM write path.
Ok(parsed.lsm_stats)
let parsed: GetMemWalStatsResponse =
serde_json::from_str(&body).map_err(|e| Error::Http {
source: format!("Failed to parse get_memwal_stats response: {e}").into(),
request_id,
status_code: None,
})?;
// `null` — and only — when the table has no MemWAL write path.
Ok(parsed.memwal_stats)
}
async fn set_lsm_write_spec(&self, spec: LsmWriteSpec) -> Result<()> {
async fn enable_memwal(&self, spec: MemWalWriteSpec) -> Result<()> {
self.check_mutable().await?;
// Map the spec onto the server's request DTO. `sharding` is internally
@@ -3270,7 +3271,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
// `maintained_indexes` asks the server to resolve every maintainable
// index at HEAD; a list is verbatim, an empty one meaning none.
let sharding = match &spec {
LsmWriteSpec::Bucket {
MemWalWriteSpec::Bucket {
column,
num_buckets,
..
@@ -3279,11 +3280,11 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
"column": column,
"num_buckets": num_buckets,
}),
LsmWriteSpec::Identity { column, .. } => serde_json::json!({
MemWalWriteSpec::Identity { column, .. } => serde_json::json!({
"mode": "identity",
"column": column,
}),
LsmWriteSpec::Unsharded { .. } => serde_json::json!({ "mode": "unsharded" }),
MemWalWriteSpec::Unsharded { .. } => serde_json::json!({ "mode": "unsharded" }),
};
let body = serde_json::json!({
"sharding": sharding,
@@ -3293,45 +3294,40 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
let request = self
.client
.post(&format!(
"/v1/table/{}/set_lsm_write_spec/",
self.identifier
))
.post(&format!("/v1/table/{}/enable_memwal/", self.identifier))
.json(&body);
let (request_id, response) = self.send(request, true).await?;
self.check_table_response(&request_id, response).await?;
Ok(())
}
async fn unset_lsm_write_spec(&self) -> Result<()> {
async fn disable_memwal(&self) -> Result<()> {
self.check_mutable().await?;
let request = self.client.post(&format!(
"/v1/table/{}/unset_lsm_write_spec/",
self.identifier
));
let request = self
.client
.post(&format!("/v1/table/{}/disable_memwal/", self.identifier));
let (request_id, response) = self.send(request, true).await?;
self.check_table_response(&request_id, response).await?;
self.wal_pk_fusion.forget(); // WAL-PK-FUSION: delete.
Ok(())
}
async fn get_lsm_write_spec(&self) -> Result<Option<LsmWriteSpec>> {
async fn get_memwal_spec(&self) -> Result<Option<MemWalWriteSpec>> {
// Read counterpart to set/unset, resolved server-side against HEAD. The
// server reads the spec from the `__lance_mem_wal` system index (shard
// column mapped from its Lance field id against the current schema) and
// re-encodes it into the same sophon-owned shape the set endpoint
// accepts — no lance/lancedb types cross the wire. `lsm_write_spec` is
// null when the LSM write path is not enabled for the table.
let request = self.client.post(&format!(
"/v1/table/{}/get_lsm_write_spec/",
self.identifier
));
// accepts — no lance/lancedb types cross the wire. `memwal_spec` is
// null when the MemWAL write path is not enabled for the table.
let request = self
.client
.post(&format!("/v1/table/{}/get_memwal_spec/", self.identifier));
let (request_id, response) = self.send(request, true).await?;
let response = self.check_table_response(&request_id, response).await?;
let body = response.text().await.err_to_http(request_id.clone())?;
// Mirror of sophon's `Sharding` (internally tagged on `mode`) and
// `LsmWriteSpecBody` / `GetLsmWriteSpecResponse`.
// `MemWalWriteSpecBody` / `GetMemWalSpecResponse`.
#[derive(Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
enum Sharding {
@@ -3340,7 +3336,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
Identity { column: String },
}
#[derive(Deserialize)]
struct LsmWriteSpecBody {
struct MemWalWriteSpecBody {
sharding: Sharding,
/// `null` selects every index the table has; `[]` selects none.
#[serde(default)]
@@ -3349,19 +3345,19 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
writer_config_defaults: std::collections::HashMap<String, String>,
}
#[derive(Deserialize)]
struct GetLsmWriteSpecResponse {
lsm_write_spec: Option<LsmWriteSpecBody>,
struct GetMemWalSpecResponse {
memwal_spec: Option<MemWalWriteSpecBody>,
}
let parsed: GetLsmWriteSpecResponse =
let parsed: GetMemWalSpecResponse =
serde_json::from_str(&body).map_err(|e| Error::Http {
source: format!("Failed to parse get_lsm_write_spec response: {}", e).into(),
source: format!("Failed to parse get_memwal_spec response: {}", e).into(),
request_id,
status_code: None,
})?;
let Some(body) = parsed.lsm_write_spec else {
// The LSM write path is not enabled for this table.
let Some(body) = parsed.memwal_spec else {
// The MemWAL write path is not enabled for this table.
return Ok(None);
};
@@ -3369,9 +3365,9 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
Sharding::Bucket {
column,
num_buckets,
} => LsmWriteSpec::bucket(column, num_buckets),
Sharding::Identity { column } => LsmWriteSpec::identity(column),
Sharding::Unsharded => LsmWriteSpec::unsharded(),
} => MemWalWriteSpec::bucket(column, num_buckets),
Sharding::Identity { column } => MemWalWriteSpec::identity(column),
Sharding::Unsharded => MemWalWriteSpec::unsharded(),
}
.with_maintained_indexes(body.maintained_indexes)
.with_writer_config_defaults(body.writer_config_defaults);
@@ -3974,10 +3970,10 @@ pub struct MergeInsertRequest {
// (the default is true)
#[serde(skip_serializing_if = "is_true")]
use_index: bool,
// Only serialize use_lsm when explicitly set (Some); a server that predates
// Only serialize use_memwal when explicitly set (Some); a server that predates
// it ignores the field and routes as it would by default.
#[serde(skip_serializing_if = "Option::is_none")]
use_lsm: Option<bool>,
use_memwal: Option<bool>,
}
impl MergeInsertRequest {
@@ -4035,7 +4031,7 @@ impl TryFrom<MergeInsertBuilder> for MergeInsertRequest {
when_not_matched_by_source_delete_filt,
// Only serialize use_index when it's false for backwards compatibility
use_index: value.use_index,
use_lsm: value.use_lsm,
use_memwal: value.use_memwal,
})
}
}
@@ -10010,13 +10006,10 @@ mod tests {
}
#[tokio::test]
async fn test_set_lsm_write_spec_unsharded() {
async fn test_enable_memwal_unsharded() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
assert_eq!(request.url().path(), "/v1/table/my_table/enable_memwal/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["sharding"], serde_json::json!({ "mode": "unsharded" }));
@@ -10030,20 +10023,17 @@ mod tests {
.body(r#"{"maintained_indexes":["id_idx"]}"#)
.unwrap()
});
let spec = crate::table::LsmWriteSpec::unsharded()
let spec = crate::table::MemWalWriteSpec::unsharded()
.with_maintained_indexes(vec!["id_idx".to_string()])
.with_writer_config_defaults([("max_memtable_rows", "1000")]);
table.set_lsm_write_spec(spec).await.unwrap();
table.enable_memwal(spec).await.unwrap();
}
#[tokio::test]
async fn test_set_lsm_write_spec_bucket() {
async fn test_enable_memwal_bucket() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
assert_eq!(request.url().path(), "/v1/table/my_table/enable_memwal/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
@@ -10055,14 +10045,14 @@ mod tests {
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(crate::table::LsmWriteSpec::bucket("id", 16))
.enable_memwal(crate::table::MemWalWriteSpec::bucket("id", 16))
.await
.unwrap();
}
/// `[]` (none) must stay distinguishable on the wire from null (all).
#[tokio::test]
async fn test_set_lsm_write_spec_no_maintained_indexes() {
async fn test_enable_memwal_no_maintained_indexes() {
let table = Table::new_with_handler("my_table", |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
@@ -10070,21 +10060,18 @@ mod tests {
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(
crate::table::LsmWriteSpec::bucket("id", 16).with_maintained_indexes(Vec::new()),
.enable_memwal(
crate::table::MemWalWriteSpec::bucket("id", 16).with_maintained_indexes(Vec::new()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_set_lsm_write_spec_identity() {
async fn test_enable_memwal_identity() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
assert_eq!(request.url().path(), "/v1/table/my_table/enable_memwal/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
@@ -10094,38 +10081,32 @@ mod tests {
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(crate::table::LsmWriteSpec::identity("tenant"))
.enable_memwal(crate::table::MemWalWriteSpec::identity("tenant"))
.await
.unwrap();
}
#[tokio::test]
async fn test_unset_lsm_write_spec() {
async fn test_disable_memwal() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/unset_lsm_write_spec/"
);
assert_eq!(request.url().path(), "/v1/table/my_table/disable_memwal/");
http::Response::builder().status(200).body("{}").unwrap()
});
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
}
#[tokio::test]
async fn test_get_lsm_write_spec() {
async fn test_get_memwal_spec() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/get_lsm_write_spec/"
);
assert_eq!(request.url().path(), "/v1/table/my_table/get_memwal_spec/");
// The server resolves the spec and re-encodes it into the same
// sophon-owned shape the set endpoint accepts (`Sharding` internally
// tagged on `mode`, wrapped in `lsm_write_spec`).
// tagged on `mode`, wrapped in `memwal_spec`).
let response = serde_json::json!({
"lsm_write_spec": {
"memwal_spec": {
"sharding": { "mode": "bucket", "column": "id", "num_buckets": 4 },
"maintained_indexes": ["id_idx"],
"writer_config_defaults": { "durable_write": "false" },
@@ -10138,12 +10119,12 @@ mod tests {
});
let spec = table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.expect("a spec should be reported");
match spec {
crate::table::LsmWriteSpec::Bucket {
crate::table::MemWalWriteSpec::Bucket {
column,
num_buckets,
maintained_indexes,
@@ -10170,13 +10151,13 @@ mod tests {
#[case::no_index(serde_json::json!([]), Some(vec![]))]
#[case::named(serde_json::json!(["id_idx"]), Some(vec!["id_idx".to_string()]))]
#[tokio::test]
async fn test_get_lsm_write_spec_round_trips_the_selection(
async fn test_get_memwal_spec_round_trips_the_selection(
#[case] reported: serde_json::Value,
#[case] expected: Option<Vec<String>>,
) {
let table = Table::new_with_handler("my_table", move |_| {
let response = serde_json::json!({
"lsm_write_spec": {
"memwal_spec": {
"sharding": { "mode": "unsharded" },
"maintained_indexes": reported,
"writer_config_defaults": {},
@@ -10189,7 +10170,7 @@ mod tests {
});
let spec = table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.expect("a spec should be reported");
@@ -10201,35 +10182,32 @@ mod tests {
}
#[tokio::test]
async fn test_get_lsm_write_spec_absent() {
async fn test_get_memwal_spec_absent() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(
request.url().path(),
"/v1/table/my_table/get_lsm_write_spec/"
);
// Null spec → the LSM write path is not enabled.
let response = serde_json::json!({ "lsm_write_spec": null });
assert_eq!(request.url().path(), "/v1/table/my_table/get_memwal_spec/");
// Null spec → the MemWAL write path is not enabled.
let response = serde_json::json!({ "memwal_spec": null });
http::Response::builder()
.status(200)
.body(response.to_string())
.unwrap()
});
assert!(table.get_lsm_write_spec().await.unwrap().is_none());
assert!(table.get_memwal_spec().await.unwrap().is_none());
}
/// Build a `get_lsm_stats` body for one bucket holding `generations`.
fn stats_body(generations: &[u64], compacting: bool) -> String {
/// Build a `get_memwal_stats` body for one table shard holding `sstables`.
fn stats_body(sstables: &[u64], compacting: bool) -> String {
serde_json::json!({
"lsm_stats": {
"buckets": [{
"memwal_stats": {
"table_shards": [{
"shard_id": "b0",
"status": "Active",
"writer_epoch": 1,
"manifest_version": 1,
"current_generation": generations.iter().max().copied().unwrap_or(0) + 1,
"current_generation": sstables.iter().max().copied().unwrap_or(0) + 1,
"replay_after_wal_entry_position": 0,
"wal_entry_position_last_seen": 0,
"generations": generations.iter()
"sstables": sstables.iter()
.map(|g| serde_json::json!({ "generation": g, "bytes": 1 }))
.collect::<Vec<_>>(),
"compacting": compacting,
@@ -10240,7 +10218,7 @@ mod tests {
.to_string()
}
/// `flush_lsm` / `compact_lsm` answer 202 with no body at all.
/// `flush_memwal` / `compact_memwal` answer 202 with no body at all.
fn accepted() -> http::Response<String> {
http::Response::builder()
.status(202)
@@ -10252,7 +10230,7 @@ mod tests {
http::Response::builder().status(200).body(body).unwrap()
}
/// A flush landing in an empty L0 finishes on the opening stats read
/// A flush landing in an empty SSTable tier finishes on the opening stats read
/// alone. Asserting zero compacts is the point: "it returned Ok" is also
/// true of a loop that ran a pointless pass.
#[tokio::test(start_paused = true)]
@@ -10261,18 +10239,18 @@ mod tests {
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
panic!("an already-converged table must issue no compact calls");
}
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
return accepted();
}
assert_eq!(path, "/v1/table/my_table/get_lsm_stats/");
assert_eq!(path, "/v1/table/my_table/get_memwal_stats/");
ok_json(stats_body(&[], false))
});
table.checkpoint_lsm().await.unwrap();
table.checkpoint_memwal().await.unwrap();
assert_eq!(compacts.load(std::sync::atomic::Ordering::SeqCst), 0);
}
@@ -10284,10 +10262,10 @@ mod tests {
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
return accepted();
}
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
@@ -10297,7 +10275,7 @@ mod tests {
ok_json(stats_body(&left, false))
});
table.checkpoint_lsm().await.unwrap();
table.checkpoint_memwal().await.unwrap();
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
3,
@@ -10306,7 +10284,7 @@ mod tests {
}
/// Generations created *during* the checkpoint are not waited on, which
/// is what lets the loop terminate on a table taking writes where "L0 is
/// is what lets the loop terminate on a table taking writes where "the SSTable tier is
/// empty" never becomes true.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_ignores_generations_created_while_it_runs() {
@@ -10314,10 +10292,10 @@ mod tests {
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
return accepted();
}
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
@@ -10331,11 +10309,11 @@ mod tests {
ok_json(body)
});
table.checkpoint_lsm().await.unwrap();
table.checkpoint_memwal().await.unwrap();
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
1,
"the loop must not chase generations written after it started"
"the loop must not chase SSTables written after it started"
);
}
@@ -10348,11 +10326,11 @@ mod tests {
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
return accepted();
}
if path.contains("compact_lsm") {
// First two triggers: every bucket already latched.
if path.contains("compact_memwal") {
// First two triggers: every table shard already latched.
if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2 {
return http::Response::builder()
.status(429)
@@ -10373,7 +10351,7 @@ mod tests {
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("contention must not abort the checkpoint");
assert_eq!(
@@ -10393,7 +10371,7 @@ mod tests {
let seen = polls.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") || path.contains("compact_lsm") {
if path.contains("flush_memwal") || path.contains("compact_memwal") {
return accepted();
}
// The opening read lands; the next two polls are latched out.
@@ -10408,7 +10386,7 @@ mod tests {
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("a contended poll must be retried, not surfaced");
assert_eq!(
@@ -10428,7 +10406,7 @@ mod tests {
let seen = flushes.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 5 {
return http::Response::builder()
.status(429)
@@ -10437,20 +10415,20 @@ mod tests {
}
return accepted();
}
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
return accepted();
}
ok_json(stats_body(&[], false))
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("contention must not be reported as a lost claim");
assert_eq!(
flushes.load(std::sync::atomic::Ordering::SeqCst),
6,
"five retries against one seal, then it lands"
"five retries against one freeze, then it lands"
);
}
@@ -10469,7 +10447,7 @@ mod tests {
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
let err = table.checkpoint_memwal().await.unwrap_err();
assert!(
matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 429),
"the fault that spent the budget must be the one reported: {err:?}"
@@ -10499,7 +10477,7 @@ mod tests {
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
let err = table.checkpoint_memwal().await.unwrap_err();
let message = err.to_string();
assert!(
matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 503),
@@ -10519,7 +10497,7 @@ mod tests {
/// A long stall with nothing compacting must keep waiting, not fail. The
/// client cannot judge this: a checkpoint queued behind unrelated tables
/// on the pod-wide compactor pool reports exactly these numbers — flat
/// generations, an idle latch — as one whose merges are failing. The
/// SSTable counts, an idle latch — as one whose merges are failing. The
/// deadline is the caller's.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_waits_out_a_long_stall_rather_than_failing() {
@@ -10527,7 +10505,7 @@ mod tests {
let seen = polls.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") || path.contains("compact_lsm") {
if path.contains("flush_memwal") || path.contains("compact_memwal") {
return accepted();
}
// Flat for far longer than any bound this loop ever had, with
@@ -10537,7 +10515,7 @@ mod tests {
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("a stall is the server being slow, not the client's call to make");
assert!(
@@ -10546,7 +10524,7 @@ mod tests {
);
}
/// A pass already owns the latch on every outstanding bucket, so the loop
/// A pass already owns the latch on every outstanding table shard, so the loop
/// waits rather than piling on triggers it would only refuse. This is the
/// sole thing `compacting` is read for.
#[tokio::test(start_paused = true)]
@@ -10557,10 +10535,10 @@ mod tests {
let seen_compacts = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
return accepted();
}
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
seen_compacts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
@@ -10574,32 +10552,32 @@ mod tests {
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("a running pass is progress, not a stall");
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
0,
"never trigger against a bucket already compacting"
"never trigger against a table shard already compacting"
);
}
/// WAL off ⇒ `None`; WAL on ⇒ a fully populated `Some` with no field
/// defaulting to a zero it did not measure. `include_generation_rows`
/// defaulting to a zero it did not measure. `include_sstable_rows`
/// rides in the body and is off unless asked for.
#[tokio::test]
async fn test_get_lsm_stats_round_trip() {
async fn test_get_memwal_stats_round_trip() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/get_lsm_stats/");
assert_eq!(request.url().path(), "/v1/table/my_table/get_memwal_stats/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["include_generation_rows"], true,
body["include_sstable_rows"], true,
"the flag must reach the server, not be silently dropped"
);
let response = serde_json::json!({
"lsm_stats": {
"buckets": [{
"memwal_stats": {
"table_shards": [{
"shard_id": "b0",
"status": "Active",
"writer_epoch": 3,
@@ -10607,7 +10585,7 @@ mod tests {
"current_generation": 9,
"replay_after_wal_entry_position": 100,
"wal_entry_position_last_seen": 140,
"generations": [{ "generation": 8, "bytes": 4096, "rows": 30 }],
"sstables": [{ "generation": 8, "bytes": 4096, "rows": 30 }],
"compacting": false,
"memtables": [
{ "generation": 9, "rows": 12, "bytes": 900, "batches": 2,
@@ -10623,19 +10601,19 @@ mod tests {
});
let stats = table
.get_lsm_stats(true)
.get_memwal_stats(true)
.await
.unwrap()
.expect("a WAL-backed table reports Some");
let bucket = &stats.buckets[0];
assert_eq!(bucket.replay_after_wal_entry_position, 100);
assert_eq!(bucket.wal_entry_position_last_seen, 140);
assert!(!bucket.compacting);
assert_eq!(bucket.generations[0].generation, 8);
assert_eq!(bucket.generations[0].rows, Some(30));
let table_shard = &stats.table_shards[0];
assert_eq!(table_shard.replay_after_wal_entry_position, 100);
assert_eq!(table_shard.wal_entry_position_last_seen, 140);
assert!(!table_shard.compacting);
assert_eq!(table_shard.sstables[0].generation, 8);
assert_eq!(table_shard.sstables[0].rows, Some(30));
// The line that answers "why is my fresh-tier vector search
// brute-force" — an absent index name is the whole explanation.
let memtables = bucket.memtables.as_ref().unwrap();
let memtables = table_shard.memtables.as_ref().unwrap();
assert_eq!(memtables[0].indexes, vec!["vec_idx".to_string()]);
}
@@ -10655,7 +10633,7 @@ mod tests {
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
let err = table.checkpoint_memwal().await.unwrap_err();
assert!(
matches!(err, Error::TableNotFound { .. }),
"a missing table must say so: {err:?}"
@@ -10676,12 +10654,12 @@ mod tests {
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if path.contains("flush_lsm") {
if path.contains("flush_memwal") {
// First flush lands; the claim is then lost, and the
// re-issued flush succeeds.
return accepted();
}
if path.contains("compact_lsm") {
if path.contains("compact_memwal") {
if n < 4 {
return http::Response::builder()
.status(421)
@@ -10694,20 +10672,20 @@ mod tests {
});
table
.checkpoint_lsm()
.checkpoint_memwal()
.await
.expect("a lost claim must be recovered by re-flushing, not surfaced");
}
#[tokio::test]
async fn test_get_lsm_stats_absent_when_wal_off() {
async fn test_get_memwal_stats_absent_when_wal_off() {
let table = Table::new_with_handler("my_table", |_request| {
http::Response::builder()
.status(200)
.body(serde_json::json!({ "lsm_stats": null }).to_string())
.body(serde_json::json!({ "memwal_stats": null }).to_string())
.unwrap()
});
assert!(table.get_lsm_stats(false).await.unwrap().is_none());
assert!(table.get_memwal_stats(false).await.unwrap().is_none());
}
#[tokio::test]
@@ -14726,7 +14704,7 @@ mod tests {
/// Verbatim from sophon's `LSM_WITH_ROW_ID_UNSUPPORTED`; the fallback keys
/// off this sentence, so a test that paraphrased it would prove nothing.
const WAL_ROW_ID_REFUSAL_BODY: &str = r#"{"code":13,"error":"Bad request: the MemWAL LSM scanner does not support with_row_id (the LSM scanner exposes _rowaddr, not a stable _rowid); set use_lsm(false) to read the base table only (results will exclude un-compacted MemWAL data)"}"#;
const WAL_ROW_ID_REFUSAL_BODY: &str = r#"{"code":13,"error":"Bad request: the MemWAL LSM scanner does not support with_row_id (the LSM scanner exposes _rowaddr, not a stable _rowid); set use_memwal(false) to read the base table only (results will exclude un-compacted MemWAL data)"}"#;
use lance::dataset::ROW_ID;
@@ -14985,7 +14963,7 @@ mod tests {
// Translated, not passed through: the caller gets the reason and the
// way out, which a bare 400 does not carry.
assert!(matches!(err, Error::NotSupported { .. }), "{err:?}");
assert!(err.to_string().contains("use_lsm(false)"), "{err}");
assert!(err.to_string().contains("use_memwal(false)"), "{err}");
assert!(
bodies
.lock()
+2 -2
View File
@@ -685,7 +685,7 @@ impl<S: HttpSend> RemoteTable<S> {
select: Select::columns(&[column]),
with_row_id: true,
// Row ids name base-table rows, and the MemWAL scanner has no stable `_rowid`.
use_lsm: Some(false),
use_memwal: Some(false),
..Default::default()
});
let body = self
@@ -1121,7 +1121,7 @@ mod tests {
.unwrap();
assert_eq!(body["columns"], serde_json::json!(["image"]));
assert_eq!(body["with_row_id"], serde_json::json!(true));
assert_eq!(body["use_lsm"], serde_json::json!(false));
assert_eq!(body["use_memwal"], serde_json::json!(false));
let requested = filter_row_ids(body["filter"].as_str().unwrap());
captured.lock().unwrap().push(TakeMockRequest::Query {
row_ids: requested.clone(),
+1 -1
View File
@@ -1497,7 +1497,7 @@ mod tests {
when_not_matched_by_source_delete: false,
when_not_matched_by_source_delete_filt: None,
use_index: true,
use_lsm: None,
use_memwal: None,
};
let input: Arc<dyn ExecutionPlan> = Arc::new(ErroringExec::new());
+149 -145
View File
@@ -80,7 +80,7 @@ pub mod datafusion;
pub(crate) mod dataset;
pub mod delete;
pub mod freshness;
pub mod lsm_stats;
pub mod memwal_stats;
pub mod merge;
pub mod optimize;
pub(crate) mod primary_key;
@@ -107,7 +107,7 @@ use futures::future::join_all;
pub use lance::dataset::refs::{BranchContents, Ref, TagContents, Tags as LanceTags};
pub use lance::dataset::scanner::DatasetRecordBatchStream;
pub use lance_index::optimize::OptimizeOptions;
pub use lsm_stats::{BucketStats, GenerationStats, LsmStats, MemtableStats};
pub use memwal_stats::{MemWalStats, MemtableStats, SsTableStats, TableShardStats};
pub use optimize::{CompactionOptions, OptimizeAction, OptimizeStats};
pub use refresh::RefreshColumnResult;
pub use schema_evolution::{
@@ -327,20 +327,20 @@ pub use self::merge::MergeResult;
/// Specification selecting Lance's MemWAL LSM-style write path for
/// `merge_insert`.
///
/// Construct via [`LsmWriteSpec::bucket`], [`LsmWriteSpec::identity`], or
/// [`LsmWriteSpec::unsharded`], then optionally chain
/// [`LsmWriteSpec::with_maintained_indexes`] (indexes the MemWAL keeps up to
/// date) and [`LsmWriteSpec::with_writer_config_defaults`] (default
/// Construct via [`MemWalWriteSpec::bucket`], [`MemWalWriteSpec::identity`], or
/// [`MemWalWriteSpec::unsharded`], then optionally chain
/// [`MemWalWriteSpec::with_maintained_indexes`] (indexes the MemWAL keeps up to
/// date) and [`MemWalWriteSpec::with_writer_config_defaults`] (default
/// `ShardWriter` configuration recorded in the MemWAL index).
///
/// A fresh spec maintains every index on the table, including ones created
/// after it is installed.
///
/// Install a spec with [`Table::set_lsm_write_spec`] and remove it with
/// [`Table::unset_lsm_write_spec`]. The actual `merge_insert` dispatch
/// Install a spec with [`Table::enable_memwal`] and remove it with
/// [`Table::disable_memwal`]. The actual `merge_insert` dispatch
/// onto the MemWAL writer is a follow-up.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LsmWriteSpec {
pub enum MemWalWriteSpec {
/// Hash-bucket sharding by a scalar column.
///
/// `column` must be a non-nested column with a supported scalar type.
@@ -384,7 +384,7 @@ pub enum LsmWriteSpec {
},
}
impl LsmWriteSpec {
impl MemWalWriteSpec {
/// Construct a hash-bucket sharding spec maintaining every index on the table.
pub fn bucket(column: impl Into<String>, num_buckets: u32) -> Self {
Self::Bucket {
@@ -401,7 +401,7 @@ impl LsmWriteSpec {
/// `column` must be a deterministic function of the unenforced primary
/// key: every row with a given primary key must always produce the same
/// `column` value. MemWAL dedups upserts by primary key but tracks
/// generations per shard, so if the same key is written with two
/// SSTables per shard, so if the same key is written with two
/// different `column` values its versions land in different shards and a
/// stale value can win. Typically `column` is the primary key itself, or
/// a stable attribute of it (e.g. a tenant id).
@@ -431,13 +431,13 @@ impl LsmWriteSpec {
/// any added with [`NativeTable::set_mem_index_registry`].
///
/// ```
/// # use lancedb::table::LsmWriteSpec;
/// # use lancedb::table::MemWalWriteSpec;
/// // Every index the table has, now and later:
/// LsmWriteSpec::unsharded().with_maintained_indexes(None);
/// MemWalWriteSpec::unsharded().with_maintained_indexes(None);
/// // Exactly these:
/// LsmWriteSpec::unsharded().with_maintained_indexes(vec!["id_idx".to_string()]);
/// MemWalWriteSpec::unsharded().with_maintained_indexes(vec!["id_idx".to_string()]);
/// // None at all:
/// LsmWriteSpec::unsharded().with_maintained_indexes(Vec::new());
/// MemWalWriteSpec::unsharded().with_maintained_indexes(Vec::new());
/// ```
pub fn with_maintained_indexes(mut self, indexes: impl Into<Option<Vec<String>>>) -> Self {
let indexes = indexes.into();
@@ -674,37 +674,37 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
message: "set_unenforced_primary_key is not supported on this table type".into(),
})
}
/// Install an [`LsmWriteSpec`] on this table.
/// Install an [`MemWalWriteSpec`] on this table.
///
/// The spec selects Lance's MemWAL LSM-style write path for future
/// `merge_insert` calls.
///
/// The default implementation returns `NotSupported`. Implementations
/// that support the MemWAL LSM write path must override this.
async fn set_lsm_write_spec(&self, _spec: LsmWriteSpec) -> Result<()> {
/// that support the MemWAL write path must override this.
async fn enable_memwal(&self, _spec: MemWalWriteSpec) -> Result<()> {
Err(Error::NotSupported {
message: "set_lsm_write_spec is not supported on this table type".into(),
message: "enable_memwal is not supported on this table type".into(),
})
}
/// Remove the [`LsmWriteSpec`] from this table.
/// Remove the [`MemWalWriteSpec`] from this table.
///
/// This is a no-op if no spec is currently set.
///
/// The default implementation returns `NotSupported`. Implementations
/// that support the MemWAL LSM write path must override this.
async fn unset_lsm_write_spec(&self) -> Result<()> {
/// that support the MemWAL write path must override this.
async fn disable_memwal(&self) -> Result<()> {
Err(Error::NotSupported {
message: "unset_lsm_write_spec is not supported on this table type".into(),
message: "disable_memwal is not supported on this table type".into(),
})
}
/// Read the [`LsmWriteSpec`] currently installed on this table, returning
/// `None` when the MemWAL LSM write path is not enabled.
/// Read the [`MemWalWriteSpec`] currently installed on this table, returning
/// `None` when the MemWAL write path is not enabled.
///
/// The default implementation returns `NotSupported`. Implementations that
/// support the MemWAL LSM write path must override this.
async fn get_lsm_write_spec(&self) -> Result<Option<LsmWriteSpec>> {
/// support the MemWAL write path must override this.
async fn get_memwal_spec(&self) -> Result<Option<MemWalWriteSpec>> {
Err(Error::NotSupported {
message: "get_lsm_write_spec is not supported on this table type".into(),
message: "get_memwal_spec is not supported on this table type".into(),
})
}
/// Whether a hybrid query on this table has already been told it cannot
@@ -732,36 +732,36 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
/// the only cost of being late to notice is a base-only read that is still
/// correct.
fn note_hybrid_pk_fusion(&self) {}
/// Seal every bucket's active memtable into L0.
/// Freeze every table shard's active memtable into an SSTable.
///
/// The default implementation returns `NotSupported`.
async fn flush_lsm(&self) -> Result<()> {
async fn flush_memwal(&self) -> Result<()> {
Err(Error::NotSupported {
message: "flush_lsm is not supported on this table type".into(),
message: "flush_memwal is not supported on this table type".into(),
})
}
/// Trigger a background L0 → base compaction pass per bucket.
/// Trigger a background SSTable compaction pass per table shard.
///
/// The default implementation returns `NotSupported`.
async fn compact_lsm(&self) -> Result<()> {
async fn compact_memwal(&self) -> Result<()> {
Err(Error::NotSupported {
message: "compact_lsm is not supported on this table type".into(),
message: "compact_memwal is not supported on this table type".into(),
})
}
/// Read live LSM state, or `None` when the LSM write path is not
/// Read live MemWAL state, or `None` when the MemWAL write path is not
/// enabled for this table.
///
/// The default implementation returns `NotSupported`.
async fn get_lsm_stats(&self, _include_generation_rows: bool) -> Result<Option<LsmStats>> {
async fn get_memwal_stats(&self, _include_sstable_rows: bool) -> Result<Option<MemWalStats>> {
Err(Error::NotSupported {
message: "get_lsm_stats is not supported on this table type".into(),
message: "get_memwal_stats is not supported on this table type".into(),
})
}
/// Drain and close any cached MemWAL shard writers for this table.
///
/// The default implementation is a no-op; table types that maintain
/// MemWAL shard writers override it.
async fn close_lsm_writers(&self) -> Result<()> {
async fn close_memwal_writers(&self) -> Result<()> {
Ok(())
}
/// Names of the blob v2 columns in this table, in declaration order.
@@ -2006,50 +2006,50 @@ impl Table {
self.inner.set_unenforced_primary_key(&borrowed).await
}
/// Install an [`LsmWriteSpec`] on this table, selecting Lance's MemWAL
/// Install an [`MemWalWriteSpec`] on this table, selecting Lance's MemWAL
/// LSM-style write path for future `merge_insert` calls.
///
/// [`LsmWriteSpec`] chooses one of three sharding strategies:
/// [`MemWalWriteSpec`] chooses one of three sharding strategies:
///
/// - [`LsmWriteSpec::bucket`] — hash-bucket writes by a scalar column.
/// - [`LsmWriteSpec::identity`] — shard by the raw value of a scalar column.
/// - [`LsmWriteSpec::unsharded`] — route every write to a single shard.
/// - [`MemWalWriteSpec::bucket`] — hash-bucket writes by a scalar column.
/// - [`MemWalWriteSpec::identity`] — shard by the raw value of a scalar column.
/// - [`MemWalWriteSpec::unsharded`] — route every write to a single shard.
///
/// A table carries one spec: this fails while one is installed, since the
/// generations already written were homed under it.
/// [`Table::unset_lsm_write_spec`] removes one.
/// SSTables already written were homed under it.
/// [`Table::disable_memwal`] removes one.
///
/// # Example
///
/// ```
/// # use lancedb::table::{LsmWriteSpec, Table};
/// # use lancedb::table::{MemWalWriteSpec, Table};
/// # async fn example(table: &Table) -> Result<(), Box<dyn std::error::Error>> {
/// table
/// .set_lsm_write_spec(
/// LsmWriteSpec::bucket("id", 16).with_maintained_indexes(vec!["id_idx".to_string()]),
/// .enable_memwal(
/// MemWalWriteSpec::bucket("id", 16).with_maintained_indexes(vec!["id_idx".to_string()]),
/// )
/// .await?;
/// # Ok(())
/// # }
/// ```
pub async fn set_lsm_write_spec(&self, spec: LsmWriteSpec) -> Result<()> {
self.inner.set_lsm_write_spec(spec).await
pub async fn enable_memwal(&self, spec: MemWalWriteSpec) -> Result<()> {
self.inner.enable_memwal(spec).await
}
/// Remove the [`LsmWriteSpec`] from this table, reverting to the standard
/// Remove the [`MemWalWriteSpec`] from this table, reverting to the standard
/// `merge_insert` write path.
///
/// Errors if no spec is currently set.
pub async fn unset_lsm_write_spec(&self) -> Result<()> {
self.inner.unset_lsm_write_spec().await
pub async fn disable_memwal(&self) -> Result<()> {
self.inner.disable_memwal().await
}
/// Read the [`LsmWriteSpec`] currently installed on this table.
/// Read the [`MemWalWriteSpec`] currently installed on this table.
///
/// `Ok(None)` means the LSM write path is not enabled at all — no spec has
/// been set, or one was removed with [`Table::unset_lsm_write_spec`]. That
/// `Ok(None)` means the MemWAL write path is not enabled at all — no spec has
/// been set, or one was removed with [`Table::disable_memwal`]. That
/// is a different answer from a spec whose
/// [`LsmWriteSpec::maintained_indexes`] is `None`, which is an installed
/// [`MemWalWriteSpec::maintained_indexes`] is `None`, which is an installed
/// spec selecting indexes automatically.
///
/// The spec read back is the one that was installed, selection included:
@@ -2062,21 +2062,21 @@ impl Table {
/// ```
/// # use lancedb::table::Table;
/// # async fn example(table: &Table) -> Result<(), Box<dyn std::error::Error>> {
/// if let Some(spec) = table.get_lsm_write_spec().await? {
/// println!("LSM write path enabled: {:?}", spec);
/// if let Some(spec) = table.get_memwal_spec().await? {
/// println!("MemWAL write path enabled: {:?}", spec);
/// }
/// # Ok(())
/// # }
/// ```
pub async fn get_lsm_write_spec(&self) -> Result<Option<LsmWriteSpec>> {
self.inner.get_lsm_write_spec().await
pub async fn get_memwal_spec(&self) -> Result<Option<MemWalWriteSpec>> {
self.inner.get_memwal_spec().await
}
/// Converge this table's LSM write path into its base table.
/// Converge this table's MemWAL write path into its base table.
///
/// One `flush` to seal every memtable into L0, then compaction triggers
/// One `flush` to freeze every memtable into an SSTable, then compaction triggers
/// until every generation that existed at that moment has reached base.
/// The loop runs client-side, reading progress from `get_lsm_stats`, so
/// The loop runs client-side, reading progress from `get_memwal_stats`, so
/// there is no held socket and nothing to reconcile if you drop this
/// future partway through.
///
@@ -2086,7 +2086,7 @@ impl Table {
/// already-converged table costs two round trips and triggers nothing.
///
/// **No deadline, and the caller owns that.** It returns when the target
/// generations are gone, propagates a terminal server fault, and
/// SSTables are gone, propagates a terminal server fault, and
/// otherwise waits however long the server takes. A slow table and a
/// stuck one are the same picture from here: the compactor pool is shared
/// across every table on the node, so a checkpoint queued behind
@@ -2099,66 +2099,69 @@ impl Table {
/// ```no_run
/// # use lancedb::Table;
/// # async fn example(table: &Table) -> Result<(), Box<dyn std::error::Error>> {
/// let before = table.get_lsm_stats(false).await?;
/// table.checkpoint_lsm().await?;
/// let after = table.get_lsm_stats(false).await?;
/// let before = table.get_memwal_stats(false).await?;
/// table.checkpoint_memwal().await?;
/// let after = table.get_memwal_stats(false).await?;
/// # Ok(())
/// # }
/// ```
pub async fn checkpoint_lsm(&self) -> Result<()> {
checkpoint::checkpoint_lsm(self).await
pub async fn checkpoint_memwal(&self) -> Result<()> {
checkpoint::checkpoint_memwal(self).await
}
/// Seal every bucket's active memtable into L0 without touching the
/// Freeze every table shard's active memtable into an SSTable without touching the
/// base table.
///
/// Independently useful: flushing makes memtable rows readable from L0 at
/// Independently useful: flushing makes memtable rows readable from an SSTable at
/// a lower per-query cost. On a node that has not claimed this table it
/// claims it and replays the WAL log first — reporting "nothing to flush"
/// without replaying would lie about durable data.
pub async fn flush_lsm(&self) -> Result<()> {
self.inner.flush_lsm().await
pub async fn flush_memwal(&self) -> Result<()> {
self.inner.flush_memwal().await
}
/// Run one bounded L0 → base compaction pass per bucket, reporting what
/// Run one bounded SSTable compaction pass per table shard, reporting what
/// it merged and what is left.
///
/// One pass, not convergence: that bounds each request's cost and gives a
/// caller driving its own cadence a progress signal per round trip.
pub async fn compact_lsm(&self) -> Result<()> {
self.inner.compact_lsm().await
pub async fn compact_memwal(&self) -> Result<()> {
self.inner.compact_memwal().await
}
/// Read live per-bucket LSM state.
/// Read live per-table-shard MemWAL state.
///
/// Answers "how far behind is my fresh tier", "which bucket is hot", and
/// Answers "how far behind is my fresh tier", "which table shard is hot", and
/// "why is my fresh-tier vector search brute-force". Mutates no table
/// state, though on a node that has not claimed this table it claims it,
/// exactly as a read would.
///
/// `include_generation_rows` reports a row count per L0 generation. Off by
/// `include_sstable_rows` reports a row count per SSTable. Off by
/// default: each count opens an uncached Lance dataset, and
/// `checkpoint_lsm` polls this needing only generation numbers.
/// `checkpoint_memwal` polls this needing only generation numbers.
///
/// `Ok(None)` only when the LSM write path is not enabled, matching
/// [`Table::get_lsm_write_spec`]. Stats is fresh-tier only, so with the
/// `Ok(None)` only when the MemWAL write path is not enabled, matching
/// [`Table::get_memwal_spec`]. Stats is fresh-tier only, so with the
/// WAL off there is no manifest to report and a struct of zeros would
/// read as measurements.
///
/// Do not build a checkpoint's termination on this: the completion
/// predicate lives in the `flush` and `compact` responses.
pub async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result<Option<LsmStats>> {
self.inner.get_lsm_stats(include_generation_rows).await
pub async fn get_memwal_stats(
&self,
include_sstable_rows: bool,
) -> Result<Option<MemWalStats>> {
self.inner.get_memwal_stats(include_sstable_rows).await
}
/// Drain and close any cached MemWAL shard writers held for this table.
///
/// When an [`LsmWriteSpec`] is installed, `merge_insert` opens MemWAL shard
/// When an [`MemWalWriteSpec`] is installed, `merge_insert` opens MemWAL shard
/// writers and caches them for reuse across calls. This closes them,
/// flushing pending data; writers reopen lazily on the next `merge_insert`.
/// It is a no-op when no writers are cached.
pub async fn close_lsm_writers(&self) -> Result<()> {
self.inner.close_lsm_writers().await
pub async fn close_memwal_writers(&self) -> Result<()> {
self.inner.close_memwal_writers().await
}
/// Retrieve the version of the table
@@ -2776,8 +2779,8 @@ impl NativeTable {
}
/// Maintain the memtable index kinds in `registry` when this table writes
/// through its [`LsmWriteSpec`], and accept them when
/// [`LsmWriteSpec::with_maintained_indexes`] names one.
/// through its [`MemWalWriteSpec`], and accept them when
/// [`MemWalWriteSpec::with_maintained_indexes`] names one.
///
/// The registry belongs to this handle, its clones and its branch handles,
/// and is not stored with the table: a handle opened separately starts from
@@ -2788,7 +2791,7 @@ impl NativeTable {
/// ```
/// # use std::sync::Arc;
/// # use lancedb::Table;
/// # use lancedb::table::{LsmWriteSpec, MemIndexRegistry, mem_index::MemIndexPlugin};
/// # use lancedb::table::{MemWalWriteSpec, MemIndexRegistry, mem_index::MemIndexPlugin};
/// # async fn example(
/// # table: &Table,
/// # plugin: Arc<dyn MemIndexPlugin>,
@@ -2797,8 +2800,8 @@ impl NativeTable {
/// if let Some(native) = table.as_native() {
/// native.set_mem_index_registry(registry);
/// }
/// let spec = LsmWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()]);
/// table.set_lsm_write_spec(spec).await?;
/// let spec = MemWalWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()]);
/// table.enable_memwal(spec).await?;
/// # Ok(())
/// # }
/// ```
@@ -3691,20 +3694,20 @@ impl BaseTable for NativeTable {
primary_key::set_unenforced_primary_key(self, columns).await
}
async fn set_lsm_write_spec(&self, spec: LsmWriteSpec) -> Result<()> {
merge::lsm::set_lsm_write_spec(self, spec).await
async fn enable_memwal(&self, spec: MemWalWriteSpec) -> Result<()> {
merge::lsm::enable_memwal(self, spec).await
}
async fn unset_lsm_write_spec(&self) -> Result<()> {
merge::lsm::unset_lsm_write_spec(self).await
async fn disable_memwal(&self) -> Result<()> {
merge::lsm::disable_memwal(self).await
}
async fn get_lsm_write_spec(&self) -> Result<Option<LsmWriteSpec>> {
merge::lsm::get_lsm_write_spec(self).await
async fn get_memwal_spec(&self) -> Result<Option<MemWalWriteSpec>> {
merge::lsm::get_memwal_spec(self).await
}
async fn close_lsm_writers(&self) -> Result<()> {
merge::lsm::close_lsm_writers(self).await
async fn close_memwal_writers(&self) -> Result<()> {
merge::lsm::close_memwal_writers(self).await
}
async fn blob_columns(&self) -> Result<Vec<String>> {
@@ -5510,7 +5513,7 @@ mod tests {
}
#[tokio::test]
async fn test_set_lsm_write_spec() {
async fn test_enable_memwal() {
use arrow_array::StringArray;
use lance::dataset::mem_wal::DatasetMemWalExt;
@@ -5542,7 +5545,7 @@ mod tests {
// Reject num_buckets out of range.
for bad in [0u32, 1025] {
let err = table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", bad))
.enable_memwal(MemWalWriteSpec::bucket("id", bad))
.await
.expect_err("should reject");
assert!(matches!(err, Error::InvalidInput { .. }), "got {:?}", err);
@@ -5550,7 +5553,7 @@ mod tests {
// Happy path: install spec; verify MemWAL details record it.
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 4))
.enable_memwal(MemWalWriteSpec::bucket("id", 4))
.await
.unwrap();
@@ -5576,14 +5579,14 @@ mod tests {
// Mutation rejected.
let err = table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 8))
.enable_memwal(MemWalWriteSpec::bucket("id", 8))
.await
.expect_err("mutation should be rejected");
assert!(matches!(err, Error::InvalidInput { .. }), "got {:?}", err);
}
#[tokio::test]
async fn test_set_lsm_write_spec_unsharded() {
async fn test_enable_memwal_unsharded() {
use lance::dataset::mem_wal::DatasetMemWalExt;
let tmp_dir = tempdir().unwrap();
@@ -5605,7 +5608,7 @@ mod tests {
let table = conn.create_table("t", reader).execute().await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -5623,7 +5626,7 @@ mod tests {
}
#[tokio::test]
async fn test_set_lsm_write_spec_identity() {
async fn test_enable_memwal_identity() {
use lance::dataset::mem_wal::DatasetMemWalExt;
let tmp_dir = tempdir().unwrap();
@@ -5651,8 +5654,8 @@ mod tests {
let table = conn.create_table("t", reader).execute().await.unwrap();
table
.set_lsm_write_spec(
LsmWriteSpec::identity("region")
.enable_memwal(
MemWalWriteSpec::identity("region")
.with_writer_config_defaults([("durable_write", "false")]),
)
.await
@@ -5680,7 +5683,7 @@ mod tests {
}
#[tokio::test]
async fn test_unset_lsm_write_spec() {
async fn test_disable_memwal() {
use lance::dataset::mem_wal::DatasetMemWalExt;
let tmp_dir = tempdir().unwrap();
@@ -5702,11 +5705,11 @@ mod tests {
let table = conn.create_table("t", reader).execute().await.unwrap();
// unset errors when no spec is set.
table.unset_lsm_write_spec().await.unwrap_err();
table.disable_memwal().await.unwrap_err();
// Install a spec, then unset it.
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 4))
.enable_memwal(MemWalWriteSpec::bucket("id", 4))
.await
.unwrap();
{
@@ -5714,16 +5717,16 @@ mod tests {
assert!(dataset.mem_wal_index_details().await.unwrap().is_some());
}
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
{
let dataset = table.as_native().unwrap().dataset.get().await.unwrap();
assert!(dataset.mem_wal_index_details().await.unwrap().is_none());
}
// A second unset errors; a fresh spec can still be installed afterwards.
table.unset_lsm_write_spec().await.unwrap_err();
table.disable_memwal().await.unwrap_err();
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 8))
.enable_memwal(MemWalWriteSpec::bucket("id", 8))
.await
.unwrap();
{
@@ -5733,7 +5736,7 @@ mod tests {
}
#[tokio::test]
async fn test_get_lsm_write_spec() {
async fn test_get_memwal_spec() {
let tmp_dir = tempdir().unwrap();
let uri = tmp_dir.path().to_str().unwrap();
@@ -5759,7 +5762,7 @@ mod tests {
let table = conn.create_table("t", reader).execute().await.unwrap();
// No spec installed yet.
assert_eq!(table.get_lsm_write_spec().await.unwrap(), None);
assert_eq!(table.get_memwal_spec().await.unwrap(), None);
// A real scalar index is needed to name it as a maintained index.
table
@@ -5771,35 +5774,35 @@ mod tests {
// Bucket spec round-trips exactly, including the routing column (recovered
// from its field id), maintained indexes, and writer config defaults.
let spec = LsmWriteSpec::bucket("id", 4)
let spec = MemWalWriteSpec::bucket("id", 4)
.with_maintained_indexes(vec![idx_name.clone()])
.with_writer_config_defaults([("durable_write", "false")]);
table.set_lsm_write_spec(spec.clone()).await.unwrap();
assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec));
table.enable_memwal(spec.clone()).await.unwrap();
assert_eq!(table.get_memwal_spec().await.unwrap(), Some(spec));
// After unset, no spec is reported.
table.unset_lsm_write_spec().await.unwrap();
assert_eq!(table.get_lsm_write_spec().await.unwrap(), None);
table.disable_memwal().await.unwrap();
assert_eq!(table.get_memwal_spec().await.unwrap(), None);
// Identity sharding round-trips (column recovered from the schema).
// A spec left at its default round-trips as the default: what is stored
// is the intent to maintain everything, not the set it resolves to now.
let spec = LsmWriteSpec::identity("region");
table.set_lsm_write_spec(spec.clone()).await.unwrap();
assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec));
table.unset_lsm_write_spec().await.unwrap();
let spec = MemWalWriteSpec::identity("region");
table.enable_memwal(spec.clone()).await.unwrap();
assert_eq!(table.get_memwal_spec().await.unwrap(), Some(spec));
table.disable_memwal().await.unwrap();
// Unsharded round-trips (no routing column).
let spec = LsmWriteSpec::unsharded();
table.set_lsm_write_spec(spec.clone()).await.unwrap();
assert_eq!(table.get_lsm_write_spec().await.unwrap(), Some(spec));
let spec = MemWalWriteSpec::unsharded();
table.enable_memwal(spec.clone()).await.unwrap();
assert_eq!(table.get_memwal_spec().await.unwrap(), Some(spec));
}
/// The maintained set defaults to every index on the table. A named index
/// the memtable cannot build fails the install, because the caller asked
/// for it; an unnamed one is skipped, because it arrived by existing.
#[tokio::test]
async fn test_set_lsm_write_spec_infers_maintained_indexes() {
async fn test_enable_memwal_infers_maintained_indexes() {
let tmp_dir = tempdir().unwrap();
let uri = tmp_dir.path().to_str().unwrap();
@@ -5839,8 +5842,9 @@ mod tests {
// Explicitly naming the bitmap index fails before anything commits.
let err = table
.set_lsm_write_spec(
LsmWriteSpec::unsharded().with_maintained_indexes(vec!["tag_bitmap".to_string()]),
.enable_memwal(
MemWalWriteSpec::unsharded()
.with_maintained_indexes(vec!["tag_bitmap".to_string()]),
)
.await
.unwrap_err();
@@ -5848,18 +5852,18 @@ mod tests {
matches!(err, Error::InvalidInput { ref message } if message.contains("tag_bitmap")),
"expected the bitmap index to be rejected, got {err:?}"
);
assert_eq!(table.get_lsm_write_spec().await.unwrap(), None);
assert_eq!(table.get_memwal_spec().await.unwrap(), None);
// The default names nothing, so the bitmap is skipped rather than
// refusing the table: it is maintained by existing, not by being asked
// for. The intent is what is stored, not the set it resolves to today.
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
assert_eq!(
table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.unwrap()
@@ -5870,16 +5874,16 @@ mod tests {
// Narrowing to a named subset: unset first, since an installed spec
// cannot be set over.
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
table
.set_lsm_write_spec(
LsmWriteSpec::unsharded().with_maintained_indexes(vec!["id_btree".to_string()]),
.enable_memwal(
MemWalWriteSpec::unsharded().with_maintained_indexes(vec!["id_btree".to_string()]),
)
.await
.unwrap();
assert_eq!(
table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.unwrap()
@@ -5888,14 +5892,14 @@ mod tests {
);
// Opting out entirely is distinct from the default.
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(Vec::new()))
.enable_memwal(MemWalWriteSpec::unsharded().with_maintained_indexes(Vec::new()))
.await
.unwrap();
assert_eq!(
table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.unwrap()
@@ -5906,7 +5910,7 @@ mod tests {
// An installed spec is never set over, whatever the new one asks for,
// and the refused call leaves it alone.
let err = table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 4))
.enable_memwal(MemWalWriteSpec::bucket("id", 4))
.await
.unwrap_err();
assert!(
@@ -5916,7 +5920,7 @@ mod tests {
);
assert_eq!(
table
.get_lsm_write_spec()
.get_memwal_spec()
.await
.unwrap()
.unwrap()
+29 -29
View File
@@ -1,18 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Converging a table's LSM write path into its base table.
//! Converging a table's MemWAL write path into its base table.
//!
//! `checkpoint_lsm` seals once, then triggers compaction and watches
//! generation numbers until the L0 that existed at the start is gone.
//! `checkpoint_memwal` freezes once, then triggers compaction and watches
//! generation numbers until the SSTables that existed at the start are gone.
//!
//! The loop runs in the client, not the server: `compact_lsm` dispatches a
//! The loop runs in the client, not the server: `compact_memwal` dispatches a
//! pass and returns, so nothing holds a socket and a client can vanish
//! mid-operation with nothing to reconcile. Completion is read from
//! generation numbers in the shard manifest — durable state, unlike a count
//! in a compact response, which a concurrent write invalidates.
//!
//! The target set is fixed at the start, so generations created *during* the
//! The target set is fixed at the start, so SSTables created *during* the
//! checkpoint are ignored. That is what lets it terminate under write load,
//! and what makes it best-effort: it converges the fresh tier as of some
//! instant. Idempotent, abandonable at any point, safe on a cadence.
@@ -68,7 +68,7 @@ fn is_lost_claim(e: &Error) -> bool {
status_of(e) == Some(421)
}
/// Interval between `get_lsm_stats` polls. One interval is roughly one
/// Interval between `get_memwal_stats` polls. One interval is roughly one
/// compaction pass, the granularity at which the answer can change.
///
/// Fixed rather than configurable, matching `wait_for_index`. It costs
@@ -149,15 +149,15 @@ where
}
}
/// Drive [`Table::checkpoint_lsm`]: seal once, fix the target watermark
/// from the resulting L0, then trigger and poll until it drains.
pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
/// Drive [`Table::checkpoint_memwal`]: freeze once, fix the target watermark
/// from the resulting SSTables, then trigger and poll until they drain.
pub(crate) async fn checkpoint_memwal(table: &Table) -> Result<()> {
for reissue in 0..=MAX_REISSUES {
// The seal turns everything written before this call into a
// generation, so the watermark has to be read after it. Idempotent:
// sealing an empty memtable is a no-op, so a re-issue does not churn
// empty generations.
match issue(|| table.flush_lsm()).await? {
// The freeze turns everything written before this call into an
// SSTable, so the watermark has to be read after it. Idempotent:
// freezing an empty memtable is a no-op, so a re-issue does not churn
// empty SSTables.
match issue(|| table.flush_memwal()).await? {
Attempt::Ok(()) => {}
Attempt::ReissueFromFlush => {
backoff(reissue).await;
@@ -165,7 +165,7 @@ pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
}
}
let stats = match issue(|| table.get_lsm_stats(false)).await? {
let stats = match issue(|| table.get_memwal_stats(false)).await? {
Attempt::Ok(stats) => stats,
Attempt::ReissueFromFlush => {
backoff(reissue).await;
@@ -173,13 +173,13 @@ pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
}
};
let Some(stats) = stats else {
// Not WAL-backed; `flush_lsm` would have errored first but for a race.
// Not WAL-backed; `flush_memwal` would have errored first but for a race.
return Ok(());
};
let targets: HashMap<String, u64> = stats
.buckets
.table_shards
.iter()
.filter_map(|b| Some((b.shard_id.clone(), b.newest_generation()?)))
.filter_map(|b| Some((b.shard_id.clone(), b.newest_sstable_generation()?)))
.collect();
if targets.is_empty() {
return Ok(());
@@ -194,18 +194,18 @@ pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
}
}
Err(Error::Runtime {
message: "checkpoint_lsm: the owning node kept losing its claim; \
message: "checkpoint_memwal: the owning node kept losing its claim; \
re-issued from flush the maximum number of times"
.into(),
})
}
/// Trigger and poll until no bucket holds a generation at or below its
/// Trigger and poll until no table shard holds an SSTable generation at or below its
/// target.
///
/// No liveness bound, deliberately. The pod-wide compactor pool (a semaphore
/// of 2 by default, shared across every table on the node) is taken *inside*
/// the pass, after the bucket latch, so a checkpoint queued behind unrelated
/// the pass, after the table shard latch, so a checkpoint queued behind unrelated
/// tables is indistinguishable from one that is merging. An idle-poll counter
/// here could only ever have fired on a table that would have finished.
async fn drain_to_targets(
@@ -213,24 +213,24 @@ async fn drain_to_targets(
targets: &HashMap<String, u64>,
) -> Result<CheckpointOutcome> {
loop {
let stats = match issue(|| table.get_lsm_stats(false)).await? {
let stats = match issue(|| table.get_memwal_stats(false)).await? {
Attempt::Ok(stats) => stats,
Attempt::ReissueFromFlush => return Ok(CheckpointOutcome::ReissueFromFlush),
};
let Some(stats) = stats else {
return Ok(CheckpointOutcome::Done);
};
// `compacting` is the bucket's compaction latch, held from dispatch
// `compacting` is the table shard's compaction latch, held from dispatch
// until the pass ends — including while it waits on the pod-wide
// permit. So it answers one question only: do not pile on. Buckets
// with nothing outstanding are skipped, not counted as idle.
// permit. So it answers one question only: do not pile on. Table
// shards with nothing outstanding are skipped, not counted as idle.
let mut outstanding = 0;
let mut all_compacting = true;
for b in &stats.buckets {
for b in &stats.table_shards {
let Some(target) = targets.get(&b.shard_id) else {
continue;
};
let n = b.outstanding_generations(*target);
let n = b.outstanding_sstables(*target);
if n > 0 {
outstanding += n;
all_compacting &= b.compacting;
@@ -241,11 +241,11 @@ async fn drain_to_targets(
}
if !all_compacting {
match table.compact_lsm().await {
match table.compact_memwal().await {
Ok(()) => {}
Err(e) if is_lost_claim(&e) => return Ok(CheckpointOutcome::ReissueFromFlush),
Err(e) if !is_retryable(&e) => return Err(e),
// A 429 here means the server could latch no bucket at all,
// A 429 here means the server could latch no table shard at all,
// which the poll above already handles. Not retried in place:
// the latch it would contend for is the one doing the work, so
// fall through and re-read — `POLL_INTERVAL` is the backoff.
+12 -12
View File
@@ -2197,7 +2197,7 @@ pub(crate) fn plan(schema: SchemaRef, columns: &[(String, String)]) -> Result<Ve
/// every declaration. For callers that stage declarations behind other work
/// and need those rejections before any of it lands.
///
/// Only the schema is consulted. Declaring also refuses a table with an LSM
/// Only the schema is consulted. Declaring also refuses a table with a MemWAL
/// write spec or retained SSTables; that is table state, checked at commit.
///
/// ```
@@ -3025,7 +3025,7 @@ mod tests {
/// The gate's reproducer: the reciprocal of the declare-under-spec check.
#[tokio::test]
async fn test_installing_an_lsm_spec_over_computed_columns_is_refused() {
use crate::table::LsmWriteSpec;
use crate::table::MemWalWriteSpec;
let tmp_dir = tempfile::tempdir().unwrap();
let conn = connect(tmp_dir.path().to_str().unwrap())
@@ -3053,14 +3053,14 @@ mod tests {
.unwrap();
let err = table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap_err();
assert!(
matches!(&err, Error::NotSupported { message } if message.contains("computed")),
"{err:?}"
);
assert!(table.get_lsm_write_spec().await.unwrap().is_none());
assert!(table.get_memwal_spec().await.unwrap().is_none());
}
/// The gate's reproducer: declaration metadata is admitted only through
@@ -3233,10 +3233,10 @@ mod tests {
);
}
/// Stale handles must not commit the computed/LSM state in either order.
/// Stale handles must not commit the computed/MemWAL state in either order.
#[tokio::test]
async fn test_stale_handles_cannot_mix_computed_and_lsm() {
use crate::table::LsmWriteSpec;
use crate::table::MemWalWriteSpec;
let tmp_dir = tempfile::tempdir().unwrap();
let uri = tmp_dir.path().to_str().unwrap();
@@ -3260,7 +3260,7 @@ mod tests {
.await
.unwrap();
let err = stale
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap_err();
assert!(matches!(err, Error::NotSupported { .. }), "install won");
@@ -3275,7 +3275,7 @@ mod tests {
table.set_unenforced_primary_key(["x"]).await.unwrap();
let stale = conn.open_table("mix2").execute().await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
let err = add_computed(&stale, &[("doubled".into(), "x * 2".into())])
@@ -3289,7 +3289,7 @@ mod tests {
/// flag is the durable marker; declaration refuses on it.
#[tokio::test]
async fn test_unset_with_retained_lsm_rows_cannot_admit_a_declaration() {
use crate::table::LsmWriteSpec;
use crate::table::MemWalWriteSpec;
use arrow_array::{Int64Array, RecordBatchIterator};
let tmp_dir = tempfile::tempdir().unwrap();
@@ -3316,7 +3316,7 @@ mod tests {
.unwrap();
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -3324,12 +3324,12 @@ mod tests {
merge
.when_matched_update_all(None)
.when_not_matched_insert_all()
.use_lsm(true);
.use_memwal(true);
merge
.execute(Box::new(RecordBatchIterator::new(vec![Ok(batch)], schema)))
.await
.unwrap();
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
let err = add_computed(&table, &[("doubled".into(), "value * 2".into())])
.await
+7 -7
View File
@@ -166,7 +166,7 @@ pub struct BaseTableAdapter {
table: Arc<dyn BaseTable>,
schema: Arc<ArrowSchema>,
fts_query: Option<FullTextSearchQuery>,
use_lsm: Option<bool>,
use_memwal: Option<bool>,
}
impl BaseTableAdapter {
@@ -182,13 +182,13 @@ impl BaseTableAdapter {
table,
schema: Arc::new(schema),
fts_query: None,
use_lsm: None,
use_memwal: None,
})
}
/// Set the MemWAL read routing of every scan; see [`QueryRequest::use_lsm`].
pub fn with_use_lsm(mut self, use_lsm: Option<bool>) -> Self {
self.use_lsm = use_lsm;
/// Set the MemWAL read routing of every scan; see [`QueryRequest::use_memwal`].
pub fn with_use_memwal(mut self, use_memwal: Option<bool>) -> Self {
self.use_memwal = use_memwal;
self
}
@@ -204,7 +204,7 @@ impl BaseTableAdapter {
table: self.table.clone(),
schema,
fts_query: Some(fts_query),
use_lsm: self.use_lsm,
use_memwal: self.use_memwal,
}
}
}
@@ -232,7 +232,7 @@ impl TableProvider for BaseTableAdapter {
let mut query = QueryRequest {
full_text_search: self.fts_query.clone(),
disable_scoring_autoprojection: disable_scoring,
use_lsm: self.use_lsm,
use_memwal: self.use_memwal,
..Default::default()
};
@@ -1,21 +1,21 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Live per-bucket LSM state — the shape [`crate::Table::get_lsm_stats`]
//! Live per-table-shard MemWAL state — the shape [`crate::Table::get_memwal_stats`]
//! returns and [`super::checkpoint`] polls.
//!
//! Nothing here is derived: sums and differences (total L0 bytes, WAL lag)
//! Nothing here is derived: sums and differences (total SSTable bytes, WAL lag)
//! are the caller's to compute. There is no "WAL is off" shape — that case is
//! `None`, because a struct of zeros would read as measurements.
use serde::Deserialize;
/// One flushed L0 generation.
/// One SSTable.
#[derive(Debug, Clone, Deserialize)]
pub struct GenerationStats {
pub struct SsTableStats {
pub generation: u64,
pub bytes: u64,
/// Present only when `include_generation_rows` was requested. Off by
/// Present only when `include_sstable_rows` was requested. Off by
/// default because each count opens an uncached Lance dataset, and the
/// checkpoint loop polls this route needing only generation numbers.
#[serde(default)]
@@ -34,11 +34,11 @@ pub struct MemtableStats {
pub indexes: Vec<String>,
}
/// 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
/// Live state of one table shard. A table is N table shards on one node; flattening to
/// a single number hides the one hot table shard that is usually why someone
/// opened this endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct BucketStats {
pub struct TableShardStats {
pub shard_id: String,
/// `Active` | `Sealed` (drop-table 2PC in flight).
pub status: String,
@@ -47,68 +47,68 @@ pub struct BucketStats {
pub current_generation: u64,
pub replay_after_wal_entry_position: u64,
pub wal_entry_position_last_seen: u64,
pub generations: Vec<GenerationStats>,
/// Whether a pass owns this bucket's compaction latch right now. Says *a*
pub sstables: Vec<SsTableStats>,
/// Whether a pass owns this table shard'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
/// Oldest first, active last. Absent for a `Sealed` table shard, whose
/// in-memory state is torn down.
#[serde(default)]
pub memtables: Option<Vec<MemtableStats>>,
}
impl BucketStats {
/// The newest flushed generation, or `None` when L0 is empty.
pub(crate) fn newest_generation(&self) -> Option<u64> {
self.generations.iter().map(|g| g.generation).max()
impl TableShardStats {
/// The newest SSTable generation, or `None` when the tier is empty.
pub(crate) fn newest_sstable_generation(&self) -> Option<u64> {
self.sstables.iter().map(|g| g.generation).max()
}
/// How many generations at or below `target` are still in L0.
/// How many SSTables at or below `target` are still uncompacted.
///
/// A count, not a boolean: one pass drains a bounded prefix rather than
/// the whole target set, so a boolean would read as "no progress" for
/// every pass but the last. Compaction drains oldest-first, so this
/// decreases monotonically.
pub(crate) fn outstanding_generations(&self, target: u64) -> usize {
self.generations
pub(crate) fn outstanding_sstables(&self, target: u64) -> usize {
self.sstables
.iter()
.filter(|g| g.generation <= target)
.count()
}
}
/// Live LSM state, one entry per bucket.
/// Live MemWAL state, one entry per table shard.
#[derive(Debug, Clone, Deserialize)]
pub struct LsmStats {
pub buckets: Vec<BucketStats>,
pub struct MemWalStats {
pub table_shards: Vec<TableShardStats>,
}
/// Server-side JSON envelope for `get_lsm_stats`. `lsm_stats` is null when
/// the table has no LSM write path.
/// Server-side JSON envelope for `get_memwal_stats`. `memwal_stats` is null when
/// the table has no MemWAL write path.
#[derive(Debug, Deserialize)]
pub(crate) struct GetLsmStatsResponse {
pub(crate) struct GetMemWalStatsResponse {
#[serde(default)]
pub lsm_stats: Option<LsmStats>,
pub memwal_stats: Option<MemWalStats>,
}
#[cfg(test)]
mod tests {
use super::*;
fn bucket(shard: &str, generations: &[u64], compacting: bool) -> BucketStats {
BucketStats {
fn table_shard(shard: &str, sstables: &[u64], compacting: bool) -> TableShardStats {
TableShardStats {
shard_id: shard.into(),
status: "Active".into(),
writer_epoch: 1,
manifest_version: 1,
current_generation: generations.iter().max().copied().unwrap_or(0) + 1,
current_generation: sstables.iter().max().copied().unwrap_or(0) + 1,
replay_after_wal_entry_position: 0,
wal_entry_position_last_seen: 0,
generations: generations
sstables: sstables
.iter()
.map(|g| GenerationStats {
.map(|g| SsTableStats {
generation: *g,
bytes: 1,
rows: None,
@@ -123,40 +123,46 @@ mod tests {
/// generation created after it must not hold the loop open — that is why
/// the predicate terminates under write load.
#[test]
fn newer_generations_do_not_extend_the_target() {
let start = bucket("b0", &[7, 8], false);
let target = start.newest_generation().expect("L0 is non-empty");
fn newer_sstables_do_not_extend_the_target() {
let start = table_shard("b0", &[7, 8], false);
let target = start
.newest_sstable_generation()
.expect("the SSTable tier is non-empty");
assert_eq!(target, 8);
// Compaction drained 7 and 8; 9 and 10 arrived while it ran.
let later = bucket("b0", &[9, 10], false);
let later = table_shard("b0", &[9, 10], false);
assert_eq!(
later.outstanding_generations(target),
later.outstanding_sstables(target),
0,
"generations above the target are somebody else's problem"
"sstables above the target are somebody else's problem"
);
// Still holding 8 means still outstanding.
assert_eq!(
bucket("b0", &[8, 9], false).outstanding_generations(target),
table_shard("b0", &[8, 9], false).outstanding_sstables(target),
1
);
}
/// The metric counts generations, not buckets: a pass drains a bounded
/// prefix, so one bucket going 3 → 2 → 1 → 0 is three steps.
/// The metric counts SSTables, not table shards: a pass drains a bounded
/// prefix, so one table shard going 3 → 2 → 1 → 0 is three steps.
#[test]
fn progress_is_measured_in_generations() {
fn progress_is_measured_in_sstables() {
let target = 3;
let counts: Vec<usize> = [&[1u64, 2, 3][..], &[2, 3][..], &[3][..], &[][..]]
.iter()
.map(|gens| bucket("b0", gens, false).outstanding_generations(target))
.map(|gens| table_shard("b0", gens, false).outstanding_sstables(target))
.collect();
assert_eq!(counts, vec![3, 2, 1, 0]);
}
#[test]
fn empty_l0_has_no_target() {
assert!(bucket("b0", &[], false).newest_generation().is_none());
fn an_empty_sstable_tier_has_no_target() {
assert!(
table_shard("b0", &[], false)
.newest_sstable_generation()
.is_none()
);
}
}
+99 -98
View File
@@ -47,7 +47,7 @@ pub struct MergeResult {
/// Total number of rows written.
///
/// On the standard `merge_insert` path this equals
/// `num_inserted_rows + num_updated_rows`. On the MemWAL LSM write path the
/// `num_inserted_rows + num_updated_rows`. On the MemWAL write path the
/// insert/update breakdown is not known until compaction; in that mode
/// `num_inserted_rows`, `num_updated_rows`, `num_deleted_rows`, `version`
/// and `num_attempts` are all `0` and this field holds the total number of
@@ -76,7 +76,7 @@ pub struct MergeInsertBuilder {
pub(crate) when_not_matched_by_source_delete_filt: Option<MergeFilter>,
pub(crate) timeout: Option<Duration>,
pub(crate) use_index: bool,
pub(crate) use_lsm: Option<bool>,
pub(crate) use_memwal: Option<bool>,
pub(crate) validate_single_shard: bool,
}
@@ -92,7 +92,7 @@ impl MergeInsertBuilder {
when_not_matched_by_source_delete_filt: None,
timeout: None,
use_index: true,
use_lsm: None,
use_memwal: None,
validate_single_shard: true,
}
}
@@ -193,27 +193,27 @@ impl MergeInsertBuilder {
/// Control MemWAL routing for this `merge_insert`.
///
/// By default (unset), a `merge_insert` on a table with an
/// [`LsmWriteSpec`](super::LsmWriteSpec) installed is routed through Lance's
/// [`MemWalWriteSpec`](super::MemWalWriteSpec) installed is routed through Lance's
/// MemWAL shard writer; a table without one uses the standard path.
///
/// - `use_lsm(true)` forces MemWAL routing and errors if the table has no
/// LSM write spec.
/// - `use_lsm(false)` forces the standard write path even when a spec is set.
pub fn use_lsm(&mut self, enable: bool) -> &mut Self {
self.use_lsm = Some(enable);
/// - `use_memwal(true)` forces MemWAL routing and errors if the table has no
/// MemWAL write spec.
/// - `use_memwal(false)` forces the standard write path even when a spec is set.
pub fn use_memwal(&mut self, enable: bool) -> &mut Self {
self.use_memwal = Some(enable);
self
}
/// Controls how an LSM `merge_insert` checks that its input targets a
/// single shard.
///
/// When a table has an LSM write spec, every row in a `merge_insert` call
/// When a table has a MemWAL write spec, every row in a `merge_insert` call
/// must route to the same shard. When `true` (the default), every row is
/// inspected to verify this. When `false`, only the first row is inspected
/// and the shard it routes to is used for the whole input — a faster path
/// for callers that have already pre-sharded their input.
///
/// Has no effect on tables without an LSM write spec.
/// Has no effect on tables without a MemWAL write spec.
pub fn validate_single_shard(&mut self, validate_single_shard: bool) -> &mut Self {
self.validate_single_shard = validate_single_shard;
self
@@ -890,7 +890,7 @@ mod lsm_tests {
use crate::connect;
use crate::error::Error;
use crate::table::{LsmWriteSpec, Table};
use crate::table::{MemWalWriteSpec, Table};
/// A reader of `[id: Int64, value: Int64]` rows; `value` is `0..n`.
fn id_value_reader(ids: Vec<i64>) -> Box<dyn RecordBatchReader + Send> {
@@ -974,7 +974,7 @@ mod lsm_tests {
let table = id_value_table(&dir).await;
// num_buckets = 1: every row routes to the single bucket.
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 1))
.enable_memwal(MemWalWriteSpec::bucket("id", 1))
.await
.unwrap();
@@ -1001,7 +1001,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -1030,7 +1030,7 @@ mod lsm_tests {
.unwrap();
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::identity("region"))
.enable_memwal(MemWalWriteSpec::identity("region"))
.await
.unwrap();
@@ -1047,18 +1047,18 @@ mod lsm_tests {
}
#[tokio::test]
async fn lsm_merge_insert_use_lsm_false_falls_back() {
async fn lsm_merge_insert_use_memwal_false_falls_back() {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 1))
.enable_memwal(MemWalWriteSpec::bucket("id", 1))
.await
.unwrap();
// use_lsm(false) opts out: the standard path runs and commits even though
// use_memwal(false) opts out: the standard path runs and commits even though
// a spec is installed.
let mut builder = table.merge_insert(&["id"]);
builder.when_not_matched_insert_all().use_lsm(false);
builder.when_not_matched_insert_all().use_memwal(false);
let result = builder
.execute(id_value_reader(vec![3, 4, 5]))
.await
@@ -1069,17 +1069,17 @@ mod lsm_tests {
}
#[tokio::test]
async fn lsm_merge_insert_use_lsm_true_without_spec_errors() {
async fn lsm_merge_insert_use_memwal_true_without_spec_errors() {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
// use_lsm(true) demands MemWAL routing; without a write spec it errors
// use_memwal(true) demands MemWAL routing; without a write spec it errors
// rather than silently falling back to the standard path.
let mut builder = table.merge_insert(&["id"]);
builder
.when_matched_update_all(None)
.when_not_matched_insert_all()
.use_lsm(true);
.use_memwal(true);
let err = builder
.execute(id_value_reader(vec![3, 4, 5]))
.await
@@ -1092,7 +1092,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 1))
.enable_memwal(MemWalWriteSpec::bucket("id", 1))
.await
.unwrap();
@@ -1109,7 +1109,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 1))
.enable_memwal(MemWalWriteSpec::bucket("id", 1))
.await
.unwrap();
@@ -1125,7 +1125,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 1))
.enable_memwal(MemWalWriteSpec::bucket("id", 1))
.await
.unwrap();
@@ -1135,7 +1135,7 @@ mod lsm_tests {
.when_not_matched_insert_all();
builder.execute(id_value_reader(vec![7, 8])).await.unwrap();
table.close_lsm_writers().await.unwrap();
table.close_memwal_writers().await.unwrap();
// The writer reopens lazily on the next merge_insert.
let mut builder = table.merge_insert(&[]);
@@ -1160,7 +1160,7 @@ mod lsm_tests {
.unwrap();
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::identity("region"))
.enable_memwal(MemWalWriteSpec::identity("region"))
.await
.unwrap();
@@ -1197,10 +1197,10 @@ mod lsm_tests {
#[tokio::test]
async fn lsm_merge_insert_no_spec_uses_standard_path() {
let dir = tempdir().unwrap();
// id_value_table sets a primary key but no LSM write spec.
// id_value_table sets a primary key but no MemWAL write spec.
let table = id_value_table(&dir).await;
// Without a spec, a default merge_insert (use_lsm unset) simply uses
// Without a spec, a default merge_insert (use_memwal unset) simply uses
// the standard path and commits — no opt-out required, no error.
let mut builder = table.merge_insert(&["id"]);
builder
@@ -1228,7 +1228,7 @@ mod lsm_tests {
.unwrap();
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::identity("region"))
.enable_memwal(MemWalWriteSpec::identity("region"))
.await
.unwrap();
@@ -1254,7 +1254,7 @@ mod lsm_tests {
assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}");
// After closing the writer, a different shard can be written.
table.close_lsm_writers().await.unwrap();
table.close_memwal_writers().await.unwrap();
let mut builder = table.merge_insert(&[]);
builder
.when_matched_update_all(None)
@@ -1266,7 +1266,7 @@ mod lsm_tests {
}
// ---------------------------------------------------------------------
// LSM read path
// MemWAL read path
// ---------------------------------------------------------------------
use crate::arrow::SendableRecordBatchStream;
@@ -1325,7 +1325,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await; // base: ids 1,2,3 (value 0,1,2)
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -1340,8 +1340,8 @@ mod lsm_tests {
vec![1, 2, 3, 4, 5]
);
// use_lsm(false) bypasses the MemWAL and reads the base table only.
let base_only = table.query().use_lsm(false).execute().await.unwrap();
// use_memwal(false) bypasses the MemWAL and reads the base table only.
let base_only = table.query().use_memwal(false).execute().await.unwrap();
let rows = collect_id_value(base_only).await;
assert_eq!(
rows.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
@@ -1350,14 +1350,14 @@ mod lsm_tests {
}
#[tokio::test]
async fn base_table_adapter_use_lsm_false_scans_the_base_only() {
async fn base_table_adapter_use_memwal_false_scans_the_base_only() {
use crate::table::datafusion::BaseTableAdapter;
use datafusion::prelude::SessionContext;
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await; // base: ids 1,2,3
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
// Ids 4,5 land in the active memtable, not the base table.
@@ -1366,7 +1366,7 @@ mod lsm_tests {
let adapter = BaseTableAdapter::try_new(table.base_table().clone())
.await
.unwrap()
.with_use_lsm(Some(false));
.with_use_memwal(Some(false));
let ctx = SessionContext::new();
ctx.register_table("base_only", Arc::new(adapter)).unwrap();
let batches = ctx
@@ -1389,7 +1389,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
lsm_upsert(&table, vec![4, 5]).await;
@@ -1407,7 +1407,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
lsm_upsert(&table, vec![4]).await;
@@ -1427,7 +1427,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await; // base: id 2 -> value 1
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -1445,7 +1445,7 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
lsm_upsert(&table, vec![2, 3, 4]).await; // id 2 -> value 0 (shadows base)
@@ -1460,14 +1460,14 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::bucket("id", 8))
.enable_memwal(MemWalWriteSpec::bucket("id", 8))
.await
.unwrap();
// Two single-row upserts that route to (likely) different buckets; each
// Two single-row upserts that route to (likely) different shards; each
// closes the writer so the next opens a fresh shard.
lsm_upsert(&table, vec![10]).await;
table.close_lsm_writers().await.unwrap();
table.close_memwal_writers().await.unwrap();
lsm_upsert(&table, vec![11]).await;
let lsm = table.query().execute().await.unwrap();
@@ -1482,14 +1482,14 @@ mod lsm_tests {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
lsm_upsert(&table, vec![4, 5]).await;
// close flushes the active memtable to an on-disk generation and drops
// the cached writer; the read must still see those rows via the shard
// manifest snapshot.
table.close_lsm_writers().await.unwrap();
table.close_memwal_writers().await.unwrap();
let lsm = table.query().execute().await.unwrap();
let ids: Vec<i64> = collect_id_value(lsm)
@@ -1503,11 +1503,11 @@ mod lsm_tests {
#[tokio::test]
async fn lsm_read_without_spec_reads_base() {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await; // no LSM write spec
let table = id_value_table(&dir).await; // no MemWAL write spec
// With no spec installed there is nothing to route: the default read and
// an explicit use_lsm(false) both read the base table without error.
for query in [table.query(), table.query().use_lsm(false)] {
// an explicit use_memwal(false) both read the base table without error.
for query in [table.query(), table.query().use_memwal(false)] {
let rows = collect_id_value(query.execute().await.unwrap()).await;
assert_eq!(
rows.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
@@ -1517,11 +1517,11 @@ mod lsm_tests {
}
#[tokio::test]
async fn lsm_read_unsupported_shape_errors_without_use_lsm_false() {
async fn lsm_read_unsupported_shape_errors_without_use_memwal_false() {
let dir = tempdir().unwrap();
let table = id_value_table(&dir).await;
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
lsm_upsert(&table, vec![4]).await;
@@ -1538,12 +1538,12 @@ mod lsm_tests {
.expect("unsupported shape on a MemWAL table must error");
assert!(matches!(err, Error::NotSupported { .. }), "got {err:?}");
// use_lsm(false) is the escape hatch: it reads the base table only.
// use_memwal(false) is the escape hatch: it reads the base table only.
let rows = collect_id_value(
table
.query()
.with_row_id()
.use_lsm(false)
.use_memwal(false)
.execute()
.await
.unwrap(),
@@ -1555,7 +1555,7 @@ mod lsm_tests {
);
}
/// A table with `id`/`text` and a primary key, ready for an LSM write spec.
/// A table with `id`/`text` and a primary key, ready for a MemWAL write spec.
async fn lsm_text_table(dir: &tempfile::TempDir) -> crate::Table {
let conn = connect(dir.path().to_str().unwrap())
.execute()
@@ -1570,7 +1570,7 @@ mod lsm_tests {
table
}
/// Upsert `rows` through the LSM write path.
/// Upsert `rows` through the MemWAL write path.
async fn upsert_text(table: &crate::Table, rows: Vec<(i64, &str)>) {
let mut builder = table.merge_insert(&[]);
builder
@@ -1614,7 +1614,7 @@ mod lsm_tests {
let table = lsm_text_table(&dir).await;
// The default set, on a table with no FTS index yet.
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -1648,7 +1648,7 @@ mod lsm_tests {
/// A spec is replaced by unsetting it and setting the new one.
///
/// `unset_lsm_write_spec` drains the open writer, so the rows it held
/// `disable_memwal` drains the open writer, so the rows it held
/// survive the replacement, and the writer the next write opens builds its
/// MemTables from the new set.
#[tokio::test]
@@ -1668,15 +1668,15 @@ mod lsm_tests {
// Installed maintaining nothing, then written to: the writer opens with
// no FTS index.
table
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(Vec::new()))
.enable_memwal(MemWalWriteSpec::unsharded().with_maintained_indexes(Vec::new()))
.await
.unwrap();
upsert_text(&table, vec![(99, "zebra")]).await;
// Setting over an installed spec is refused; unset, then set.
let err = table
.set_lsm_write_spec(
LsmWriteSpec::unsharded().with_maintained_indexes(vec![fts_index.clone()]),
.enable_memwal(
MemWalWriteSpec::unsharded().with_maintained_indexes(vec![fts_index.clone()]),
)
.await
.expect_err("an installed spec cannot be set over");
@@ -1684,9 +1684,9 @@ mod lsm_tests {
err.to_string().contains("already set"),
"unexpected error: {err}"
);
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![fts_index]))
.enable_memwal(MemWalWriteSpec::unsharded().with_maintained_indexes(vec![fts_index]))
.await
.unwrap();
@@ -1716,7 +1716,7 @@ mod lsm_tests {
/// A row written before the index exists is searchable once it does.
///
/// The MemTable holding it was built without the index. Creating the index
/// replaces the writer's configs, which seals that MemTable and waits for
/// replaces the writer's configs, which freezes that MemTable and waits for
/// its flush, so no resident MemTable is left that cannot answer the read.
#[tokio::test]
async fn lsm_a_row_written_before_the_index_is_searchable_after_it() {
@@ -1727,7 +1727,7 @@ mod lsm_tests {
let table = lsm_text_table(&dir).await;
// No index yet, and the default set: maintain whatever the table has.
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -1783,7 +1783,7 @@ mod lsm_tests {
.unwrap();
let fts_index = table.list_indices().await.unwrap()[0].name.clone();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![fts_index]))
.enable_memwal(MemWalWriteSpec::unsharded().with_maintained_indexes(vec![fts_index]))
.await
.unwrap();
@@ -1878,7 +1878,7 @@ mod lsm_tests {
}
let vec_index = table.list_indices().await.unwrap()[0].name.clone();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![vec_index]))
.enable_memwal(MemWalWriteSpec::unsharded().with_maintained_indexes(vec![vec_index]))
.await
.unwrap();
@@ -2129,8 +2129,8 @@ mod lsm_tests {
}
/// A kind Lance has no memtable index for is refused when named until the
/// table's registry maintains it; then it is maintained, flushed with each
/// generation, and answers searches on the flushed generations.
/// table's registry maintains it; then it is maintained, flushed into each
/// SSTable, and answers searches on the SSTables.
#[tokio::test]
async fn lsm_maintains_an_index_kind_from_the_tables_registry() {
let dir = tempdir().unwrap();
@@ -2150,10 +2150,11 @@ mod lsm_tests {
.execute()
.await
.unwrap();
let spec =
|| LsmWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()]);
let spec = || {
MemWalWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()])
};
let refused = table.set_lsm_write_spec(spec()).await.unwrap_err();
let refused = table.enable_memwal(spec()).await.unwrap_err();
assert!(
refused.to_string().contains("region_bitmap")
&& refused
@@ -2166,7 +2167,7 @@ mod lsm_tests {
.with_plugin(Arc::new(BitmapAsBTree))
.unwrap();
table.as_native().unwrap().set_mem_index_registry(registry);
table.set_lsm_write_spec(spec()).await.unwrap();
table.enable_memwal(spec()).await.unwrap();
// The registry is not stored with the table.
let other = conn.open_table("t").execute().await.unwrap();
@@ -2224,13 +2225,13 @@ mod lsm_tests {
active.index_store.index_names()
);
table.close_lsm_writers().await.unwrap();
table.close_memwal_writers().await.unwrap();
assert_eq!(fresh().await, vec![10, 11]);
assert_generations_answer_through_region_bitmap(&table, vec![10, 11]).await;
assert_sstables_answer_through_region_bitmap(&table, vec![10, 11]).await;
}
/// The generations each shard's manifest publishes.
async fn published_generations(table: &Table) -> Vec<lance::Dataset> {
/// The SSTables each shard's manifest publishes.
async fn published_sstables(table: &Table) -> Vec<lance::Dataset> {
use lance::dataset::mem_wal::ShardManifestStore;
let uri = table.uri().await.unwrap();
@@ -2238,7 +2239,7 @@ mod lsm_tests {
.await
.unwrap();
let mem_wal = std::path::Path::new(&uri).join("_mem_wal");
let mut generations = Vec::new();
let mut sstables = Vec::new();
for shard in std::fs::read_dir(&mem_wal).unwrap() {
let Ok(shard_id) = shard.unwrap().file_name().to_string_lossy().parse() else {
continue;
@@ -2250,30 +2251,30 @@ mod lsm_tests {
.expect("a shard directory has a manifest");
for sstable in manifest.sstables {
let path = mem_wal.join(shard_id.to_string()).join(&sstable.path);
generations.push(lance::Dataset::open(path.to_str().unwrap()).await.unwrap());
sstables.push(lance::Dataset::open(path.to_str().unwrap()).await.unwrap());
}
}
generations
sstables
}
/// Every published generation carries `region_bitmap` as a bitmap index on
/// Every published SSTable carries `region_bitmap` as a bitmap index on
/// `region`, and a search for `region = 'fresh'` on it uses that index.
async fn assert_generations_answer_through_region_bitmap(table: &Table, fresh: Vec<i64>) {
async fn assert_sstables_answer_through_region_bitmap(table: &Table, fresh: Vec<i64>) {
use lance::index::DatasetIndexExt;
let generations = published_generations(table).await;
let sstables = published_sstables(table).await;
assert!(
!generations.is_empty(),
"closing the writer flushed a generation"
!sstables.is_empty(),
"closing the writer flushed an SSTable"
);
let mut found = Vec::new();
for generation in generations {
let region = generation.schema().field("region").unwrap().id;
let indices = generation.load_indices().await.unwrap();
for sstable in sstables {
let region = sstable.schema().field("region").unwrap().id;
let indices = sstable.load_indices().await.unwrap();
let index = indices
.iter()
.find(|index| index.name == "region_bitmap")
.expect("the generation carries the index the plugin trained");
.expect("the SSTable carries the index the plugin trained");
assert_eq!(index.fields, vec![region]);
assert!(
index
@@ -2284,7 +2285,7 @@ mod lsm_tests {
index.index_details
);
let mut scan = generation.scan();
let mut scan = sstable.scan();
scan.filter("region = 'fresh'").unwrap();
let plan = scan.explain_plan(false).await.unwrap();
assert!(plan.contains("ScalarIndexQuery"), "{plan}");
@@ -2332,7 +2333,7 @@ mod lsm_tests {
"a clone shares the handle's registry"
);
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
@@ -2375,13 +2376,13 @@ mod lsm_tests {
active.index_store.index_names()
);
table.close_lsm_writers().await.unwrap();
// Only the generation written after the index exists carries it.
let generations = published_generations(&table).await;
table.close_memwal_writers().await.unwrap();
// Only the SSTable written after the index exists carries it.
let sstables = published_sstables(&table).await;
let mut carrying = 0;
for generation in &generations {
for sstable in &sstables {
use lance::index::DatasetIndexExt;
if generation
if sstable
.load_indices()
.await
.unwrap()
@@ -2393,8 +2394,8 @@ mod lsm_tests {
}
assert!(
carrying >= 1,
"{} generations, none carry the index",
generations.len()
"{} SSTables, none carry the index",
sstables.len()
);
}
}
+59 -59
View File
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! MemWAL LSM write path for `merge_insert`.
//! MemWAL write path for `merge_insert`.
//!
//! [`set_lsm_write_spec`] installs an [`LsmWriteSpec`] on a table by creating
//! Lance's MemWAL index; [`unset_lsm_write_spec`] removes it. Once a spec is
//! [`enable_memwal`] installs an [`MemWalWriteSpec`] on a table by creating
//! Lance's MemWAL index; [`disable_memwal`] removes it. Once a spec is
//! installed, `merge_insert` upsert calls are dispatched through Lance's MemWAL
//! `ShardWriter` (LSM-style append) instead of the standard merge path — see
//! [`lsm_dispatch_decision`] and [`execute_lsm_merge_insert`].
@@ -14,7 +14,7 @@
//! unsharded). [`MergeInsertBuilder::validate_single_shard`] controls whether
//! every row is checked or only the first. A dataset writes to one shard at a
//! time; its writer is cached in the [`ShardWriterCache`] held alongside the
//! dataset, and [`close_lsm_writers`] closes it.
//! dataset, and [`close_memwal_writers`] closes it.
use std::collections::HashMap;
use std::sync::Arc;
@@ -40,9 +40,9 @@ use uuid::Uuid;
use crate::error::{Error, Result};
use crate::index::IndexConfig;
use crate::table::merge::{MergeInsertBuilder, MergeResult};
use crate::table::{BaseTable, LsmWriteSpec, NativeTable};
use crate::table::{BaseTable, MemWalWriteSpec, NativeTable};
/// Spec id of the sole sharding spec installed by [`set_lsm_write_spec`].
/// Spec id of the sole sharding spec installed by [`enable_memwal`].
/// Must match Lance's `InitializeMemWalBuilder` (`SHARDING_SPEC_ID`).
const SHARDING_SPEC_ID: u32 = 1;
@@ -61,23 +61,23 @@ const NUM_BUCKETS_PARAM: &str = "num_buckets";
const SHARD_NAMESPACE: Uuid = Uuid::from_u128(0x4c53_4d57_5249_5445_5f53_4841_5244_3031);
// =============================================================================
// set_lsm_write_spec
// enable_memwal
// =============================================================================
/// Install an [`LsmWriteSpec`] on the table.
/// Install an [`MemWalWriteSpec`] on the table.
///
/// The bucket / identity / unsharded sharding spec is constructed and validated
/// by Lance's
/// [`InitializeMemWalBuilder`](lance::dataset::mem_wal::InitializeMemWalBuilder).
#[allow(clippy::redundant_pub_crate)]
pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec) -> Result<()> {
pub(crate) async fn enable_memwal(table: &NativeTable, spec: MemWalWriteSpec) -> Result<()> {
table.dataset.ensure_mutable()?;
{
let dataset = table.dataset.get().await?;
if dataset.mem_wal_index_details().await?.is_some() {
return Err(Error::InvalidInput {
message: "set_lsm_write_spec: an LSM write spec is already set on this \
message: "enable_memwal: a MemWAL write spec is already set on this \
table and cannot be changed"
.into(),
});
@@ -115,14 +115,14 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
let schema = arrow_schema::Schema::from(dataset.schema());
if !crate::table::computed_columns::computed_columns(&schema).is_empty() {
return Err(Error::NotSupported {
message: "an LSM write spec cannot be installed on a table with computed \
message: "a MemWAL write spec cannot be installed on a table with computed \
columns: rows in un-compacted tiers are invisible to refresh"
.into(),
});
}
if crate::materialized_view::read_definition(&dataset.schema().metadata)?.is_some() {
return Err(Error::NotSupported {
message: "an LSM write spec cannot be installed on a materialized view: \
message: "a MemWAL write spec cannot be installed on a materialized view: \
rows in un-compacted tiers are invisible to refresh"
.into(),
});
@@ -131,7 +131,7 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
.initialize_mem_wal()
.mem_index_registry(table.mem_index_registry());
let writer_config_defaults = match spec {
LsmWriteSpec::Bucket {
MemWalWriteSpec::Bucket {
column,
num_buckets,
writer_config_defaults,
@@ -140,7 +140,7 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
builder = builder.bucket_sharding(column, num_buckets);
writer_config_defaults
}
LsmWriteSpec::Identity {
MemWalWriteSpec::Identity {
column,
writer_config_defaults,
..
@@ -148,7 +148,7 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
builder = builder.identity_sharding(column);
writer_config_defaults
}
LsmWriteSpec::Unsharded {
MemWalWriteSpec::Unsharded {
writer_config_defaults,
..
} => {
@@ -178,22 +178,22 @@ fn index_name_list(indices: &[IndexConfig]) -> String {
}
// =============================================================================
// unset_lsm_write_spec
// disable_memwal
// =============================================================================
/// Remove the [`LsmWriteSpec`] from the table by dropping the MemWAL index.
/// Remove the [`MemWalWriteSpec`] from the table by dropping the MemWAL index.
///
/// Any cached shard writers are drained and closed first. Errors if no spec is
/// currently set.
#[allow(clippy::redundant_pub_crate)]
pub(crate) async fn unset_lsm_write_spec(table: &NativeTable) -> Result<()> {
pub(crate) async fn disable_memwal(table: &NativeTable) -> Result<()> {
table.dataset.ensure_mutable()?;
{
let dataset = table.dataset.get().await?;
if dataset.mem_wal_index_details().await?.is_none() {
return Err(Error::InvalidInput {
message: "unset_lsm_write_spec: no LSM write spec is set on this table".into(),
message: "disable_memwal: no MemWAL write spec is set on this table".into(),
});
}
}
@@ -209,46 +209,46 @@ pub(crate) async fn unset_lsm_write_spec(table: &NativeTable) -> Result<()> {
}
// =============================================================================
// get_lsm_write_spec
// get_memwal_spec
// =============================================================================
/// Read the [`LsmWriteSpec`] currently installed on the table, if any.
/// Read the [`MemWalWriteSpec`] currently installed on the table, if any.
///
/// Reconstructs the spec from the MemWAL index's stored details — the same
/// metadata [`set_lsm_write_spec`] writes — read directly via
/// metadata [`enable_memwal`] writes — read directly via
/// [`mem_wal_index_details`](DatasetMemWalExt::mem_wal_index_details) (the raw
/// manifest record, not the `describe_indices` enrichment path, so it is
/// unaffected by system-index filtering there). Returns `Ok(None)` when no
/// MemWAL index is installed.
#[allow(clippy::redundant_pub_crate)]
pub(crate) async fn get_lsm_write_spec(table: &NativeTable) -> Result<Option<LsmWriteSpec>> {
pub(crate) async fn get_memwal_spec(table: &NativeTable) -> Result<Option<MemWalWriteSpec>> {
let dataset = table.dataset.get().await?;
let Some(details) = dataset.mem_wal_index_details().await? else {
return Ok(None);
};
let spec = lsm_write_spec_from_details(&details, dataset.schema())?;
let spec = memwal_spec_from_details(&details, dataset.schema())?;
Ok(Some(spec))
}
/// Reconstruct the public [`LsmWriteSpec`] from stored MemWAL index details.
/// Reconstruct the public [`MemWalWriteSpec`] from stored MemWAL index details.
///
/// The sharding transform and its parameters recover the mode; the routing
/// column is resolved from the sharding field's source id back to its schema
/// name (only bucket / identity carry a column). `maintained_indexes` and
/// `writer_config_defaults` are copied verbatim, so the result round-trips
/// what `set_lsm_write_spec` installed.
fn lsm_write_spec_from_details(
/// what `enable_memwal` installed.
fn memwal_spec_from_details(
details: &MemWalIndexDetails,
schema: &LanceSchema,
) -> Result<LsmWriteSpec> {
) -> Result<MemWalWriteSpec> {
let spec = details
.sharding_specs
.first()
.ok_or_else(|| Error::Runtime {
message: "get_lsm_write_spec: MemWAL index has no sharding spec".to_string(),
message: "get_memwal_spec: MemWAL index has no sharding spec".to_string(),
})?;
let field = spec.fields.first().ok_or_else(|| Error::Runtime {
message: "get_lsm_write_spec: MemWAL index has an empty sharding spec".to_string(),
message: "get_memwal_spec: MemWAL index has an empty sharding spec".to_string(),
})?;
let base = match field.transform.as_deref() {
@@ -259,16 +259,16 @@ fn lsm_write_spec_from_details(
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
.ok_or_else(|| Error::Runtime {
message: "get_lsm_write_spec: MemWAL bucket spec has a missing or invalid num_buckets parameter".to_string(),
message: "get_memwal_spec: MemWAL bucket spec has a missing or invalid num_buckets parameter".to_string(),
})?;
LsmWriteSpec::bucket(sharding_column(field, schema)?, num_buckets)
MemWalWriteSpec::bucket(sharding_column(field, schema)?, num_buckets)
}
Some(IDENTITY_TRANSFORM) => LsmWriteSpec::identity(sharding_column(field, schema)?),
Some(UNSHARDED_TRANSFORM) => LsmWriteSpec::unsharded(),
Some(IDENTITY_TRANSFORM) => MemWalWriteSpec::identity(sharding_column(field, schema)?),
Some(UNSHARDED_TRANSFORM) => MemWalWriteSpec::unsharded(),
other => {
return Err(Error::Runtime {
message: format!(
"get_lsm_write_spec: MemWAL index has an unsupported sharding transform {:?}",
"get_memwal_spec: MemWAL index has an unsupported sharding transform {:?}",
other
),
});
@@ -284,30 +284,30 @@ fn lsm_write_spec_from_details(
/// Resolve the single routing column name from a sharding field's source id.
///
/// `set_lsm_write_spec` records the shard column by its Lance field id, so the
/// `enable_memwal` records the shard column by its Lance field id, so the
/// name is recovered by looking that id up in the current schema.
fn sharding_column(field: &ShardingField, schema: &LanceSchema) -> Result<String> {
let source_id = *field.source_ids.first().ok_or_else(|| Error::Runtime {
message: "get_lsm_write_spec: sharding field has no source column".to_string(),
message: "get_memwal_spec: sharding field has no source column".to_string(),
})?;
schema
.field_by_id(source_id)
.map(|f| f.name.clone())
.ok_or_else(|| Error::Runtime {
message: format!(
"get_lsm_write_spec: sharding source field id {} not found in schema",
"get_memwal_spec: sharding source field id {} not found in schema",
source_id
),
})
}
// =============================================================================
// close_lsm_writers
// close_memwal_writers
// =============================================================================
/// Drain and close every cached MemWAL shard writer for the table.
#[allow(clippy::redundant_pub_crate)]
pub(crate) async fn close_lsm_writers(table: &NativeTable) -> Result<()> {
pub(crate) async fn close_memwal_writers(table: &NativeTable) -> Result<()> {
table.dataset.shard_writer().drain_and_close().await
}
@@ -321,7 +321,7 @@ pub(crate) async fn close_lsm_writers(table: &NativeTable) -> Result<()> {
/// so the writer lives where the dataset lives — cached for the session and
/// reused across `merge_insert` calls. A dataset writes to one shard at a
/// time; routing a `merge_insert` to a different shard requires closing the
/// current writer first via [`close_lsm_writers`]. `ShardWriter::put` takes
/// current writer first via [`close_memwal_writers`]. `ShardWriter::put` takes
/// `&self`, so concurrent puts on the cached writer are safe; `close` consumes
/// the writer, so the entry wraps it in `RwLock<Option<ShardWriter>>`.
#[derive(Default)]
@@ -370,8 +370,8 @@ impl ShardWriterEntry {
}
/// The cached writer's latest in-memory manifest (current generation +
/// flushed generations). `Ok(None)` if the writer was already closed.
/// Used by the LSM read path to snapshot this shard authoritatively
/// SSTables). `Ok(None)` if the writer was already closed.
/// Used by the MemWAL read path to snapshot this shard authoritatively
/// without re-reading the on-disk manifest.
async fn manifest(&self) -> Result<Option<lance_index::mem_wal::ShardManifest>> {
let guard = self.inner.read().await;
@@ -449,7 +449,7 @@ impl ShardWriterCache {
/// writer opened afterwards reads the committed set.
///
/// Holds the entry's read lock across the refresh, so a concurrent close
/// waits rather than retiring the writer mid-seal. Writes take the same
/// waits rather than retiring the writer mid-freeze. Writes take the same
/// lock first, so the order matches theirs.
pub(crate) async fn refresh_maintained_indexes(&self, dataset: &Dataset) {
let entry = {
@@ -463,13 +463,13 @@ impl ShardWriterCache {
let Some(writer) = guard.as_ref() else {
return;
};
// Replacing the configs seals the active memtable so the next one carries
// Replacing the configs freezes the active memtable so the next one carries
// the new indexes. The outgoing one keeps the set it was built with, so
// its flush has to land before a read: while it is still frozen, an
// indexed read sees a resident memtable that cannot answer and is
// refused.
let sealed = match dataset.refresh_mem_wal_index_configs(writer).await {
Ok(sealed) => sealed,
let frozen = match dataset.refresh_mem_wal_index_configs(writer).await {
Ok(frozen) => frozen,
Err(error) => {
log::warn!(
"the maintained index set changed but the open shard writer could \
@@ -478,18 +478,18 @@ impl ShardWriterCache {
return;
}
};
if let Some(fence) = sealed
if let Some(fence) = frozen
&& let Err(error) = fence.wait().await
{
log::warn!(
"the shard writer took the new maintained index set, but the memtable \
it sealed was not flushed, so an indexed read stays refused until \
it froze was not flushed, so an indexed read stays refused until \
it is: {error}"
);
}
}
/// Snapshot the cached writer's shard for the LSM read path: its shard id,
/// Snapshot the cached writer's shard for the MemWAL read path: its shard id,
/// authoritative in-memory manifest, and active + frozen memtable refs.
/// Returns `None` when no writer is currently cached (e.g. nothing has been
/// written this session, or the writer was closed).
@@ -537,7 +537,7 @@ fn check_shard_match(cached: Uuid, wanted: Uuid) -> Result<()> {
}
Err(Error::InvalidInput {
message: format!(
"merge_insert: a shard writer is already open for shard {} but this input routes to shard {}; call close_lsm_writers before writing to a different shard",
"merge_insert: a shard writer is already open for shard {} but this input routes to shard {}; call close_memwal_writers before writing to a different shard",
cached, wanted
),
})
@@ -569,7 +569,7 @@ pub(crate) struct LsmPlan {
/// Outcome of [`lsm_dispatch_decision`].
#[allow(clippy::redundant_pub_crate)]
pub(crate) enum LsmDispatch {
/// No LSM write spec applies; use the standard `merge_insert` path.
/// No MemWAL write spec applies; use the standard `merge_insert` path.
Standard,
/// Route the `merge_insert` through the MemWAL shard writer.
Lsm(LsmPlan),
@@ -583,18 +583,18 @@ pub(crate) async fn lsm_dispatch_decision(
params: &MergeInsertBuilder,
) -> Result<LsmDispatch> {
// Explicit opt-out: use the standard path regardless of any installed spec.
if params.use_lsm == Some(false) {
if params.use_memwal == Some(false) {
return Ok(LsmDispatch::Standard);
}
let dataset = table.dataset.get().await?;
let Some(details) = dataset.mem_wal_index_details().await? else {
// No write spec installed. `use_lsm(true)` demanded MemWAL routing, so
// No write spec installed. `use_memwal(true)` demanded MemWAL routing, so
// that is an error; otherwise fall back to the standard path.
if params.use_lsm == Some(true) {
if params.use_memwal == Some(true) {
return Err(Error::InvalidInput {
message: "use_lsm(true) was set but the table has no MemWAL write spec; \
install one with set_lsm_write_spec or leave use_lsm unset"
message: "use_memwal(true) was set but the table has no MemWAL write spec; \
install one with enable_memwal or leave use_memwal unset"
.to_string(),
});
}
@@ -616,7 +616,7 @@ pub(crate) async fn lsm_dispatch_decision(
if !params.on.is_empty() && params.on != pk_cols {
return Err(Error::InvalidInput {
message: format!(
"merge_insert: `on` columns {:?} must match the table's unenforced primary key {:?} when an LSM write spec is set; pass an empty `on` to default to the primary key",
"merge_insert: `on` columns {:?} must match the table's unenforced primary key {:?} when a MemWAL write spec is set; pass an empty `on` to default to the primary key",
params.on, pk_cols
),
});
@@ -624,7 +624,7 @@ pub(crate) async fn lsm_dispatch_decision(
if !is_upsert_only(params) {
return Err(Error::InvalidInput {
message: "merge_insert: when an LSM write spec is set, only the upsert form (when_matched_update_all without a filter + when_not_matched_insert_all, no by-source delete) is supported; call use_lsm(false) to use the standard merge_insert path".to_string(),
message: "merge_insert: when a MemWAL write spec is set, only the upsert form (when_matched_update_all without a filter + when_not_matched_insert_all, no by-source delete) is supported; call use_memwal(false) to use the standard merge_insert path".to_string(),
});
}
+18 -18
View File
@@ -95,8 +95,8 @@ async fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> R
return Ok(false);
}
// A MemWAL write spec means reads auto-route through the LSM scanner in
// `create_plan` even when `use_lsm` is unset. The namespace request has no
// use_lsm field, so pushing the default query down would silently omit
// `create_plan` even when `use_memwal` is unset. The namespace request has no
// use_memwal field, so pushing the default query down would silently omit
// un-compacted rows — force local execution whenever a spec is installed.
let dataset = table.dataset.get().await?;
if dataset.mem_wal_index_details().await?.is_some() {
@@ -106,10 +106,10 @@ async fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> R
}
fn requires_local_namespace_execution(query: &AnyQuery) -> bool {
// The namespace QueryTable request cannot represent approx_mode, search_effort or use_lsm.
// For use_lsm that is worse than a tuning miss: MemWAL read routing lives only
// The namespace QueryTable request cannot represent approx_mode, search_effort or use_memwal.
// For use_memwal that is worse than a tuning miss: MemWAL read routing lives only
// in `create_plan`, so a pushed-down query would return stale base-only data.
if query.base().use_lsm.is_some() || query.base().take_offsets.is_some() {
if query.base().use_memwal.is_some() || query.base().take_offsets.is_some() {
return true;
}
matches!(
@@ -167,25 +167,25 @@ pub async fn create_plan(
let ds_ref = table.dataset.get().await?;
// MemWAL read routing driven by `use_lsm`:
// MemWAL read routing driven by `use_memwal`:
// * unset — route through the LSM scanner iff the table carries a write spec
// * Some(true) — force LSM routing; error if the table has no write spec
// * Some(true) — force MemWAL routing; error if the table has no write spec
// * Some(false) — read the base table only, bypassing the MemWAL
// The LSM scanner surfaces in-flight `merge_insert` data (active/frozen
// memtables + flushed generations); validation and dispatch live in `lsm`.
// memtables + SSTables); validation and dispatch live in `lsm`.
let has_spec = ds_ref.mem_wal_index_details().await?.is_some();
let use_lsm = match query.base.use_lsm {
let use_memwal = match query.base.use_memwal {
Some(true) if !has_spec => {
return Err(Error::InvalidInput {
message: "use_lsm(true) was set but the table has no MemWAL write spec; \
install one with set_lsm_write_spec or leave use_lsm unset"
message: "use_memwal(true) was set but the table has no MemWAL write spec; \
install one with enable_memwal or leave use_memwal unset"
.to_string(),
});
}
Some(enable) => enable,
None => has_spec,
};
if use_lsm {
if use_memwal {
return lsm::create_lsm_plan(table, ds_ref, query).await;
}
@@ -1195,7 +1195,7 @@ mod tests {
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(crate::table::LsmWriteSpec::unsharded())
.enable_memwal(crate::table::MemWalWriteSpec::unsharded())
.await
.unwrap();
let lsm_error = table
@@ -1395,7 +1395,7 @@ mod tests {
}
#[tokio::test]
async fn test_execute_query_use_lsm_with_namespace_pushdown_runs_locally() {
async fn test_execute_query_use_memwal_with_namespace_pushdown_runs_locally() {
use crate::connect;
use crate::table::query::execute_query;
use arrow_array::{Int32Array, RecordBatch};
@@ -1418,7 +1418,7 @@ mod tests {
.unwrap();
let table = conn
.create_table("test_use_lsm_namespace_fallback", batch)
.create_table("test_use_memwal_namespace_fallback", batch)
.execute()
.await
.unwrap();
@@ -1429,13 +1429,13 @@ mod tests {
.pushdown_operations
.insert(NamespaceClientPushdownOperation::QueryTable);
// `use_lsm` set (even to false) must force local execution — the namespace
// request has no use_lsm field, so a pushdown would silently ignore it.
// `use_memwal` set (even to false) must force local execution — the namespace
// request has no use_memwal field, so a pushdown would silently ignore it.
let query_vector = Arc::new(Float32Array::from(vec![0.0, 0.0]));
let query = AnyQuery::VectorQuery(VectorQueryRequest {
base: QueryRequest {
limit: Some(1),
use_lsm: Some(false),
use_memwal: Some(false),
..Default::default()
},
column: Some("vector".to_string()),
+23 -23
View File
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! MemWAL LSM read path.
//! MemWAL read path.
//!
//! When a table has an LSM write spec installed (see [`set_lsm_write_spec`]),
//! When a table has a MemWAL write spec installed (see [`enable_memwal`]),
//! reads are routed through Lance's [`LsmScanner`] instead of the plain
//! base-table scan unless the query sets
//! [`use_lsm(false)`](crate::query::QueryBase::use_lsm). This makes data
//! [`use_memwal(false)`](crate::query::QueryBase::use_memwal). This makes data
//! written via the LSM `merge_insert` path — which lives in the active/frozen
//! in-memory memtables and the flushed SSTable generations until an external
//! in-memory memtables and the SSTables until an external
//! compaction merges it into the base table — visible to queries, deduplicated by
//! primary key (newest generation wins).
//!
@@ -17,10 +17,10 @@
//! three run through a single [`LsmScanner`], so a `where` filter is honored as a
//! prefilter uniformly — including for vector search, where `LsmScanner` threads
//! it into the vector planner's prefilter. Shapes the LSM path cannot honor are
//! rejected with [`Error::NotSupported`]; the caller must set `use_lsm(false)` to
//! rejected with [`Error::NotSupported`]; the caller must set `use_memwal(false)` to
//! run those against the base table.
//!
//! [`set_lsm_write_spec`]: crate::Table::set_lsm_write_spec
//! [`enable_memwal`]: crate::Table::enable_memwal
use std::collections::HashMap;
use std::sync::Arc;
@@ -57,7 +57,7 @@ const LSM_OVERFETCH_FACTOR: f64 = 2.0;
/// The caller guarantees `ds_ref` carries a MemWAL write spec (routing is decided
/// in [`create_plan`](super::create_plan)). Errors with [`Error::NotSupported`]
/// for query shapes the LSM scanner cannot honor — the caller must set
/// `use_lsm(false)` to run those against the base table.
/// `use_memwal(false)` to run those against the base table.
pub(super) async fn create_lsm_plan(
table: &NativeTable,
ds_ref: Arc<Dataset>,
@@ -67,11 +67,11 @@ pub(super) async fn create_lsm_plan(
// A time-traveled (checked-out) handle pins an older dataset version, but the
// WAL manifests and cached writer expose current live state — mixing them would
// surface WAL rows written after the requested version. `use_lsm(false)` reads
// surface WAL rows written after the requested version. `use_memwal(false)` reads
// the base table at the pinned version.
if table.dataset.time_travel_version().is_some() {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner cannot read from a time-traveled dataset version; set use_lsm(false) to read the base table at this version".to_string(),
message: "the MemWAL LSM scanner cannot read from a time-traveled dataset version; set use_memwal(false) to read the base table at this version".to_string(),
});
}
@@ -80,7 +80,7 @@ pub(super) async fn create_lsm_plan(
.mem_wal_index_details()
.await?
.ok_or_else(|| Error::Runtime {
message: "the MemWAL LSM write spec disappeared during read planning".to_string(),
message: "the MemWAL write spec disappeared during read planning".to_string(),
})?;
let pk_columns = pk_columns(&ds_ref)?;
@@ -135,10 +135,10 @@ pub(super) async fn create_lsm_plan(
restore_projection(plan, &query, &pk_columns)
}
/// Reject query shapes the LSM read path does not implement. On a MemWAL table
/// Reject query shapes the MemWAL read path does not implement. On a MemWAL table
/// reads route through the LSM scanner by default, so an unsupported shape is a
/// hard error rather than a silent fallback to the base-only scan — which would
/// exclude un-compacted MemWAL data. The caller must set `use_lsm(false)` to
/// exclude un-compacted MemWAL data. The caller must set `use_memwal(false)` to
/// run these against the base table, accepting that the results omit un-compacted
/// MemWAL data.
///
@@ -148,7 +148,7 @@ fn reject_unsupported(query: &VectorQueryRequest) -> Result<()> {
let unsupported = |what: &str| {
Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner does not support {what}; set use_lsm(false) to read the base table only (results will exclude un-compacted MemWAL data)"
"the MemWAL LSM scanner does not support {what}; set use_memwal(false) to read the base table only (results will exclude un-compacted MemWAL data)"
),
})
};
@@ -275,7 +275,7 @@ fn exclusion_watermarks(
exclude
}
/// Assemble the per-shard snapshots (flushed SSTable generations) and the
/// Assemble the per-shard snapshots (SSTables) and the
/// in-memory memtables (active + frozen) for the table.
///
/// Snapshots for all shards come from their on-disk manifests; for the shard
@@ -413,7 +413,7 @@ fn base_scanner(
QueryFilter::Datafusion(expr) => scanner.filter_expr(expr.clone()),
QueryFilter::Substrait(_) => {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner does not support Substrait filters; set use_lsm(false) to read the base table only".to_string(),
message: "the MemWAL LSM scanner does not support Substrait filters; set use_memwal(false) to read the base table only".to_string(),
});
}
};
@@ -458,11 +458,11 @@ async fn fts_plan(
offset: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
// Pre-check for a lancedb-flavored error; `LsmScanner` also validates the
// single-column requirement, but without the `use_lsm(false)` guidance.
// single-column requirement, but without the `use_memwal(false)` guidance.
let columns: Vec<String> = fts.columns().into_iter().collect();
if columns.len() > 1 {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner full-text search supports a single column; set use_lsm(false) to read the base table only".to_string(),
message: "the MemWAL LSM scanner full-text search supports a single column; set use_memwal(false) to read the base table only".to_string(),
});
}
let column = columns.first().ok_or_else(|| Error::NotSupported {
@@ -482,7 +482,7 @@ async fn fts_plan(
{
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner full-text search requires the FTS index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes) and carried by every resident MemTable; otherwise un-compacted documents are omitted. set use_lsm(false) to read the base table only"
"the MemWAL LSM scanner full-text search requires the FTS index on '{column}' to be maintained by the write spec (MemWalWriteSpec::with_maintained_indexes) and carried by every resident MemTable; otherwise un-compacted documents are omitted. set use_memwal(false) to read the base table only"
),
});
}
@@ -633,7 +633,7 @@ fn resolve_single_index(
if names.len() > 1 {
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner cannot resolve the {arm} index catch-up watermark for '{column}': it has multiple {arm} indexes; set use_lsm(false) to read the base table only"
"the MemWAL LSM scanner cannot resolve the {arm} index catch-up watermark for '{column}': it has multiple {arm} indexes; set use_memwal(false) to read the base table only"
),
});
}
@@ -682,7 +682,7 @@ fn restore_projection(
///
/// Note: the base and SSTable arms use `fast_search` (indexed data only), so a
/// base-table row not covered by a vector index is invisible here — it surfaces
/// only via the memtable or `use_lsm(false)`.
/// only via the memtable or `use_memwal(false)`.
#[allow(clippy::too_many_arguments)]
async fn vector_plan(
dataset: &Dataset,
@@ -717,7 +717,7 @@ async fn vector_plan(
if !index_maintained(dataset, &column, details, "VectorIndexDetails").await? {
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise compacted rows not yet re-indexed are omitted. set use_lsm(false) to read the base table only"
"the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (MemWalWriteSpec::with_maintained_indexes); otherwise compacted rows not yet re-indexed are omitted. set use_memwal(false) to read the base table only"
),
});
}
@@ -726,7 +726,7 @@ async fn vector_plan(
// clear error rather than failing deep in the planner.
if is_binary_vector_column(&arrow_schema, &column) {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner does not support binary (uint8) vector search; set use_lsm(false) to read the base table only".to_string(),
message: "the MemWAL LSM scanner does not support binary (uint8) vector search; set use_memwal(false) to read the base table only".to_string(),
});
}
@@ -855,7 +855,7 @@ mod tests {
fn exclusion_watermark_gates_on_lagging_index_catchup() {
let shard = Uuid::from_u128(1);
let details = MemWalIndexDetails {
// Compaction has drained generations through 5 into the base table...
// Compaction has drained SSTables through generation 5 into the base table...
compacted_sstables: vec![CompactedSsTable::new(shard, 5)],
// ...but the FTS index has only caught up through generation 2.
index_catchup: vec![IndexCatchupProgress::new(
+11 -11
View File
@@ -87,7 +87,7 @@ async fn execute_refresh_column_with_source(
column: &str,
) -> Result<RefreshExecution> {
table.dataset.ensure_mutable()?;
ensure_no_lsm_write_spec(table).await?;
ensure_no_memwal_spec(table).await?;
let dataset = table.dataset.get().await?;
let expression = declared_expression(&dataset, column)?;
@@ -303,7 +303,7 @@ pub(crate) async fn execute_refresh_column_async(
// Validate before spawning so bad input is reported by this call rather
// than only by the job.
table.dataset.ensure_mutable()?;
ensure_no_lsm_write_spec(table).await?;
ensure_no_memwal_spec(table).await?;
let dataset = table.dataset.get().await?;
declared_expression(&dataset, column)?;
drop(dataset);
@@ -323,12 +323,12 @@ pub(crate) async fn execute_refresh_column_async(
})))
}
/// Refuse to refresh under an LSM write spec.
/// Refuse to refresh under a MemWAL write spec.
///
/// Refresh enumerates base fragments, and a write spec keeps visible rows in
/// un-compacted MemWAL tiers it cannot reach -- success would silently omit
/// readable rows.
async fn ensure_no_lsm_write_spec(table: &NativeTable) -> Result<()> {
async fn ensure_no_memwal_spec(table: &NativeTable) -> Result<()> {
use lance::dataset::mem_wal::DatasetMemWalExt;
// Unset drops the MemWAL index, so the spec alone stops describing a table
@@ -341,7 +341,7 @@ async fn ensure_no_lsm_write_spec(table: &NativeTable) -> Result<()> {
.list_mem_wal_latest_shard_ids()
.await?
.is_empty();
if retained_sstables || table.get_lsm_write_spec().await?.is_some() {
if retained_sstables || table.get_memwal_spec().await?.is_some() {
return Err(Error::NotSupported {
message: "refresh_column is not supported on a table with an LSM write \
spec: rows in un-compacted tiers are invisible to refresh"
@@ -1637,7 +1637,7 @@ mod tests {
/// writer left in that state.
#[tokio::test]
async fn test_refresh_refuses_a_foreign_lsm_state() {
use crate::table::LsmWriteSpec;
use crate::table::MemWalWriteSpec;
let tmp_dir = tempfile::tempdir().unwrap();
let conn = connect(tmp_dir.path().to_str().unwrap())
@@ -1655,7 +1655,7 @@ mod tests {
let table = conn.create_table("lsm", batch).execute().await.unwrap();
table.set_unenforced_primary_key(["x"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
super::super::computed_columns::add_foreign_kind(&table, "doubled", "sql").await;
@@ -1673,7 +1673,7 @@ mod tests {
/// flag still marks retained SSTable rows; refresh refuses on the flag.
#[tokio::test]
async fn test_refresh_refuses_retained_catchup_state() {
use crate::table::LsmWriteSpec;
use crate::table::MemWalWriteSpec;
let tmp_dir = tempfile::tempdir().unwrap();
let conn = connect(tmp_dir.path().to_str().unwrap())
@@ -1697,14 +1697,14 @@ mod tests {
.unwrap();
table.set_unenforced_primary_key(["x"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.enable_memwal(MemWalWriteSpec::unsharded())
.await
.unwrap();
let mut merge = table.merge_insert(&["x"]);
merge
.when_matched_update_all(None)
.when_not_matched_insert_all()
.use_lsm(true);
.use_memwal(true);
merge
.execute(Box::new(arrow_array::RecordBatchIterator::new(
vec![Ok(batch)],
@@ -1712,7 +1712,7 @@ mod tests {
)))
.await
.unwrap();
table.unset_lsm_write_spec().await.unwrap();
table.disable_memwal().await.unwrap();
super::super::computed_columns::add_foreign_kind(&table, "doubled", "sql").await;
let err = table.refresh_column("doubled").await.unwrap_err();
+2 -2
View File
@@ -131,7 +131,7 @@ pub(crate) async fn execute_declare(
) -> Result<AddColumnsResult> {
use lance::dataset::mem_wal::DatasetMemWalExt;
// An LSM write spec keeps visible rows in tiers refresh cannot reach;
// A MemWAL write spec keeps visible rows in tiers refresh cannot reach;
// checked against latest committed state, not this handle's snapshot.
table.checkout_latest().await?;
computed_columns::ensure_not_function_bound(
@@ -149,7 +149,7 @@ pub(crate) async fn execute_declare(
.list_mem_wal_latest_shard_ids()
.await?
.is_empty();
if retained_sstables || table.get_lsm_write_spec().await?.is_some() {
if retained_sstables || table.get_memwal_spec().await?.is_some() {
return Err(Error::NotSupported {
message: "computed columns are not supported on a table with an LSM write \
spec: rows in un-compacted tiers are invisible to refresh"