Compare commits

..

4 Commits

Author SHA1 Message Date
Jack Ye 61e6614c09 chore: retain computed column support in beta.4 update 2026-08-27 17:24:32 -07:00
lancedb automation d41ed98b52 chore: update lance dependency to v12.0.0-beta.4 2026-08-27 21:26:01 +00:00
Wyatt Alt a10c2e39b9 feat: let a computed-column batch read its own earlier declarations
`add_columns().computed()` accepted several columns in one call but bound
each against the table's schema as it stood before the call, so `a` and
`b = a + 1` had to be two commits. A server staging declarations behind
other schema work has no atomic way to do that, and a caller reading the
builder's plural signature reasonably expects the batch to be one.

Each accepted column now joins the schema the next one resolves against,
so the batch is planned and committed as one. Order is the dependency
order; reading ahead is still an unknown column. `validate_declarations`
exposes the schema-level checks -- the Function-binding guard and the
planning -- without a commit, for callers that must reject before earlier
work in the same request lands; LSM state is table state and stays a
commit-time check.

Dependent columns need refresh to be dependency-aware, or `b =
coalesce(a, 0)` refreshed before `a` bakes zeros from `a`'s placeholder
null and the fill-once contract keeps them. Refresh now walks the
dependency graph once and fills each reachable column once in dependency
order, each as its own commit, then the requested column. Every fill in
the pass covers only the fragments of the snapshot the pass started from:
a commit may rebase over a concurrent append, and the fragment that admits
carries placeholder nulls no earlier fill covered, so it waits for a later
refresh and is reported as `rows_remaining`. Two concurrent fills of one
input collide on its field in lance's conflict check, so a dependent fill
can only commit over inputs that were durable when it read them.
`rows_filled` counts the requested column only; the async result's
`published_version` is the last commit of the pass, inputs included.
2026-08-27 20:46:32 +00:00
lancedb automation c05da95d4c chore: update lance dependency to v12.0.0-beta.2 2026-08-26 01:31:44 +00:00
29 changed files with 836 additions and 415 deletions
Generated
+42 -42
View File
@@ -3455,8 +3455,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsst"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"rand 0.9.5",
@@ -4815,8 +4815,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
[[package]]
name = "lance"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arc-swap",
"arrow",
@@ -4888,8 +4888,8 @@ dependencies = [
[[package]]
name = "lance-arrow"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -4911,7 +4911,7 @@ dependencies = [
[[package]]
name = "lance-arrow-scalar"
version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -4925,7 +4925,7 @@ dependencies = [
[[package]]
name = "lance-arrow-stats"
version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -4934,8 +4934,8 @@ dependencies = [
[[package]]
name = "lance-bitpacking"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrayref",
"crunchy",
@@ -4945,8 +4945,8 @@ dependencies = [
[[package]]
name = "lance-core"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -4983,8 +4983,8 @@ dependencies = [
[[package]]
name = "lance-datafusion"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"arrow-array",
@@ -5013,8 +5013,8 @@ dependencies = [
[[package]]
name = "lance-datagen"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"arrow-array",
@@ -5031,8 +5031,8 @@ dependencies = [
[[package]]
name = "lance-derive"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"proc-macro2",
"quote",
@@ -5041,8 +5041,8 @@ dependencies = [
[[package]]
name = "lance-encoding"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -5075,8 +5075,8 @@ dependencies = [
[[package]]
name = "lance-file"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -5107,8 +5107,8 @@ dependencies = [
[[package]]
name = "lance-index"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arc-swap",
"arrow",
@@ -5172,8 +5172,8 @@ dependencies = [
[[package]]
name = "lance-index-core"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5195,8 +5195,8 @@ dependencies = [
[[package]]
name = "lance-io"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"arrow-array",
@@ -5236,8 +5236,8 @@ dependencies = [
[[package]]
name = "lance-linalg"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5251,8 +5251,8 @@ dependencies = [
[[package]]
name = "lance-namespace"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"async-trait",
@@ -5264,8 +5264,8 @@ dependencies = [
[[package]]
name = "lance-namespace-impls"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"arrow-ipc",
@@ -5318,8 +5318,8 @@ dependencies = [
[[package]]
name = "lance-select"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5333,8 +5333,8 @@ dependencies = [
[[package]]
name = "lance-table"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow",
"arrow-array",
@@ -5374,8 +5374,8 @@ dependencies = [
[[package]]
name = "lance-testing"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5388,8 +5388,8 @@ dependencies = [
[[package]]
name = "lance-tokenizer"
version = "12.0.0-beta.2"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
version = "12.0.0-beta.4"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.4#474ae88dd75c437d5d506859b627510978e502e9"
dependencies = [
"frostem",
"icu_segmenter",
+14 -14
View File
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
rust-version = "1.91.0"
[workspace.dependencies]
lance = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance = { "version" = "=12.0.0-beta.4", default-features = false, "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=12.0.0-beta.4", default-features = false, "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=12.0.0-beta.4", default-features = false, "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=12.0.0-beta.4", "tag" = "v12.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" }
lancedb = { path = "rust/lancedb", default-features = false }
ahash = "0.8"
# Note that this one does not include pyarrow
+7 -7
View File
@@ -221,7 +221,7 @@ abstract checkpointLsm(): Promise<void>
Converge this table's LSM write path into its base table.
Freezes once, then triggers compaction and polls until the SSTables that existed
Seals once, then triggers compaction and polls until the L0 that existed
at the start is gone. The target set is fixed at the start, so
generations created *during* the checkpoint are ignored — that is what
lets it terminate under write load, and what makes it best-effort: it
@@ -289,7 +289,7 @@ It is a no-op when no writers are cached.
abstract compactLsm(): Promise<void>
```
Trigger a background SSTable compaction pass per table shard.
Trigger a background L0 → base compaction pass per bucket.
Returns once the passes are *dispatched*, not once they finish — watch
[Table#getLsmStats](Table.md#getlsmstats) for progress, or use
@@ -505,7 +505,7 @@ Drop an index from the table.
abstract flushLsm(): Promise<void>
```
Freeze every table shard's active memtable into a new SSTable.
Seal every bucket's active memtable into a new L0 generation.
Returns once the seal is committed. Sealing an empty memtable is a no-op,
so this is safe to call repeatedly.
@@ -519,10 +519,10 @@ so this is safe to call repeatedly.
### getLsmStats()
```ts
abstract getLsmStats(includeSstableRows?): Promise<undefined | LsmStats>
abstract getLsmStats(includeGenerationRows?): Promise<undefined | LsmStats>
```
Read live per-table-shard LSM state.
Read live per-bucket LSM state.
Answers "how far behind is my fresh tier", "which bucket is hot", and
"why is my fresh-tier vector search brute-force". Mutates no table state.
@@ -531,8 +531,8 @@ Resolves to `undefined` only when the LSM write path is not enabled.
#### Parameters
* **includeSstableRows?**: `boolean`
Also count rows per SSTable.
* **includeGenerationRows?**: `boolean`
Also count rows per L0 generation.
Off by default because each count opens an uncached Lance dataset.
#### Returns
+2 -2
View File
@@ -60,6 +60,7 @@
- [BranchDiff](interfaces/BranchDiff.md)
- [BranchIndexSummary](interfaces/BranchIndexSummary.md)
- [BranchRowCountSummary](interfaces/BranchRowCountSummary.md)
- [BucketStats](interfaces/BucketStats.md)
- [CherryPickError](interfaces/CherryPickError.md)
- [CherryPickPreview](interfaces/CherryPickPreview.md)
- [CherryPickResult](interfaces/CherryPickResult.md)
@@ -86,6 +87,7 @@
- [FtsToken](interfaces/FtsToken.md)
- [FullTextQuery](interfaces/FullTextQuery.md)
- [FullTextSearchOptions](interfaces/FullTextSearchOptions.md)
- [GenerationStats](interfaces/GenerationStats.md)
- [HnswPqOptions](interfaces/HnswPqOptions.md)
- [HnswSqOptions](interfaces/HnswSqOptions.md)
- [IndexConfig](interfaces/IndexConfig.md)
@@ -124,9 +126,7 @@
- [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)
@@ -2,12 +2,12 @@
***
[@lancedb/lancedb](../globals.md) / TableShardStats
[@lancedb/lancedb](../globals.md) / BucketStats
# Interface: TableShardStats
# Interface: BucketStats
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
Live state of one bucket. A table is N buckets on one node; flattening to a
single number hides the one hot bucket that is usually why someone opened
this endpoint.
## Properties
@@ -18,7 +18,7 @@ this endpoint.
compacting: boolean;
```
Whether a pass owns this table shard's compaction latch right now. Says *a*
Whether a pass owns this bucket's compaction latch right now. Says *a*
driver is running, not *whose*, and the latch is held from dispatch —
including while the pass queues for a pod-wide compactor permit. Read it
as "do not pile on", never as "mine is progressing".
@@ -35,13 +35,13 @@ The generation the active memtable will become.
***
### sstables
### generations
```ts
sstables: SsTableStats[];
generations: GenerationStats[];
```
SSTables not yet merged into the base table.
Flushed L0 generations not yet merged into the base table.
***
@@ -61,7 +61,7 @@ Version of the shard manifest these numbers were read from.
optional memtables: MemtableStats[];
```
Oldest first, active last. Absent for a `"Sealed"` table shard, whose
Oldest first, active last. Absent for a `"Sealed"` bucket, whose
in-memory state is torn down.
***
@@ -82,7 +82,7 @@ WAL position replay resumes from.
shardId: string;
```
The shard this table shard writes.
The shard this bucket writes.
***
+40
View File
@@ -0,0 +1,40 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / GenerationStats
# Interface: GenerationStats
One flushed L0 generation.
## Properties
### bytes
```ts
bytes: number;
```
On-disk size of the generation.
***
### generation
```ts
generation: number;
```
The generation number. Increases as memtables are sealed into L0.
***
### rows?
```ts
optional rows: number;
```
Present only when `includeGenerationRows` was requested. Off by default
because each count opens an uncached Lance dataset.
+5 -5
View File
@@ -6,17 +6,17 @@
# Interface: LsmStats
Live per-table-shard LSM state, as returned by `Table#getLsmStats`.
Live per-bucket LSM state, as returned by `Table#getLsmStats`.
Nothing here is derived: sums and differences (total SSTable bytes, WAL lag) are
Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are
the caller's to compute.
## Properties
### tableShards
### buckets
```ts
tableShards: TableShardStats[];
buckets: BucketStats[];
```
One entry per table shard backing this table.
One entry per bucket backing this table.
-40
View File
@@ -1,40 +0,0 @@
[**@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.
@@ -22,11 +22,11 @@ import java.util.Optional;
import java.util.OptionalLong;
/**
* Live state of one tableShard. A table is N tableShards on one node; flattening to a single number hides
* the one hot tableShard that is usually why someone opened this endpoint.
* Live state of one bucket. A table is N buckets on one node; flattening to a single number hides
* the one hot bucket that is usually why someone opened this endpoint.
*/
public class TableShardStats {
private static final String CONTEXT = "tableShard stats";
public class BucketStats {
private static final String CONTEXT = "bucket stats";
private final String shardId;
private final String status;
@@ -35,11 +35,11 @@ public class TableShardStats {
private final long currentGeneration;
private final long replayAfterWalEntryPosition;
private final long walEntryPositionLastSeen;
private final List<SsTableStats> sstables;
private final List<GenerationStats> generations;
private final boolean compacting;
private final List<MemtableStats> memtables;
TableShardStats(
BucketStats(
String shardId,
String status,
long writerEpoch,
@@ -47,7 +47,7 @@ public class TableShardStats {
long currentGeneration,
long replayAfterWalEntryPosition,
long walEntryPositionLastSeen,
List<SsTableStats> sstables,
List<GenerationStats> generations,
boolean compacting,
List<MemtableStats> memtables) {
this.shardId = shardId;
@@ -57,12 +57,12 @@ public class TableShardStats {
this.currentGeneration = currentGeneration;
this.replayAfterWalEntryPosition = replayAfterWalEntryPosition;
this.walEntryPositionLastSeen = walEntryPositionLastSeen;
this.sstables = Collections.unmodifiableList(sstables);
this.generations = Collections.unmodifiableList(generations);
this.compacting = compacting;
this.memtables = memtables == null ? null : Collections.unmodifiableList(memtables);
}
/** The shard this tableShard writes. */
/** The shard this bucket writes. */
public String shardId() {
return shardId;
}
@@ -100,13 +100,13 @@ public class TableShardStats {
return walEntryPositionLastSeen;
}
/** SSTables not yet merged into the base table. */
public List<SsTableStats> sstables() {
return sstables;
/** Flushed L0 generations not yet merged into the base table. */
public List<GenerationStats> generations() {
return generations;
}
/**
* Whether a pass owns this tableShard's compaction latch right now. Says <em>a</em> driver is
* Whether a pass owns this bucket's compaction latch right now. Says <em>a</em> driver is
* running, not <em>whose</em>, and the latch is held from dispatch including while the pass
* queues for a pod-wide compactor permit. Read it as "do not pile on", never as "mine is
* progressing".
@@ -115,15 +115,15 @@ public class TableShardStats {
return compacting;
}
/** Oldest first, active last. Empty for a {@code "Sealed"} tableShard, whose state is torn down. */
/** Oldest first, active last. Empty for a {@code "Sealed"} bucket, whose state is torn down. */
public Optional<List<MemtableStats>> memtables() {
return Optional.ofNullable(memtables);
}
/** The newest SSTable generation, or empty when the tier is empty. */
OptionalLong newestSstableGeneration() {
/** The newest flushed generation, or empty when L0 is empty. */
OptionalLong newestGeneration() {
OptionalLong newest = OptionalLong.empty();
for (SsTableStats generation : sstables) {
for (GenerationStats generation : generations) {
if (!newest.isPresent() || generation.generation() > newest.getAsLong()) {
newest = OptionalLong.of(generation.generation());
}
@@ -132,15 +132,15 @@ public class TableShardStats {
}
/**
* How many SSTables at or below {@code target} are still uncompacted.
* How many generations at or below {@code target} are still in L0.
*
* <p>A count, not a boolean: one pass drains a bounded prefix rather than the whole target set,
* so a boolean would read as "no progress" for every pass but the last. Compaction drains
* oldest-first, so this decreases monotonically.
*/
long outstandingSstables(long target) {
long outstandingGenerations(long target) {
long count = 0;
for (SsTableStats generation : sstables) {
for (GenerationStats generation : generations) {
if (generation.generation() <= target) {
count++;
}
@@ -148,11 +148,11 @@ public class TableShardStats {
return count;
}
static TableShardStats fromJson(JsonNode node) {
static BucketStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
List<SsTableStats> sstables = new ArrayList<SsTableStats>();
for (JsonNode generation : JsonFields.requiredArray(node, "sstables", CONTEXT)) {
sstables.add(SsTableStats.fromJson(generation));
List<GenerationStats> generations = new ArrayList<GenerationStats>();
for (JsonNode generation : JsonFields.requiredArray(node, "generations", CONTEXT)) {
generations.add(GenerationStats.fromJson(generation));
}
JsonNode memtablesNode = JsonFields.optionalArray(node, "memtables", CONTEXT);
@@ -164,7 +164,7 @@ public class TableShardStats {
}
}
return new TableShardStats(
return new BucketStats(
JsonFields.requiredText(node, "shard_id", CONTEXT),
JsonFields.requiredText(node, "status", CONTEXT),
JsonFields.requiredLong(node, "writer_epoch", CONTEXT),
@@ -172,21 +172,21 @@ public class TableShardStats {
JsonFields.requiredLong(node, "current_generation", CONTEXT),
JsonFields.requiredLong(node, "replay_after_wal_entry_position", CONTEXT),
JsonFields.requiredLong(node, "wal_entry_position_last_seen", CONTEXT),
sstables,
generations,
JsonFields.requiredBoolean(node, "compacting", CONTEXT),
memtables);
}
@Override
public String toString() {
return "TableShardStats{shardId="
return "BucketStats{shardId="
+ shardId
+ ", status="
+ status
+ ", currentGeneration="
+ currentGeneration
+ ", sstables="
+ sstables
+ ", generations="
+ generations
+ ", compacting="
+ compacting
+ "}";
@@ -17,21 +17,21 @@ import com.fasterxml.jackson.databind.JsonNode;
import java.util.OptionalLong;
/** One SSTable. */
public class SsTableStats {
/** One flushed L0 generation. */
public class GenerationStats {
private static final String CONTEXT = "generation stats";
private final long generation;
private final long bytes;
private final Long rows;
SsTableStats(long generation, long bytes, Long rows) {
GenerationStats(long generation, long bytes, Long rows) {
this.generation = generation;
this.bytes = bytes;
this.rows = rows;
}
/** The generation number. Increases as memtables are frozen into SSTables. */
/** The generation number. Increases as memtables are sealed into L0. */
public long generation() {
return generation;
}
@@ -42,16 +42,16 @@ public class SsTableStats {
}
/**
* Rows in this generation, present only when {@code includeSstableRows} was requested. Off by
* Rows in this generation, present only when {@code includeGenerationRows} was requested. Off by
* default because each count opens an uncached Lance dataset.
*/
public OptionalLong rows() {
return rows == null ? OptionalLong.empty() : OptionalLong.of(rows);
}
static SsTableStats fromJson(JsonNode node) {
static GenerationStats fromJson(JsonNode node) {
JsonFields.requiredObject(node, CONTEXT);
return new SsTableStats(
return new GenerationStats(
JsonFields.requiredLong(node, "generation", CONTEXT),
JsonFields.requiredLong(node, "bytes", CONTEXT),
JsonFields.optionalLong(node, "rows", CONTEXT));
@@ -59,6 +59,6 @@ public class SsTableStats {
@Override
public String toString() {
return "SsTableStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}";
return "GenerationStats{generation=" + generation + ", bytes=" + bytes + ", rows=" + rows + "}";
}
}
@@ -26,7 +26,7 @@ import java.util.OptionalLong;
*
* <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,
* freeze into SSTables, and are merged into the base table by compaction.
* seal into L0 generations, and are merged into the base table by compaction.
*
* <p>These routes are not part of the Lance Namespace specification, so they are issued directly
* rather than through {@link org.lance.namespace.LanceNamespace}.
@@ -38,7 +38,7 @@ import java.util.OptionalLong;
* .buildRestClient();
*
* LanceDbTableLsm lsm = new LanceDbTableLsm(client, "my_table");
* lsm.setLsmWriteSpec(LsmWriteSpec.tableShard("id", 16));
* lsm.setLsmWriteSpec(LsmWriteSpec.bucket("id", 16));
* // ... merge_insert traffic ...
* lsm.checkpointLsm();
* }</pre>
@@ -94,7 +94,7 @@ public class LanceDbTableLsm {
* Install an {@link LsmWriteSpec} on this table, selecting the MemWAL LSM write path for future
* {@code mergeInsert} calls.
*
* <p>All variants require the table to have an unenforced primary key; tableShard sharding
* <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) {
@@ -130,7 +130,7 @@ public class LanceDbTableLsm {
}
/**
* Freeze every table shard's active memtable into a new SSTable.
* Seal every bucket's active memtable into a new L0 generation.
*
* <p>Returns once the seal is committed. Sealing an empty memtable is a no-op, so this is safe to
* call repeatedly.
@@ -140,7 +140,7 @@ public class LanceDbTableLsm {
}
/**
* Trigger a background SSTable compaction pass per table shard.
* Trigger a background L0 → base compaction pass per bucket.
*
* <p>Returns once the passes are <em>dispatched</em>, not once they finish — watch {@link
* #getLsmStats}, or use {@link #checkpointLsm} to wait for convergence.
@@ -150,9 +150,9 @@ public class LanceDbTableLsm {
}
/**
* Read live per-tableShard LSM state.
* Read live per-bucket LSM state.
*
* <p>Answers "how far behind is my fresh tier", "which tableShard is hot", and "why is my fresh-tier
* <p>Answers "how far behind is my fresh tier", "which bucket is hot", and "why is my fresh-tier
* vector search brute-force". Mutates no table state.
*
* <p>Empty only when the LSM write path is not enabled — that is, when the server sends an absent
@@ -160,13 +160,13 @@ public class LanceDbTableLsm {
* one throws rather than decoding to something empty, because {@link #checkpointLsm} reads
* convergence out of these numbers and cannot tell a defaulted array from a drained one.
*
* @param includeSstableRows Also count rows per SSTable. Off by default because each
* @param includeGenerationRows Also count rows per L0 generation. Off by default because each
* count opens an uncached Lance dataset.
* @throws IllegalStateException if the response is absent or does not decode.
*/
public Optional<LsmStats> getLsmStats(boolean includeSstableRows) {
public Optional<LsmStats> getLsmStats(boolean includeGenerationRows) {
Map<String, Object> body = new LinkedHashMap<String, Object>();
body.put("include_sstable_rows", includeSstableRows);
body.put("include_generation_rows", includeGenerationRows);
JsonNode response = client.post(route("get_lsm_stats"), body);
if (response == null) {
throw new IllegalStateException("get_lsm_stats returned an empty response body");
@@ -186,8 +186,8 @@ public class LanceDbTableLsm {
/**
* Converge this table's LSM write path into its base table.
*
* <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
* <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.
@@ -204,7 +204,7 @@ public class LanceDbTableLsm {
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 sstables.
// no-op, so a re-issue does not churn empty generations.
if (issueVoid(this::flushLsm)) {
backoff(reissue);
continue;
@@ -220,7 +220,7 @@ public class LanceDbTableLsm {
return;
}
Map<String, Long> targets = newestSstableGenerations(stats.value.get());
Map<String, Long> targets = newestGenerations(stats.value.get());
if (targets.isEmpty()) {
return;
}
@@ -236,7 +236,7 @@ public class LanceDbTableLsm {
}
/**
* Trigger and poll until no tableShard holds a generation at or below its target.
* Trigger and poll until no bucket holds a generation at or below its target.
*
* @return true when the drain finished, false when the table needs re-claiming from flush.
*/
@@ -250,21 +250,21 @@ public class LanceDbTableLsm {
return true;
}
// `compacting` is the tableShard's compaction latch, held from dispatch until the pass
// `compacting` is the bucket's compaction latch, held from dispatch until the pass
// ends — including while it waits on a pod-wide permit. So it answers one question
// only: do not pile on. Buckets with nothing outstanding are skipped, not counted
// as idle.
long outstanding = 0;
boolean allCompacting = true;
for (TableShardStats tableShard : stats.value.get().tableShards()) {
Long target = targets.get(tableShard.shardId());
for (BucketStats bucket : stats.value.get().buckets()) {
Long target = targets.get(bucket.shardId());
if (target == null) {
continue;
}
long remaining = tableShard.outstandingSstables(target);
long remaining = bucket.outstandingGenerations(target);
if (remaining > 0) {
outstanding += remaining;
allCompacting &= tableShard.compacting();
allCompacting &= bucket.compacting();
}
}
if (outstanding == 0) {
@@ -281,7 +281,7 @@ public class LanceDbTableLsm {
if (!isRetryable(e)) {
throw e;
}
// A 429 here means the server could latch no tableShard at all, which the poll
// A 429 here means the server could latch no bucket at all, which the poll
// above already handles. Not retried in place: the latch it would contend for
// is the one doing the work, so fall through and re-read — POLL_INTERVAL_MS is
// the backoff.
@@ -291,13 +291,13 @@ public class LanceDbTableLsm {
}
}
/** The newest generation held by each tableShard, skipping tableShards holding none. */
private static Map<String, Long> newestSstableGenerations(LsmStats stats) {
/** The newest generation held by each bucket, skipping buckets holding none. */
private static Map<String, Long> newestGenerations(LsmStats stats) {
Map<String, Long> targets = new HashMap<String, Long>();
for (TableShardStats tableShard : stats.tableShards()) {
OptionalLong newest = tableShard.newestSstableGeneration();
for (BucketStats bucket : stats.buckets()) {
OptionalLong newest = bucket.newestGeneration();
if (newest.isPresent()) {
targets.put(tableShard.shardId(), newest.getAsLong());
targets.put(bucket.shardId(), newest.getAsLong());
}
}
return targets;
@@ -20,37 +20,37 @@ import java.util.Collections;
import java.util.List;
/**
* Live per-tableShard LSM state, as returned by {@link LanceDbTableLsm#getLsmStats()}.
* Live per-bucket LSM state, as returned by {@link LanceDbTableLsm#getLsmStats()}.
*
* <p>Nothing here is derived: sums and differences (total SSTable bytes, WAL lag) are the caller's to
* <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<TableShardStats> tableShards;
private final List<BucketStats> buckets;
LsmStats(List<TableShardStats> tableShards) {
this.tableShards = Collections.unmodifiableList(tableShards);
LsmStats(List<BucketStats> buckets) {
this.buckets = Collections.unmodifiableList(buckets);
}
/** One entry per tableShard. */
public List<TableShardStats> tableShards() {
return tableShards;
/** One entry per bucket. */
public List<BucketStats> buckets() {
return buckets;
}
static LsmStats 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));
List<BucketStats> buckets = new ArrayList<BucketStats>();
for (JsonNode bucket : JsonFields.requiredArray(node, "buckets", CONTEXT)) {
buckets.add(BucketStats.fromJson(bucket));
}
return new LsmStats(tableShards);
return new LsmStats(buckets);
}
@Override
public String toString() {
return "LsmStats{tableShards=" + tableShards + "}";
return "LsmStats{buckets=" + buckets + "}";
}
}
@@ -132,10 +132,10 @@ public class LanceDbTableLsmTest {
enqueue("set_lsm_write_spec", 200, "");
lsm.setLsmWriteSpec(
LsmWriteSpec.tableShard("id", 16).withMaintainedIndexes(Arrays.asList("id_idx")));
LsmWriteSpec.bucket("id", 16).withMaintainedIndexes(Arrays.asList("id_idx")));
JsonNode body = MAPPER.readTree(requestBodies.get(0));
assertEquals("tableShard", body.get("sharding").get("mode").asText());
assertEquals("bucket", body.get("sharding").get("mode").asText());
assertEquals("id", body.get("sharding").get("column").asText());
assertEquals(16, body.get("sharding").get("num_buckets").asInt());
assertEquals(1, body.get("maintained_indexes").size());
@@ -201,7 +201,7 @@ public class LanceDbTableLsmTest {
enqueue(
"get_lsm_write_spec",
200,
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"tableShard\",\"column\":\"id\","
"{\"lsm_write_spec\":{\"sharding\":{\"mode\":\"bucket\",\"column\":\"id\","
+ "\"num_buckets\":16},\"maintained_indexes\":[\"id_idx\"],"
+ "\"writer_config_defaults\":{\"durable_write\":\"true\"}}}");
@@ -228,14 +228,14 @@ public class LanceDbTableLsmTest {
@Test
public void testGetLsmStats() throws Exception {
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false, 7L, 8L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
Optional<LsmStats> got = lsm.getLsmStats(true);
assertEquals("/v1/table/my_table/get_lsm_stats/", requestPaths.get(0));
assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_sstable_rows").asBoolean());
assertTrue(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
assertTrue(got.isPresent());
TableShardStats decoded = got.get().tableShards().get(0);
BucketStats decoded = got.get().buckets().get(0);
assertEquals("shard-0", decoded.shardId());
assertEquals("Active", decoded.status());
assertEquals(1, decoded.writerEpoch());
@@ -243,8 +243,8 @@ public class LanceDbTableLsmTest {
assertEquals(9, decoded.currentGeneration());
assertFalse(decoded.compacting());
assertEquals(Arrays.asList(7L, 8L), generationNumbers(decoded));
assertEquals(1024, decoded.sstables().get(0).bytes());
assertFalse(decoded.sstables().get(0).rows().isPresent(), "rows absent unless requested");
assertEquals(1024, decoded.generations().get(0).bytes());
assertFalse(decoded.generations().get(0).rows().isPresent(), "rows absent unless requested");
assertFalse(decoded.memtables().isPresent(), "absent memtables stay absent");
}
@@ -254,19 +254,19 @@ public class LanceDbTableLsmTest {
enqueue(
"get_lsm_stats",
200,
"{\"lsm_stats\":{\"tableShards\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":3,\"wal_entry_position_last_seen\":11,"
+ "\"sstables\":[{\"generation\":7,\"bytes\":1024,\"rows\":42}],"
+ "\"generations\":[{\"generation\":7,\"bytes\":1024,\"rows\":42}],"
+ "\"compacting\":true,\"memtables\":[{\"generation\":8,\"rows\":5,"
+ "\"bytes\":64,\"batches\":2,\"indexes\":[\"id_idx\"]}]}]}}");
TableShardStats decoded = lsm.getLsmStats(true).get().tableShards().get(0);
BucketStats decoded = lsm.getLsmStats(true).get().buckets().get(0);
assertEquals(3, decoded.replayAfterWalEntryPosition());
assertEquals(11, decoded.walEntryPositionLastSeen());
assertTrue(decoded.compacting());
assertEquals(42, decoded.sstables().get(0).rows().getAsLong());
assertEquals(42, decoded.generations().get(0).rows().getAsLong());
assertTrue(decoded.memtables().isPresent());
MemtableStats memtable = decoded.memtables().get().get(0);
assertEquals(8, memtable.generation());
@@ -289,7 +289,7 @@ public class LanceDbTableLsmTest {
lsm.getLsmStats();
assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_sstable_rows").asBoolean());
assertFalse(MAPPER.readTree(requestBodies.get(0)).get("include_generation_rows").asBoolean());
}
// ===========================================================================
@@ -334,8 +334,8 @@ public class LanceDbTableLsmTest {
@Test
public void testCheckpointReturnsWhenNoGenerationsOutstanding() {
enqueue("flush_lsm", 200, "");
// A table shard with no SSTables yields no target, so the drain never starts.
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false)));
// A bucket with no L0 generations yields no target, so the drain never starts.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
@@ -345,12 +345,12 @@ public class LanceDbTableLsmTest {
@Test
public void testCheckpointConvergesOnceTargetGenerationsAreGone() {
enqueue("flush_lsm", 200, "");
// Watermark read: shard-0 holds sstables 7 and 8, so target = 8.
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false, 7L, 8L)));
// Watermark read: shard-0 holds generations 7 and 8, so target = 8.
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
// First drain poll: both still outstanding, nothing compacting -> dispatch a pass.
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false, 7L, 8L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 7L, 8L)));
// Second drain poll: drained past the target -> done.
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false, 9L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 9L)));
enqueue("compact_lsm", 200, "");
lsm.checkpointLsm();
@@ -362,14 +362,14 @@ public class LanceDbTableLsmTest {
@Test
public void testCheckpointDoesNotPileOnWhileEveryTargetBucketIsCompacting() {
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", true, 4L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L)));
// Still compacting on the first poll, so no pass is dispatched; then it drains.
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", true, 4L)));
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false, 5L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", true, 4L)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false, 5L)));
lsm.checkpointLsm();
assertEquals(0, countCalls("compact_lsm"), "a latched tableShard is left alone");
assertEquals(0, countCalls("compact_lsm"), "a latched bucket is left alone");
}
@Test
@@ -378,7 +378,7 @@ public class LanceDbTableLsmTest {
// 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(tableShard("shard-0", false)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
@@ -389,7 +389,7 @@ public class LanceDbTableLsmTest {
public void testCheckpointRetriesRetryableStatusInPlace() {
enqueue("flush_lsm", 429, "latch held");
enqueue("flush_lsm", 200, "");
enqueue("get_lsm_stats", 200, stats(tableShard("shard-0", false)));
enqueue("get_lsm_stats", 200, stats(bucket("shard-0", false)));
lsm.checkpointLsm();
@@ -421,27 +421,27 @@ public class LanceDbTableLsmTest {
/**
* A stats payload that does not decode must fail closed. Every one of these bodies used to be
* read as "no tableShards", which is indistinguishable from a drained table, so {@code checkpointLsm}
* read as "no buckets", which is indistinguishable from a drained table, so {@code checkpointLsm}
* reported convergence for a checkpoint that never ran.
*/
@Test
public void testCheckpointRejectsMalformedStats() {
Map<String, String> malformed = new LinkedHashMap<String, String>();
malformed.put("no response body at all", "");
malformed.put("stats object with no tableShards", "{\"lsm_stats\":{}}");
malformed.put("tableShard missing its required fields", "{\"lsm_stats\":{\"tableShards\":[{}]}}");
malformed.put("stats object with no buckets", "{\"lsm_stats\":{}}");
malformed.put("bucket missing its required fields", "{\"lsm_stats\":{\"buckets\":[{}]}}");
malformed.put(
"tableShard missing sstables",
"{\"lsm_stats\":{\"tableShards\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
"bucket missing generations",
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0,"
+ "\"compacting\":false}]}}");
malformed.put(
"generation with a non-numeric generation number",
"{\"lsm_stats\":{\"tableShards\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
"{\"lsm_stats\":{\"buckets\":[{\"shard_id\":\"shard-0\",\"status\":\"Active\","
+ "\"writer_epoch\":1,\"manifest_version\":2,\"current_generation\":9,"
+ "\"replay_after_wal_entry_position\":0,\"wal_entry_position_last_seen\":0,"
+ "\"sstables\":[{\"generation\":\"7\",\"bytes\":1024}],"
+ "\"generations\":[{\"generation\":\"7\",\"bytes\":1024}],"
+ "\"compacting\":false}]}}");
for (Map.Entry<String, String> each : malformed.entrySet()) {
@@ -492,22 +492,22 @@ public class LanceDbTableLsmTest {
// harness
// ===========================================================================
private static List<Long> generationNumbers(TableShardStats tableShard) {
private static List<Long> generationNumbers(BucketStats bucket) {
List<Long> numbers = new ArrayList<Long>();
for (SsTableStats generation : tableShard.sstables()) {
for (GenerationStats generation : bucket.generations()) {
numbers.add(generation.generation());
}
return numbers;
}
/** Build an {@code lsm_stats} response body from tableShard fragments. */
private static String stats(String... tableShards) {
return "{\"lsm_stats\":{\"tableShards\":[" + String.join(",", tableShards) + "]}}";
/** Build an {@code lsm_stats} response body from bucket fragments. */
private static String stats(String... buckets) {
return "{\"lsm_stats\":{\"buckets\":[" + String.join(",", buckets) + "]}}";
}
private static String tableShard(String shardId, boolean compacting, Long... sstables) {
private static String bucket(String shardId, boolean compacting, Long... generations) {
StringBuilder gens = new StringBuilder();
for (Long generation : sstables) {
for (Long generation : generations) {
if (gens.length() > 0) {
gens.append(",");
}
@@ -517,7 +517,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,\"sstables\":["
+ "\"wal_entry_position_last_seen\":0,\"generations\":["
+ gens
+ "],\"compacting\":"
+ compacting
+1 -1
View File
@@ -28,7 +28,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<arrow.version>15.0.0</arrow.version>
<lance-core.version>12.0.0-beta.2</lance-core.version>
<lance-core.version>12.0.0-beta.4</lance-core.version>
<spotless.skip>false</spotless.skip>
<spotless.version>2.30.0</spotless.version>
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
+2 -2
View File
@@ -157,8 +157,8 @@ export {
TokenizeTableOptions,
LsmWriteSpec,
LsmStats,
TableShardStats,
SsTableStats,
BucketStats,
GenerationStats,
MemtableStats,
ColumnAlteration,
FieldMetadataUpdate,
+10 -10
View File
@@ -55,8 +55,8 @@ import { sanitizeType } from "./sanitize";
import { IntoSql, toSQL } from "./util";
export { IndexConfig } from "./native";
export {
TableShardStats,
SsTableStats,
BucketStats,
GenerationStats,
LsmStats,
MemtableStats,
} from "./native";
@@ -741,7 +741,7 @@ export abstract class Table {
*/
abstract closeLsmWriters(): Promise<void>;
/**
* Freeze every table shard's active memtable into a new SSTable.
* Seal every bucket's active memtable into a new L0 generation.
*
* Returns once the seal is committed. Sealing an empty memtable is a no-op,
* so this is safe to call repeatedly.
@@ -749,7 +749,7 @@ export abstract class Table {
*/
abstract flushLsm(): Promise<void>;
/**
* Trigger a background SSTable compaction pass per table shard.
* Trigger a background L0 → base compaction pass per bucket.
*
* Returns once the passes are *dispatched*, not once they finish — watch
* {@link Table#getLsmStats} for progress, or use
@@ -760,9 +760,9 @@ export abstract class Table {
/**
* Converge this table's LSM write path into its base table.
*
* Freezes once, then triggers compaction and polls until the SSTables that existed
* Seals once, then triggers compaction and polls until the L0 that existed
* at the start is gone. The target set is fixed at the start, so
* SSTables created *during* the checkpoint are ignored — that is what
* generations created *during* the checkpoint are ignored — that is what
* lets it terminate under write load, and what makes it best-effort: it
* converges the fresh tier as of some instant. Idempotent, abandonable at
* any point, and safe to run on a cadence.
@@ -786,12 +786,12 @@ export abstract class Table {
* "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} includeSstableRows Also count rows per SSTable.
* @param {boolean} includeGenerationRows Also count rows per L0 generation.
* Off by default because each count opens an uncached Lance dataset.
* @returns {Promise<LsmStats | undefined>}
*/
abstract getLsmStats(
includeSstableRows?: boolean,
includeGenerationRows?: boolean,
): Promise<LsmStats | undefined>;
/** Retrieve the version of the table */
@@ -1388,9 +1388,9 @@ export class LocalTable extends Table {
}
async getLsmStats(
includeSstableRows: boolean = false,
includeGenerationRows: boolean = false,
): Promise<LsmStats | undefined> {
return (await this.inner.getLsmStats(includeSstableRows)) ?? undefined;
return (await this.inner.getLsmStats(includeGenerationRows)) ?? undefined;
}
async version(): Promise<number> {
+26 -26
View File
@@ -542,11 +542,11 @@ impl Table {
#[napi(catch_unwind)]
pub async fn get_lsm_stats(
&self,
include_sstable_rows: bool,
include_generation_rows: bool,
) -> napi::Result<Option<LsmStats>> {
let stats = self
.inner_ref()?
.get_lsm_stats(include_sstable_rows)
.get_lsm_stats(include_generation_rows)
.await
.default_error()?;
Ok(stats.map(LsmStats::from))
@@ -950,21 +950,21 @@ impl From<lancedb::table::LsmWriteSpec> for LsmWriteSpec {
}
}
/// One SSTable.
/// One flushed L0 generation.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct SsTableStats {
/// The generation number. Increases as memtables are frozen into SSTables.
pub struct GenerationStats {
/// The generation number. Increases as memtables are sealed into L0.
pub generation: i64,
/// On-disk size of the SSTable.
/// On-disk size of the generation.
pub bytes: i64,
/// Present only when `includeSstableRows` was requested. Off by default
/// Present only when `includeGenerationRows` was requested. Off by default
/// because each count opens an uncached Lance dataset.
pub rows: Option<i64>,
}
impl From<lancedb::table::SsTableStats> for SsTableStats {
fn from(g: lancedb::table::SsTableStats) -> Self {
impl From<lancedb::table::GenerationStats> for GenerationStats {
fn from(g: lancedb::table::GenerationStats) -> Self {
Self {
generation: g.generation as i64,
bytes: g.bytes as i64,
@@ -977,7 +977,7 @@ impl From<lancedb::table::SsTableStats> for SsTableStats {
#[napi(object)]
#[derive(Clone, Debug)]
pub struct MemtableStats {
/// The generation this memtable will become once frozen.
/// The generation this memtable will become once sealed.
pub generation: i64,
/// Rows currently buffered.
pub rows: i64,
@@ -1002,13 +1002,13 @@ impl From<lancedb::table::MemtableStats> for MemtableStats {
}
}
/// 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
/// Live state of one bucket. A table is N buckets on one node; flattening to a
/// single number hides the one hot bucket that is usually why someone opened
/// this endpoint.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct TableShardStats {
/// The shard this table shard writes.
pub struct BucketStats {
/// The shard this bucket writes.
pub shard_id: String,
/// `"Active"` or `"Sealed"` (drop-table 2PC in flight).
pub status: String,
@@ -1023,20 +1023,20 @@ pub struct TableShardStats {
/// Highest WAL position the writer has seen. The difference against
/// `replayAfterWalEntryPosition` is the WAL lag.
pub wal_entry_position_last_seen: i64,
/// 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*
/// Flushed L0 generations not yet merged into the base table.
pub generations: Vec<GenerationStats>,
/// Whether a pass owns this bucket's compaction latch right now. Says *a*
/// driver is running, not *whose*, and the latch is held from dispatch —
/// including while the pass queues for a pod-wide compactor permit. Read it
/// as "do not pile on", never as "mine is progressing".
pub compacting: bool,
/// Oldest first, active last. Absent for a `"Sealed"` table shard, whose
/// Oldest first, active last. Absent for a `"Sealed"` bucket, whose
/// in-memory state is torn down.
pub memtables: Option<Vec<MemtableStats>>,
}
impl From<lancedb::table::TableShardStats> for TableShardStats {
fn from(b: lancedb::table::TableShardStats) -> Self {
impl From<lancedb::table::BucketStats> for BucketStats {
fn from(b: lancedb::table::BucketStats) -> Self {
Self {
shard_id: b.shard_id,
status: b.status,
@@ -1045,7 +1045,7 @@ impl From<lancedb::table::TableShardStats> for TableShardStats {
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,
sstables: b.sstables.into_iter().map(Into::into).collect(),
generations: b.generations.into_iter().map(Into::into).collect(),
compacting: b.compacting,
memtables: b
.memtables
@@ -1054,21 +1054,21 @@ impl From<lancedb::table::TableShardStats> for TableShardStats {
}
}
/// Live per-table-shard LSM state, as returned by `Table#getLsmStats`.
/// Live per-bucket LSM state, as returned by `Table#getLsmStats`.
///
/// Nothing here is derived: sums and differences (total SSTable bytes, WAL lag) are
/// Nothing here is derived: sums and differences (total L0 bytes, WAL lag) are
/// the caller's to compute.
#[napi(object)]
#[derive(Clone, Debug)]
pub struct LsmStats {
/// One entry per table shard backing this table.
pub table_shards: Vec<TableShardStats>,
/// One entry per bucket backing this table.
pub buckets: Vec<BucketStats>,
}
impl From<lancedb::table::LsmStats> for LsmStats {
fn from(stats: lancedb::table::LsmStats) -> Self {
Self {
table_shards: stats.table_shards.into_iter().map(Into::into).collect(),
buckets: stats.buckets.into_iter().map(Into::into).collect(),
}
}
}
+1 -1
View File
@@ -385,7 +385,7 @@ class Table:
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_sstable_rows: bool) -> Optional[dict]: ...
async def get_lsm_stats(self, include_generation_rows: bool) -> Optional[dict]: ...
async def close_lsm_writers(self) -> None: ...
@property
def tags(self) -> Tags: ...
+2 -2
View File
@@ -1029,11 +1029,11 @@ class RemoteTable(Table):
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
def get_lsm_stats(self, *, include_sstable_rows: bool = False) -> Optional[dict]:
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_sstable_rows=include_sstable_rows)
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
)
def close_lsm_writers(self) -> None:
+13 -13
View File
@@ -4189,11 +4189,11 @@ class LanceTable(Table):
[`AsyncTable.compact_lsm`][lancedb.AsyncTable.compact_lsm]."""
return LOOP.run(self._table.compact_lsm())
def get_lsm_stats(self, *, include_sstable_rows: bool = False) -> Optional[dict]:
def get_lsm_stats(self, *, include_generation_rows: bool = False) -> Optional[dict]:
"""Synchronous version of
[`AsyncTable.get_lsm_stats`][lancedb.AsyncTable.get_lsm_stats]."""
return LOOP.run(
self._table.get_lsm_stats(include_sstable_rows=include_sstable_rows)
self._table.get_lsm_stats(include_generation_rows=include_generation_rows)
)
def close_lsm_writers(self) -> None:
@@ -4916,16 +4916,16 @@ class AsyncTable:
async def checkpoint_lsm(self) -> None:
"""Converge this table's LSM write path into its base table.
One flush, freezing every memtable into an SSTable, then compaction triggers
One flush, sealing every memtable into L0, then compaction triggers
until every generation that existed at that moment has reached base.
The loop runs client-side, reading progress from ``get_lsm_stats``.
Best-effort: SSTables created *while* it runs are deliberately not
Best-effort: generations created *while* it runs are deliberately not
waited on, which is what lets it terminate on a table taking writes.
Idempotent and safe on a cadence.
There is no deadline, and the caller owns that. It returns when the
target SSTables are gone, raises on a terminal server fault, and
target generations are gone, raises on a terminal server fault, and
otherwise waits however long the server takes. A slow table and a
stuck one are the same picture from the client: the compactor pool is
shared across every table on the node, so a checkpoint queued behind
@@ -4936,25 +4936,25 @@ class AsyncTable:
await self._inner.checkpoint_lsm()
async def flush_lsm(self) -> None:
"""Freeze every table shard's active memtable into an SSTable.
"""Seal every bucket's active memtable into L0.
Does not touch the base table — compacting SSTables into base is
Does not touch the base table — moving L0 into base is
`compact_lsm`. On a node that has not claimed this table, this claims
it and replays its WAL log first.
"""
await self._inner.flush_lsm()
async def compact_lsm(self) -> None:
"""Trigger a background SSTable compaction pass per table shard.
"""Trigger a background L0 to base compaction pass per bucket.
Returns once the passes are dispatched, not once they finish: watch
``get_lsm_stats`` for progress, or use ``checkpoint_lsm`` to loop
until the current SSTables have reached base.
until the current L0 has reached base.
"""
await self._inner.compact_lsm()
async def get_lsm_stats(
self, *, include_sstable_rows: bool = False
self, *, include_generation_rows: bool = False
) -> Optional[dict]:
"""Read live per-bucket LSM state.
@@ -4967,12 +4967,12 @@ class AsyncTable:
Parameters
----------
include_sstable_rows
Report a row count per SSTable. Off by default: each count
include_generation_rows
Report a row count per L0 generation. Off by default: each count
opens an uncached Lance dataset, and ``checkpoint_lsm`` polls this
needing only generation numbers.
"""
return await self._inner.get_lsm_stats(include_sstable_rows)
return await self._inner.get_lsm_stats(include_generation_rows)
async def close_lsm_writers(self) -> None:
"""Drain and close any cached MemWAL shard writers for this table.
+5 -5
View File
@@ -1278,9 +1278,9 @@ def test_get_lsm_stats_sync():
with lsm_test_table(lsm_handler) as table:
assert table.get_lsm_stats() == {"buckets": [bucket]}
# Off by default, and forwarded when asked for.
assert seen_bodies == [{"include_sstable_rows": False}]
table.get_lsm_stats(include_sstable_rows=True)
assert seen_bodies[-1] == {"include_sstable_rows": True}
assert seen_bodies == [{"include_generation_rows": False}]
table.get_lsm_stats(include_generation_rows=True)
assert seen_bodies[-1] == {"include_generation_rows": True}
def test_get_lsm_stats_sync_returns_none_when_lsm_disabled():
@@ -1309,7 +1309,7 @@ def test_flush_and_compact_lsm_sync():
def test_checkpoint_lsm_sync():
"""Freeze, read the watermark, and return once no SSTables remain.
"""Seal, read the watermark, and return once L0 holds nothing.
The convergence loop itself is covered in Rust; this pins the sync
binding to the endpoints it drives.
@@ -1319,7 +1319,7 @@ def test_checkpoint_lsm_sync():
def lsm_handler(request, route):
called.append(route)
if route == "get_lsm_stats":
# An empty SSTable tier yields no target watermark, so the loop is done
# An empty L0 yields no target watermark, so the loop is done
# after the seal without ever polling compaction.
send_json(request, {"lsm_stats": {"buckets": []}})
else:
+16 -16
View File
@@ -33,16 +33,16 @@ use pyo3::{
mod scannable;
/// Convert `LsmStats` to a Python dict, preserving the per-table-shard list.
/// Convert `LsmStats` to a Python dict, preserving the per-bucket list.
///
/// Deliberately not flattened to a table-level summary: a table is N
/// 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
/// buckets on one node, and the per-bucket detail is the reason the
/// endpoint exists — flattening hides the single hot bucket someone opened
/// it to find.
fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult<Py<PyDict>> {
let out = PyDict::new(py);
let table_shards = PyList::empty(py);
for b in &stats.table_shards {
let buckets = PyList::empty(py);
for b in &stats.buckets {
let e = PyDict::new(py);
e.set_item("shard_id", &b.shard_id)?;
e.set_item("status", &b.status)?;
@@ -58,15 +58,15 @@ fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult
b.wal_entry_position_last_seen,
)?;
let sstables = PyList::empty(py);
for g in &b.sstables {
let generations = PyList::empty(py);
for g in &b.generations {
let ge = PyDict::new(py);
ge.set_item("generation", g.generation)?;
ge.set_item("bytes", g.bytes)?;
ge.set_item("rows", g.rows)?;
sstables.append(ge)?;
generations.append(ge)?;
}
e.set_item("sstables", sstables)?;
e.set_item("generations", generations)?;
e.set_item("compacting", b.compacting)?;
e.set_item(
@@ -88,9 +88,9 @@ fn lsm_stats_to_py(py: Python<'_>, stats: &lancedb::table::LsmStats) -> PyResult
})
.transpose()?,
)?;
table_shards.append(e)?;
buckets.append(e)?;
}
out.set_item("table_shards", table_shards)?;
out.set_item("buckets", buckets)?;
Ok(out.unbind())
}
@@ -1492,7 +1492,7 @@ impl Table {
})
}
/// Freeze every table shard's active memtable into an SSTable.
/// 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(
@@ -1501,7 +1501,7 @@ impl Table {
)
}
/// Trigger a background SSTable compaction pass per table shard. Returns once the
/// 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>> {
let inner = self_.inner_ref()?.clone();
@@ -1511,15 +1511,15 @@ impl Table {
}
/// Live LSM state, or `None` when the LSM write path is not enabled.
#[pyo3(signature = (include_sstable_rows=false))]
#[pyo3(signature = (include_generation_rows=false))]
pub fn get_lsm_stats(
self_: PyRef<'_, Self>,
include_sstable_rows: bool,
include_generation_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_sstable_rows)
.get_lsm_stats(include_generation_rows)
.await
.infer_error()?;
Python::attach(|py| stats.map(|s| lsm_stats_to_py(py, &s)).transpose())
+1 -1
View File
@@ -878,7 +878,7 @@ pub struct QueryRequest {
/// [`crate::Table::set_lsm_write_spec`]) is routed through the LSM scanner so
/// it also sees data written via the `merge_insert` LSM path that has not yet
/// been compacted into the base table — the active and frozen in-memory
/// memtables and the SSTables, deduplicated by primary key
/// memtables and the flushed (L0) generations, deduplicated by primary key
/// against the base table (newest generation wins); a table without a spec
/// reads the base table.
///
+8 -9
View File
@@ -2951,13 +2951,13 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
Ok(())
}
async fn get_lsm_stats(&self, include_sstable_rows: bool) -> Result<Option<LsmStats>> {
async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result<Option<LsmStats>> {
// Read-semantics POST, like `get_lsm_write_spec`.
let request = self
.client
.post(&format!("/v1/table/{}/get_lsm_stats/", self.identifier))
.json(&serde_json::json!({
"include_sstable_rows": include_sstable_rows,
"include_generation_rows": include_generation_rows,
}));
let (request_id, response) = self.send_lsm_route(request).await?;
let body = response.text().await.err_to_http(request_id.clone())?;
@@ -5734,9 +5734,8 @@ mod tests {
))
.execute()
.await;
let err = match result {
Ok(_) => panic!("legacy remote query unexpectedly succeeded"),
Err(err) => err,
let Err(err) = result else {
panic!("legacy remote query unexpectedly succeeded")
};
assert!(
@@ -8260,7 +8259,7 @@ mod tests {
http::Response::builder().status(200).body(body).unwrap()
}
/// A flush landing in an empty SSTable tier finishes on the opening stats read
/// A flush landing in an empty L0 finishes on the opening stats read
/// alone. Asserting zero compacts is the point: "it returned Ok" is also
/// true of a loop that ran a pointless pass.
#[tokio::test(start_paused = true)]
@@ -8314,7 +8313,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 "the SSTable tier is
/// is what lets the loop terminate on a table taking writes where "L0 is
/// empty" never becomes true.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_ignores_generations_created_while_it_runs() {
@@ -8593,7 +8592,7 @@ mod tests {
}
/// WAL off ⇒ `None`; WAL on ⇒ a fully populated `Some` with no field
/// defaulting to a zero it did not measure. `include_sstable_rows`
/// defaulting to a zero it did not measure. `include_generation_rows`
/// rides in the body and is off unless asked for.
#[tokio::test]
async fn test_get_lsm_stats_round_trip() {
@@ -8602,7 +8601,7 @@ mod tests {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["include_sstable_rows"], true,
body["include_generation_rows"], true,
"the flag must reach the server, not be silently dropped"
);
let response = serde_json::json!({
+11 -11
View File
@@ -102,7 +102,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::{LsmStats, MemtableStats, SsTableStats, TableShardStats};
pub use lsm_stats::{BucketStats, GenerationStats, LsmStats, MemtableStats};
pub use optimize::{CompactionOptions, OptimizeAction, OptimizeStats};
pub use refresh::RefreshColumnResult;
pub use schema_evolution::{
@@ -673,7 +673,7 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
message: "get_lsm_write_spec is not supported on this table type".into(),
})
}
/// Freeze every table shard's active memtable into an SSTable.
/// Seal every bucket's active memtable into L0.
///
/// The default implementation returns `NotSupported`.
async fn flush_lsm(&self) -> Result<()> {
@@ -681,7 +681,7 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
message: "flush_lsm is not supported on this table type".into(),
})
}
/// Trigger a background SSTable compaction pass per table shard.
/// Trigger a background L0 → base compaction pass per bucket.
///
/// The default implementation returns `NotSupported`.
async fn compact_lsm(&self) -> Result<()> {
@@ -693,7 +693,7 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
/// enabled for this table.
///
/// The default implementation returns `NotSupported`.
async fn get_lsm_stats(&self, _include_sstable_rows: bool) -> Result<Option<LsmStats>> {
async fn get_lsm_stats(&self, _include_generation_rows: bool) -> Result<Option<LsmStats>> {
Err(Error::NotSupported {
message: "get_lsm_stats is not supported on this table type".into(),
})
@@ -1897,7 +1897,7 @@ impl Table {
/// Converge this table's LSM write path into its base table.
///
/// One `flush` to freeze every memtable into an SSTable, then compaction triggers
/// One `flush` to seal every memtable into L0, then compaction triggers
/// until every generation that existed at that moment has reached base.
/// The loop runs client-side, reading progress from `get_lsm_stats`, so
/// there is no held socket and nothing to reconcile if you drop this
@@ -1932,10 +1932,10 @@ impl Table {
checkpoint::checkpoint_lsm(self).await
}
/// Freeze every table shard's active memtable into an SSTable without touching the
/// Seal every bucket's active memtable into L0 without touching the
/// base table.
///
/// Independently useful: flushing makes memtable rows readable from an SSTable at
/// Independently useful: flushing makes memtable rows readable from L0 at
/// a lower per-query cost. On a node that has not claimed this table it
/// claims it and replays the WAL log first — reporting "nothing to flush"
/// without replaying would lie about durable data.
@@ -1943,7 +1943,7 @@ impl Table {
self.inner.flush_lsm().await
}
/// Run one bounded SSTable compaction pass per table shard, reporting what
/// Run one bounded L0 → base compaction pass per bucket, reporting what
/// it merged and what is left.
///
/// One pass, not convergence: that bounds each request's cost and gives a
@@ -1959,7 +1959,7 @@ impl Table {
/// state, though on a node that has not claimed this table it claims it,
/// exactly as a read would.
///
/// `include_sstable_rows` reports a row count per SSTable. Off by
/// `include_generation_rows` reports a row count per L0 generation. Off by
/// default: each count opens an uncached Lance dataset, and
/// `checkpoint_lsm` polls this needing only generation numbers.
///
@@ -1970,8 +1970,8 @@ impl Table {
///
/// 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_sstable_rows: bool) -> Result<Option<LsmStats>> {
self.inner.get_lsm_stats(include_sstable_rows).await
pub async fn get_lsm_stats(&self, include_generation_rows: bool) -> Result<Option<LsmStats>> {
self.inner.get_lsm_stats(include_generation_rows).await
}
/// Drain and close any cached MemWAL shard writers held for this table.
+6 -6
View File
@@ -4,7 +4,7 @@
//! Converging a table's LSM write path into its base table.
//!
//! `checkpoint_lsm` seals once, then triggers compaction and watches
//! generation numbers until the SSTables that existed at the start are gone.
//! generation numbers until the L0 that existed at the start is gone.
//!
//! The loop runs in the client, not the server: `compact_lsm` dispatches a
//! pass and returns, so nothing holds a socket and a client can vanish
@@ -150,7 +150,7 @@ where
}
/// Drive [`Table::checkpoint_lsm`]: seal once, fix the target watermark
/// from the resulting SSTables, then trigger and poll until they drain.
/// from the resulting L0, then trigger and poll until it drains.
pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
for reissue in 0..=MAX_REISSUES {
// The seal turns everything written before this call into a
@@ -177,9 +177,9 @@ pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
return Ok(());
};
let targets: HashMap<String, u64> = stats
.table_shards
.buckets
.iter()
.filter_map(|b| Some((b.shard_id.clone(), b.newest_sstable_generation()?)))
.filter_map(|b| Some((b.shard_id.clone(), b.newest_generation()?)))
.collect();
if targets.is_empty() {
return Ok(());
@@ -226,11 +226,11 @@ async fn drain_to_targets(
// with nothing outstanding are skipped, not counted as idle.
let mut outstanding = 0;
let mut all_compacting = true;
for b in &stats.table_shards {
for b in &stats.buckets {
let Some(target) = targets.get(&b.shard_id) else {
continue;
};
let n = b.outstanding_sstables(*target);
let n = b.outstanding_generations(*target);
if n > 0 {
outstanding += n;
all_compacting &= b.compacting;
+97 -8
View File
@@ -22,7 +22,7 @@
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef};
use arrow_schema::{DataType, Field as ArrowField, Fields, Schema as ArrowSchema, SchemaRef};
use datafusion_common::tree_node::TreeNode;
use datafusion_physical_plan::PhysicalExpr;
use lance::dataset::NewColumnTransform;
@@ -1273,6 +1273,11 @@ pub(crate) fn bind(schema: SchemaRef, column: &str, expression: &str) -> Result<
/// refresh time: that the expression parses, that every column it reads
/// exists, and that the target name is free. A declaration that survives this
/// is one a refresh can always act on.
///
/// Each accepted column joins the schema the next one resolves against, so a
/// batch may declare `a` and then `b = a + 1` in one commit. Refresh fills a
/// column's computed inputs before the column, so the order of refresh calls
/// does not matter.
pub(crate) fn plan(schema: SchemaRef, columns: &[(String, String)]) -> Result<Vec<ArrowField>> {
if columns.is_empty() {
return Err(Error::InvalidInput {
@@ -1280,11 +1285,11 @@ pub(crate) fn plan(schema: SchemaRef, columns: &[(String, String)]) -> Result<Ve
});
}
let mut schema = schema;
let mut fields = Vec::with_capacity(columns.len());
let mut declared: Vec<&str> = Vec::with_capacity(columns.len());
for (name, expression) in columns {
if schema.field_with_name(name).is_ok() || declared.contains(&name.as_str()) {
if schema.field_with_name(name).is_ok() {
return Err(Error::ColumnAlreadyExists { name: name.clone() });
}
@@ -1292,16 +1297,50 @@ pub(crate) fn plan(schema: SchemaRef, columns: &[(String, String)]) -> Result<Ve
// Declared columns start entirely null, so nullability is a property
// of the declaration rather than of what the expression yields.
fields.push(
ArrowField::new(name, bound.data_type, true)
.with_metadata(computed_column_metadata(expression, &bound.inputs)),
);
declared.push(name);
let field = ArrowField::new(name, bound.data_type, true)
.with_metadata(computed_column_metadata(expression, &bound.inputs));
schema = Arc::new(ArrowSchema::new_with_metadata(
schema
.fields()
.iter()
.cloned()
.chain(std::iter::once(Arc::new(field.clone())))
.collect::<Fields>(),
schema.metadata().clone(),
));
fields.push(field);
}
Ok(fields)
}
/// Run the schema-level checks of
/// [`AddColumnsBuilder::computed`](super::AddColumnsBuilder::computed) against
/// `schema` without committing: the Function-binding guard and the planning of
/// 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
/// write spec or retained SSTables; that is table state, checked at commit.
///
/// ```
/// # use std::sync::Arc;
/// # use arrow_schema::{DataType, Field, Schema};
/// use lancedb::table::computed_columns::validate_declarations;
///
/// let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int32, false)]));
/// let declarations = vec![
/// ("a".to_string(), "x + 1".to_string()),
/// ("b".to_string(), "a * 2".to_string()),
/// ];
/// assert!(validate_declarations(schema.clone(), &declarations).is_ok());
/// assert!(validate_declarations(schema, &[("c".into(), "random()".into())]).is_err());
/// ```
pub fn validate_declarations(schema: SchemaRef, columns: &[(String, String)]) -> Result<()> {
ensure_no_function_bindings_for_mutation(schema.as_ref(), "schema evolution")?;
plan(schema, columns).map(drop)
}
/// Build the transform that declares `columns` against `schema`.
///
/// An all-null column is how a binding with no values yet is carried into a
@@ -1340,6 +1379,22 @@ pub(super) async fn add_foreign_kind(table: &crate::Table, name: &str, kind: &st
#[cfg(test)]
mod tests {
/// The gate's reproducer: the validator applies the same schema-level
/// guard declaring does, so a staging caller is refused before it commits
/// anything else.
#[test]
fn test_validate_declarations_matches_schema_admission_barriers() {
let schema = Arc::new(ArrowSchema::new_with_metadata(
vec![ArrowField::new("x", DataType::Int32, true)],
HashMap::from([(
FUNCTION_BINDINGS_META_KEY.to_string(),
"not valid binding metadata".to_string(),
)]),
));
let declarations = vec![("a".to_string(), "x + 1".to_string())];
assert!(super::validate_declarations(schema, &declarations).is_err());
}
#[test]
fn output_arrow_type_grammar_matches_the_shared_golden() {
let golden: serde_json::Value = serde_json::from_str(include_str!(
@@ -1582,6 +1637,40 @@ mod tests {
assert!(declared(&table).await.is_empty());
}
/// A batch may build on itself: one commit, and the later entry's inputs
/// name the earlier one.
#[tokio::test]
async fn test_a_declaration_may_read_one_declared_before_it() {
let table = table_with_ints("chain").await;
let before = table.version().await.unwrap();
add_computed(
&table,
&[("a".into(), "x + 1".into()), ("b".into(), "a * 2".into())],
)
.await
.unwrap();
assert_eq!(table.version().await.unwrap(), before + 1);
let declared = declared(&table).await;
assert_eq!(declared[1].name, "b");
assert_eq!(declared[1].inputs, vec!["a".to_string()]);
// Order is the dependency order; reading ahead is still unknown.
let err = add_computed(
&table,
&[("c".into(), "d + 1".into()), ("d".into(), "x + 1".into())],
)
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidExpression { column, .. } if column == "c"));
assert!(
validate_declarations(
table.schema().await.unwrap(),
&[("e".into(), "random()".into())]
)
.is_err()
);
}
/// A column added by an ordinary transform is materialized, not bound, so
/// it carries no declaration to report.
#[tokio::test]
+38 -44
View File
@@ -1,21 +1,21 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Live per-table_shard LSM state — the shape [`crate::Table::get_lsm_stats`]
//! Live per-bucket LSM state — the shape [`crate::Table::get_lsm_stats`]
//! returns and [`super::checkpoint`] polls.
//!
//! Nothing here is derived: sums and differences (total SSTable bytes, WAL lag)
//! Nothing here is derived: sums and differences (total L0 bytes, WAL lag)
//! are the caller's to compute. There is no "WAL is off" shape — that case is
//! `None`, because a struct of zeros would read as measurements.
use serde::Deserialize;
/// One SSTable.
/// One flushed L0 generation.
#[derive(Debug, Clone, Deserialize)]
pub struct SsTableStats {
pub struct GenerationStats {
pub generation: u64,
pub bytes: u64,
/// Present only when `include_sstable_rows` was requested. Off by
/// Present only when `include_generation_rows` was requested. Off by
/// default because each count opens an uncached Lance dataset, and the
/// checkpoint loop polls this route needing only generation numbers.
#[serde(default)]
@@ -34,11 +34,11 @@ pub struct MemtableStats {
pub indexes: Vec<String>,
}
/// 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
/// Live state of one bucket. A table is N buckets on one node; flattening to
/// a single number hides the one hot bucket that is usually why someone
/// opened this endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct TableShardStats {
pub struct BucketStats {
pub shard_id: String,
/// `Active` | `Sealed` (drop-table 2PC in flight).
pub status: String,
@@ -47,42 +47,42 @@ pub struct TableShardStats {
pub current_generation: u64,
pub replay_after_wal_entry_position: u64,
pub wal_entry_position_last_seen: u64,
pub sstables: Vec<SsTableStats>,
/// Whether a pass owns this table_shard's compaction latch right now. Says *a*
pub generations: Vec<GenerationStats>,
/// Whether a pass owns this bucket's compaction latch right now. Says *a*
/// driver is running, not *whose*, and the latch is held from dispatch —
/// including while the pass queues for a pod-wide compactor permit. Read
/// it as "do not pile on", never as "mine is progressing".
pub compacting: bool,
/// Oldest first, active last. Absent for a `Sealed` table_shard, whose
/// Oldest first, active last. Absent for a `Sealed` bucket, whose
/// in-memory state is torn down.
#[serde(default)]
pub memtables: Option<Vec<MemtableStats>>,
}
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()
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()
}
/// How many SSTables at or below `target` are still uncompacted.
/// How many generations at or below `target` are still in L0.
///
/// A count, not a boolean: one pass drains a bounded prefix rather than
/// the whole target set, so a boolean would read as "no progress" for
/// every pass but the last. Compaction drains oldest-first, so this
/// decreases monotonically.
pub(crate) fn outstanding_sstables(&self, target: u64) -> usize {
self.sstables
pub(crate) fn outstanding_generations(&self, target: u64) -> usize {
self.generations
.iter()
.filter(|g| g.generation <= target)
.count()
}
}
/// Live LSM state, one entry per table_shard.
/// Live LSM state, one entry per bucket.
#[derive(Debug, Clone, Deserialize)]
pub struct LsmStats {
pub table_shards: Vec<TableShardStats>,
pub buckets: Vec<BucketStats>,
}
/// Server-side JSON envelope for `get_lsm_stats`. `lsm_stats` is null when
@@ -97,18 +97,18 @@ pub(crate) struct GetLsmStatsResponse {
mod tests {
use super::*;
fn table_shard(shard: &str, sstables: &[u64], compacting: bool) -> TableShardStats {
TableShardStats {
fn bucket(shard: &str, generations: &[u64], compacting: bool) -> BucketStats {
BucketStats {
shard_id: shard.into(),
status: "Active".into(),
writer_epoch: 1,
manifest_version: 1,
current_generation: sstables.iter().max().copied().unwrap_or(0) + 1,
current_generation: generations.iter().max().copied().unwrap_or(0) + 1,
replay_after_wal_entry_position: 0,
wal_entry_position_last_seen: 0,
sstables: sstables
generations: generations
.iter()
.map(|g| SsTableStats {
.map(|g| GenerationStats {
generation: *g,
bytes: 1,
rows: None,
@@ -123,46 +123,40 @@ mod tests {
/// generation created after it must not hold the loop open — that is why
/// the predicate terminates under write load.
#[test]
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");
fn newer_generations_do_not_extend_the_target() {
let start = bucket("b0", &[7, 8], false);
let target = start.newest_generation().expect("L0 is non-empty");
assert_eq!(target, 8);
// Compaction drained 7 and 8; 9 and 10 arrived while it ran.
let later = table_shard("b0", &[9, 10], false);
let later = bucket("b0", &[9, 10], false);
assert_eq!(
later.outstanding_sstables(target),
later.outstanding_generations(target),
0,
"sstables above the target are somebody else's problem"
"generations above the target are somebody else's problem"
);
// Still holding 8 means still outstanding.
assert_eq!(
table_shard("b0", &[8, 9], false).outstanding_sstables(target),
bucket("b0", &[8, 9], false).outstanding_generations(target),
1
);
}
/// 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.
/// The metric counts generations, not buckets: a pass drains a bounded
/// prefix, so one bucket going 3 → 2 → 1 → 0 is three steps.
#[test]
fn progress_is_measured_in_sstables() {
fn progress_is_measured_in_generations() {
let target = 3;
let counts: Vec<usize> = [&[1u64, 2, 3][..], &[2, 3][..], &[3][..], &[][..]]
.iter()
.map(|gens| table_shard("b0", gens, false).outstanding_sstables(target))
.map(|gens| bucket("b0", gens, false).outstanding_generations(target))
.collect();
assert_eq!(counts, vec![3, 2, 1, 0]);
}
#[test]
fn an_empty_sstable_tier_has_no_target() {
assert!(
table_shard("b0", &[], false)
.newest_sstable_generation()
.is_none()
);
fn empty_l0_has_no_target() {
assert!(bucket("b0", &[], false).newest_generation().is_none());
}
}
+363 -24
View File
@@ -7,6 +7,16 @@
//! therefore idempotent and does not observe input mutation -- once a row is
//! filled, changing what the expression reads leaves the stored result alone.
//!
//! A column's computed inputs are filled first -- the dependency graph is
//! walked once, each reachable column filled once in dependency order, each
//! fill its own commit. Every fill in the pass, the requested column's
//! included, covers only the fragments of the snapshot the pass started
//! from: a commit may rebase over a concurrent append, and the fragment that
//! admits carries placeholder nulls no earlier fill covered, so it waits for
//! a later refresh rather than being read as values. Two concurrent fills of
//! one input collide on its field in lance's conflict check, so a dependent
//! fill can only commit over inputs that were durable when it read them.
//!
//! Two passes per fragment. The first scans only the unfilled live rows and
//! evaluates the expression over them, which yields the exact fill count and
//! decides whether the fragment is staged at all -- a fragment where nothing
@@ -19,6 +29,7 @@
//! inputs masked to null first, so a poison value in a row nobody is filling
//! cannot fail the refresh.
use std::collections::HashSet;
use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, RecordBatch, RecordBatchOptions};
@@ -41,7 +52,8 @@ use crate::{Error, Result};
/// The result of refreshing a computed column.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RefreshColumnResult {
/// Rows that had a value computed.
/// Rows that had a value computed, in the requested column only; inputs
/// filled on its behalf are not counted.
#[serde(default)]
pub rows_filled: u64,
/// The commit version associated with the operation.
@@ -51,7 +63,79 @@ pub struct RefreshColumnResult {
struct RefreshExecution {
result: RefreshColumnResult,
/// The snapshot the requested column was evaluated against, after its
/// inputs were filled.
source_version: u64,
/// The last version any fill in the pass committed, inputs included.
published_version: Option<u64>,
/// Unfilled live rows of the requested column in fragments the pass did
/// not cover, counted on the snapshot the result reports -- the published
/// one, or the source when nothing was published.
rows_deferred: u64,
}
/// One column's fill against one snapshot.
struct Fill {
rows_filled: u64,
/// The dataset the commit produced, if anything was filled.
committed: Option<Arc<Dataset>>,
}
/// Test-only one-shot pauses before a named column's fill:
/// `(column, reached, resume)`, one per column, consumed when hit.
#[cfg(test)]
type Pause = (String, Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
#[cfg(test)]
static PAUSES_BEFORE_FILL: std::sync::Mutex<Vec<Pause>> = std::sync::Mutex::new(Vec::new());
/// The SQL-computed columns `column` reads, transitively, each once, in an
/// order that fills every column after the columns it reads.
///
/// Declarations are acyclic by construction: a column can only read what
/// existed when it was declared.
fn dependency_order(schema: &ArrowSchema, column: &str) -> Result<Vec<String>> {
fn visit(
schema: &ArrowSchema,
column: &str,
visited: &mut HashSet<String>,
order: &mut Vec<String>,
) -> Result<()> {
let Some(declaration) = schema
.field_with_name(column)
.ok()
.and_then(computed_column_from_field)
else {
return Ok(());
};
for input in &declaration.inputs {
let input = super::computed_columns::root(input);
if visited.contains(input) {
continue;
}
let Some(input_declaration) = schema
.field_with_name(input)
.ok()
.and_then(computed_column_from_field)
else {
continue;
};
if !matches!(input_declaration.kind, ComputedColumnKind::Sql { .. }) {
return Err(Error::NotSupported {
message: format!(
"computed column '{column}' reads '{input}', which this refresh \
cannot fill first; refresh '{input}' before '{column}'"
),
});
}
visit(schema, input, visited, order)?;
visited.insert(input.to_string());
order.push(input.to_string());
}
Ok(())
}
let mut order = Vec::new();
visit(schema, column, &mut HashSet::new(), &mut order)?;
Ok(order)
}
/// Internal implementation of the refresh logic.
@@ -70,9 +154,96 @@ async fn execute_refresh_column_with_source(
) -> Result<RefreshExecution> {
table.dataset.ensure_mutable()?;
ensure_no_lsm_write_spec(table).await?;
let dataset = table.dataset.get().await?;
let mut dataset = table.dataset.get().await?;
declared_expression(&dataset, column)?;
let expression = declared_expression(&dataset, column)?;
// The pass covers exactly these fragments. A commit below may rebase over
// a concurrent append, and the fragment that admits was scanned by no
// earlier fill, so it is excluded from every later one.
let eligible: HashSet<u64> = dataset
.get_fragments()
.iter()
.map(|f| f.id() as u64)
.collect();
let mut published_version = None;
for input in dependency_order(&ArrowSchema::from(dataset.schema()), column)? {
pause_before_fill(&input).await;
if let Some(committed) = fill_column(table, &dataset, &input, &eligible)
.await?
.committed
{
published_version = Some(committed.version().version);
dataset = committed;
}
}
pause_before_fill(column).await;
let source_version = dataset.version().version;
let fill = fill_column(table, &dataset, column, &eligible).await?;
if let Some(committed) = &fill.committed {
published_version = Some(committed.version().version);
}
// Counted after the commit: it may have rebased over an append, and the
// fragment that admits is deferred but part of the published version.
let terminal = fill.committed.as_ref().unwrap_or(&dataset);
let rows_deferred = count_deferred(terminal, column, &eligible).await?;
Ok(RefreshExecution {
result: RefreshColumnResult {
rows_filled: fill.rows_filled,
version: published_version.unwrap_or(source_version),
},
source_version,
published_version,
rows_deferred,
})
}
/// Unfilled live rows of `column` in `dataset`'s fragments outside `eligible`.
async fn count_deferred(dataset: &Dataset, column: &str, eligible: &HashSet<u64>) -> Result<u64> {
let deferred: Vec<_> = dataset
.get_fragments()
.into_iter()
.filter(|fragment| !eligible.contains(&(fragment.id() as u64)))
.map(|fragment| fragment.metadata().clone())
.collect();
if deferred.is_empty() {
return Ok(0);
}
let mut scanner = dataset.scan();
scanner
.with_fragments(deferred)
.filter(&format!("{} IS NULL", quote_identifier(column)))?;
Ok(scanner.count_rows().await?)
}
#[cfg(test)]
async fn pause_before_fill(column: &str) {
let pause = {
let mut pauses = PAUSES_BEFORE_FILL.lock().unwrap();
pauses
.iter()
.position(|(paused, _, _)| paused == column)
.map(|index| pauses.remove(index))
};
if let Some((_, reached, resume)) = pause {
reached.notify_one();
resume.notified().await;
}
}
#[cfg(not(test))]
async fn pause_before_fill(_column: &str) {}
/// Fill `column`'s unfilled live rows in the `eligible` fragments as they
/// stand in `dataset`, committing against that snapshot.
async fn fill_column(
table: &NativeTable,
dataset: &Arc<Dataset>,
column: &str,
eligible: &HashSet<u64>,
) -> Result<Fill> {
let expression = declared_expression(dataset, column)?;
let schema = Arc::new(ArrowSchema::from(dataset.schema()));
let bound = Arc::new(super::computed_columns::bind(schema, column, &expression)?);
let field = dataset
@@ -91,23 +262,22 @@ async fn execute_refresh_column_with_source(
let mut rows_filled = 0u64;
let mut replacements = Vec::new();
for fragment in dataset.get_fragments() {
let gained = count_fragment_gains(&dataset, &fragment, &bound, column).await?;
if !eligible.contains(&(fragment.id() as u64)) {
continue;
}
let gained = count_fragment_gains(dataset, &fragment, &bound, column).await?;
if gained == 0 {
continue;
}
rows_filled += gained;
let values = fill_stream(&dataset, &fragment, bound.clone(), column).await?;
let values = fill_stream(dataset, &fragment, bound.clone(), column).await?;
replacements.push(fragment.write_columns(values, &column_schema).await?);
}
if replacements.is_empty() {
let source_version = dataset.version().version;
return Ok(RefreshExecution {
result: RefreshColumnResult {
rows_filled: 0,
version: source_version,
},
source_version,
return Ok(Fill {
rows_filled: 0,
committed: None,
});
}
@@ -125,15 +295,10 @@ async fn execute_refresh_column_with_source(
false,
)
.await?;
let version = new_dataset.version().version;
table.dataset.update(new_dataset);
Ok(RefreshExecution {
result: RefreshColumnResult {
rows_filled,
version,
},
source_version: read_version,
table.dataset.update(new_dataset.clone());
Ok(Fill {
rows_filled,
committed: Some(Arc::new(new_dataset)),
})
}
@@ -158,10 +323,9 @@ pub(crate) async fn execute_refresh_column_async(
Ok(crate::function::RefreshColumnResult {
rows_assigned: execution.result.rows_filled,
rows_failed: 0,
rows_remaining: 0,
rows_remaining: execution.rows_deferred,
source_version: execution.source_version,
published_version: (execution.result.rows_filled > 0)
.then_some(execution.result.version),
published_version: execution.published_version,
})
})))
}
@@ -414,6 +578,181 @@ mod tests {
table.add(batch).execute().await.unwrap();
}
/// The gate's reproducer: `b = coalesce(a, 0)` refreshed before `a`
/// must not bake zeros from `a`'s placeholder null.
#[tokio::test]
async fn test_dependent_refresh_cannot_fill_from_placeholder_null() {
let table = table_with("dependent_refresh_order", vec![1, 2, 3]).await;
table
.add_columns()
.computed("a", "x + 1")
.computed("b", "coalesce(a, 0)")
.execute()
.await
.unwrap();
let result = table.refresh_column("b").await.unwrap();
assert_eq!(result.rows_filled, 3);
assert_eq!(read(&table, "a").await, vec![Some(2), Some(3), Some(4)]);
assert_eq!(
table.count_rows(Some("b = a".to_string())).await.unwrap(),
3
);
assert_eq!(table.refresh_column("a").await.unwrap().rows_filled, 0);
// Appended rows: the input is filled in the new fragment first too.
append(&table, vec![10]).await;
assert_eq!(table.refresh_column("b").await.unwrap().rows_filled, 1);
assert_eq!(
table.count_rows(Some("b = 0".to_string())).await.unwrap(),
0
);
}
/// The gate's reproducer: a fragment appended between the input fill and
/// the requested column's fill has inputs the pass never covered, so it
/// is left unfilled rather than read as null.
#[tokio::test]
async fn test_dependent_refresh_fences_an_append_after_input_fill() {
let table = table_with("dependent_refresh_append_gap", vec![1, 2, 3]).await;
table
.add_columns()
.computed("a_gap", "x + 1")
.computed("b_gap", "coalesce(a_gap, 0)")
.execute()
.await
.unwrap();
let reached = Arc::new(tokio::sync::Notify::new());
let resume = Arc::new(tokio::sync::Notify::new());
super::PAUSES_BEFORE_FILL.lock().unwrap().push((
"b_gap".to_string(),
reached.clone(),
resume.clone(),
));
let refresh = {
let table = table.clone();
tokio::spawn(async move {
table
.refresh_column_async("b_gap")
.await
.unwrap()
.wait()
.await
})
};
reached.notified().await;
append(&table, vec![10]).await;
resume.notify_one();
let result = refresh.await.unwrap().unwrap();
assert_eq!(result.rows_assigned, 3);
// The target commit rebased over the append: the published version
// holds the deferred row, and the count is taken there.
assert_eq!(result.rows_remaining, 1);
table.refresh_column("a_gap").await.unwrap();
assert_eq!(table.count_rows(Some("b_gap = 0".into())).await.unwrap(), 0);
assert_eq!(table.refresh_column("b_gap").await.unwrap().rows_filled, 1);
}
/// The gate's reproducer: an append landing between two input fills is
/// rebased into the second's snapshot, but the first never covered it.
#[tokio::test]
async fn test_dependent_refresh_fences_an_append_between_input_fills() {
let table = table_with("dependent_refresh_between_inputs", vec![1, 2, 3]).await;
table
.add_columns()
.computed("a_mid", "x + 1")
.computed("c_mid", "x + 2")
.computed("b_mid", "coalesce(a_mid, 0) + coalesce(c_mid, 0)")
.execute()
.await
.unwrap();
let reached = Arc::new(tokio::sync::Notify::new());
let resume = Arc::new(tokio::sync::Notify::new());
super::PAUSES_BEFORE_FILL.lock().unwrap().push((
"c_mid".to_string(),
reached.clone(),
resume.clone(),
));
let refresh = {
let table = table.clone();
tokio::spawn(async move {
table
.refresh_column_async("b_mid")
.await
.unwrap()
.wait()
.await
})
};
reached.notified().await;
append(&table, vec![10]).await;
resume.notify_one();
let result = refresh.await.unwrap().unwrap();
assert_eq!(result.rows_assigned, 3);
// The appended row is in the reported source version but was deferred.
assert_eq!(result.rows_remaining, 1);
table.refresh_column("a_mid").await.unwrap();
table.refresh_column("c_mid").await.unwrap();
assert_eq!(table.count_rows(Some("b_mid = 0".into())).await.unwrap(), 0);
assert_eq!(table.refresh_column("b_mid").await.unwrap().rows_filled, 1);
assert_eq!(
table.count_rows(Some("b_mid = 23".into())).await.unwrap(),
1
);
}
/// A dependency commit is a publication even when the requested column
/// itself fills nothing.
#[tokio::test]
async fn test_refresh_async_reports_a_dependency_publication() {
let table = table_with("refresh_async_dependency_publication", vec![1, 2, 3]).await;
table
.add_columns()
.computed("a", "x + 1")
.computed("b", "nullif(a, a)")
.execute()
.await
.unwrap();
let result = table
.refresh_column_async("b")
.await
.unwrap()
.wait()
.await
.unwrap();
assert_eq!(result.rows_assigned, 0);
assert_eq!(result.published_version, Some(result.source_version));
}
/// The gate's reproducer: a dense graph is walked once, not once per path.
#[tokio::test]
async fn test_dependency_refresh_visits_each_column_once() {
let table = table_with("dependency_refresh_deduplicates", vec![1]).await;
let mut declaration = table.add_columns();
for i in 0..=6 {
let expression = if i == 0 {
"x + 1".to_string()
} else {
(0..i)
.map(|j| format!("a{j}"))
.collect::<Vec<_>>()
.join(" + ")
};
declaration = declaration.computed(format!("a{i}"), expression);
}
declaration.execute().await.unwrap();
let order = super::dependency_order(table.schema().await.unwrap().as_ref(), "a6").unwrap();
assert_eq!(order, (0..6).map(|i| format!("a{i}")).collect::<Vec<_>>());
// One commit per column, the requested one included.
let before = table.version().await.unwrap();
table.refresh_column("a6").await.unwrap();
assert_eq!(table.version().await.unwrap(), before + 7);
}
#[tokio::test]
async fn test_refresh_fills_a_declared_column() {
let table = table_with("refresh_fills", vec![1, 2, 3]).await;