From 39a8ed2b901fc37574d2f6ea88aca934f61753e0 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 23 Jul 2026 08:55:31 -0700 Subject: [PATCH] refactor(mem-wal): rename flushed MemTable/generation to SSTable A flushed MemTable generation is a persisted, immutable, BTree-indexed Lance dataset - functionally an SSTable in LSM terms. Rename the unit noun (FlushedGeneration / flushed_generations / flushed MemTable) to SsTable / sstables across the proto, lance-table core, the mem_wal scanner/writer, Python and Java bindings, benches, and the MemWAL spec. "sstable" already implies flushed, so the qualifier is dropped. Unchanged: the flush verb, WAL-durability terms (all_flushed_to_wal, rows_flushed, unflushed_memtable_bytes), and the generation number concept (LsmGeneration, MergedGeneration, current_generation, on-disk _gen_{i}). MemWAL is experimental; proto field numbers are preserved (wire-compatible) and no deprecation shims are added. --- docs/src/format/table/mem_wal.md | 88 ++++---- java/lance-jni/src/mem_wal.rs | 32 +-- .../lance/memwal/LsmPointLookupPlanner.java | 4 +- .../java/org/lance/memwal/LsmScanner.java | 6 +- .../lance/memwal/LsmVectorSearchPlanner.java | 6 +- .../org/lance/memwal/MergedGeneration.java | 4 +- .../java/org/lance/memwal/ShardSnapshot.java | 18 +- .../java/org/lance/memwal/ShardWriter.java | 5 +- .../{FlushedGeneration.java => SsTable.java} | 10 +- .../java/org/lance/memwal/MemWalTest.java | 28 +-- protos/table.proto | 12 +- python/python/lance/lance/__init__.pyi | 2 +- python/python/lance/mem_wal.py | 26 +-- python/python/tests/test_mem_wal.py | 46 ++-- python/src/mem_wal.rs | 32 +-- rust/lance-table/src/system_index/mem_wal.rs | 34 ++- .../mem_wal/fts/mem_wal_fts_read_bench.rs | 47 ++--- .../benches/mem_wal/fts/run_fts_read_sweep.sh | 10 +- .../mem_wal/kv/mem_wal_kv_point_lookup.rs | 103 +++++---- .../mem_wal_point_lookup_bench.rs | 75 +++---- .../mem_wal/vector/hnsw/disk_ann_compare.py | 182 ++++++++++++---- .../vector/hnsw/mem_wal_recall_hnsw.rs | 23 +- .../mem_wal/vector/mem_wal_vector_bench.rs | 37 ++-- rust/lance/src/dataset.rs | 2 +- rust/lance/src/dataset/mem_wal.rs | 2 +- rust/lance/src/dataset/mem_wal/api.rs | 37 ++-- rust/lance/src/dataset/mem_wal/index.rs | 2 +- rust/lance/src/dataset/mem_wal/manifest.rs | 6 +- rust/lance/src/dataset/mem_wal/memtable.rs | 2 +- .../src/dataset/mem_wal/memtable/flush.rs | 129 ++++++------ .../scanner/exec/brute_force_vector.rs | 2 +- .../mem_wal/memtable/scanner/exec/fts.rs | 4 +- rust/lance/src/dataset/mem_wal/scanner.rs | 10 +- .../src/dataset/mem_wal/scanner/block_list.rs | 68 +++--- .../src/dataset/mem_wal/scanner/builder.rs | 88 ++++---- .../src/dataset/mem_wal/scanner/collector.rs | 98 +++++---- .../dataset/mem_wal/scanner/data_source.rs | 41 ++-- .../mem_wal/scanner/exec/pk_block_filter.rs | 8 +- .../src/dataset/mem_wal/scanner/fts_search.rs | 70 +++---- .../src/dataset/mem_wal/scanner/planner.rs | 110 +++++----- .../dataset/mem_wal/scanner/point_lookup.rs | 60 +++--- .../src/dataset/mem_wal/scanner/projection.rs | 4 +- .../{flushed_cache.rs => sstable_cache.rs} | 76 ++++--- .../dataset/mem_wal/scanner/vector_search.rs | 110 +++++----- rust/lance/src/dataset/mem_wal/util.rs | 19 +- rust/lance/src/dataset/mem_wal/wal.rs | 2 +- rust/lance/src/dataset/mem_wal/write.rs | 196 +++++++++--------- 47 files changed, 1008 insertions(+), 968 deletions(-) rename java/src/main/java/org/lance/memwal/{FlushedGeneration.java => SsTable.java} (78%) rename rust/lance/src/dataset/mem_wal/scanner/{flushed_cache.rs => sstable_cache.rs} (86%) diff --git a/docs/src/format/table/mem_wal.md b/docs/src/format/table/mem_wal.md index 92a5c5fce4a..fd02d37f44d 100644 --- a/docs/src/format/table/mem_wal.md +++ b/docs/src/format/table/mem_wal.md @@ -14,7 +14,7 @@ Append-only MemWAL tables may omit a primary key. MemWAL adds a set of shards on top of the base table. Writers append to shards. -Each shard keeps recent data in an in-memory MemTable, persists writes to a per-shard WAL, flushes MemTables as small Lance datasets, and later merges those flushed generations into the base table. +Each shard keeps recent data in an in-memory MemTable, persists writes to a per-shard WAL, flushes MemTables as small Lance datasets, and later merges those SSTables into the base table. The base table manifest contains one MemWAL system index entry named `__lance_mem_wal`. This index stores MemWAL configuration and global progress metadata inline in `IndexMetadata.index_details`. @@ -24,7 +24,7 @@ Each shard's own manifest remains authoritative for shard-local mutable state. A **MemWAL shard** is the unit of horizontal write scaling. Each shard has exactly one active writer epoch at a time. -Writers claim a shard, append WAL entries, update the in-memory MemTable, and publish flushed MemTable generations by updating the shard manifest. +Writers claim a shard, append WAL entries, update the in-memory MemTable, and publish SSTable generations by updating the shard manifest. For primary-key tables, all rows for the same primary key must map to the same shard. If one primary key can appear in multiple shards, asynchronous merge order between shards can make an older row overwrite a newer row. @@ -53,14 +53,14 @@ Readers that need the latest shard set list `_mem_wal/` and read each shard's la Within a shard, writes first enter an in-memory **MemTable** and are durably appended to the shard **write-ahead log (WAL)**. The MemTable is periodically **flushed** to storage as a Lance dataset. -Flushed MemTables are asynchronously **merged** into the base table. +SSTables are asynchronously **merged** into the base table. ### MemTable A MemTable holds rows inserted into a shard before those rows are flushed to storage. It serves two purposes: -1. It buffers data and per-MemTable indexes before a flushed generation is written. +1. It buffers data and per-MemTable indexes before an SSTable is written. 2. It lets readers access data that has not been flushed yet when strong consistency is required. The storage format does not prescribe the in-memory MemTable layout. @@ -78,7 +78,7 @@ Generation numbers order data freshness within one shard: - Base table data has generation 0. - Higher MemWAL generations are newer. - Within the active in-memory generation, higher row positions are newer. -- Within a flushed generation, flush-time deletion vectors hide older duplicate primary-key rows, so readers see at most the newest row for each primary key. +- Within an SSTable, flush-time deletion vectors hide older duplicate primary-key rows, so readers see at most the newest row for each primary key. ## WAL @@ -123,15 +123,15 @@ For example, position 5 is encoded as: 1010000000000000000000000000000000000000000000000000000000000000.arrow ``` -## Flushed MemTable +## SSTable -A flushed MemTable is a persisted MemTable generation. +An SSTable is a persisted MemTable generation — the immutable result of flushing a MemTable. It is stored as a Lance dataset under its shard directory. !!! note - This structure is similar to a sorted string table in other LSM implementations, but MemWAL flushed generations are not sorted by key. + Unlike a classic LSM sorted string table, a MemWAL SSTable is not sorted by key; random access is instead served by its BTree primary-key sidecar. It is called an SSTable because it is an immutable, persisted, indexed run. -### Flushed MemTable Storage Layout +### SSTable Storage Layout Generation `i` is flushed to: @@ -141,10 +141,10 @@ _mem_wal/{shard_id}/{random8}_gen_{i}/ `{random8}` is an 8-character random hex value generated for each flush attempt. If a flush attempt fails, a retry writes a different directory instead of reusing a partially written one. -The shard manifest records the successful directory name in `flushed_generations.path`. +The shard manifest records the successful directory name in the SSTable's `path`. The generation directory is a standard Lance dataset written with the base table's data storage version. -Each flushed generation is written as one fragment. +Each SSTable is written as one fragment. Additional MemWAL sidecars may be present: ```text @@ -160,17 +160,17 @@ Additional MemWAL sidecars may be present: The exact Lance dataset internals follow the [Lance table storage layout](layout.md). -### Flushed Row Order +### SSTable Row Order -Flushed MemTable rows are written in forward insert order. +SSTable rows are written in forward insert order. Physical row offsets increase with write time. -For a duplicate primary key within one flushed generation, the newest row has the largest physical offset. +For a duplicate primary key within one SSTable, the newest row has the largest physical offset. -Primary-key flushed generations use a deletion vector to expose last-write-wins semantics. +Primary-key SSTables use a deletion vector to expose last-write-wins semantics. During flush, the writer scans rows in forward order, keeps the last occurrence of each primary key, and marks all earlier duplicate offsets deleted. The deletion vector is attached to fragment 0 in the generation manifest. -Append-only flushed generations without a primary key do not perform primary-key deduplication and retain every row. +Append-only SSTables without a primary key do not perform primary-key deduplication and retain every row. ### Tombstone Rows @@ -179,10 +179,10 @@ Tombstone rows follow the same forward row ordering and deletion-vector rules as If the newest row for a primary key is a tombstone, the deletion vector keeps that tombstone row and hides older rows for the key. Read planning then filters `_tombstone = false`, so the key is absent from query results. -### Flushed Primary-Key Sidecars +### SSTable Primary-Key Sidecars Primary-key MemTables maintain an implicit BTree for primary-key deduplication, independent of `maintained_indexes`. -When a primary-key MemTable is flushed, the flushed generation writes two primary-key sidecars: +When a primary-key MemTable is flushed, the SSTable writes two primary-key sidecars: - `bloom_filter.bin` stores the generation's primary-key bloom filter and lets point lookups skip generations that cannot contain the queried key. - `_pk_index/` stores a standalone BTree over primary-key values to forward row ids. @@ -237,20 +237,20 @@ Composite primary-key columns must use one of the supported encodings above. The sidecar row ids are in the same forward row-position space as the data files, deletion vector, and maintained user indexes. The sidecar is used for cross-generation membership and block-list checks. -It is not used to choose the newest row inside the same flushed generation; the deletion vector has already hidden older same-generation duplicates. +It is not used to choose the newest row inside the same SSTable; the deletion vector has already hidden older same-generation duplicates. ### Maintained User Indexes -When the MemWAL index lists `maintained_indexes`, flush may build matching indexes inside the flushed generation. +When the MemWAL index lists `maintained_indexes`, flush may build matching indexes inside the SSTable. These index files live in the generation's `_indices/{index_uuid}/` directory and are recorded in the generation manifest. The implicit primary-key BTree sidecar is not included in `maintained_indexes` and does not live under `_indices/`. These indexes use the same row-position space as the forward-written data files. If the generation has a primary key, the generation deletion vector masks stale duplicate rows for indexed reads as well. -### Merging Flushed Generations +### Merging SSTables -Flushed generations are merged into the base table in ascending generation order within each shard. +SSTables are merged into the base table in ascending generation order within each shard. Lower generation numbers are older and must merge before higher generation numbers. The base table merge uses merge-insert semantics so newer rows overwrite older rows for the same primary key. @@ -266,14 +266,14 @@ The manifest contains: - **Identity**: `shard_id`, `shard_spec_id`, and `shard_field_entries`. - **Fencing state**: `writer_epoch`. - **WAL pointers**: `replay_after_wal_entry_position` and `wal_entry_position_last_seen`. -- **Generation state**: `current_generation` and `flushed_generations`. +- **Generation state**: `current_generation` and `sstables`. - **Lifecycle state**: `status`, either `ACTIVE` or `SEALED`. `shard_field_entries` stores computed shard field values as raw Arrow scalar bytes keyed by `ShardingField.field_id`. The matching `ShardingField.result_type` determines how to decode each value. For example, `int32` values are four little-endian bytes and `utf8` values are raw UTF-8 bytes. -`replay_after_wal_entry_position` is the most recent 1-based WAL position covered by a flushed generation. +`replay_after_wal_entry_position` is the most recent 1-based WAL position covered by an SSTable. The default value 0 means no WAL entry has been covered and recovery starts at position 1. `wal_entry_position_last_seen` is a best-effort hint for the most recent WAL position observed at manifest update time. @@ -330,7 +330,7 @@ The `index_details` field contains a `MemWalIndexDetails` protobuf message. Important fields: - `sharding_specs`: sharding configuration used by writers and shard pruning. -- `maintained_indexes`: names of base-table indexes to maintain in MemTables and flushed generations. +- `maintained_indexes`: names of base-table indexes to maintain in MemTables and SSTables. - `writer_config_defaults`: string map of default writer configuration values persisted for all writers. - `merged_generations`: per-shard merge progress, updated atomically with base-table merge commits. - `index_catchup`: per-index coverage progress after data has merged to the base table. @@ -423,7 +423,7 @@ The MemWAL storage layout is: └── bloom_filter.bin ``` -Some flushed-generation subdirectories are conditional. +Some SSTable subdirectories are conditional. For example, `_deletions/` is present only when the generation manifest references a deletion vector, `_indices/` is present only when maintained user indexes are built, and `_pk_index/` plus `bloom_filter.bin` are meaningful for primary-key tables. ## Implementation Expectation @@ -433,11 +433,11 @@ Implementations may choose different in-memory structures, buffering policies, b An implementation is compatible when it: -1. Writes WAL entries, shard manifests, flushed generations, and MemWAL index metadata using the documented layout. +1. Writes WAL entries, shard manifests, SSTables, and MemWAL index metadata using the documented layout. 2. Preserves WAL position, writer fencing, and manifest versioning invariants. 3. Exposes last-write-wins semantics for primary-key tables. 4. Preserves append-only semantics for tables without primary keys. -5. Maintains generation ordering when merging flushed MemTables into the base table. +5. Maintains generation ordering when merging SSTables into the base table. ## Writer Expectations @@ -472,11 +472,11 @@ Fence sentinel entries make this collision path explicit without storing data ba ## Background Job Expectations -Background jobs merge flushed generations into the base table and remove obsolete shard data. +Background jobs merge SSTables into the base table and remove obsolete shard data. ### MemTable Merger -Flushed MemTables must merge into the base table in ascending generation order within each shard. +SSTables must merge into the base table in ascending generation order within each shard. The merge uses Lance merge-insert semantics and updates `merged_generations[shard_id]` atomically with the base-table commit. On commit conflict, a merger reloads the conflicting base-table version: @@ -486,7 +486,7 @@ On commit conflict, a merger reloads the conflicting base-table version: ### Garbage Collector -The garbage collector may remove obsolete flushed generations after: +The garbage collector may remove obsolete SSTables after: 1. The generation has been merged to the base table. 2. Every maintained index has caught up to cover the merged generation, or the generation is no longer needed for indexed reads. @@ -503,19 +503,19 @@ The garbage collector may remove obsolete flushed generations after: ### LSM Tree Merging Read -For primary-key tables, readers merge rows from the base table, flushed MemTables, and optionally in-memory MemTables by primary key. +For primary-key tables, readers merge rows from the base table, SSTables, and optionally in-memory MemTables by primary key. The newest row wins. Freshness ordering within one shard is: 1. Higher generation wins. 2. Within the active in-memory generation, higher row position wins. -3. Within a flushed generation, the generation's deletion vector has already hidden older duplicate primary-key rows. +3. Within an SSTable, the generation's deletion vector has already hidden older duplicate primary-key rows. The base table has generation 0. MemWAL generations are positive. This ordering applies only to sources selected for the same read plan. -Readers must not include a flushed generation that is already covered by the base table according to `merged_generations[shard_id]`, because otherwise the positive MemWAL generation would incorrectly outrank base-table rows during deduplication. +Readers must not include an SSTable that is already covered by the base table according to `merged_generations[shard_id]`, because otherwise the positive MemWAL generation would incorrectly outrank base-table rows during deduplication. Rows from different shards do not need primary-key deduplication if the sharding spec guarantees that each primary key maps to exactly one shard. Append-only tables without a primary key do not perform primary-key deduplication. @@ -524,7 +524,7 @@ Rows from all selected sources are distinct appended rows. ### Tombstones Readers must treat `_tombstone = true` rows as delete markers. -In flushed generations, deletion vectors first resolve same-generation duplicate primary keys. +In SSTables, deletion vectors first resolve same-generation duplicate primary keys. Then query planning filters tombstone rows from user-visible results. In active in-memory MemTables, the newest visible row position for a primary key wins; if that row is a tombstone, the key is absent. @@ -540,11 +540,11 @@ Otherwise, reads are eventually consistent because unflushed data or newly-creat Reading a stale MemWAL index snapshot does not corrupt last-write-wins ordering, but it can reduce freshness: -- If a merged flushed generation is still listed, readers must skip it when `generation <= merged_generations[shard_id]`. - For primary-key tables, including it would let an older flushed row outrank newer base-table contents because MemWAL generations are positive and the base table is modeled as generation 0. +- If a merged SSTable is still listed, readers must skip it when `generation <= merged_generations[shard_id]`. + For primary-key tables, including it would let an older SSTable row outrank newer base-table contents because MemWAL generations are positive and the base table is modeled as generation 0. For append-only tables, including it would return the same append twice. -- If a garbage-collected flushed generation is still listed, readers may skip it after failing to open it because its data must already be in the base table or be filtered out by `merged_generations`. -- If a newly flushed generation is not listed, the read is consistent with the older snapshot but may miss fresher data. +- If a garbage-collected SSTable is still listed, readers may skip it after failing to open it because its data must already be in the base table or be filtered out by `merged_generations`. +- If a newly SSTable is not listed, the read is consistent with the older snapshot but may miss fresher data. Readers that require latest shard membership should list `_mem_wal/` and read shard manifests instead of relying only on snapshots. @@ -553,14 +553,14 @@ Readers that require latest shard membership should list `_mem_wal/` and read sh A query planner collects sources from: 1. The base table. -2. Flushed MemTables that are not yet safely replaceable by base-table indexed reads. +2. SSTables that are not yet safely replaceable by base-table indexed reads. 3. Active in-memory MemTables, when available and required by the requested consistency level. Each source is tagged with its shard and generation. For primary-key reads, the planner applies LSM deduplication across selected sources. For append-only reads, the planner concatenates selected sources without primary-key deduplication. -Bloom filters and `_pk_index/` sidecars help prune flushed generations during point lookups and cross-generation deduplication. +Bloom filters and `_pk_index/` sidecars help prune SSTables during point lookups and cross-generation deduplication. ### Shard Pruning @@ -574,10 +574,10 @@ For example, with `bucket(user_id, 10)` and predicate `user_id = 123`: ### Indexed Read Plan -When data is merged from a flushed MemTable into the base table, base-table indexes may lag behind the data commit. +When data is merged from an SSTable into the base table, base-table indexes may lag behind the data commit. `index_catchup` records which merged generation each base-table index covers. -If an indexed query needs index `I` and `I` has only caught up to generation `G` while `merged_generations[shard_id]` is higher, the planner should read the gap from flushed-generation indexes instead of scanning unindexed base-table rows. +If an indexed query needs index `I` and `I` has only caught up to generation `G` while `merged_generations[shard_id]` is higher, the planner should read the gap from SSTable indexes instead of scanning unindexed base-table rows. Once index `I` catches up, the planner can use the base-table index for those merged rows. ## Appendices @@ -616,7 +616,7 @@ MemWAL index: Shard manifest: current_generation: 8 - flushed_generations: + sstables: - generation: 6, path: "abc12345_gen_6" - generation: 7, path: "def67890_gen_7" ``` diff --git a/java/lance-jni/src/mem_wal.rs b/java/lance-jni/src/mem_wal.rs index 9047c6288b0..3c11ee8da25 100644 --- a/java/lance-jni/src/mem_wal.rs +++ b/java/lance-jni/src/mem_wal.rs @@ -26,7 +26,7 @@ use jni::objects::{JClass, JMap, JObject, JString, JValueGen}; use jni::sys::{jdouble, jint, jlong}; use lance::dataset::Dataset as LanceDataset; use lance::dataset::mem_wal::scanner::{ - FlushedGeneration, LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, + LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, SsTable, parse_filter_expr as parse_lsm_filter_expr, write_pk_sidecar, }; use lance::dataset::mem_wal::write::{MemTableStats, WriteStatsSnapshot}; @@ -204,10 +204,10 @@ fn inner_delete(env: &mut JNIEnv, this: JObject, stream_addr: jlong) -> Result<( Ok(()) } -/// Test-support: write a primary-key dedup sidecar (`_pk_index/`) for a -/// flushed-generation dataset already staged at `gen_path`, mirroring what -/// production flush emits. Lets Java tests stage a *faithful* flushed -/// generation (dataset + sidecar); production always writes the sidecar during +/// Test-support: write a primary-key dedup sidecar (`_pk_index/`) for an +/// SSTable dataset already staged at `gen_path`, mirroring what +/// production flush emits. Lets Java tests stage a *faithful* SSTable +/// (dataset + sidecar); production always writes the sidecar during /// flush, so a dataset-without-sidecar is not a state the system produces. /// Mirrors the Python `_write_pk_sidecar` binding. #[unsafe(no_mangle)] @@ -1119,13 +1119,13 @@ fn read_shard_snapshots(env: &mut JNIEnv, list_obj: &JObject) -> Result ShardSnapshot { shard_id: manifest.shard_id, spec_id: manifest.shard_spec_id, current_generation: manifest.current_generation, - flushed_generations: manifest - .flushed_generations + sstables: manifest + .sstables .into_iter() - .map(|generation| FlushedGeneration { - generation: generation.generation, - path: generation.path, + .map(|sstable| SsTable { + generation: sstable.generation, + path: sstable.path, }) .collect(), } diff --git a/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java b/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java index 0488f9e4530..d58e4e81d57 100644 --- a/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java +++ b/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java @@ -44,7 +44,7 @@ public class LsmPointLookupPlanner implements AutoCloseable { /** * @param dataset the base dataset - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include */ public LsmPointLookupPlanner(Dataset dataset, List shardSnapshots) { this(dataset, shardSnapshots, null); @@ -52,7 +52,7 @@ public LsmPointLookupPlanner(Dataset dataset, List shardSnapshots /** * @param dataset the base dataset - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include * @param pkColumns primary key column names; inferred from schema metadata when {@code null} */ public LsmPointLookupPlanner( diff --git a/java/src/main/java/org/lance/memwal/LsmScanner.java b/java/src/main/java/org/lance/memwal/LsmScanner.java index 29bfb097375..509e70f7b0a 100644 --- a/java/src/main/java/org/lance/memwal/LsmScanner.java +++ b/java/src/main/java/org/lance/memwal/LsmScanner.java @@ -31,8 +31,8 @@ * LSM-aware scanner covering all MemWAL data levels. * *

Results are deduplicated by primary key, always returning the newest version of each row - * across the base table, flushed MemTables, and (when created from a {@link ShardWriter}) the - * active MemTable. + * across the base table, SSTables, and (when created from a {@link ShardWriter}) the active + * MemTable. * *

The builder methods ({@link #project}, {@link #filter}, {@link #limit}, {@link * #withRowAddress}, {@link #withMemtableGen}) mutate this scanner and return it for chaining. @@ -55,7 +55,7 @@ private LsmScanner() {} * read-your-writes consistency. * * @param dataset the base dataset to scan - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include * @return an LSM scanner */ public static LsmScanner fromSnapshots(Dataset dataset, List shardSnapshots) { diff --git a/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java b/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java index 75c0d86c475..b17bb8e37a0 100644 --- a/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java +++ b/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java @@ -44,7 +44,7 @@ public class LsmVectorSearchPlanner implements AutoCloseable { /** * @param dataset the base dataset - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include * @param vectorColumn name of the {@code FixedSizeList} vector column */ public LsmVectorSearchPlanner( @@ -54,7 +54,7 @@ public LsmVectorSearchPlanner( /** * @param dataset the base dataset - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include * @param vectorColumn name of the {@code FixedSizeList} vector column * @param pkColumns primary key column names; inferred from schema metadata when {@code null} * @param distanceType distance metric, one of {@code "l2"}, {@code "cosine"}, {@code "dot"}, @@ -71,7 +71,7 @@ public LsmVectorSearchPlanner( /** * @param dataset the base dataset - * @param shardSnapshots shard snapshots specifying the flushed generations to include + * @param shardSnapshots shard snapshots specifying the SSTables to include * @param vectorColumn name of the {@code FixedSizeList} vector column * @param pkColumns primary key column names; inferred from schema metadata when {@code null} * @param distanceType distance metric, one of {@code "l2"}, {@code "cosine"}, {@code "dot"}, diff --git a/java/src/main/java/org/lance/memwal/MergedGeneration.java b/java/src/main/java/org/lance/memwal/MergedGeneration.java index 481e79ba3c0..418f3de2c9d 100644 --- a/java/src/main/java/org/lance/memwal/MergedGeneration.java +++ b/java/src/main/java/org/lance/memwal/MergedGeneration.java @@ -17,7 +17,7 @@ import com.google.common.base.Preconditions; /** - * Identifies a flushed MemWAL generation that has been merged into the base table. + * Identifies an SSTable that has been merged into the base table. * *

Pass a list of these to {@link org.lance.merge.MergeInsertParams#markGenerationsAsMerged} so * Lance knows which generations are now part of the base table. @@ -28,7 +28,7 @@ public class MergedGeneration { /** * @param shardId UUID string for the write shard - * @param generation generation number from {@link ShardSnapshot#flushedGenerations()} + * @param generation generation number from {@link ShardSnapshot#sstables()} */ public MergedGeneration(String shardId, long generation) { Preconditions.checkNotNull(shardId, "shardId must not be null"); diff --git a/java/src/main/java/org/lance/memwal/ShardSnapshot.java b/java/src/main/java/org/lance/memwal/ShardSnapshot.java index 493b2fce496..42e20bb5177 100644 --- a/java/src/main/java/org/lance/memwal/ShardSnapshot.java +++ b/java/src/main/java/org/lance/memwal/ShardSnapshot.java @@ -24,13 +24,13 @@ * Snapshot of a MemWAL shard's state, used when constructing scanners and planners. * *

The builder methods ({@link #withSpecId}, {@link #withCurrentGeneration}, {@link - * #withFlushedGeneration}) mutate this instance and return it for chaining. + * #withSsTable}) mutate this instance and return it for chaining. */ public class ShardSnapshot { private final String shardId; private int specId = 0; private long currentGeneration = 0; - private final List flushedGenerations = new ArrayList<>(); + private final List sstables = new ArrayList<>(); /** * @param shardId UUID string for the write shard @@ -52,10 +52,10 @@ public ShardSnapshot withCurrentGeneration(long currentGeneration) { return this; } - /** Add a flushed generation with its storage path. */ - public ShardSnapshot withFlushedGeneration(long generation, String path) { + /** Add an SSTable with its storage path. */ + public ShardSnapshot withSsTable(long generation, String path) { Preconditions.checkNotNull(path, "path must not be null"); - this.flushedGenerations.add(new FlushedGeneration(generation, path)); + this.sstables.add(new SsTable(generation, path)); return this; } @@ -74,9 +74,9 @@ public long currentGeneration() { return currentGeneration; } - /** The flushed generations included in this snapshot. */ - public List flushedGenerations() { - return Collections.unmodifiableList(flushedGenerations); + /** The SSTables included in this snapshot. */ + public List sstables() { + return Collections.unmodifiableList(sstables); } @Override @@ -85,7 +85,7 @@ public String toString() { .add("shardId", shardId) .add("specId", specId) .add("currentGeneration", currentGeneration) - .add("flushedGenerations", flushedGenerations) + .add("sstables", sstables) .toString(); } } diff --git a/java/src/main/java/org/lance/memwal/ShardWriter.java b/java/src/main/java/org/lance/memwal/ShardWriter.java index 3414ebda616..da4c621e934 100644 --- a/java/src/main/java/org/lance/memwal/ShardWriter.java +++ b/java/src/main/java/org/lance/memwal/ShardWriter.java @@ -151,9 +151,8 @@ public MemTableStats memtableStats() { /** * Create an LSM scanner that includes this writer's active MemTable. * - *

The scanner covers the base table, the given flushed generations, and the current active - * MemTable, providing read-your-writes consistency. This writer's own shard is included - * automatically. + *

The scanner covers the base table, the given SSTables, and the current active MemTable, + * providing read-your-writes consistency. This writer's own shard is included automatically. * * @param shardSnapshots snapshots of other shards to include * @return an LSM scanner diff --git a/java/src/main/java/org/lance/memwal/FlushedGeneration.java b/java/src/main/java/org/lance/memwal/SsTable.java similarity index 78% rename from java/src/main/java/org/lance/memwal/FlushedGeneration.java rename to java/src/main/java/org/lance/memwal/SsTable.java index 66288161438..55cb2e63f77 100644 --- a/java/src/main/java/org/lance/memwal/FlushedGeneration.java +++ b/java/src/main/java/org/lance/memwal/SsTable.java @@ -15,22 +15,22 @@ import com.google.common.base.MoreObjects; -/** A flushed MemWAL generation and the storage path of its Lance files. */ -public class FlushedGeneration { +/** An SSTable and the storage path of its Lance files. */ +public class SsTable { private final long generation; private final String path; - public FlushedGeneration(long generation, String path) { + public SsTable(long generation, String path) { this.generation = generation; this.path = path; } - /** The generation number of this flushed MemTable. */ + /** The generation number of this SSTable. */ public long generation() { return generation; } - /** The storage path of the flushed Lance files. */ + /** The storage path of the SSTable Lance files. */ public String path() { return path; } diff --git a/java/src/test/java/org/lance/memwal/MemWalTest.java b/java/src/test/java/org/lance/memwal/MemWalTest.java index 3ee998733ea..a2107f7c988 100644 --- a/java/src/test/java/org/lance/memwal/MemWalTest.java +++ b/java/src/test/java/org/lance/memwal/MemWalTest.java @@ -160,12 +160,12 @@ private static Dataset writeAppendOnlyDataset( } /** - * Stage a faithful flushed generation at {@code genPath}: the Lance dataset plus its - * primary-key dedup sidecar ({@code _pk_index/}), mirroring what production flush emits. The LSM - * scanner's cross-generation block-list opens the sidecar, so a dataset alone (no sidecar) is not - * a state production produces. Mirrors the Python {@code _write_flushed_gen} test helper. + * Stage a faithful SSTable at {@code genPath}: the Lance dataset plus its primary-key + * dedup sidecar ({@code _pk_index/}), mirroring what production flush emits. The LSM scanner's + * cross-generation block-list opens the sidecar, so a dataset alone (no sidecar) is not a state + * production produces. Mirrors the Python {@code _write_sstable} test helper. */ - private static void writeFlushedGen( + private static void writeSsTable( BufferAllocator allocator, String genPath, long[] ids, String prefix) throws Exception { writeLookupDataset(allocator, genPath, ids, prefix).close(); try (VectorSchemaRoot root = lookupRoot(allocator, ids, prefix); @@ -177,8 +177,8 @@ private static void writeFlushedGen( } /** - * Test-support native: write the primary-key dedup sidecar for a flushed-generation dataset - * already staged at {@code genPath}. See {@link #writeFlushedGen}. + * Test-support native: write the primary-key dedup sidecar for an SSTable dataset already staged + * at {@code genPath}. See {@link #writeSsTable}. */ private static native void nativeWritePkSidecar( String genPath, long streamAddress, List pkColumns); @@ -451,12 +451,12 @@ void testLsmScannerFromSnapshots(@TempDir Path tempDir) throws Exception { Dataset dataset = writeLookupDataset(allocator, basePath, new long[] {1, 2, 3}, "base")) { dataset.initializeMemWal(new InitializeMemWalParams()); - // Flushed generation overwrites id=2. + // SSTable overwrites id=2. String genPath = basePath + "/_mem_wal/" + shardId + "/gen_1"; - writeFlushedGen(allocator, genPath, new long[] {2}, "gen1"); + writeSsTable(allocator, genPath, new long[] {2}, "gen1"); ShardSnapshot snapshot = - new ShardSnapshot(shardId).withFlushedGeneration(1, "gen_1").withCurrentGeneration(2); + new ShardSnapshot(shardId).withSsTable(1, "gen_1").withCurrentGeneration(2); try (LsmScanner scanner = LsmScanner.fromSnapshots(dataset, Collections.singletonList(snapshot)); @@ -464,7 +464,7 @@ void testLsmScannerFromSnapshots(@TempDir Path tempDir) throws Exception { Map byId = readByName(reader); assertEquals(3, byId.size(), "Expected 3 deduplicated rows"); assertEquals("base_1", byId.get(1L)); - assertEquals("gen1_2", byId.get(2L), "Flushed generation must win over base"); + assertEquals("gen1_2", byId.get(2L), "SSTable must win over base"); assertEquals("base_3", byId.get(3L)); } @@ -487,14 +487,14 @@ void testPointLookup(@TempDir Path tempDir) throws Exception { dataset.initializeMemWal(new InitializeMemWalParams()); String genPath = basePath + "/_mem_wal/" + shardId + "/gen_1"; - writeFlushedGen(allocator, genPath, new long[] {2}, "gen1"); + writeSsTable(allocator, genPath, new long[] {2}, "gen1"); ShardSnapshot snapshot = - new ShardSnapshot(shardId).withFlushedGeneration(1, "gen_1").withCurrentGeneration(2); + new ShardSnapshot(shardId).withSsTable(1, "gen_1").withCurrentGeneration(2); try (LsmPointLookupPlanner planner = new LsmPointLookupPlanner(dataset, Collections.singletonList(snapshot))) { - // id=2 must resolve to the flushed-generation value. + // id=2 must resolve to the SSTable value. assertEquals("gen1_2", lookup(planner, allocator, 2L)); // id=1 only exists in the base table. assertEquals("base_1", lookup(planner, allocator, 1L)); diff --git a/protos/table.proto b/protos/table.proto index 7d4fb19d50b..8ad99a74e88 100644 --- a/protos/table.proto +++ b/protos/table.proto @@ -648,8 +648,8 @@ message ShardManifest { // Field 7 removed: merged_generation moved to MemWalIndexDetails.merged_generations // which is the authoritative source for merge progress. - // List of flushed MemTable generations and their directory paths. - repeated FlushedGeneration flushed_generations = 8; + // List of SSTables (flushed MemTable generations) and their directory paths. + repeated SsTable sstables = 8; // Lifecycle status. Default ACTIVE; SEALED marks an in-flight drop // (drop-table 2PC). A SEALED manifest refuses claims at claim_epoch. @@ -666,9 +666,9 @@ message ShardFieldEntry { bytes value = 2; } -// A flushed MemTable generation and its storage location. -message FlushedGeneration { - // Generation number. +// An SSTable: a flushed MemTable generation, stored as a Lance dataset. +message SsTable { + // Generation number identifying this SSTable. uint64 generation = 1; // Directory name relative to the shard directory. @@ -685,7 +685,7 @@ message MergedGeneration { } // Tracks which merged generation a base table index has been rebuilt to cover. -// Used to determine whether to read from flushed MemTable indexes or base table. +// Used to determine whether to read from SSTable indexes or base table. message IndexCatchupProgress { // Name of the base table index (must match an entry in maintained_indexes). string index_name = 1; diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index b9cdc221b82..102a1e38506 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -753,7 +753,7 @@ class _ShardSnapshot: def __init__(self, shard_id: str) -> None: ... def with_spec_id(self, spec_id: int) -> Self: ... def with_current_generation(self, generation: int) -> Self: ... - def with_flushed_generation(self, generation: int, path: str) -> Self: ... + def with_sstable(self, generation: int, path: str) -> Self: ... class _ShardWriter: shard_id: str diff --git a/python/python/lance/mem_wal.py b/python/python/lance/mem_wal.py index 426a5f7ee50..8fabf7b5d1d 100644 --- a/python/python/lance/mem_wal.py +++ b/python/python/lance/mem_wal.py @@ -9,7 +9,7 @@ 1. **WAL** – append-only durable log (raw writes) 2. **Active MemTable** – in-memory write buffer -3. **Flushed MemTable** – Lance files written to object store +3. **SSTable** – Lance files written to object store 4. **Base table** – canonical Lance dataset files (after merge_insert) """ @@ -125,7 +125,7 @@ def _sharding_spec_to_dict(spec: Union[ShardingSpec, Mapping[str, object]]) -> d @dataclass class MergedGeneration: - """Identifies a flushed MemWAL generation that has been merged. + """Identifies an SSTable (by generation) that has been merged. Pass a list of these to mark_generations_as_merged so Lance knows which generations are now in the base table. @@ -135,8 +135,8 @@ class MergedGeneration: shard_id : str UUID string for the write shard. generation : int - Generation number (from - :attr:`ShardSnapshot.flushed_generations`). + Generation number of the merged SSTable (as passed to + :meth:`ShardSnapshot.with_sstable`). """ shard_id: str @@ -170,9 +170,9 @@ def with_current_generation(self, generation: int) -> "ShardSnapshot": self._raw = self._raw.with_current_generation(generation) return self - def with_flushed_generation(self, generation: int, path: str) -> "ShardSnapshot": - """Add a flushed generation with its storage path.""" - self._raw = self._raw.with_flushed_generation(generation, path) + def with_sstable(self, generation: int, path: str) -> "ShardSnapshot": + """Add an SSTable with its storage path.""" + self._raw = self._raw.with_sstable(generation, path) return self def __repr__(self) -> str: @@ -292,7 +292,7 @@ def lsm_scanner( ) -> "LsmScanner": """Create an LSM scanner that includes the active MemTable. - This scanner covers the base table, the given flushed generations, + This scanner covers the base table, the given SSTables, and the current active MemTable — providing strong read-your-writes consistency. @@ -321,10 +321,10 @@ class LsmScanner: """LSM-aware scanner covering all data levels. Deduplicates by primary key, always returning the newest version of - each row across base table, flushed MemTables, and the active MemTable. + each row across base table, SSTables, and the active MemTable. Obtain an instance from `ShardWriter.lsm_scanner` (includes - active MemTable) or `LsmScanner.from_snapshots` (flushed only). + active MemTable) or `LsmScanner.from_snapshots` (SSTables only). The builder methods (`project`, `filter`, `limit`) return ``self`` for chaining. @@ -354,7 +354,7 @@ def from_snapshots( dataset : LanceDataset The base dataset to scan. shard_snapshots : list of ShardSnapshot - Shard snapshots specifying flushed generations to include. + Shard snapshots specifying SSTables to include. """ raw = _LsmScanner.from_snapshots(dataset._ds, [s._raw for s in shard_snapshots]) return LsmScanner(raw) @@ -456,7 +456,7 @@ class LsmPointLookupPlanner: dataset : LanceDataset The base dataset. shard_snapshots : list of ShardSnapshot - Shard snapshots specifying flushed generations to include. + Shard snapshots specifying SSTables to include. pk_columns : list of str, optional Primary key column names. Inferred from schema metadata if omitted. @@ -515,7 +515,7 @@ class LsmVectorSearchPlanner: dataset : LanceDataset The base dataset. shard_snapshots : list of ShardSnapshot - Shard snapshots specifying flushed generations to include. + Shard snapshots specifying SSTables to include. vector_column : str Name of the ``FixedSizeList`` vector column. pk_columns : list of str, optional diff --git a/python/python/tests/test_mem_wal.py b/python/python/tests/test_mem_wal.py index d93f5fa41dd..17c1c6bda52 100644 --- a/python/python/tests/test_mem_wal.py +++ b/python/python/tests/test_mem_wal.py @@ -56,15 +56,15 @@ def _append_only_table(ids, prefix: str) -> pa.Table: ) -def _write_flushed_gen(base_path: str, shard_id: str, gen_folder: str, data: pa.Table): - """Write a flushed-generation Lance dataset at the expected sub-path. +def _write_sstable(base_path: str, shard_id: str, gen_folder: str, data: pa.Table): + """Write an SSTable Lance dataset at the expected sub-path. - The collector resolves flushed generation paths as: + The collector resolves SSTable paths as: {base_dataset_path}/_mem_wal/{shard_id}/{gen_folder} Production flush also writes a primary-key dedup sidecar (`_pk_index/`) that the LSM scanner opens to dedup across generations; stage it here too so the - flushed generation faithfully matches what flush produces. + SSTable faithfully matches what flush produces. """ from lance.lance import _write_pk_sidecar @@ -75,15 +75,15 @@ def _write_flushed_gen(base_path: str, shard_id: str, gen_folder: str, data: pa. def test_point_lookup_with_memtables(tmp_path): """ - Lookup against a base table that has one flushed generation containing an - update. The flushed version must win over the base table version. + Lookup against a base table that has one SSTable containing an + update. The SSTable version must win over the base table version. Setup ----- base : ids [1, 2, 3] names ["base_1", "base_2", "base_3"] gen_1 : ids [2] names ["gen1_2"] ← update to id=2 - ShardSnapshot: flushed_generation(gen=1, path="gen_1"), current_generation=2 + ShardSnapshot: sstable(gen=1, path="gen_1"), current_generation=2 """ ds_path = str(tmp_path / "base") shard_id = str(uuid.uuid4()) @@ -94,20 +94,16 @@ def test_point_lookup_with_memtables(tmp_path): ) base_ds.initialize_mem_wal() - # --- Flushed generation: overwrites id=2 --- - _write_flushed_gen(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) + # --- SSTable: overwrites id=2 --- + _write_sstable(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) - # --- ShardSnapshot describing the flushed state --- - snap = ( - ShardSnapshot(shard_id) - .with_flushed_generation(1, "gen_1") - .with_current_generation(2) - ) + # --- ShardSnapshot describing the SSTable state --- + snap = ShardSnapshot(shard_id).with_sstable(1, "gen_1").with_current_generation(2) planner = LsmPointLookupPlanner(base_ds, [snap]) assert not hasattr(planner, "lookup") - # id=2 must return the flushed version + # id=2 must return the SSTable version plan = planner.plan_lookup(pa.array([2], type=pa.int64())) assert plan.schema.names == ["id", "name"] assert plan.dataset_schema.names == ["id", "name"] @@ -116,7 +112,7 @@ def test_point_lookup_with_memtables(tmp_path): result = plan.to_table() assert len(result) == 1, "Expected exactly one row for id=2" assert result.column("name")[0].as_py() == "gen1_2", ( - "Flushed generation must win over base table" + "SSTable must win over base table" ) # id=1 is only in the base table @@ -147,13 +143,9 @@ def test_lsm_scanner_with_memtables(tmp_path): ) base_ds.initialize_mem_wal() - _write_flushed_gen(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) + _write_sstable(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) - snap = ( - ShardSnapshot(shard_id) - .with_flushed_generation(1, "gen_1") - .with_current_generation(2) - ) + snap = ShardSnapshot(shard_id).with_sstable(1, "gen_1").with_current_generation(2) scanner = LsmScanner.from_snapshots(base_ds, [snap]) table = scanner.to_table() @@ -162,7 +154,7 @@ def test_lsm_scanner_with_memtables(tmp_path): name_by_id = {row["id"]: row["name"] for row in table.to_pylist()} assert name_by_id[1] == "base_1" - assert name_by_id[2] == "gen1_2", "Flushed gen must overwrite base for id=2" + assert name_by_id[2] == "gen1_2", "SSTable gen must overwrite base for id=2" assert name_by_id[3] == "base_3" offset_table = ( @@ -171,7 +163,7 @@ def test_lsm_scanner_with_memtables(tmp_path): assert len(offset_table) == 2, "Offset-only LSM scan should not require a limit" -def test_shard_writer_lsm_scanner_includes_own_flushed_generations(tmp_path): +def test_shard_writer_lsm_scanner_includes_own_sstables(tmp_path): ds_path = str(tmp_path / "base") shard_id = str(uuid.uuid4()) ds = lance.write_dataset(_lookup_table([0], "base"), ds_path, schema=_LOOKUP_SCHEMA) @@ -193,7 +185,7 @@ def test_shard_writer_lsm_scanner_includes_own_flushed_generations(tmp_path): if name_by_id.get(1) == "writer_1" and name_by_id.get(2) == "writer_2": break if time.time() >= deadline: - assert False, "writer.lsm_scanner() did not include flushed writer rows" + assert False, "writer.lsm_scanner() did not include SSTable writer rows" time.sleep(0.05) @@ -333,7 +325,7 @@ def test_shard_writer_e2e_correctness(tmp_path): End-to-end correctness test for ShardWriter covering: - Multi-round writes that trigger WAL and MemTable flushes - File-system layout verification (_mem_wal//wal/ and manifest/) - - Flushed generation data readable via LsmScanner + - SSTable data readable via LsmScanner - New writer created after close can write and scan correctly Mirrors Rust test: shard_writer_tests::test_shard_writer_e2e_correctness diff --git a/python/src/mem_wal.rs b/python/src/mem_wal.rs index 400759770da..17ada971ae4 100644 --- a/python/src/mem_wal.rs +++ b/python/src/mem_wal.rs @@ -18,7 +18,7 @@ use datafusion::prelude::SessionContext; use futures::TryStreamExt; use lance::dataset::Dataset as LanceDataset; use lance::dataset::mem_wal::scanner::{ - FlushedGeneration, LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, + LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, SsTable, parse_filter_expr as parse_lsm_filter_expr, }; use lance::dataset::mem_wal::write::{MemTableStats, WriteStatsSnapshot}; @@ -52,10 +52,10 @@ pub fn py_evaluate_sharding_spec<'py>( result.to_pyarrow(py) } -/// Write a primary-key dedup sidecar (`_pk_index/`) for a flushed-generation +/// Write a primary-key dedup sidecar (`_pk_index/`) for an SSTable /// dataset already written at `gen_path`, mirroring what production flush emits. /// -/// Test-support only: lets Python tests stage a *faithful* flushed generation +/// Test-support only: lets Python tests stage a *faithful* SSTable /// (dataset + sidecar). Production always writes the sidecar during flush, so a /// dataset-without-sidecar is not a state the system otherwise produces. #[pyfunction(name = "_write_pk_sidecar", signature = (gen_path, data, pk_columns))] @@ -161,7 +161,7 @@ impl PyMergedGeneration { /// Snapshot of a MemWAL shard's state at a point in time. /// -/// Used to specify which flushed generations to include when creating an +/// Used to specify which SSTables to include when creating an /// `_LsmScanner`. Supports a builder pattern for adding generations. #[pyclass(name = "_ShardSnapshot", module = "_lib", skip_from_py_object)] #[derive(Clone)] @@ -195,13 +195,13 @@ impl PyShardSnapshot { slf } - /// Add a flushed generation by its generation number and storage path. - pub fn with_flushed_generation( + /// Add an SSTable by its generation number and storage path. + pub fn with_sstable( mut slf: PyRefMut<'_, Self>, generation: u64, path: String, ) -> PyRefMut<'_, Self> { - slf.inner = slf.inner.clone().with_flushed_generation(generation, path); + slf.inner = slf.inner.clone().with_sstable(generation, path); slf } @@ -212,10 +212,10 @@ impl PyShardSnapshot { pub fn __repr__(&self) -> String { format!( - "_ShardSnapshot(shard_id='{}', current_gen={}, flushed_gens={})", + "_ShardSnapshot(shard_id='{}', current_gen={}, sstables={})", self.inner.shard_id, self.inner.current_generation, - self.inner.flushed_generations.len() + self.inner.sstables.len() ) } } @@ -384,7 +384,7 @@ impl PyShardWriter { /// Create an LSM scanner that includes the active MemTable for strong consistency. /// - /// The scanner covers: base table + given flushed generations + current active MemTable. + /// The scanner covers: base table + given SSTables + current active MemTable. #[pyo3(signature = (shard_snapshots=vec![]))] pub fn lsm_scanner( &self, @@ -537,7 +537,7 @@ impl PyExecutionPlan { } } -/// LSM-aware scanner covering base table, flushed MemTables, and active MemTable. +/// LSM-aware scanner covering base table, SSTables, and active MemTable. /// /// Provides deduplication by primary key, always returning the newest version /// of each row across all LSM levels. @@ -1021,12 +1021,12 @@ fn shard_snapshot_from_manifest(manifest: lance_index::mem_wal::ShardManifest) - shard_id: manifest.shard_id, spec_id: manifest.shard_spec_id, current_generation: manifest.current_generation, - flushed_generations: manifest - .flushed_generations + sstables: manifest + .sstables .into_iter() - .map(|generation| FlushedGeneration { - generation: generation.generation, - path: generation.path, + .map(|sstable| SsTable { + generation: sstable.generation, + path: sstable.path, }) .collect(), } diff --git a/rust/lance-table/src/system_index/mem_wal.rs b/rust/lance-table/src/system_index/mem_wal.rs index 1d82fd9e44f..43959b3e125 100644 --- a/rust/lance-table/src/system_index/mem_wal.rs +++ b/rust/lance-table/src/system_index/mem_wal.rs @@ -15,27 +15,27 @@ pub const MEM_WAL_INDEX_NAME: &str = "__lance_mem_wal"; /// Type alias for shard identifier (UUID v4). pub type ShardId = Uuid; -/// A flushed MemTable generation and its storage location. +/// An SSTable: a flushed MemTable generation, stored as a Lance dataset. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] -pub struct FlushedGeneration { +pub struct SsTable { pub generation: u64, pub path: String, } -impl From<&FlushedGeneration> for pb::FlushedGeneration { - fn from(fg: &FlushedGeneration) -> Self { +impl From<&SsTable> for pb::SsTable { + fn from(sstable: &SsTable) -> Self { Self { - generation: fg.generation, - path: fg.path.clone(), + generation: sstable.generation, + path: sstable.path.clone(), } } } -impl From for FlushedGeneration { - fn from(fg: pb::FlushedGeneration) -> Self { +impl From for SsTable { + fn from(sstable: pb::SsTable) -> Self { Self { - generation: fg.generation, - path: fg.path, + generation: sstable.generation, + path: sstable.path, } } } @@ -88,7 +88,7 @@ impl TryFrom for MergedGeneration { } /// Tracks which merged generation a base table index has been rebuilt to cover. -/// Used to determine whether to read from flushed MemTable indexes or base table. +/// Used to determine whether to read from SSTable indexes or base table. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] pub struct IndexCatchupProgress { pub index_name: String, @@ -198,7 +198,7 @@ pub struct ShardManifest { /// 1-based. pub wal_entry_position_last_seen: u64, pub current_generation: u64, - pub flushed_generations: Vec, + pub sstables: Vec, /// Lifecycle status (drop-table 2PC). Defaults to `Active`; preserved /// across claims via `..base` so only fresh constructions set it. pub status: ShardStatus, @@ -207,7 +207,7 @@ pub struct ShardManifest { impl DeepSizeOf for ShardManifest { fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize { self.shard_field_values.deep_size_of_children(context) - + self.flushed_generations.deep_size_of_children(context) + + self.sstables.deep_size_of_children(context) } } @@ -229,7 +229,7 @@ impl From<&ShardManifest> for pb::ShardManifest { replay_after_wal_entry_position: rm.replay_after_wal_entry_position, wal_entry_position_last_seen: rm.wal_entry_position_last_seen, current_generation: rm.current_generation, - flushed_generations: rm.flushed_generations.iter().map(|fg| fg.into()).collect(), + sstables: rm.sstables.iter().map(|sstable| sstable.into()).collect(), status: rm.status.to_i32(), } } @@ -258,11 +258,7 @@ impl TryFrom for ShardManifest { replay_after_wal_entry_position: rm.replay_after_wal_entry_position, wal_entry_position_last_seen: rm.wal_entry_position_last_seen, current_generation: rm.current_generation, - flushed_generations: rm - .flushed_generations - .into_iter() - .map(FlushedGeneration::from) - .collect(), + sstables: rm.sstables.into_iter().map(SsTable::from).collect(), status: ShardStatus::from_i32(rm.status), }) } diff --git a/rust/lance/benches/mem_wal/fts/mem_wal_fts_read_bench.rs b/rust/lance/benches/mem_wal/fts/mem_wal_fts_read_bench.rs index 2c7469b3def..462acb5b717 100644 --- a/rust/lance/benches/mem_wal/fts/mem_wal_fts_read_bench.rs +++ b/rust/lance/benches/mem_wal/fts/mem_wal_fts_read_bench.rs @@ -5,11 +5,11 @@ //! //! Sibling of `mem_wal_vector_bench.rs` / `mem_wal_point_lookup_bench.rs`: //! same `--phase prepare|search` shape, same `ShardWriter`-based ingestion -//! of flushed generations + an active memtable, same `--uri` cloud/local +//! of SSTables + an active memtable, same `--uri` cloud/local //! detection, and the same JSON output contract. The payload is real //! HuggingFace FineWeb `text` and the query path is -//! [`LsmFtsSearchPlanner`] (local scoring) over the base table + flushed -//! generations + active memtable. +//! [`LsmFtsSearchPlanner`] (local scoring) over the base table + SSTables +//! + active memtable. //! //! Each `search` invocation times a query set against the LSM hierarchy //! and reports latency percentiles. With `--with-baseline`, it also builds @@ -108,7 +108,7 @@ struct Args { uri: String, base_rows: usize, max_memtable_rows: usize, - flushed_generations: usize, + sstables: usize, batch_rows: usize, queries: usize, k: usize, @@ -126,7 +126,7 @@ impl Default for Args { uri: String::new(), base_rows: 1_000_000, max_memtable_rows: 100_000, - flushed_generations: 2, + sstables: 2, batch_rows: 1_000, queries: 200, k: 10, @@ -175,7 +175,7 @@ fn parse_args() -> Result { } "--base-rows" => args.base_rows = parse_val(&flag, &value)?, "--max-memtable-rows" => args.max_memtable_rows = parse_val(&flag, &value)?, - "--flushed-generations" => args.flushed_generations = parse_val(&flag, &value)?, + "--sstables" => args.sstables = parse_val(&flag, &value)?, "--batch-rows" => args.batch_rows = parse_val(&flag, &value)?, "--queries" => args.queries = parse_val(&flag, &value)?, "--k" => args.k = parse_val(&flag, &value)?, @@ -664,7 +664,7 @@ async fn run_search(args: &Args) -> Result { // covering both the memtable payload and the query-term sample, instead // of re-reading the whole base corpus from parquet. let active_rows = args.max_memtable_rows / 2; - let total_memtable_rows = args.flushed_generations * args.max_memtable_rows + active_rows; + let total_memtable_rows = args.sstables * args.max_memtable_rows + active_rows; let sample_rows = args.base_rows.min(50_000); let load_rows = total_memtable_rows.max(sample_rows); println!("loading {load_rows} FineWeb rows for memtable payload + query sample ..."); @@ -703,10 +703,8 @@ async fn run_search(args: &Args) -> Result { Duration::from_millis(500) }; - // Ingest flushed generations + 1 active (50% full). - let mut gen_sizes: Vec = (0..args.flushed_generations) - .map(|_| args.max_memtable_rows) - .collect(); + // Ingest SSTables + 1 active (50% full). + let mut gen_sizes: Vec = (0..args.sstables).map(|_| args.max_memtable_rows).collect(); gen_sizes.push(active_rows); let id_base = args.base_rows as i64; @@ -723,19 +721,19 @@ async fn run_search(args: &Args) -> Result { cursor += chunk; written += chunk; } - let is_flushed = gen_idx < args.flushed_generations; + let is_sstable = gen_idx < args.sstables; println!( " gen {}: wrote {} rows ({})", gen_idx + 1, gen_rows, - if is_flushed { "flushed" } else { "active" } + if is_sstable { "sstable" } else { "active" } ); - if is_flushed { + if is_sstable { tokio::time::sleep(flush_wait).await; } } // Wait for any triggered (sealed) memtable flushes to commit to the - // manifest before we snapshot it — otherwise the flushed generations + // manifest before we snapshot it — otherwise the SSTables // race the read and may not all be visible yet. writer.wait_for_flush_drain().await?; println!( @@ -749,17 +747,14 @@ async fn run_search(args: &Args) -> Result { let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); - for fg in &m.flushed_generations { - shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + for sstable in &m.sstables { + shard_snapshot = shard_snapshot.with_sstable(sstable.generation, sstable.path.clone()); } } - let num_flushed = manifest - .as_ref() - .map(|m| m.flushed_generations.len()) - .unwrap_or(0); - println!("manifest: {num_flushed} flushed generations"); + let num_sstables = manifest.as_ref().map(|m| m.sstables.len()).unwrap_or(0); + println!("manifest: {num_sstables} SSTables"); - // Flushed generations carry the same maintained secondary indexes as + // SSTables carry the same maintained secondary indexes as // the active memtable: the flush handler builds them during flush // (lance #6901), so each generation already has the FTS index and // both scoring modes use the fast indexed path. No manual indexing @@ -826,7 +821,7 @@ async fn run_search(args: &Args) -> Result { "uri_kind": if is_cloud_uri(&args.uri) { "cloud" } else { "local" }, "base_rows": args.base_rows, "max_memtable_rows": args.max_memtable_rows, - "flushed_generations": num_flushed, + "sstables": num_sstables, "active_rows": active_rows, "k": args.k, "queries": queries.len(), @@ -847,12 +842,12 @@ async fn run_search(args: &Args) -> Result { async fn run(args: Args) -> Result<()> { println!( - "bench=mem_wal_fts_read phase={} uri={} base_rows={} max_memtable_rows={} flushed_generations={} queries={} k={} with_baseline={}", + "bench=mem_wal_fts_read phase={} uri={} base_rows={} max_memtable_rows={} sstables={} queries={} k={} with_baseline={}", args.phase.as_str(), args.uri, args.base_rows, args.max_memtable_rows, - args.flushed_generations, + args.sstables, args.queries, args.k, args.with_baseline, diff --git a/rust/lance/benches/mem_wal/fts/run_fts_read_sweep.sh b/rust/lance/benches/mem_wal/fts/run_fts_read_sweep.sh index 7ea692ee0b1..31ededa04e2 100755 --- a/rust/lance/benches/mem_wal/fts/run_fts_read_sweep.sh +++ b/rust/lance/benches/mem_wal/fts/run_fts_read_sweep.sh @@ -8,7 +8,7 @@ # # For each (backend, base_rows) the bench's `prepare` phase runs once to # write the base dataset + FTS index + MemWAL; then for each k the `search` -# phase ingests flushed generations + an active memtable through ShardWriter +# phase ingests SSTables + an active memtable through ShardWriter # and times the FTS query panel under both Local and LocalWithGlobalRescore # scoring modes. # @@ -23,8 +23,8 @@ # CACHE_DIR FineWeb shard download cache (default /lance-fineweb-cache) # BASE_ROWS_LIST space-separated base sizes (default "100000 1000000") # K_LIST space-separated top-k values (default "10 100") -# MAX_MEMTABLE_ROWS active/flushed memtable cap (default 100000) -# GENS_LIST space-separated flushed-generation counts (default "1 2 5") +# MAX_MEMTABLE_ROWS active/SSTable cap (default 100000) +# GENS_LIST space-separated SSTable counts (default "1 2 5") # QUERIES queries per config (default 200) # WITH_BASELINE "1" to also build the merged-index accuracy baseline # and report local-vs-merged Jaccard (default off) @@ -117,7 +117,7 @@ for backend in $BACKENDS; do --base-rows "$base_rows" --batch-rows 1000 \ --cache-dir "$CACHE_DIR" || continue - # search for each (flushed-generations, k) + # search for each (SSTables, k) for gens in $GENS_LIST; do for k in $K_LIST; do name="search_${backend}_${btag}_g${gens}_k${k}" @@ -136,7 +136,7 @@ for backend in $BACKENDS; do --phase search --uri "$uri" \ --base-rows "$base_rows" \ --max-memtable-rows "$MAX_MEMTABLE_ROWS" \ - --flushed-generations "$gens" \ + --sstables "$gens" \ --batch-rows 1000 \ --queries "$QUERIES" --k "$k" \ "${baseline_flag[@]}" \ diff --git a/rust/lance/benches/mem_wal/kv/mem_wal_kv_point_lookup.rs b/rust/lance/benches/mem_wal/kv/mem_wal_kv_point_lookup.rs index e5c6cf234e1..335a41d3bbd 100644 --- a/rust/lance/benches/mem_wal/kv/mem_wal_kv_point_lookup.rs +++ b/rust/lance/benches/mem_wal/kv/mem_wal_kv_point_lookup.rs @@ -43,8 +43,7 @@ use datafusion::common::ScalarValue; use datafusion::prelude::SessionContext; use futures::TryStreamExt; use lance::dataset::mem_wal::scanner::{ - FlushedMemTableCache, InMemoryMemTableRef, LsmDataSourceCollector, LsmPointLookupPlanner, - ShardSnapshot, + InMemoryMemTableRef, LsmDataSourceCollector, LsmPointLookupPlanner, ShardSnapshot, SsTableCache, }; use lance::dataset::mem_wal::{DatasetMemWalExt, ShardWriterConfig}; use lance::dataset::{Dataset, WriteParams}; @@ -422,7 +421,7 @@ enum LanceReadMode { Plan, /// Probe the active MemTable's BTree index directly and materialize the /// row from the BatchStore, bypassing DataFusion. Single-active-memtable - /// fast path (no flushed generations); misses fall through as "not found". + /// fast path (no SSTables); misses fall through as "not found". Fast, /// Call the production `LsmPointLookupPlanner::lookup` API, which uses the /// direct BTree fast path internally and falls back to the plan path for @@ -492,27 +491,27 @@ impl KeyType { } /// Where the data under test lives. `Active` = the in-memory active MemTable -/// (never flushed). `Flushed` = an on-disk flushed generation (a Lance data +/// (never flushed). `SsTable` = an on-disk SSTable (a Lance data /// file + on-disk BTree index, read via the indexed-scan path) vs a single /// RocksDB SST on disk. #[derive(Debug, Clone, Copy, PartialEq)] enum Storage { Active, - Flushed, + SsTable, } impl Storage { fn parse(v: &str) -> std::result::Result { match v { "active" => Ok(Self::Active), - "flushed" => Ok(Self::Flushed), - _ => Err(format!("unknown storage '{v}', expected active|flushed")), + "sstable" => Ok(Self::SsTable), + _ => Err(format!("unknown storage '{v}', expected active|sstable")), } } fn as_str(self) -> &'static str { match self { Self::Active => "active", - Self::Flushed => "flushed", + Self::SsTable => "sstable", } } } @@ -553,7 +552,7 @@ struct Args { engine: Engine, key_type: KeyType, storage: Storage, - /// Number of flushed generations below the single active MemTable (Lance) / + /// Number of SSTables below the single active MemTable (Lance) / /// immutable SSTs below the active memtable (RocksDB). 0 = the existing /// single-tier behavior. >0 builds a full LSM: rows are split into /// `generations+1` parts, the first `generations` are flushed to on-disk @@ -574,12 +573,12 @@ struct Args { /// the caches and hit NVMe. Caps the RocksDB write buffer + uses a small /// block cache + compacts to one SST, and drops the OS page cache before /// the read phase (both engines). Use with a `--rows`×`--value-size` larger - /// than RAM. Only affects the `--storage flushed` path. + /// than RAM. Only affects the `--storage sstable` path. cold: bool, - /// Prewarm all flushed generations (open + warm indexes) into the dataset + /// Prewarm all SSTables (open + warm indexes) into the dataset /// session before the read phase, via `DatasetMemWalExt::prewarm_mem_wal`. /// Default on. `--prewarm false` disables it to measure the lazy-warm - /// baseline (the flushed cache is still set, so each generation is opened + /// baseline (the SSTable cache is still set, so each generation is opened /// on its first gen-key lookup instead of up front). Only affects the Lance /// `--storage active` LSM path. prewarm: bool, @@ -833,7 +832,7 @@ async fn run_lance( let n = writer .manifest() .await? - .map(|m| m.flushed_generations.len()) + .map(|m| m.sstables.len()) .unwrap_or(0); if n > g { break; @@ -849,10 +848,10 @@ async fn run_lance( let n_gens = writer .manifest() .await? - .map(|m| m.flushed_generations.len()) + .map(|m| m.sstables.len()) .unwrap_or(0); println!( - "[lance] wrote {} rows in {:.2}s = {:.0} rows/s (cpu {:.2}s, flushed_gens={n_gens}+active)", + "[lance] wrote {} rows in {:.2}s = {:.0} rows/s (cpu {:.2}s, sstables={n_gens}+active)", args.rows, write_s, write_rows_per_s, write_cpu_s ); @@ -862,8 +861,8 @@ async fn run_lance( let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); - for fg in &m.flushed_generations { - shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + for sstable in &m.sstables { + shard_snapshot = shard_snapshot.with_sstable(sstable.generation, sstable.path.clone()); } } // Keep a handle to the active MemTable for the direct fast path before @@ -871,22 +870,22 @@ async fn run_lance( let active = Arc::new(in_memory_refs.active.clone()); let collector = LsmDataSourceCollector::new(dataset.clone(), vec![shard_snapshot.clone()]) .with_in_memory_memtables(shard_id, in_memory_refs); - // Thread the dataset session + a flushed-dataset cache into the planner, and - // prewarm every flushed generation (open + warm its indexes) up front via + // Thread the dataset session + an SSTable cache into the planner, and + // prewarm every SSTable (open + warm its indexes) up front via // the general MemWAL API, so gen-key lookups never re-open a generation per // query (the equivalent of RocksDB keeping its DB + SSTs resident). Without // this, each plan-path lookup pays a fresh manifest read + Dataset open — a // fixed per-lookup cost independent of generation count. - let flushed_cache = Arc::new(FlushedMemTableCache::new((gens as u64).max(1))); + let sstable_cache = Arc::new(SsTableCache::new((gens as u64).max(1))); if args.prewarm { dataset - .prewarm_mem_wal(std::slice::from_ref(&shard_snapshot), Some(&flushed_cache)) + .prewarm_mem_wal(std::slice::from_ref(&shard_snapshot), Some(&sstable_cache)) .await?; } let planner = Arc::new( LsmPointLookupPlanner::new(collector, vec![KEY_COL.to_string()], arrow_schema) .with_session(dataset.session()) - .with_flushed_cache(flushed_cache), + .with_sstable_cache(sstable_cache), ); // Warmup + correctness: a hit key must resolve to exactly one row under @@ -1205,7 +1204,7 @@ async fn run_lance( } // ---------------------------------------------------------------------- -// Lance flushed (on-disk) engine +// Lance SSTable (on-disk) engine // ---------------------------------------------------------------------- /// Drop the OS page cache so subsequent reads hit storage (cold). Best-effort: @@ -1220,7 +1219,7 @@ fn drop_page_cache() { /// One indexed point lookup via the **DataFusion** path: `scan().filter("id = /// key")` parses + plans + executes a query per lookup (uses the on-disk BTree /// index). Returns the matched row count. -async fn flushed_probe(dataset: &Dataset, key: i64) -> Result { +async fn sstable_probe(dataset: &Dataset, key: i64) -> Result { use futures::StreamExt; let mut scanner = dataset.scan(); scanner.filter(&format!("{KEY_COL} = {key}"))?; @@ -1234,8 +1233,8 @@ async fn flushed_probe(dataset: &Dataset, key: i64) -> Result { /// One point lookup via the **direct** path: search the on-disk BTree scalar /// index for the row id, then `take` that row — bypassing DataFusion plan -/// construction. Diagnostic for how much of the flushed read cost is the plan. -async fn flushed_probe_direct( +/// construction. Diagnostic for how much of the SSTable read cost is the plan. +async fn sstable_probe_direct( dataset: &Dataset, scalar_index: &Arc, key: i64, @@ -1256,25 +1255,25 @@ async fn flushed_probe_direct( Ok(batch.num_rows()) } -/// Dispatch a flushed point lookup: `direct` = on-disk BTree index search + take +/// Dispatch an SSTable point lookup: `direct` = on-disk BTree index search + take /// (no DataFusion); otherwise the DataFusion `scan().filter()` path. -async fn flushed_lookup( +async fn sstable_lookup( dataset: &Dataset, scalar_index: &Arc, key: i64, direct: bool, ) -> Result { if direct { - flushed_probe_direct(dataset, scalar_index, key).await + sstable_probe_direct(dataset, scalar_index, key).await } else { - flushed_probe(dataset, key).await + sstable_probe(dataset, key).await } } -/// Batched flushed lookup over a chunk of keys: `direct` searches the index for +/// Batched SSTable lookup over a chunk of keys: `direct` searches the index for /// each key then issues one `take_rows` for all; otherwise one DataFusion scan /// with `id IN (...)`. Returns the total matched row count. -async fn flushed_batch( +async fn sstable_batch( dataset: &Dataset, scalar_index: &Arc, keys: &[i64], @@ -1320,11 +1319,11 @@ async fn flushed_batch( } } -/// Flushed Lance: write all rows as one on-disk Lance dataset with a BTree +/// SSTable Lance: write all rows as one on-disk Lance dataset with a BTree /// scalar index — the exact artifact a MemTable flush emits (forward-written /// data file + on-disk BTree index) — then point-lookup through the indexed /// scan path. Int keys only (the SQL filter literal is the integer). -async fn run_lance_flushed( +async fn run_lance_sstable( args: &Args, insert_order: &[i64], queries: &[(i64, bool)], @@ -1332,12 +1331,12 @@ async fn run_lance_flushed( assert_eq!( args.key_type, KeyType::Int, - "flushed mode currently supports --key-type int only" + "sstable mode currently supports --key-type int only" ); let sampler = RssSampler::start(); let key_type = args.key_type; let schema = make_schema(key_type); - let uri = format!("{}/lance_flushed", args.uri.trim_end_matches('/')); + let uri = format!("{}/lance_sstable", args.uri.trim_end_matches('/')); let _ = std::fs::remove_dir_all(&uri); // --- write + flush: build the on-disk data file + BTree index --- @@ -1378,13 +1377,13 @@ async fn run_lance_flushed( .iter() .find(|i| i.name == BTREE_INDEX_NAME) .map(|i| i.uuid.to_string()) - .ok_or_else(|| lance_core::Error::internal("flushed: btree index not found"))?; + .ok_or_else(|| lance_core::Error::internal("sstable: btree index not found"))?; dataset .open_scalar_index(KEY_COL, &uuid, &NoOpMetricsCollector) .await? }; println!( - "[lance] flushed read path = {}", + "[lance] sstable read path = {}", if direct { "direct btree-index search + take" } else { @@ -1394,8 +1393,8 @@ async fn run_lance_flushed( // warmup + correctness: a hit resolves to exactly one row via the index. if let Some((probe, _)) = queries.iter().find(|(_, h)| *h) { - let n = flushed_lookup(&dataset, &scalar_index, *probe, direct).await?; - assert_eq!(n, 1, "flushed warmup lookup for key {probe} returned {n}"); + let n = sstable_lookup(&dataset, &scalar_index, *probe, direct).await?; + assert_eq!(n, 1, "sstable warmup lookup for key {probe} returned {n}"); } // Cold mode: drop the OS page cache so reads hit NVMe (data > RAM assumed). @@ -1404,7 +1403,7 @@ async fn run_lance_flushed( println!("[lance] dropped page cache (cold reads from NVMe)"); } - // --- batch-get path: gather `batch_get` keys per call from the flushed gen --- + // --- batch-get path: gather `batch_get` keys per call from the SSTable --- if args.batch_get > 0 { let bg = args.batch_get; let hit_keys: Vec = queries @@ -1418,7 +1417,7 @@ async fn run_lance_flushed( let t = Instant::now(); for chunk in hit_keys.chunks(bg) { let t0 = Instant::now(); - found_total += flushed_batch(&dataset, &scalar_index, chunk, direct).await?; + found_total += sstable_batch(&dataset, &scalar_index, chunk, direct).await?; latencies_us.push(t0.elapsed().as_nanos() as f64 / 1000.0); } let read_qps_1t = hit_keys.len() as f64 / t.elapsed().as_secs_f64().max(1e-9); @@ -1438,7 +1437,7 @@ async fn run_lance_flushed( let chunks: Vec<&[i64]> = keys.chunks(bg).collect(); let mut i = shard; while i < chunks.len() { - let _ = flushed_batch(&dataset, &si, chunks[i], direct).await; + let _ = sstable_batch(&dataset, &si, chunks[i], direct).await; i += threads; } })); @@ -1455,7 +1454,7 @@ async fn run_lance_flushed( args.threads, stats.p50_us, stats.p99_us ); return Ok(EngineResult { - engine: "lance-flushed-batch", + engine: "lance-sstable-batch", write_rows_per_s, write_cpu_s, read_p50_us: stats.p50_us, @@ -1480,7 +1479,7 @@ async fn run_lance_flushed( let t_read = Instant::now(); for &(key, expect_hit) in queries { let t0 = Instant::now(); - let n = flushed_lookup(&dataset, &scalar_index, key, direct).await?; + let n = sstable_lookup(&dataset, &scalar_index, key, direct).await?; latencies_us.push(t0.elapsed().as_nanos() as f64 / 1000.0); if expect_hit { assert_eq!(n, 1, "expected hit for key {key}, got {n}"); @@ -1510,7 +1509,7 @@ async fn run_lance_flushed( handles.push(tokio::spawn(async move { let mut i = shard; while i < keys.len() { - let _ = flushed_lookup(&dataset, &scalar_index, keys[i], direct).await; + let _ = sstable_lookup(&dataset, &scalar_index, keys[i], direct).await; i += threads; } })); @@ -1535,7 +1534,7 @@ async fn run_lance_flushed( ); Ok(EngineResult { - engine: "lance-flushed", + engine: "lance-sstable", write_rows_per_s, write_cpu_s, read_p50_us: stats.p50_us, @@ -1584,7 +1583,7 @@ fn run_rocksdb(args: &Args, insert_order: &[i64], queries: &[(i64, bool)]) -> Re opts.set_min_write_buffer_number_to_merge(2); opts.set_disable_auto_compactions(true); opts.set_db_write_buffer_size(write_buf); - // Block cache for the `--storage flushed` SST reads. Warm: large enough to + // Block cache for the `--storage sstable` SST reads. Warm: large enough to // hold the SST index/filter + hot data blocks. Cold: small (128MB) so data // blocks miss the cache and reads go to NVMe (index/filter stay in memory). { @@ -1653,17 +1652,17 @@ fn run_rocksdb(args: &Args, insert_order: &[i64], queries: &[(i64, bool)]) -> Re write_buf >> 20 ); - // Single-tier `--storage flushed` (no extra generations): flush the active + // Single-tier `--storage sstable` (no extra generations): flush the active // to one SST (compact to one if cold). With generations>0 the per-chunk // flushes already produced N separate L0 SSTs + the active memtable. - if gens == 0 && args.storage == Storage::Flushed { + if gens == 0 && args.storage == Storage::SsTable { db.flush() .map_err(|e| lance_core::Error::io(format!("rocksdb flush: {e}")))?; if args.cold { db.compact_range::<&[u8], &[u8]>(None, None); } } - if args.storage == Storage::Flushed || gens > 0 { + if args.storage == Storage::SsTable || gens > 0 { let n_sst = db .property_int_value("rocksdb.num-files-at-level0") .ok() @@ -1938,7 +1937,7 @@ async fn run(args: Args) -> Result<()> { if matches!(args.engine, Engine::Lance | Engine::Both) { let res = match args.storage { Storage::Active => run_lance(&args, &insert_order, &queries).await?, - Storage::Flushed => run_lance_flushed(&args, &insert_order, &queries).await?, + Storage::SsTable => run_lance_sstable(&args, &insert_order, &queries).await?, }; results.push(res); } diff --git a/rust/lance/benches/mem_wal/point_lookup/mem_wal_point_lookup_bench.rs b/rust/lance/benches/mem_wal/point_lookup/mem_wal_point_lookup_bench.rs index d723cfde9c8..51fe1b9ebc2 100644 --- a/rust/lance/benches/mem_wal/point_lookup/mem_wal_point_lookup_bench.rs +++ b/rust/lance/benches/mem_wal/point_lookup/mem_wal_point_lookup_bench.rs @@ -5,7 +5,7 @@ //! //! Measures lookup latency against three tiers of the LSM tree: //! - Base table (on-disk, merged data) -//! - Flushed MemTable generations (on-disk L0) +//! - SSTables (on-disk L0) //! - Active MemTable (in-memory write buffer) //! //! Two phases, selected with `--phase`: @@ -151,7 +151,7 @@ struct Args { uri: String, base_rows: usize, max_memtable_rows: usize, - flushed_generations: usize, + sstables: usize, batch_rows: usize, queries: usize, output: Option, @@ -164,7 +164,7 @@ impl Default for Args { uri: String::new(), base_rows: 1_000_000, max_memtable_rows: 100_000, - flushed_generations: 2, + sstables: 2, batch_rows: 1_000, queries: 500, output: None, @@ -205,7 +205,7 @@ fn parse_args() -> Result { } "--base-rows" => args.base_rows = parse_val(&flag, &value)?, "--max-memtable-rows" => args.max_memtable_rows = parse_val(&flag, &value)?, - "--flushed-generations" => args.flushed_generations = parse_val(&flag, &value)?, + "--sstables" => args.sstables = parse_val(&flag, &value)?, "--batch-rows" => args.batch_rows = parse_val(&flag, &value)?, "--queries" => args.queries = parse_val(&flag, &value)?, "--output" => args.output = Some(PathBuf::from(value)), @@ -276,11 +276,11 @@ fn is_cloud_uri(uri: &str) -> bool { fn generate_lookup_ids( base_rows: usize, max_memtable_rows: usize, - flushed_generations: usize, + sstables: usize, queries: usize, ) -> (Vec>, Vec<&'static str>) { - let flushed_total = flushed_generations * max_memtable_rows; - let active_start = base_rows + flushed_total; + let sstable_total = sstables * max_memtable_rows; + let active_start = base_rows + sstable_total; let active_end = active_start + max_memtable_rows / 2; let mut groups = Vec::new(); @@ -296,19 +296,19 @@ fn generate_lookup_ids( groups.push(base_ids); names.push("base"); - // Flushed IDs (only if there are flushed generations) - if flushed_generations > 0 { - let flushed_start = base_rows; - let flushed_end = base_rows + flushed_total; - let flushed_ids: Vec = (0..queries) + // SSTable IDs (only if there are SSTables) + if sstables > 0 { + let sstable_start = base_rows; + let sstable_end = base_rows + sstable_total; + let sstable_ids: Vec = (0..queries) .map(|i| { - let range = flushed_end - flushed_start; + let range = sstable_end - sstable_start; let step = range.max(1) / queries.max(1); - (flushed_start + (i * step) % range) as i64 + (sstable_start + (i * step) % range) as i64 }) .collect(); - groups.push(flushed_ids); - names.push("flushed"); + groups.push(sstable_ids); + names.push("sstable"); } // Active memtable IDs @@ -353,11 +353,11 @@ async fn run_lookup(args: &Args) -> Result { }; let id_base = args.base_rows as i64; - let num_flushed = args.flushed_generations; + let num_sstables = args.sstables; let active_rows = max_memtable_rows / 2; - // Ingest flushed generations (each triggers a flush) + 1 active (50% full) - let mut gen_sizes: Vec = (0..num_flushed).map(|_| max_memtable_rows).collect(); + // Ingest SSTables (each triggers a flush) + 1 active (50% full) + let mut gen_sizes: Vec = (0..num_sstables).map(|_| max_memtable_rows).collect(); gen_sizes.push(active_rows); let mut cursor = 0usize; @@ -370,22 +370,22 @@ async fn run_lookup(args: &Args) -> Result { writer.put(vec![batch]).await?; cursor += rows; } - let is_flushed = gen_idx < num_flushed; + let is_sstable = gen_idx < num_sstables; println!( " gen {}: wrote {} rows ({}) cursor={}", gen_idx + 1, gen_rows, - if is_flushed { "flushed" } else { "active" }, + if is_sstable { "sstable" } else { "active" }, cursor, ); - if is_flushed { + if is_sstable { tokio::time::sleep(flush_wait).await; } } println!( - "ingested {} rows total ({} flushed gens + active)", - cursor, num_flushed + "ingested {} rows total ({} SSTables + active)", + cursor, num_sstables ); let manifest = writer.manifest().await.unwrap(); @@ -395,18 +395,15 @@ async fn run_lookup(args: &Args) -> Result { let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); - for fg in &m.flushed_generations { - shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + for sstable in &m.sstables { + shard_snapshot = shard_snapshot.with_sstable(sstable.generation, sstable.path.clone()); } } - let num_flushed = manifest - .as_ref() - .map(|m| m.flushed_generations.len()) - .unwrap_or(0); + let num_sstables = manifest.as_ref().map(|m| m.sstables.len()).unwrap_or(0); println!( - "manifest: {} flushed generations, current_generation={}", - num_flushed, + "manifest: {} SSTables, current_generation={}", + num_sstables, manifest.as_ref().map(|m| m.current_generation).unwrap_or(0) ); @@ -417,8 +414,12 @@ async fn run_lookup(args: &Args) -> Result { let planner = LsmPointLookupPlanner::new(collector, pk_columns, arrow_schema); // Generate lookup IDs for each category - let (id_groups, category_names) = - generate_lookup_ids(args.base_rows, max_memtable_rows, num_flushed, args.queries); + let (id_groups, category_names) = generate_lookup_ids( + args.base_rows, + max_memtable_rows, + num_sstables, + args.queries, + ); let session_ctx = SessionContext::new(); let task_ctx = session_ctx.task_ctx(); @@ -475,7 +476,7 @@ async fn run_lookup(args: &Args) -> Result { output.insert("phase".into(), json!("lookup")); output.insert("base_rows".into(), json!(args.base_rows)); output.insert("max_memtable_rows".into(), json!(max_memtable_rows)); - output.insert("flushed_generations".into(), json!(num_flushed)); + output.insert("sstables".into(), json!(num_sstables)); output.insert("active_rows".into(), json!(active_rows)); output.insert("queries_per_category".into(), json!(args.queries)); for (key, val) in &results { @@ -490,12 +491,12 @@ async fn run_lookup(args: &Args) -> Result { async fn run(args: Args) -> Result<()> { println!( - "bench=mem_wal_point_lookup phase={} uri={} base_rows={} max_memtable_rows={} flushed_generations={} batch_rows={} queries={}", + "bench=mem_wal_point_lookup phase={} uri={} base_rows={} max_memtable_rows={} sstables={} batch_rows={} queries={}", args.phase.as_str(), args.uri, args.base_rows, args.max_memtable_rows, - args.flushed_generations, + args.sstables, args.batch_rows, args.queries, ); diff --git a/rust/lance/benches/mem_wal/vector/hnsw/disk_ann_compare.py b/rust/lance/benches/mem_wal/vector/hnsw/disk_ann_compare.py index e3b81123ece..f93a06e5188 100644 --- a/rust/lance/benches/mem_wal/vector/hnsw/disk_ann_compare.py +++ b/rust/lance/benches/mem_wal/vector/hnsw/disk_ann_compare.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors -"""Disk-ANN comparison: Lance on-disk IVF_HNSW_SQ (the flushed-memtable index) +"""Disk-ANN comparison: Lance on-disk IVF_HNSW_SQ (the SSTable index) vs DiskANN vs FAISS, all backed by local NVMe. faiss/diskannpy/lance bundle conflicting tcmalloc/MKL/OpenMP and crash if @@ -18,7 +18,12 @@ recall@10 vs p50/p99 latency and QPS. The Lance index is served fully cached (large index_cache_size_bytes). """ -import argparse, json, os, time + +import argparse +import json +import os +import time + import numpy as np K = 10 @@ -27,7 +32,9 @@ DIM = 1536 EF_SWEEP = [16, 32, 64, 128, 256] HF_TREE = "https://huggingface.co/api/datasets/KShivendu/dbpedia-entities-openai-1M/tree/main/data" -HF_BASE = "https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M/resolve/main/" +HF_BASE = ( + "https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M/resolve/main/" +) def data_dir(base, rows): @@ -42,10 +49,13 @@ def normalize(x): # ---------------- prepare ---------------- def load_corpus(cache_dir, needed): - import requests, pyarrow.parquet as pq + import pyarrow.parquet as pq + import requests + os.makedirs(cache_dir, exist_ok=True) shards = sorted( - e["path"] for e in requests.get(HF_TREE, timeout=60).json() + e["path"] + for e in requests.get(HF_TREE, timeout=60).json() if e["type"] == "file" and e["path"].endswith(".parquet") ) out = np.empty((needed, DIM), dtype=np.float32) @@ -62,7 +72,7 @@ def load_corpus(cache_dir, needed): col = pq.read_table(local, columns=["openai"]).column("openai") arr = np.stack(col.to_pylist()).astype(np.float32) take = min(len(arr), needed - n) - out[n:n + take] = arr[:take] + out[n : n + take] = arr[:take] n += take print(f" shard {os.path.basename(rel)} -> {take} (cum {n})", flush=True) assert n == needed, f"only got {n}/{needed}" @@ -73,7 +83,6 @@ def numpy_ground_truth(corpus, queries): gt = np.empty((len(queries), K), dtype=np.int64) # corpus is normalized -> cosine == inner product; chunk over corpus. chunk = 200_000 - sims_top = None # Compute full similarity in query-major chunks to bound memory. sim = np.zeros((len(queries), len(corpus)), dtype=np.float32) for s in range(0, len(corpus), chunk): @@ -98,10 +107,13 @@ def cmd_prepare(args): rng = np.random.default_rng(SEED) qidx = rng.choice(args.rows, size=NUM_QUERIES, replace=False) queries = corpus[qidx].copy() - print(f"corpus={len(corpus)} queries={len(queries)} dim={DIM}; computing GT...", flush=True) + print( + f"corpus={len(corpus)} queries={len(queries)} dim={DIM}; computing GT...", + flush=True, + ) t = time.perf_counter() gt = numpy_ground_truth(corpus, queries) - print(f" GT in {time.perf_counter()-t:.1f}s", flush=True) + print(f" GT in {time.perf_counter() - t:.1f}s", flush=True) np.save(os.path.join(d, "corpus.npy"), corpus) np.save(os.path.join(d, "queries.npy"), queries) np.save(os.path.join(d, "gt.npy"), gt) @@ -110,7 +122,9 @@ def cmd_prepare(args): # ---------------- shared run helpers ---------------- def recall_at_k(gt, got): - return sum(len(set(g.tolist()) & set(r.tolist())) for g, r in zip(gt, got)) / (len(gt) * K) + return sum(len(set(g.tolist()) & set(r.tolist())) for g, r in zip(gt, got)) / ( + len(gt) * K + ) def latency_qps(query_fn, queries, repeats=3): @@ -133,56 +147,97 @@ def sweep(name, make_q, params, queries, gt): got = np.stack([qf(v) for v in queries]) rec = recall_at_k(gt, got) p50, p99, qps = latency_qps(qf, queries) - rows.append({"param": p, "recall": rec, "p50_us": p50, "p99_us": p99, "qps": qps}) - print(f" {name} param={p} recall={rec:.4f} p50={p50:.0f}us p99={p99:.0f}us qps={qps:.0f}", flush=True) + rows.append( + {"param": p, "recall": rec, "p50_us": p50, "p99_us": p99, "qps": qps} + ) + print( + f" {name} param={p} recall={rec:.4f} " + f"p50={p50:.0f}us p99={p99:.0f}us qps={qps:.0f}", + flush=True, + ) return rows # ---------------- systems ---------------- def run_lance(base, rows, corpus, queries, gt): - import lance, pyarrow as pa, shutil + import shutil + + import lance + import pyarrow as pa + uri = os.path.join(base, f"lance_{rows}") shutil.rmtree(uri, ignore_errors=True) - vecs = pa.FixedSizeListArray.from_arrays(pa.array(corpus.reshape(-1), type=pa.float32()), DIM) + vecs = pa.FixedSizeListArray.from_arrays( + pa.array(corpus.reshape(-1), type=pa.float32()), DIM + ) tbl = pa.table({"id": pa.array(np.arange(rows, dtype=np.int64)), "vec": vecs}) ds = lance.write_dataset(tbl, uri, mode="overwrite") - # The flushed memtable index is a SINGLE-partition HNSW+SQ, so model it with + # The SSTable index is a SINGLE-partition HNSW+SQ, so model it with # num_partitions=1 (nprobes=1); ef is the search knob, like DiskANN/FAISS. t = time.perf_counter() - ds.create_index("vec", "IVF_HNSW_SQ", metric="cosine", num_partitions=1, - m=20, ef_construction=150) + ds.create_index( + "vec", + "IVF_HNSW_SQ", + metric="cosine", + num_partitions=1, + m=20, + ef_construction=150, + ) build_s = time.perf_counter() - t ds = lance.dataset(uri, index_cache_size_bytes=48 * 1024**3) def make_q(ef): def q(v): - return ds.to_table(nearest={"column": "vec", "q": v, "k": K, - "nprobes": 1, "ef": ef}, - columns=["id"]).column("id").to_numpy() + return ( + ds.to_table( + nearest={"column": "vec", "q": v, "k": K, "nprobes": 1, "ef": ef}, + columns=["id"], + ) + .column("id") + .to_numpy() + ) + return q - return {"build_s": build_s, "nlist": 1, "sweep": sweep("lance", make_q, None, queries, gt)} + + return { + "build_s": build_s, + "nlist": 1, + "sweep": sweep("lance", make_q, None, queries, gt), + } -def run_lance_flushed(base, rows, corpus, queries, gt, lance_path, id_offset, column): - # Open a flushed MemTable generation directly from its dataset path and +def run_lance_sstable(base, rows, corpus, queries, gt, lance_path, id_offset, column): + # Open an SSTable generation directly from its dataset path and # benchmark its on-disk IVF_HNSW_SQ index (single partition), fully cached. import lance + ds = lance.dataset(lance_path, index_cache_size_bytes=48 * 1024**3) def make_q(ef): def q(v): - ids = ds.to_table(nearest={"column": column, "q": v, "k": K, - "nprobes": 1, "ef": ef}, - columns=["id"]).column("id").to_numpy() - return ids - id_offset # map flushed-gen id -> corpus index + ids = ( + ds.to_table( + nearest={"column": column, "q": v, "k": K, "nprobes": 1, "ef": ef}, + columns=["id"], + ) + .column("id") + .to_numpy() + ) + return ids - id_offset # map SSTable id -> corpus index + return q - return {"lance_path": lance_path, "id_offset": id_offset, - "sweep": sweep("lance_flushed", make_q, None, queries, gt)} + + return { + "lance_path": lance_path, + "id_offset": id_offset, + "sweep": sweep("lance_sstable", make_q, None, queries, gt), + } def run_faiss(base, rows, corpus, queries, gt): # Full-precision HNSW reference (shows what no quantization buys). import faiss + index = faiss.IndexHNSWFlat(DIM, 32, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction = 200 t = time.perf_counter() @@ -194,16 +249,20 @@ def make_q(ef): def q(v): index.hnsw.efSearch = ef return index.search(v.reshape(1, -1), K)[1][0] + return q + return {"build_s": build_s, "sweep": sweep("faiss", make_q, None, queries, gt)} def run_faiss_sq(base, rows, corpus, queries, gt): # HNSW + 8-bit scalar quantization — apples-to-apples with Lance IVF_HNSW_SQ. import faiss + try: - index = faiss.IndexHNSWSQ(DIM, faiss.ScalarQuantizer.QT_8bit, 32, - faiss.METRIC_INNER_PRODUCT) + index = faiss.IndexHNSWSQ( + DIM, faiss.ScalarQuantizer.QT_8bit, 32, faiss.METRIC_INNER_PRODUCT + ) except Exception: # Fall back to L2; on unit-normalized vectors L2 ranking == cosine. index = faiss.IndexHNSWSQ(DIM, faiss.ScalarQuantizer.QT_8bit, 32) @@ -218,28 +277,44 @@ def make_q(ef): def q(v): index.hnsw.efSearch = ef return index.search(v.reshape(1, -1), K)[1][0] + return q + return {"build_s": build_s, "sweep": sweep("faiss_sq", make_q, None, queries, gt)} def run_diskann(base, rows, corpus, queries, gt): import diskannpy as dap + idx_dir = os.path.join(base, f"diskann_{rows}") os.makedirs(idx_dir, exist_ok=True) t = time.perf_counter() dap.build_memory_index( - data=corpus, distance_metric="cosine", index_directory=idx_dir, - index_prefix="ann", complexity=150, graph_degree=64, - num_threads=0, alpha=1.2, use_pq_build=False, num_pq_bytes=0, + data=corpus, + distance_metric="cosine", + index_directory=idx_dir, + index_prefix="ann", + complexity=150, + graph_degree=64, + num_threads=0, + alpha=1.2, + use_pq_build=False, + num_pq_bytes=0, ) build_s = time.perf_counter() - t - idx = dap.StaticMemoryIndex(index_directory=idx_dir, index_prefix="ann", - num_threads=0, initial_search_complexity=256) + idx = dap.StaticMemoryIndex( + index_directory=idx_dir, + index_prefix="ann", + num_threads=0, + initial_search_complexity=256, + ) def make_q(L): def q(v): return idx.search(v, k_neighbors=K, complexity=max(L, K)).identifiers + return q + return {"build_s": build_s, "sweep": sweep("diskann", make_q, None, queries, gt)} @@ -249,12 +324,24 @@ def cmd_run(args): queries = np.load(os.path.join(d, "queries.npy")) gt = np.load(os.path.join(d, "gt.npy")) print(f"=== {args.system} rows={args.rows} corpus={len(corpus)} ===", flush=True) - if args.system == "lance_flushed": - res = run_lance_flushed(args.base, args.rows, corpus, queries, gt, - args.lance_path, args.id_offset, args.column) + if args.system == "lance_sstable": + res = run_lance_sstable( + args.base, + args.rows, + corpus, + queries, + gt, + args.lance_path, + args.id_offset, + args.column, + ) else: - fn = {"lance": run_lance, "faiss": run_faiss, "faiss_sq": run_faiss_sq, - "diskann": run_diskann}[args.system] + fn = { + "lance": run_lance, + "faiss": run_faiss, + "faiss_sq": run_faiss_sq, + "diskann": run_diskann, + }[args.system] res = fn(args.base, args.rows, corpus, queries, gt) res["rows"] = args.rows res["system"] = args.system @@ -267,9 +354,16 @@ def cmd_run(args): def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) - p = sub.add_parser("prepare"); p.add_argument("--rows", type=int, required=True); p.add_argument("--base", required=True) - r = sub.add_parser("run"); r.add_argument("--rows", type=int, required=True); r.add_argument("--base", required=True); r.add_argument("--system", required=True) - r.add_argument("--lance-path", default=None); r.add_argument("--id-offset", type=int, default=0); r.add_argument("--column", default="vector") + p = sub.add_parser("prepare") + p.add_argument("--rows", type=int, required=True) + p.add_argument("--base", required=True) + r = sub.add_parser("run") + r.add_argument("--rows", type=int, required=True) + r.add_argument("--base", required=True) + r.add_argument("--system", required=True) + r.add_argument("--lance-path", default=None) + r.add_argument("--id-offset", type=int, default=0) + r.add_argument("--column", default="vector") args = ap.parse_args() (cmd_prepare if args.cmd == "prepare" else cmd_run)(args) diff --git a/rust/lance/benches/mem_wal/vector/hnsw/mem_wal_recall_hnsw.rs b/rust/lance/benches/mem_wal/vector/hnsw/mem_wal_recall_hnsw.rs index a4968161647..625900cf834 100644 --- a/rust/lance/benches/mem_wal/vector/hnsw/mem_wal_recall_hnsw.rs +++ b/rust/lance/benches/mem_wal/vector/hnsw/mem_wal_recall_hnsw.rs @@ -355,7 +355,7 @@ async fn run_checkpoint( let temp = tempfile::tempdir().map_err(|e| lance_core::Error::io(format!("tempdir: {}", e)))?; // When BENCH_URI_BASE is set, write to a persistent path and flush the // MemTable to an on-disk generation (instead of querying the active - // MemTable), printing the flushed generation's dataset path for a + // MemTable), printing the SSTable's dataset path for a // downstream direct-read benchmark. let flush_base = std::env::var("BENCH_URI_BASE").ok(); let local_dir = flush_base.as_ref().map(|b| format!("{}/cp_{}", b, cp)); @@ -449,9 +449,14 @@ async fn run_checkpoint( let mut waited = 0u64; let gen_path = loop { if let Some(m) = writer.manifest().await? - && let Some(fg) = m.flushed_generations.last() + && let Some(sstable) = m.sstables.last() { - break format!("{}/_mem_wal/{}/{}", dir, shard_id.as_hyphenated(), fg.path); + break format!( + "{}/_mem_wal/{}/{}", + dir, + shard_id.as_hyphenated(), + sstable.path + ); } tokio::time::sleep(Duration::from_millis(100)).await; waited += 1; @@ -466,7 +471,7 @@ async fn run_checkpoint( } }; println!( - "FLUSHED_OK cp={} id_offset={} flush_s={:.2} path={}", + "SSTABLE_OK cp={} id_offset={} flush_s={:.2} path={}", cp, id_offset, seal_start.elapsed().as_secs_f64(), @@ -475,7 +480,7 @@ async fn run_checkpoint( std::io::stdout().flush().ok(); writer.close().await?; - // Item 4: open the flushed generation directly from its dataset path and + // Item 4: open the SSTable directly from its dataset path and // benchmark its on-disk IVF_HNSW_SQ read in Rust (single-thread per-query // latency + recall vs brute force), sweeping ef. nprobes=1 (single part). let gen_uri = format!("file://{}", gen_path); @@ -525,7 +530,7 @@ async fn run_checkpoint( let p99_q = lat[lat.len() * 99 / 100]; let mean_recall = recall_sum / num_queries as f64; println!( - "FLUSHED_READ cp={} ef={} mean_recall={:.4} median_us={} p99_us={}", + "SSTABLE_READ cp={} ef={} mean_recall={:.4} median_us={} p99_us={}", cp, ef, mean_recall, median_q, p99_q ); std::io::stdout().flush().ok(); @@ -536,13 +541,13 @@ async fn run_checkpoint( // Raw-index path: call VectorIndex::search() directly (partition-find + // HNSW+SQ search, returns _rowid/_distance) bypassing the DataFusion - // scanner/take/projection. The RAW_READ vs FLUSHED_READ latency delta at + // scanner/take/projection. The RAW_READ vs SSTABLE_READ latency delta at // matched ef isolates the DataFusion per-query overhead from the actual // index search cost. let idx_metas = gen_ds.load_indices_by_name(VECTOR_INDEX_NAME).await?; let uuid = idx_metas .first() - .ok_or_else(|| lance_core::Error::io("flushed gen has no vector index".to_string()))? + .ok_or_else(|| lance_core::Error::io("SSTable has no vector index".to_string()))? .uuid; let vidx = gen_ds .open_vector_index(VECTOR_COL, &uuid, &NoOpMetricsCollector) @@ -594,7 +599,7 @@ async fn run_checkpoint( }; // IVFIndex::search is intentionally unimplemented (top-level does // partition-aware search); replicate the ANN exec node: pick the - // closest partition then search it. Single-partition flushed gen. + // closest partition then search it. Single-partition SSTable. let t = Instant::now(); let (parts, _) = vidx.find_partitions(&query)?; let pid = parts.value(0) as usize; diff --git a/rust/lance/benches/mem_wal/vector/mem_wal_vector_bench.rs b/rust/lance/benches/mem_wal/vector/mem_wal_vector_bench.rs index a8fe8e81f29..e5bd050fd65 100644 --- a/rust/lance/benches/mem_wal/vector/mem_wal_vector_bench.rs +++ b/rust/lance/benches/mem_wal/vector/mem_wal_vector_bench.rs @@ -6,7 +6,7 @@ //! //! Uses real embeddings from the `lance-format/fineweb-edu` HuggingFace //! dataset (384-dim) with an IVF-RQ index on the base table, then ingests -//! additional rows through ShardWriter to populate flushed generations and +//! additional rows through ShardWriter to populate SSTables and //! an active memtable. //! //! Three phases, selected with `--phase`: @@ -102,7 +102,7 @@ struct Args { uri: String, base_rows: usize, max_memtable_rows: usize, - flushed_generations: usize, + sstables: usize, batch_rows: usize, queries: usize, k: usize, @@ -120,7 +120,7 @@ impl Default for Args { uri: String::new(), base_rows: 1_000_000, max_memtable_rows: 100_000, - flushed_generations: 2, + sstables: 2, batch_rows: 1_000, queries: 100, k: 10, @@ -166,7 +166,7 @@ fn parse_args() -> Result { } "--base-rows" => args.base_rows = parse_val(&flag, &value)?, "--max-memtable-rows" => args.max_memtable_rows = parse_val(&flag, &value)?, - "--flushed-generations" => args.flushed_generations = parse_val(&flag, &value)?, + "--sstables" => args.sstables = parse_val(&flag, &value)?, "--batch-rows" => args.batch_rows = parse_val(&flag, &value)?, "--queries" => args.queries = parse_val(&flag, &value)?, "--k" => args.k = parse_val(&flag, &value)?, @@ -534,11 +534,11 @@ async fn run_search(args: &Args) -> Result { Duration::from_millis(500) }; - // Ingest N flushed generations + 1 active (50% full) - let num_flushed_target = args.flushed_generations; + // Ingest N SSTables + 1 active (50% full) + let num_sstable_target = args.sstables; let active_rows = args.max_memtable_rows / 2; - let total_memtable_rows = num_flushed_target * args.max_memtable_rows + active_rows; - let mut gen_sizes: Vec = (0..num_flushed_target) + let total_memtable_rows = num_sstable_target * args.max_memtable_rows + active_rows; + let mut gen_sizes: Vec = (0..num_sstable_target) .map(|_| args.max_memtable_rows) .collect(); gen_sizes.push(active_rows); @@ -615,14 +615,14 @@ async fn run_search(args: &Args) -> Result { gen_rows, ingest_start.elapsed().as_secs_f64(), ); - if gen_idx < num_flushed_target { + if gen_idx < num_sstable_target { tokio::time::sleep(flush_wait).await; } } println!( - "ingested {} total memtable rows ({} flushed + active) in {:.1}s", + "ingested {} total memtable rows ({} SSTables + active) in {:.1}s", total_memtable_rows, - num_flushed_target, + num_sstable_target, ingest_start.elapsed().as_secs_f64(), ); @@ -632,17 +632,14 @@ async fn run_search(args: &Args) -> Result { let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); - for fg in &m.flushed_generations { - shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + for sstable in &m.sstables { + shard_snapshot = shard_snapshot.with_sstable(sstable.generation, sstable.path.clone()); } } - let num_flushed = manifest - .as_ref() - .map(|m| m.flushed_generations.len()) - .unwrap_or(0); + let num_sstables = manifest.as_ref().map(|m| m.sstables.len()).unwrap_or(0); println!( - "manifest: {} flushed generations, current_generation={}", - num_flushed, + "manifest: {} SSTables, current_generation={}", + num_sstables, manifest.as_ref().map(|m| m.current_generation).unwrap_or(0) ); @@ -760,7 +757,7 @@ async fn run_search(args: &Args) -> Result { "phase": "search", "base_rows": args.base_rows, "max_memtable_rows": args.max_memtable_rows, - "flushed_generations": num_flushed, + "sstables": num_sstables, "active_rows": active_rows, "vector_dim": VECTOR_DIM, "k": args.k, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 1308cb43800..316a2377f98 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -2448,7 +2448,7 @@ impl Dataset { /// The `ObjectStoreParams` this dataset was opened with, or `None` when /// opened without explicit params. Lets a caller re-open a derived path - /// (e.g. a MemWAL flushed generation) with the same store this dataset used. + /// (e.g. a MemWAL SSTable) with the same store this dataset used. pub fn store_params(&self) -> Option<&ObjectStoreParams> { self.store_params.as_deref() } diff --git a/rust/lance/src/dataset/mem_wal.rs b/rust/lance/src/dataset/mem_wal.rs index f5b89d06ff4..784ee1fa76c 100644 --- a/rust/lance/src/dataset/mem_wal.rs +++ b/rust/lance/src/dataset/mem_wal.rs @@ -52,7 +52,7 @@ use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; /// Column name for the mem_wal tombstone (delete sentinel) marker. /// /// `_tombstone` is a *physical* column present only in mem_wal memtables and -/// flushed generations — it is deliberately kept out of the base table (hard +/// SSTables — it is deliberately kept out of the base table (hard /// delete), so it is **not** a virtual [`is_system_column`](lance_core::is_system_column). /// A row with `_tombstone = true` is a delete sentinel: the newest value for /// its primary key, carrying null in every non-PK column, that wins diff --git a/rust/lance/src/dataset/mem_wal/api.rs b/rust/lance/src/dataset/mem_wal/api.rs index 4351ecc95b7..005b47d2b53 100644 --- a/rust/lance/src/dataset/mem_wal/api.rs +++ b/rust/lance/src/dataset/mem_wal/api.rs @@ -24,7 +24,7 @@ use crate::index::DatasetIndexInternalExt; use crate::index::mem_wal::{load_mem_wal_index_details, new_mem_wal_index_meta}; use super::ShardWriterConfig; -use super::scanner::flushed_cache::open_flushed_dataset; +use super::scanner::sstable_cache::open_sstable; use super::scanner::{DatasetCache, ShardSnapshot}; use super::util::derived_store_params; use super::write::MemIndexConfig; @@ -479,10 +479,10 @@ pub trait DatasetMemWalExt { Ok(Vec::new()) } - /// Prewarm the flushed generations of the given MemWAL shards into this + /// Prewarm the SSTables of the given MemWAL shards into this /// dataset's session caches. /// - /// For every flushed generation in `snapshots`, opens the generation's + /// For every SSTable in `snapshots`, opens the generation's /// on-disk dataset (populating the session's metadata/index caches, and the /// optional `cache` of opened `Arc`s) and prewarms each of its /// indexes. Opens run concurrently. @@ -587,7 +587,7 @@ impl DatasetMemWalExt for Dataset { let session = self.session(); // Every open below targets a generation URI, never the base's own. let store_params = self.store_params().map(derived_store_params); - // Resolve flushed paths exactly as the LSM collector does, so the + // Resolve SSTable paths exactly as the LSM collector does, so the // session/cache entries we warm key-match the paths later lookups open. let base_path = self.uri().trim_end_matches('/').to_string(); let opens = snapshots @@ -597,17 +597,12 @@ impl DatasetMemWalExt for Dataset { let base_path = &base_path; let session = &session; let store_params = &store_params; - snapshot.flushed_generations.iter().map(move |flushed| { - let path = format!("{}/_mem_wal/{}/{}", base_path, shard_id, flushed.path); + snapshot.sstables.iter().map(move |sstable| { + let path = format!("{}/_mem_wal/{}/{}", base_path, shard_id, sstable.path); async move { - let dataset = open_flushed_dataset( - &path, - Some(session), - store_params.as_ref(), - cache, - None, - ) - .await?; + let dataset = + open_sstable(&path, Some(session), store_params.as_ref(), cache, None) + .await?; prewarm_all_indexes(&dataset).await } }) @@ -776,7 +771,7 @@ async fn load_vector_index_config( #[cfg(test)] mod tests { - use super::super::scanner::FlushedMemTableCache; + use super::super::scanner::SsTableCache; use super::*; use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator}; @@ -807,9 +802,9 @@ mod tests { #[tokio::test] async fn test_prewarm_mem_wal_opens_and_warms_indexes() { - // `prewarm_mem_wal` opens each flushed generation (into the base + // `prewarm_mem_wal` opens each SSTable (into the base // dataset's session + the supplied cache) and warms its indexes. We - // place a flushed-generation dataset with a BTree index at the + // place an SSTable dataset with a BTree index at the // canonical `{base}/_mem_wal/{shard}/{folder}` path, prewarm it via a // snapshot, and assert the generation is cached and its index loadable. let tmp = tempfile::tempdir().unwrap(); @@ -822,7 +817,7 @@ mod tests { .await .unwrap(); - // Flushed generation with a BTree index on `id`. + // SSTable with a BTree index on `id`. let shard_id = Uuid::new_v4(); let folder = "deadbeef_gen_1"; let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, folder); @@ -844,9 +839,9 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, folder.to_string()); + .with_sstable(1, folder.to_string()); - let cache: Arc = Arc::new(FlushedMemTableCache::new(4)); + let cache: Arc = Arc::new(SsTableCache::new(4)); base.prewarm_mem_wal(std::slice::from_ref(&snapshot), Some(&cache)) .await .expect("prewarm must open the generation and warm its index"); @@ -862,7 +857,7 @@ mod tests { #[tokio::test] async fn test_prewarm_mem_wal_empty_is_noop() { - // No snapshots / no flushed generations: prewarm is a clean no-op. + // No snapshots / no SSTables: prewarm is a clean no-op. let tmp = tempfile::tempdir().unwrap(); let base_uri = format!("{}/base", tmp.path().to_str().unwrap()); let schema = id_v_schema(); diff --git a/rust/lance/src/dataset/mem_wal/index.rs b/rust/lance/src/dataset/mem_wal/index.rs index c43a933ad18..b3da5b5b67e 100644 --- a/rust/lance/src/dataset/mem_wal/index.rs +++ b/rust/lance/src/dataset/mem_wal/index.rs @@ -716,7 +716,7 @@ impl IndexStore { /// BTree (the sidecar dedup index). Single-column emits the typed PK value; /// composite emits the order-preserving `Binary` encoded tuple. Empty when /// there is no primary key. Row positions line up 1:1 with the forward- - /// written data file, so they are the flushed row ids directly. + /// written data file, so they are the SSTable row ids directly. pub fn pk_training_batches(&self, batch_size: usize) -> Result> { match &self.pk_index { None => Ok(Vec::new()), diff --git a/rust/lance/src/dataset/mem_wal/manifest.rs b/rust/lance/src/dataset/mem_wal/manifest.rs index 9c2a3aa2163..acfcbcc3a7c 100644 --- a/rust/lance/src/dataset/mem_wal/manifest.rs +++ b/rust/lance/src/dataset/mem_wal/manifest.rs @@ -146,7 +146,7 @@ impl ShardManifestStore { replay_after_wal_entry_position: 0, wal_entry_position_last_seen: 0, current_generation: 1, - flushed_generations: vec![], + sstables: vec![], status: ShardStatus::Active, }; @@ -462,7 +462,7 @@ impl ShardManifestStore { replay_after_wal_entry_position: 0, wal_entry_position_last_seen: 0, current_generation: 1, - flushed_generations: vec![], + sstables: vec![], status: ShardStatus::Active, } }; @@ -619,7 +619,7 @@ mod tests { replay_after_wal_entry_position: 0, wal_entry_position_last_seen: 0, current_generation: 1, - flushed_generations: vec![], + sstables: vec![], status: ShardStatus::Active, } } diff --git a/rust/lance/src/dataset/mem_wal/memtable.rs b/rust/lance/src/dataset/mem_wal/memtable.rs index f7fa3527469..102271bfd23 100644 --- a/rust/lance/src/dataset/mem_wal/memtable.rs +++ b/rust/lance/src/dataset/mem_wal/memtable.rs @@ -630,7 +630,7 @@ impl MemTable { /// /// This is used when flushing MemTable to persistent storage to ensure /// the flushed data is ordered from newest to oldest. This enables more - /// efficient K-way merge during LSM scan because flushed generations + /// efficient K-way merge during LSM scan because SSTables /// will be pre-sorted in the order needed for deduplication. /// /// The total number of rows in the MemTable is also returned to allow diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index 6937894b2d0..0a5c97ee6c7 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -12,7 +12,7 @@ use lance_core::cache::LanceCache; use lance_core::utils::deletion::DeletionVector; use lance_core::{Error, Result}; use lance_index::IndexType; -use lance_index::mem_wal::{FlushedGeneration, ShardManifest}; +use lance_index::mem_wal::{ShardManifest, SsTable}; use lance_index::scalar::{IndexStore, ScalarIndexParams}; use lance_io::object_store::{ObjectStore, ObjectStoreParams}; use lance_table::format::IndexMetadata; @@ -30,16 +30,14 @@ use super::super::memtable::MemTable; use crate::Dataset; use crate::dataset::builder::DatasetBuilder; use crate::dataset::mem_wal::manifest::ShardManifestStore; -use crate::dataset::mem_wal::scanner::GenerationWarmer; +use crate::dataset::mem_wal::scanner::SsTableWarmer; use crate::dataset::mem_wal::scanner::exec::{compute_pk_hash, validate_pk_types}; -use crate::dataset::mem_wal::util::{ - derived_store_params, flushed_memtable_path, generate_random_hash, -}; +use crate::dataset::mem_wal::util::{derived_store_params, generate_random_hash, sstable_path}; use crate::session::Session; #[derive(Debug, Clone)] pub struct FlushResult { - pub generation: FlushedGeneration, + pub sstable: SsTable, pub rows_flushed: usize, pub covered_wal_entry_position: u64, } @@ -75,7 +73,7 @@ pub struct MemTableFlusher { manifest_store: Arc, /// When present, each new generation is warmed before it is committed, so /// the first query sees zero cold reads. `None` => no warming. - warmer: Option>, + warmer: Option>, /// Store params the base dataset was opened with, reused for the flusher's /// own opens + writes. Used verbatim only for the base's own URI; generation /// URIs go through [`derived_store_params`]. `None` opens by URI alone. @@ -106,7 +104,7 @@ impl MemTableFlusher { } /// Attach the warmer fired pre-commit for each new generation. - pub fn with_warmer(mut self, warmer: Option>) -> Self { + pub fn with_warmer(mut self, warmer: Option>) -> Self { self.warmer = warmer; self } @@ -131,7 +129,7 @@ impl MemTableFlusher { .await } - /// Open a flushed generation under `_mem_wal/`. The params must be adapted + /// Open an SSTable under `_mem_wal/`. The params must be adapted /// first: a path-bound store binding would redirect the open at the base /// table (see [`derived_store_params`]). async fn open_generation(&self, uri: &str) -> Result { @@ -188,9 +186,9 @@ impl MemTableFlusher { } } - /// Storage file version of the shard's base dataset. Flushed generations + /// Storage file version of the shard's base dataset. SSTables /// (data fragments and index files) are written at this same version so the - /// whole shard stays on one format (e.g. a 2.2 base => 2.2 flushed gens). + /// whole shard stays on one format (e.g. a 2.2 base => 2.2 SSTables). /// /// Falls back to [`LanceFileVersion::default`] when no base dataset exists at /// `base_uri` (e.g. flusher unit tests that run without a committed base). @@ -236,8 +234,7 @@ impl MemTableFlusher { let random_hash = generate_random_hash(); let generation = memtable.generation(); let gen_folder_name = format!("{}_gen_{}", random_hash, generation); - let gen_path = - flushed_memtable_path(&self.base_path, &self.shard_id, &random_hash, generation); + let gen_path = sstable_path(&self.base_path, &self.shard_id, &random_hash, generation); info!( "Flushing MemTable generation {} to {} ({} rows, {} batches)", @@ -249,8 +246,8 @@ impl MemTableFlusher { let (rows_flushed, deleted) = self.write_data_file(&gen_path, memtable).await?; - // Persist the within-generation deletion vector so the flushed - // generation exposes newest-per-PK on every read path. + // Persist the within-generation deletion vector so the + // SSTable exposes newest-per-PK on every read path. if !deleted.is_empty() { let uri = self.path_to_uri(&gen_path); let dataset = self.open_generation(&uri).await?; @@ -281,12 +278,12 @@ impl MemTableFlusher { .await?; info!( - "Flushed generation {} for shard {} (manifest version {})", + "Flushed SSTable {} for shard {} (manifest version {})", generation, self.shard_id, new_manifest.version ); Ok(FlushResult { - generation: FlushedGeneration { + sstable: SsTable { generation, path: gen_folder_name, }, @@ -355,8 +352,8 @@ impl MemTableFlusher { let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), memtable.schema().clone()); - // Use very large max_rows_per_file to ensure 1 fragment per flushed memtable. - // Inherit the base dataset's storage version so the flushed generation + // Use very large max_rows_per_file to ensure 1 fragment per SSTable. + // Inherit the base dataset's storage version so the SSTable // matches it (a 2.2 base also fixes the v2.1 miniblock 32 KiB chunk cap // that the dense HNSW graph List columns overflow at scale). let write_params = WriteParams { @@ -399,7 +396,7 @@ impl MemTableFlusher { let dv = DeletionVector::from(deleted.clone()); let deletion_file = write_deletion_file( &dataset.base, - 0, // 1 fragment per flushed generation + 0, // 1 fragment per SSTable dataset.version().version, &dv, dataset.object_store.as_ref(), @@ -470,8 +467,7 @@ impl MemTableFlusher { let random_hash = generate_random_hash(); let generation = memtable.generation(); let gen_folder_name = format!("{}_gen_{}", random_hash, generation); - let gen_path = - flushed_memtable_path(&self.base_path, &self.shard_id, &random_hash, generation); + let gen_path = sstable_path(&self.base_path, &self.shard_id, &random_hash, generation); info!( "Flushing MemTable generation {} with indexes to {} ({} rows, {} batches)", @@ -497,7 +493,7 @@ impl MemTableFlusher { .await?; if !btree_indexes.is_empty() { info!( - "Created {} BTree indexes on flushed generation {}", + "Created {} BTree indexes on SSTable {}", btree_indexes.len(), generation ); @@ -516,7 +512,7 @@ impl MemTableFlusher { .await? else { info!( - "Skipped empty HNSW index '{}' on flushed generation {} (no vectors)", + "Skipped empty HNSW index '{}' on SSTable {} (no vectors)", hnsw_config.name, generation ); continue; @@ -540,7 +536,7 @@ impl MemTableFlusher { all_indexes.push(index_meta); info!( - "Created HNSW index '{}' on flushed generation {}", + "Created HNSW index '{}' on SSTable {}", hnsw_config.name, generation ); } @@ -586,12 +582,12 @@ impl MemTableFlusher { .await?; info!( - "Flushed generation {} for shard {} (manifest version {})", + "Flushed SSTable {} for shard {} (manifest version {})", generation, self.shard_id, new_manifest.version ); Ok(FlushResult { - generation: FlushedGeneration { + sstable: SsTable { generation, path: gen_folder_name, }, @@ -600,7 +596,7 @@ impl MemTableFlusher { }) } - /// Create BTree indexes on the flushed dataset (uncommitted). + /// Create BTree indexes on the SSTable dataset (uncommitted). /// /// Returns index metadata without committing to the dataset manifest. /// The caller is responsible for writing a single manifest with all indexes. @@ -668,7 +664,7 @@ impl MemTableFlusher { /// keys index the typed value; composite keys index the order-preserving /// `Binary` encoded tuple (see [`super::super::index::encode_pk_tuple`]). /// Row positions line up 1:1 with the forward-written data file, so they are - /// the flushed row ids directly. No-op without a primary-key index. + /// the SSTable row ids directly. No-op without a primary-key index. async fn create_pk_index( &self, gen_path: &Path, @@ -882,7 +878,7 @@ impl MemTableFlusher { /// the existing Lance `IVF_HNSW_SQ` reader path. /// /// # Arguments - /// * `gen_path` - Path to the flushed generation folder + /// * `gen_path` - Path to the SSTable folder /// * `config` - HNSW index configuration /// * `mem_index` - In-memory HNSW index (snapshotted, not consumed) /// @@ -1129,7 +1125,7 @@ impl MemTableFlusher { Ok(Some(index_meta)) } - /// Update the shard manifest with the new flushed generation. + /// Update the shard manifest with the new SSTable. async fn update_manifest( &self, epoch: u64, @@ -1141,8 +1137,8 @@ impl MemTableFlusher { self.manifest_store .commit_update(epoch, |current| { - let mut flushed_generations = current.flushed_generations.clone(); - flushed_generations.push(FlushedGeneration { + let mut sstables = current.sstables.clone(); + sstables.push(SsTable { generation, path: gen_path.clone(), }); @@ -1154,7 +1150,7 @@ impl MemTableFlusher { .wal_entry_position_last_seen .max(covered_wal_entry_position), current_generation: generation + 1, - flushed_generations, + sstables, ..current.clone() } }) @@ -1335,7 +1331,7 @@ mod tests { ); let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap(); - assert_eq!(result.generation.generation, 1); + assert_eq!(result.sstable.generation, 1); assert_eq!(result.rows_flushed, 10); assert_eq!(result.covered_wal_entry_position, 1); @@ -1344,10 +1340,10 @@ mod tests { assert_eq!(updated_manifest.version, 2); assert_eq!(updated_manifest.replay_after_wal_entry_position, 1); assert_eq!(updated_manifest.current_generation, 2); - assert_eq!(updated_manifest.flushed_generations.len(), 1); + assert_eq!(updated_manifest.sstables.len(), 1); } - /// A `GenerationWarmer` that counts calls and optionally fails. + /// A `SsTableWarmer` that counts calls and optionally fails. #[derive(Debug)] struct CountingWarmer { calls: Arc, @@ -1355,7 +1351,7 @@ mod tests { } #[async_trait::async_trait] - impl GenerationWarmer for CountingWarmer { + impl SsTableWarmer for CountingWarmer { async fn warm(&self, _path: &str) -> Result<()> { self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if self.fail { @@ -1390,7 +1386,7 @@ mod tests { let durable = frag_id + 1; let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let warmer: Arc = Arc::new(CountingWarmer { + let warmer: Arc = Arc::new(CountingWarmer { calls: calls.clone(), fail: true, }); @@ -1406,7 +1402,7 @@ mod tests { // Flush must succeed despite the warmer erroring. let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap(); - assert_eq!(result.generation.generation, 1); + assert_eq!(result.sstable.generation, 1); assert_eq!( calls.load(std::sync::atomic::Ordering::SeqCst), 1, @@ -1414,14 +1410,14 @@ mod tests { ); let updated = manifest_store.read_latest().await.unwrap().unwrap(); assert_eq!( - updated.flushed_generations.len(), + updated.sstables.len(), 1, "generation still committed after a failed warm" ); } /// Flushing a generation with within-generation duplicate PKs writes a - /// deletion vector so the flushed dataset exposes newest-per-PK on scan. + /// deletion vector so the SSTable dataset exposes newest-per-PK on scan. #[tokio::test] async fn test_flush_writes_dedup_deletion_vector() { use futures::TryStreamExt; @@ -1460,13 +1456,13 @@ mod tests { let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap(); assert_eq!(result.rows_flushed, 5, "all physical rows are written"); - // Scanning the flushed generation must honor the deletion vector and + // Scanning the SSTable must honor the deletion vector and // return only the newest version of each PK. let gen_uri = format!( "{}/_mem_wal/{}/{}", base_uri.trim_end_matches('/'), shard_id, - result.generation.path + result.sstable.path ); let dataset = Dataset::open(&gen_uri).await.unwrap(); let batches: Vec = dataset @@ -1513,7 +1509,7 @@ mod tests { /// probe by value — including for a within-gen-superseded PK (existence, /// not visibility). #[tokio::test] - async fn flushed_pk_index_sidecar_is_probeable() { + async fn sstable_pk_index_sidecar_is_probeable() { use lance_core::cache::LanceCache; use lance_index::metrics::NoOpMetricsCollector; use lance_index::registry::IndexPluginRegistry; @@ -1570,7 +1566,7 @@ mod tests { .clone() .join("_mem_wal") .join(shard_id.to_string()) - .join(result.generation.path.as_str()); + .join(result.sstable.path.as_str()); let index_store = Arc::new(LanceIndexStore::new( store.clone(), pk_index_path(&gen_path), @@ -1667,7 +1663,7 @@ mod tests { .clone() .join("_mem_wal") .join(shard_id.to_string()) - .join(result.generation.path.as_str()); + .join(result.sstable.path.as_str()); let index_store = Arc::new(LanceIndexStore::new( store.clone(), pk_index_path(&gen_path), @@ -1764,13 +1760,13 @@ mod tests { "{}/_mem_wal/{}/{}", base_uri.trim_end_matches('/'), shard_id, - result.generation.path + result.sstable.path ); let dataset = Dataset::open(&gen_uri).await.unwrap(); assert_eq!( dataset.version().version, 1, - "flushed dataset must be a single-version dataset" + "SSTable dataset must be a single-version dataset" ); // Index half of the combined manifest. @@ -1891,19 +1887,16 @@ mod tests { .await .unwrap(); - assert_eq!(result.generation.generation, 1); + assert_eq!(result.sstable.generation, 1); assert_eq!(result.rows_flushed, 10); - // Verify the flushed dataset is a single-version dataset with the BTree index - let gen_uri = format!( - "{}/_mem_wal/{}/{}", - base_uri, shard_id, result.generation.path - ); + // Verify the SSTable dataset is a single-version dataset with the BTree index + let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path); let dataset = Dataset::open(&gen_uri).await.unwrap(); assert_eq!( dataset.version().version, 1, - "flushed dataset must be a single-version dataset" + "SSTable dataset must be a single-version dataset" ); let indices = dataset.load_indices().await.unwrap(); @@ -2028,26 +2021,23 @@ mod tests { .await .unwrap(); - assert_eq!(result.generation.generation, 1); + assert_eq!(result.sstable.generation, 1); assert_eq!(result.rows_flushed, num_vectors); - // Verify the flushed dataset is a single-version dataset with the HNSW index - let gen_uri = format!( - "{}/_mem_wal/{}/{}", - base_uri, shard_id, result.generation.path - ); + // Verify the SSTable dataset is a single-version dataset with the HNSW index + let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path); let dataset = Dataset::open(&gen_uri).await.unwrap(); assert_eq!( dataset.version().version, 1, - "flushed dataset must be a single-version dataset" + "SSTable dataset must be a single-version dataset" ); let indices = dataset.load_indices().await.unwrap(); assert_eq!(indices.len(), 1); assert_eq!(indices[0].name, "vector_hnsw"); - // End-to-end query: pick a row from the flushed dataset, query for + // End-to-end query: pick a row from the SSTable dataset, query for // it, and verify the index path returns it as the nearest neighbor. // This exercises the on-disk HNSW + SQ8 format including the IVF // partition routing and the storage_metadata ScalarQuantizationMetadata @@ -2178,19 +2168,16 @@ mod tests { .await .unwrap(); - assert_eq!(result.generation.generation, 1); + assert_eq!(result.sstable.generation, 1); assert_eq!(result.rows_flushed, 3); - // Verify the flushed dataset is a single-version dataset with the FTS index - let gen_uri = format!( - "{}/_mem_wal/{}/{}", - base_uri, shard_id, result.generation.path - ); + // Verify the SSTable dataset is a single-version dataset with the FTS index + let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path); let dataset = Dataset::open(&gen_uri).await.unwrap(); assert_eq!( dataset.version().version, 1, - "flushed dataset must be a single-version dataset" + "SSTable dataset must be a single-version dataset" ); let indices = dataset.load_indices().await.unwrap(); diff --git a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/brute_force_vector.rs b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/brute_force_vector.rs index 0e28882f634..e852d2ad917 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/brute_force_vector.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/brute_force_vector.rs @@ -39,7 +39,7 @@ use crate::dataset::mem_wal::write::BatchStore; /// Distance metric used when [`VectorQuery::distance_type`] is `None`. The /// indexed path defers to the index's own metric, but with no index there is /// no inherent default — L2 matches what most callers configure and what the -/// flushed/base arms use when re-ranking unindexed candidates. +/// SSTable/base arms use when re-ranking unindexed candidates. const DEFAULT_DISTANCE_TYPE: DistanceType = DistanceType::L2; /// Brute-force KNN over an active memtable without an HNSW. Produces the same diff --git a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs index e35ee6d1b6a..61e8ac85efc 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs @@ -115,9 +115,9 @@ impl FtsIndexExec { .map(|f| f.as_ref().clone()) .collect(); // `_score` is nullable here to stay schema-compatible with - // `lance_index::scalar::inverted::FTS_SCHEMA` (the schema base/flushed + // `lance_index::scalar::inverted::FTS_SCHEMA` (the schema base/SSTable // FTS exec nodes emit). The LSM `full_text_search` planner unions the - // active arm with base/flushed arms; UnionExec requires schema equality + // active arm with base/SSTable arms; UnionExec requires schema equality // including nullability. The actual emitted column is always populated. fields.push(Field::new(SCORE_COLUMN, DataType::Float32, true)); if with_row_id { diff --git a/rust/lance/src/dataset/mem_wal/scanner.rs b/rust/lance/src/dataset/mem_wal/scanner.rs index f1d84611e04..768e41ed043 100644 --- a/rust/lance/src/dataset/mem_wal/scanner.rs +++ b/rust/lance/src/dataset/mem_wal/scanner.rs @@ -6,7 +6,7 @@ //! This module provides scanners that read from multiple data sources //! in an LSM tree architecture: //! - Base table (merged data) -//! - Flushed MemTables (persisted but not yet merged) +//! - SSTables (persisted but not yet merged) //! - Active MemTable (in-memory buffer) //! //! The scanner handles deduplication by primary key, keeping the newest @@ -41,11 +41,11 @@ mod builder; mod collector; mod data_source; pub mod exec; -pub(crate) mod flushed_cache; mod fts_search; mod planner; mod point_lookup; mod projection; +pub(crate) mod sstable_cache; mod vector_search; pub use block_list::write_pk_sidecar; @@ -53,13 +53,11 @@ pub use builder::LsmScanner; pub use collector::{ ActiveMemTableRef, InMemoryMemTableRef, InMemoryMemTables, LsmDataSourceCollector, }; -pub use data_source::{ - FlushedGeneration, FreshTierWatermark, LsmDataSource, LsmGeneration, ShardSnapshot, -}; -pub use flushed_cache::{DatasetCache, FlushedMemTableCache, GenerationWarmer}; +pub use data_source::{FreshTierWatermark, LsmDataSource, LsmGeneration, ShardSnapshot, SsTable}; pub use fts_search::{LsmFtsSearchPlanner, SCORE_COLUMN}; pub use point_lookup::LsmPointLookupPlanner; pub use projection::DISTANCE_COLUMN; +pub use sstable_cache::{DatasetCache, SsTableCache, SsTableWarmer}; pub use vector_search::LsmVectorSearchPlanner; /// Parse a SQL filter expression against a MemWAL source schema. diff --git a/rust/lance/src/dataset/mem_wal/scanner/block_list.rs b/rust/lance/src/dataset/mem_wal/scanner/block_list.rs index e74ebda0f63..91fdda8c65f 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/block_list.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/block_list.rs @@ -5,7 +5,7 @@ //! //! A generation's membership is a [`GenMembership`]: in-memory generations //! (active / frozen) are probed by value against their maintained primary-key -//! index (no per-query set), while flushed generations are probed against their +//! index (no per-query set), while SSTables are probed against their //! standalone on-disk PK BTree (the sidecar written at flush, opened by path). //! Probing is batched — [`GenMembership::contains_keys`] tests a whole batch of //! keys per generation in one pass. Each source gets a `Vec` of @@ -32,7 +32,7 @@ use lance_index::scalar::{ use uuid::Uuid; use super::data_source::{FreshTierWatermark, LsmDataSource, LsmGeneration}; -use super::flushed_cache::{DatasetCache, open_flushed_dataset}; +use super::sstable_cache::{DatasetCache, open_sstable}; use crate::dataset::mem_wal::index::encode_pk_tuple; use crate::dataset::mem_wal::util::PK_INDEX_DIR; use crate::dataset::mem_wal::write::{BatchStore, IndexStore}; @@ -55,7 +55,7 @@ pub enum GenMembership { /// Inclusive visible row watermark; `None` when no rows are visible. max_visible_row: Option, }, - /// Probe the flushed generation's standalone on-disk PK BTree. + /// Probe the SSTable's standalone on-disk PK BTree. OnDisk(Arc), } @@ -111,7 +111,7 @@ impl GenMembership { } /// Whether this generation has no (visible) membership — used to skip adding - /// an empty blocked set. A flushed generation always has rows (flush rejects + /// an empty blocked set. An SSTable always has rows (flush rejects /// an empty memtable), so it is never empty. fn is_empty(&self) -> bool { match self { @@ -159,7 +159,7 @@ pub async fn compute_source_block_lists( sources: &[LsmDataSource], session: Option<&Arc>, store_params: Option<&ObjectStoreParams>, - flushed_cache: Option<&Arc>, + sstable_cache: Option<&Arc>, ) -> Result { // Membership per non-base source, grouped by shard (generations are // per-shard, so supersession is within-shard only). @@ -167,7 +167,7 @@ pub async fn compute_source_block_lists( let mut has_base = false; // Flushed PK-BTree opens are cold S3 reads; overlap them with // `try_join_all`. Order is irrelevant — gens are sorted per-shard below. - let mut flushed_loads = Vec::new(); + let mut sstable_loads = Vec::new(); for source in sources { match source { LsmDataSource::BaseTable { .. } => has_base = true, @@ -184,18 +184,18 @@ pub async fn compute_source_block_lists( .or_default() .push((*generation, membership)); } - LsmDataSource::FlushedMemTable { + LsmDataSource::SsTable { path, shard_id, generation, .. - } => flushed_loads.push(async move { - let index = open_pk_index(path, session, store_params, flushed_cache).await?; + } => sstable_loads.push(async move { + let index = open_pk_index(path, session, store_params, sstable_cache).await?; Ok::<_, Error>((*shard_id, *generation, GenMembership::OnDisk(index))) }), } } - for (shard_id, generation, membership) in futures::future::try_join_all(flushed_loads).await? { + for (shard_id, generation, membership) in futures::future::try_join_all(sstable_loads).await? { by_shard .entry(shard_id) .or_default() @@ -227,7 +227,7 @@ pub async fn compute_source_block_lists( /// The fresh-tier block-list: one [`GenMembership`] per generation that shadows /// the base table — active + frozen memtables (probed against their index) and -/// flushed generations (probed against their on-disk PK BTree). A base/external +/// SSTables (probed against their on-disk PK BTree). A base/external /// reader can test any PK against these (via [`GenMembership::contains`]) to /// decide whether the fresh tier shadows it. The base source, if present, is /// skipped (it is what gets shadowed). @@ -241,14 +241,14 @@ pub async fn fresh_tier_block_list( sources: &[LsmDataSource], session: Option<&Arc>, store_params: Option<&ObjectStoreParams>, - flushed_cache: Option<&Arc>, + sstable_cache: Option<&Arc>, watermarks: Option<&HashMap>, ) -> Result> { // Membership per source, in source order (`None` = skipped). Flushed // PK-BTree opens are cold S3 reads, so collect them tagged with their slot // and overlap with `try_join_all` rather than opening one at a time. let mut slots: Vec> = Vec::with_capacity(sources.len()); - let mut flushed_loads = Vec::new(); + let mut sstable_loads = Vec::new(); for source in sources { match source { LsmDataSource::BaseTable { .. } => slots.push(None), @@ -281,7 +281,7 @@ pub async fn fresh_tier_block_list( }; slots.push(membership); } - LsmDataSource::FlushedMemTable { + LsmDataSource::SsTable { path, shard_id, generation, @@ -301,16 +301,16 @@ pub async fn fresh_tier_block_list( } else { let slot = slots.len(); slots.push(None); - flushed_loads.push(async move { + sstable_loads.push(async move { let index = - open_pk_index(path, session, store_params, flushed_cache).await?; + open_pk_index(path, session, store_params, sstable_cache).await?; Ok::<_, Error>((slot, GenMembership::OnDisk(index))) }); } } } } - for (slot, membership) in futures::future::try_join_all(flushed_loads).await? { + for (slot, membership) in futures::future::try_join_all(sstable_loads).await? { slots[slot] = Some(membership); } Ok(slots @@ -356,11 +356,11 @@ fn bounded_in_memory_membership( } } -/// Open the standalone PK BTree at `{flushed gen}/_pk_index` for one flushed -/// generation. Reuses the flushed dataset's (session-configured) object store +/// Open the standalone PK BTree at `{SSTable gen}/_pk_index` for one +/// SSTable. Reuses the SSTable dataset's (session-configured) object store /// and **its index cache**, then loads the sidecar directly by path through the /// BTree plugin — it is not a manifest index. The opened index and its pages -/// are cached in the session's index cache (keyed by the immutable flushed +/// are cached in the session's index cache (keyed by the immutable SSTable /// path), so repeated probes reuse them with no separate cache path and no /// upfront scan; concurrent first-opens may each load before the cache fills. /// A stable cache UUID for a non-manifest index identified only by its path. @@ -385,12 +385,12 @@ async fn open_pk_index( path: &str, session: Option<&Arc>, store_params: Option<&ObjectStoreParams>, - flushed_cache: Option<&Arc>, + sstable_cache: Option<&Arc>, ) -> Result> { - let dataset = open_flushed_dataset(path, session, store_params, flushed_cache, None).await?; - // Namespace the session index cache by the (immutable) flushed path so this + let dataset = open_sstable(path, session, store_params, sstable_cache, None).await?; + // Namespace the session index cache by the (immutable) SSTable path so this // sidecar's pages live alongside every other index instead of a bespoke - // cache. `fri_uuid` is None — flushed generations carry no fragment-reuse. + // cache. `fri_uuid` is None — SSTables carry no fragment-reuse. let index_cache = dataset.index_cache.for_index(&path_cache_uuid(path), None); let index_dir = dataset.base.clone().join(PK_INDEX_DIR); let store: Arc = Arc::new(LanceIndexStore::new( @@ -416,13 +416,13 @@ async fn open_pk_index( Ok(index) } -/// Write a flushed generation's standalone PK sidecar at `{uri}/_pk_index` from +/// Write an SSTable's standalone PK sidecar at `{uri}/_pk_index` from /// `batches`, mirroring what flush does in production. `pk_columns` are the /// primary-key column names (field ids are synthesized by position — `insert` /// resolves columns by name). A no-op when no batch carries the PK columns. /// /// Used by Rust scanner tests and by the Python test-support binding to stage -/// faithful flushed generations (a flushed dataset alone, with no sidecar, is +/// faithful SSTables (an SSTable dataset alone, with no sidecar, is /// not a state production ever produces). pub async fn write_pk_sidecar( uri: &str, @@ -766,32 +766,32 @@ mod tests { assert!(!blocks(&sets, 100).await); // gen 3 — after the snapshot } - /// A flushed generation at or above the active generation was produced by a + /// An SSTable at or above the active generation was produced by a /// flush after the snapshot and is excluded; one strictly below it is /// immutable and included. #[tokio::test] - async fn fresh_tier_watermark_excludes_flushed_at_or_above_active() { + async fn fresh_tier_watermark_excludes_sstables_at_or_above_active() { use crate::dataset::mem_wal::scanner::data_source::FreshTierWatermark; use crate::dataset::{Dataset, WriteParams}; use arrow_array::RecordBatchIterator; use std::collections::HashMap; - // A flushed generation 2 holding pk=5, staged as a flushed dataset with + // An SSTable 2 holding pk=5, staged as an SSTable dataset with // its standalone PK sidecar (what the on-disk membership probes). - let flushed_batch = id_batch(&[5]); - let schema = flushed_batch.schema(); + let sstable_batch = id_batch(&[5]); + let schema = sstable_batch.schema(); let tmp = tempfile::tempdir().unwrap(); let path = format!("{}/gen2", tmp.path().to_str().unwrap()); - let reader = RecordBatchIterator::new(vec![Ok(flushed_batch.clone())], schema.clone()); + let reader = RecordBatchIterator::new(vec![Ok(sstable_batch.clone())], schema.clone()); Dataset::write(reader, &path, Some(WriteParams::default())) .await .unwrap(); - write_pk_sidecar(&path, &[flushed_batch], &["id"]) + write_pk_sidecar(&path, &[sstable_batch], &["id"]) .await .unwrap(); let shard = Uuid::new_v4(); - let sources = vec![LsmDataSource::FlushedMemTable { + let sources = vec![LsmDataSource::SsTable { path, shard_id: shard, generation: LsmGeneration::memtable(2), diff --git a/rust/lance/src/dataset/mem_wal/scanner/builder.rs b/rust/lance/src/dataset/mem_wal/scanner/builder.rs index 4fc765e7fb5..fbcb2f8479c 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/builder.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/builder.rs @@ -25,10 +25,10 @@ use uuid::Uuid; use super::collector::{InMemoryMemTableRef, InMemoryMemTables, LsmDataSourceCollector}; use super::data_source::{FreshTierWatermark, ShardSnapshot}; -use super::flushed_cache::{DatasetCache, GenerationWarmer}; use super::planner::LsmScanPlanner; use super::point_lookup::LsmPointLookupPlanner; use super::projection::validate_projection_names; +use super::sstable_cache::{DatasetCache, SsTableWarmer}; use crate::dataset::Dataset; use crate::dataset::mem_wal::util::derived_store_params; use crate::session::Session; @@ -92,7 +92,7 @@ fn extract_pk_point_keys(filter: &Expr, pk_col: &str) -> Option } /// Either a base Lance table, or an explicit base path used to resolve -/// flushed-generation directories when no base dataset is configured. +/// SSTable directories when no base dataset is configured. enum BaseSource { Table(Arc), PathOnly(String), @@ -152,12 +152,12 @@ fn key_to_fsl(key: &dyn Array, dim: i32) -> Result { Ok(builder.finish()) } -/// Scanner for LSM tree data spanning base table, flushed MemTables, and active MemTable. +/// Scanner for LSM tree data spanning base table, SSTables, and active MemTable. /// /// This scanner provides a unified interface for querying data across multiple /// LSM tree levels: /// - Base table (merged data, generation = 0) -/// - Flushed MemTables (persisted but not yet merged, generation = 1, 2, ...) +/// - SSTables (persisted but not yet merged, generation = 1, 2, ...) /// - Active MemTable (in-memory buffer, highest generation) /// /// The scanner automatically handles deduplication by primary key, keeping @@ -208,17 +208,17 @@ pub struct LsmScanner { // Primary key columns (required for deduplication) pk_columns: Vec, - /// Session for opening flushed generations (shares the base's caches). + /// Session for opening SSTables (shares the base's caches). /// Defaults to the base table's session. session: Option>, - /// Store params for opening flushed generations, reusing the base dataset's + /// Store params for opening SSTables, reusing the base dataset's /// store. Defaults to the base table's params. store_params: Option, - /// Cache of opened flushed-generation datasets. When set, repeated + /// Cache of opened SSTable datasets. When set, repeated /// queries against the same generation skip the manifest read entirely. - flushed_cache: Option>, - /// Optional warmer fired on first open of a flushed generation. - warmer: Option>, + sstable_cache: Option>, + /// Optional warmer fired on first open of an SSTable. + warmer: Option>, /// Over-fetch multiple for block-listed sources in search plans /// (see [`super::LsmFtsSearchPlanner::with_overfetch_factor`]). overfetch_factor: Option, @@ -243,7 +243,7 @@ impl LsmScanner { // the shared index / metadata caches without extra wiring. An // explicit `with_session` still overrides this. let session = Some(base_table.session()); - // The scanner only ever opens flushed generations with these — the base + // The scanner only ever opens SSTables with these — the base // table is already open and handed in — so they must not carry a // path-bound store binding. let store_params = base_table.store_params().map(derived_store_params); @@ -263,25 +263,25 @@ impl LsmScanner { pk_columns, session, store_params, - flushed_cache: None, + sstable_cache: None, warmer: None, overfetch_factor: None, } } /// Create a scanner that reads only the fresh tier (active memtable and - /// flushed generations) without including a base Lance table. + /// SSTables) without including a base Lance table. /// /// This is useful when the caller owns the base read path separately and - /// only needs the WAL's contribution: active memtable ∪ L0 flushed - /// generations. Deduplication semantics are unchanged — newer generations + /// only needs the WAL's contribution: active memtable ∪ L0 SSTables. + /// Deduplication semantics are unchanged — newer generations /// still win on PK conflicts. /// /// # Arguments /// /// * `schema` - Schema used for projection, filter parsing, and empty plans. - /// Should match the schema flushed generations were written with. - /// * `base_path` - Table-root URI used to resolve relative flushed paths. + /// Should match the schema SSTables were written with. + /// * `base_path` - Table-root URI used to resolve relative SSTable paths. /// * `shard_snapshots` - Snapshots of shard states from MemWAL index. /// * `pk_columns` - Primary key column names for deduplication. pub fn without_base_table( @@ -306,7 +306,7 @@ impl LsmScanner { pk_columns, session: None, store_params: None, - flushed_cache: None, + sstable_cache: None, warmer: None, overfetch_factor: None, } @@ -341,14 +341,14 @@ impl LsmScanner { self } - /// Set the session used to open flushed generations. Defaults to the base + /// Set the session used to open SSTables. Defaults to the base /// table's; set explicitly on a fresh-tier-only scanner (no base table). pub fn with_session(mut self, session: Arc) -> Self { self.session = Some(session); self } - /// Set the store params used to open flushed generations. Defaults to the + /// Set the store params used to open SSTables. Defaults to the /// base table's; set explicitly on a fresh-tier-only scanner (no base table). /// /// Pass the params the *base* was opened with. As in [`Self::new`], they are @@ -360,21 +360,21 @@ impl LsmScanner { self } - /// Inject a cache of opened flushed-generation datasets. + /// Inject a cache of opened SSTable datasets. /// /// With a cache, repeated queries against the same generation become a /// pure `Arc::clone` with no manifest read or object-store I/O. The cache /// is owned and sized by the caller (any [`DatasetCache`] impl, e.g. - /// [`FlushedMemTableCache`](super::FlushedMemTableCache)); not set by + /// [`SsTableCache`](super::SsTableCache)); not set by /// default, so behavior is unchanged unless opted in. - pub fn with_flushed_cache(mut self, cache: Arc) -> Self { - self.flushed_cache = Some(cache); + pub fn with_sstable_cache(mut self, cache: Arc) -> Self { + self.sstable_cache = Some(cache); self } - /// Inject the warmer fired on first open of a flushed generation. Not set by + /// Inject the warmer fired on first open of an SSTable. Not set by /// default, so behavior is unchanged unless opted in. - pub fn with_warmer(mut self, warmer: Arc) -> Self { + pub fn with_warmer(mut self, warmer: Arc) -> Self { self.warmer = Some(warmer); self } @@ -436,7 +436,7 @@ impl LsmScanner { } /// Find the `k` nearest neighbors of `key` in `column`. Routes `create_plan` - /// through the LSM vector planner (base ∪ flushed ∪ in-memory). Mirrors + /// through the LSM vector planner (base ∪ SSTables ∪ in-memory). Mirrors /// [`crate::dataset::scanner::Scanner::nearest`]; the LSM path supports a /// single Float32 query vector. When combined with an offset, the LSM path /// fetches `k + offset` per source before applying the final page. Tune with @@ -554,7 +554,7 @@ impl LsmScanner { Arc::new(GlobalLimitExec::new(plan, skip, self.limit)) } - /// Vector (KNN) search across base ∪ flushed ∪ in-memory, via the LSM vector + /// Vector (KNN) search across base ∪ SSTables ∪ in-memory, via the LSM vector /// planner. Honors the builder filter as a prefilter. async fn plan_vector(&self) -> Result> { let nearest = self @@ -584,8 +584,8 @@ impl LsmScanner { if let Some(store_params) = &self.store_params { planner = planner.with_store_params(store_params.clone()); } - if let Some(cache) = &self.flushed_cache { - planner = planner.with_flushed_cache(cache.clone()); + if let Some(cache) = &self.sstable_cache { + planner = planner.with_sstable_cache(cache.clone()); } if let Some(warmer) = &self.warmer { planner = planner.with_warmer(warmer.clone()); @@ -608,7 +608,7 @@ impl LsmScanner { Ok(self.apply_limit_offset(plan)) } - /// Full-text search across base ∪ flushed ∪ in-memory, via the LSM FTS + /// Full-text search across base ∪ SSTables ∪ in-memory, via the LSM FTS /// planner. Query/scanner limits bound per-source fetches when present; /// otherwise the search remains unbounded and any offset is applied above. async fn plan_fts(&self) -> Result> { @@ -663,8 +663,8 @@ impl LsmScanner { if let Some(store_params) = &self.store_params { planner = planner.with_store_params(store_params.clone()); } - if let Some(cache) = &self.flushed_cache { - planner = planner.with_flushed_cache(cache.clone()); + if let Some(cache) = &self.sstable_cache { + planner = planner.with_sstable_cache(cache.clone()); } if let Some(warmer) = &self.warmer { planner = planner.with_warmer(warmer.clone()); @@ -683,7 +683,7 @@ impl LsmScanner { Ok(self.apply_limit_offset(plan)) } - /// Plain (filter / projection / limit) scan over base ∪ flushed ∪ in-memory. + /// Plain (filter / projection / limit) scan over base ∪ SSTables ∪ in-memory. async fn plan_scan(&self) -> Result> { let collector = self.build_collector(); let base_schema = self.schema(); @@ -711,8 +711,8 @@ impl LsmScanner { if let Some(store_params) = &self.store_params { planner = planner.with_store_params(store_params.clone()); } - if let Some(cache) = &self.flushed_cache { - planner = planner.with_flushed_cache(cache.clone()); + if let Some(cache) = &self.sstable_cache { + planner = planner.with_sstable_cache(cache.clone()); } if let Some(warmer) = &self.warmer { planner = planner.with_warmer(warmer.clone()); @@ -733,8 +733,8 @@ impl LsmScanner { if let Some(store_params) = &self.store_params { planner = planner.with_store_params(store_params.clone()); } - if let Some(cache) = &self.flushed_cache { - planner = planner.with_flushed_cache(cache.clone()); + if let Some(cache) = &self.sstable_cache { + planner = planner.with_sstable_cache(cache.clone()); } if let Some(warmer) = &self.warmer { planner = planner.with_warmer(warmer.clone()); @@ -753,7 +753,7 @@ impl LsmScanner { } /// Find rows matching a full-text query. Routes `create_plan` through the - /// LSM FTS planner (base ∪ flushed ∪ in-memory), local-scored by BM25 and + /// LSM FTS planner (base ∪ SSTables ∪ in-memory), local-scored by BM25 and /// merged by `_score` DESC. Mirrors /// [`crate::dataset::scanner::Scanner::full_text_search`]: the searched /// column(s) come from the query (set via `FullTextSearchQuery::with_column`); @@ -804,7 +804,7 @@ impl LsmScanner { } /// Test which `pks` have been (re)written in the WAL fresh tier — the active - /// and frozen memtables and flushed generations this scanner spans — i.e. + /// and frozen memtables and SSTables this scanner spans — i.e. /// are shadowed above the base table. `pks` is a batch whose columns include /// the primary-key columns; the returned `Vec` is aligned with its /// rows. Hashing matches the scanner's internal dedup, so the caller never @@ -829,7 +829,7 @@ impl LsmScanner { &sources, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), watermarks, ) .await?; @@ -942,13 +942,13 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_spec_id(1) .with_current_generation(5) - .with_flushed_generation(1, "path/gen_1".to_string()) - .with_flushed_generation(2, "path/gen_2".to_string()); + .with_sstable(1, "path/gen_1".to_string()) + .with_sstable(2, "path/gen_2".to_string()); assert_eq!(snapshot.shard_id, shard_id); assert_eq!(snapshot.spec_id, 1); assert_eq!(snapshot.current_generation, 5); - assert_eq!(snapshot.flushed_generations.len(), 2); + assert_eq!(snapshot.sstables.len(), 2); } #[test] diff --git a/rust/lance/src/dataset/mem_wal/scanner/collector.rs b/rust/lance/src/dataset/mem_wal/scanner/collector.rs index 6645f159b12..522340c8f8f 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/collector.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/collector.rs @@ -50,17 +50,17 @@ pub struct InMemoryMemTables { /// This collector gathers all data sources that need to be scanned /// for a query, including: /// - The base table (merged data) — optional; omit for fresh-tier-only scans -/// - Flushed MemTables from each shard +/// - SSTables from each shard /// - In-memory memtables per shard (active + frozen-awaiting-flush) /// /// When the base table is omitted (see [`Self::without_base_table`]), `collect` -/// returns only flushed-generation and active-memtable sources. This is used +/// returns only SSTable and active-memtable sources. This is used /// by callers that own the base read path elsewhere and only need the WAL's -/// fresh tier (active memtable ∪ L0 flushed generations). +/// fresh tier (active memtable ∪ L0 SSTables). pub struct LsmDataSourceCollector { /// Base Lance table (None when scanning only the fresh tier). base_table: Option>, - /// Base path for resolving relative flushed-generation paths. + /// Base path for resolving relative SSTable paths. base_path: String, /// Shard snapshots from MemWAL index. shard_snapshots: Vec, @@ -89,8 +89,8 @@ impl LsmDataSourceCollector { /// Create a collector without a base table (fresh-tier scan only). /// - /// The collector emits only flushed-generation and active-memtable sources. - /// `base_path` is the table-root URI used to resolve relative flushed paths + /// The collector emits only SSTable and active-memtable sources. + /// `base_path` is the table-root URI used to resolve relative SSTable paths /// (typically the same URI that would have been the base dataset's URI). pub fn without_base_table( base_path: impl Into, @@ -147,16 +147,12 @@ impl LsmDataSourceCollector { &self.in_memory_memtables } - /// Whether the collector has any on-disk source (base table or a flushed - /// generation). The point-lookup fast path uses this to decide, after + /// Whether the collector has any on-disk source (base table or an + /// SSTable). The point-lookup fast path uses this to decide, after /// missing every in-memory memtable, between "definitely absent" (`false`) /// and "must consult disk via the plan path" (`true`). Cheap: no allocation. pub fn has_on_disk_sources(&self) -> bool { - self.base_table.is_some() - || self - .shard_snapshots - .iter() - .any(|s| !s.flushed_generations.is_empty()) + self.base_table.is_some() || self.shard_snapshots.iter().any(|s| !s.sstables.is_empty()) } /// The in-memory memtables (active + frozen across all shards) as @@ -233,10 +229,10 @@ impl LsmDataSourceCollector { /// frozen memtable. During the post-flush grace window a generation is both /// committed to the manifest (a flushed source) and held in memory (an /// in-memory source); it must be served only from memory — which preserves - /// the per-batch boundaries the flushed dataset has lost, so as-of reads + /// the per-batch boundaries the SSTable dataset has lost, so as-of reads /// stay snapshot-bounded — and its on-disk copy skipped to avoid scanning /// the generation twice. See `ShardWriterConfig::frozen_memtable_grace`. - fn flushed_gen_pinned_in_memory(&self, shard_id: &Uuid, generation: u64) -> bool { + fn sstable_pinned_in_memory(&self, shard_id: &Uuid, generation: u64) -> bool { self.in_memory_memtables .get(shard_id) .is_some_and(|mems| mems.frozen.iter().any(|f| f.generation == generation)) @@ -246,7 +242,7 @@ impl LsmDataSourceCollector { /// /// Returns sources in a consistent order: /// 1. Base table (gen=0), if configured - /// 2. Flushed MemTables per shard, ordered by generation + /// 2. SSTables per shard, ordered by generation /// 3. In-memory memtables per shard (active + frozen-awaiting-flush) pub fn collect(&self) -> Result> { let mut sources = Vec::new(); @@ -258,15 +254,15 @@ impl LsmDataSourceCollector { } for snapshot in &self.shard_snapshots { - for flushed in &snapshot.flushed_generations { - if self.flushed_gen_pinned_in_memory(&snapshot.shard_id, flushed.generation) { + for sstable in &snapshot.sstables { + if self.sstable_pinned_in_memory(&snapshot.shard_id, sstable.generation) { continue; } - let path = self.resolve_flushed_path(&snapshot.shard_id, &flushed.path); - sources.push(LsmDataSource::FlushedMemTable { + let path = self.resolve_sstable_path(&snapshot.shard_id, &sstable.path); + sources.push(LsmDataSource::SsTable { path, shard_id: snapshot.shard_id, - generation: LsmGeneration::memtable(flushed.generation), + generation: LsmGeneration::memtable(sstable.generation), }); } } @@ -299,15 +295,15 @@ impl LsmDataSourceCollector { continue; } - for flushed in &snapshot.flushed_generations { - if self.flushed_gen_pinned_in_memory(&snapshot.shard_id, flushed.generation) { + for sstable in &snapshot.sstables { + if self.sstable_pinned_in_memory(&snapshot.shard_id, sstable.generation) { continue; } - let path = self.resolve_flushed_path(&snapshot.shard_id, &flushed.path); - sources.push(LsmDataSource::FlushedMemTable { + let path = self.resolve_sstable_path(&snapshot.shard_id, &sstable.path); + sources.push(LsmDataSource::SsTable { path, shard_id: snapshot.shard_id, - generation: LsmGeneration::memtable(flushed.generation), + generation: LsmGeneration::memtable(sstable.generation), }); } } @@ -325,25 +321,21 @@ impl LsmDataSourceCollector { /// Get the total number of data sources. pub fn num_sources(&self) -> usize { - let flushed_count: usize = self - .shard_snapshots - .iter() - .map(|s| s.flushed_generations.len()) - .sum(); + let sstable_count: usize = self.shard_snapshots.iter().map(|s| s.sstables.len()).sum(); let base_count = if self.base_table.is_some() { 1 } else { 0 }; let in_memory_count: usize = self .in_memory_memtables .values() .map(|m| 1 + m.frozen.len()) .sum(); - base_count + flushed_count + in_memory_count + base_count + sstable_count + in_memory_count } - /// Resolve a flushed MemTable path to an absolute path. + /// Resolve an SSTable path to an absolute path. /// - /// Flushed MemTables are stored at: `{base_path}/_mem_wal/{shard_id}/{folder_name}` - /// The `folder_name` is what's stored in `FlushedGeneration.path`. - fn resolve_flushed_path(&self, shard_id: &Uuid, folder_name: &str) -> String { + /// SSTables are stored at: `{base_path}/_mem_wal/{shard_id}/{folder_name}` + /// The `folder_name` is what's stored in `SsTable.path`. + fn resolve_sstable_path(&self, shard_id: &Uuid, folder_name: &str) -> String { format!("{}/_mem_wal/{}/{}", self.base_path, shard_id, folder_name) } } @@ -351,7 +343,7 @@ impl LsmDataSourceCollector { #[cfg(test)] mod tests { use super::*; - use crate::dataset::mem_wal::scanner::data_source::FlushedGeneration; + use crate::dataset::mem_wal::scanner::data_source::SsTable; fn create_test_snapshots() -> Vec { let shard_a = Uuid::new_v4(); @@ -362,12 +354,12 @@ mod tests { shard_id: shard_a, spec_id: 1, current_generation: 3, - flushed_generations: vec![ - FlushedGeneration { + sstables: vec![ + SsTable { generation: 1, path: "abc_gen_1".to_string(), }, - FlushedGeneration { + SsTable { generation: 2, path: "def_gen_2".to_string(), }, @@ -377,7 +369,7 @@ mod tests { shard_id: shard_b, spec_id: 1, current_generation: 2, - flushed_generations: vec![FlushedGeneration { + sstables: vec![SsTable { generation: 1, path: "xyz_gen_1".to_string(), }], @@ -390,8 +382,8 @@ mod tests { let snapshots = create_test_snapshots(); // 1 base table + 2 flushed from shard_a + 1 flushed from shard_b = 4 // Using a mock dataset is complex, so we just test the counting logic - assert_eq!(snapshots[0].flushed_generations.len(), 2); - assert_eq!(snapshots[1].flushed_generations.len(), 1); + assert_eq!(snapshots[0].sstables.len(), 2); + assert_eq!(snapshots[1].sstables.len(), 1); } #[test] @@ -466,10 +458,10 @@ mod tests { /// During the post-flush grace window a generation is both committed to the /// manifest (a flushed source) and still pinned in memory (a frozen /// source). The collector must emit it once, from memory — so as-of reads - /// keep batch-resolved membership — and skip the on-disk copy. Flushed - /// generations NOT pinned in memory are still emitted from disk. + /// keep batch-resolved membership — and skip the on-disk copy. SSTables + /// NOT pinned in memory are still emitted from disk. #[test] - fn test_collect_suppresses_flushed_gen_pinned_in_memory() { + fn test_collect_suppresses_sstable_pinned_in_memory() { let shard = Uuid::new_v4(); // Manifest lists gens 1 and 2 as flushed; gen 2 is still pinned in // memory (just flushed, within grace), gen 1 has been swept. @@ -477,12 +469,12 @@ mod tests { shard_id: shard, spec_id: 0, current_generation: 3, - flushed_generations: vec![ - FlushedGeneration { + sstables: vec![ + SsTable { generation: 1, path: "gen_1".to_string(), }, - FlushedGeneration { + SsTable { generation: 2, path: "gen_2".to_string(), }, @@ -498,7 +490,7 @@ mod tests { let sources = collector.collect().unwrap(); // gen 1: on-disk (not pinned). gen 2: in-memory only (pinned, disk // copy suppressed). gen 3: active. No duplicate gen 2. - let flushed: Vec = sources + let sstable_gens: Vec = sources .iter() .filter(|s| !s.is_active_memtable()) .map(|s| s.generation().as_u64()) @@ -508,7 +500,11 @@ mod tests { .filter(|s| s.is_active_memtable()) .map(|s| s.generation().as_u64()) .collect(); - assert_eq!(flushed, vec![1], "only the unpinned flushed gen from disk"); + assert_eq!( + sstable_gens, + vec![1], + "only the unpinned SSTable gen from disk" + ); assert_eq!(in_memory, vec![2, 3], "pinned gen 2 served from memory"); } } diff --git a/rust/lance/src/dataset/mem_wal/scanner/data_source.rs b/rust/lance/src/dataset/mem_wal/scanner/data_source.rs index 0d5f3fdc925..b919b2c0cad 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/data_source.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/data_source.rs @@ -93,12 +93,12 @@ impl Default for LsmGeneration { } } -/// A flushed generation with its storage path. +/// An SSTable with its storage path. #[derive(Debug, Clone)] -pub struct FlushedGeneration { +pub struct SsTable { /// Generation number. pub generation: u64, - /// Path to the flushed MemTable directory (relative to table root). + /// Path to the SSTable directory (relative to table root). pub path: String, } @@ -114,8 +114,8 @@ pub struct ShardSnapshot { pub spec_id: u32, /// Current generation being written (next flush will be this generation). pub current_generation: u64, - /// List of flushed generations and their paths. - pub flushed_generations: Vec, + /// List of SSTables and their paths. + pub sstables: Vec, } impl ShardSnapshot { @@ -125,7 +125,7 @@ impl ShardSnapshot { shard_id, spec_id: 0, current_generation: 1, - flushed_generations: Vec::new(), + sstables: Vec::new(), } } @@ -141,10 +141,9 @@ impl ShardSnapshot { self } - /// Add a flushed generation. - pub fn with_flushed_generation(mut self, generation: u64, path: String) -> Self { - self.flushed_generations - .push(FlushedGeneration { generation, path }); + /// Add an SSTable. + pub fn with_sstable(mut self, generation: u64, path: String) -> Self { + self.sstables.push(SsTable { generation, path }); self } } @@ -156,9 +155,9 @@ pub enum LsmDataSource { /// The base dataset. dataset: Arc, }, - /// Flushed MemTable stored as Lance table on disk. - FlushedMemTable { - /// Absolute path to the flushed MemTable directory. + /// SSTable stored as Lance table on disk. + SsTable { + /// Absolute path to the SSTable directory. path: String, /// Shard this MemTable belongs to. shard_id: Uuid, @@ -185,7 +184,7 @@ impl LsmDataSource { pub fn generation(&self) -> LsmGeneration { match self { Self::BaseTable { .. } => LsmGeneration::BASE_TABLE, - Self::FlushedMemTable { generation, .. } => *generation, + Self::SsTable { generation, .. } => *generation, Self::ActiveMemTable { generation, .. } => *generation, } } @@ -194,7 +193,7 @@ impl LsmDataSource { pub fn shard_id(&self) -> Option { match self { Self::BaseTable { .. } => None, - Self::FlushedMemTable { shard_id, .. } => Some(*shard_id), + Self::SsTable { shard_id, .. } => Some(*shard_id), Self::ActiveMemTable { shard_id, .. } => Some(*shard_id), } } @@ -213,7 +212,7 @@ impl LsmDataSource { pub fn display_name(&self) -> String { match self { Self::BaseTable { .. } => "base_table".to_string(), - Self::FlushedMemTable { + Self::SsTable { shard_id, generation, .. @@ -279,14 +278,14 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_spec_id(1) .with_current_generation(5) - .with_flushed_generation(1, "abc123_gen_1".to_string()) - .with_flushed_generation(2, "def456_gen_2".to_string()); + .with_sstable(1, "abc123_gen_1".to_string()) + .with_sstable(2, "def456_gen_2".to_string()); assert_eq!(snapshot.shard_id, shard_id); assert_eq!(snapshot.spec_id, 1); assert_eq!(snapshot.current_generation, 5); - assert_eq!(snapshot.flushed_generations.len(), 2); - assert_eq!(snapshot.flushed_generations[0].generation, 1); - assert_eq!(snapshot.flushed_generations[1].generation, 2); + assert_eq!(snapshot.sstables.len(), 2); + assert_eq!(snapshot.sstables[0].generation, 1); + assert_eq!(snapshot.sstables[1].generation, 2); } } diff --git a/rust/lance/src/dataset/mem_wal/scanner/exec/pk_block_filter.rs b/rust/lance/src/dataset/mem_wal/scanner/exec/pk_block_filter.rs index b81a470a882..ef2dc695dfa 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/exec/pk_block_filter.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/exec/pk_block_filter.rs @@ -5,7 +5,7 @@ //! //! Drops a row when any newer generation's membership ([`GenMembership`]) //! contains its primary key — in-memory generations probe their PK index by -//! value, flushed generations probe their on-disk PK BTree. Each generation is +//! value, SSTables probe their on-disk PK BTree. Each generation is //! probed once per batch (see the perf note below). Used both as the KNN //! post-filter (vector search, with over-fetch) and the cross-generation scan //! filter (`k = 0`). @@ -22,7 +22,7 @@ //! `BTreeIndex::contains_keys` (one page pass, no per-key `SearchResult` //! allocation); the in-memory arm maps a sync PK lookup over the keys. Probes //! are not disk-bound in steady state: the opened index and its (small, -//! memtable-sized) pages are held by the injected `FlushedMemTableCache` / +//! memtable-sized) pages are held by the injected `SsTableCache` / //! `LanceCache`, so after the first touch every probe is memory-resident. //! Already-blocked rows are dropped from the key set before probing older //! generations, preserving the per-row short-circuit. @@ -180,8 +180,8 @@ struct PkBlockFilterStream { warned: bool, } -/// Keep only the rows no newer-gen membership contains. Async because flushed -/// generations are probed against their on-disk PK BTree. +/// Keep only the rows no newer-gen membership contains. Async because SSTables +/// are probed against their on-disk PK BTree. async fn filter_batch(batch: RecordBatch, config: Arc) -> DFResult { let FilterConfig { pk_columns, diff --git a/rust/lance/src/dataset/mem_wal/scanner/fts_search.rs b/rust/lance/src/dataset/mem_wal/scanner/fts_search.rs index 346cee851f4..d876ec39071 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/fts_search.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/fts_search.rs @@ -4,7 +4,7 @@ //! Full-text search planner for LSM scanner (local scoring). //! //! Builds an execution plan that scores an FTS query across the base -//! table, flushed memtable generations, and the active/frozen-undrained +//! table, SSTable generations, and the active/frozen-undrained //! in-memory memtables, returning rows ordered by BM25 `_score` DESC. //! //! # Scoring @@ -22,17 +22,17 @@ //! benchmark in this PR shows it carries a real latency penalty, so the //! local path lands first and the global option is optimized separately. //! -//! Staleness: within a flushed generation, the deletion vector written +//! Staleness: within an SSTable, the deletion vector written //! at flush time (see #6929) already masks rows superseded by a newer //! generation, so per-source results are clean within each tier. The -//! same primary key can still appear across tiers (active vs flushed) +//! same primary key can still appear across tiers (active vs SSTable) //! when an updated row sits in the active memtable while the older -//! copy lives in a flushed generation; cross-tier deduplication is +//! copy lives in an SSTable; cross-tier deduplication is //! left to the caller in local mode. //! //! Everything here is contained in the `mem_wal` module — it reuses the //! existing per-source FTS read paths (`scanner.full_text_search` for -//! base/flushed Lance datasets, `MemTableScanner` for the active +//! base/SSTable Lance datasets, `MemTableScanner` for the active //! memtable) and requires no changes to `lance-index`. use std::sync::Arc; @@ -54,8 +54,8 @@ use super::block_list::compute_source_block_lists; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; use super::exec::PkBlockFilterExec; -use super::flushed_cache::{DatasetCache, GenerationWarmer, open_flushed_dataset}; use super::projection::{project_to_canonical, validate_projection_names}; +use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable}; use crate::dataset::mem_wal::memtable::scanner::MemTableScanner; use crate::session::Session; use lance_io::object_store::ObjectStoreParams; @@ -110,18 +110,18 @@ pub struct LsmFtsSearchPlanner { collector: LsmDataSourceCollector, pk_columns: Vec, base_schema: SchemaRef, - /// Session threaded into flushed-generation opens (shared caches). + /// Session threaded into SSTable opens (shared caches). session: Option>, - /// Store params for opening flushed generations, reusing the base dataset's store. + /// Store params for opening SSTables, reusing the base dataset's store. store_params: Option, - /// Cache of opened flushed-generation datasets. - flushed_cache: Option>, - /// Optional warmer fired on first open of a flushed generation. - warmer: Option>, + /// Cache of opened SSTable datasets. + sstable_cache: Option>, + /// Optional warmer fired on first open of an SSTable. + warmer: Option>, /// Over-fetch multiple for blocked sources. overfetch_factor: f64, /// Optional prefilter predicate applied to every source arm so FTS hits - /// failing the predicate are dropped. Base/flushed arms use the dataset + /// failing the predicate are dropped. Base/SSTable arms use the dataset /// scanner's native filter; memtable arms filter the materialized hits. filter: Option, } @@ -139,7 +139,7 @@ impl LsmFtsSearchPlanner { base_schema, session: None, store_params: None, - flushed_cache: None, + sstable_cache: None, warmer: None, overfetch_factor: DEFAULT_OVERFETCH_FACTOR, filter: None, @@ -148,7 +148,7 @@ impl LsmFtsSearchPlanner { /// Attach an optional prefilter predicate. Every source arm restricts its /// FTS hits to rows matching the predicate, matching a normal filtered - /// full-text scan over base ∪ flushed ∪ in-memory data. + /// full-text scan over base ∪ SSTables ∪ in-memory data. pub fn with_filter(mut self, filter: Option) -> Self { self.filter = filter; self @@ -162,27 +162,27 @@ impl LsmFtsSearchPlanner { self } - /// Set the session used to open flushed generations. + /// Set the session used to open SSTables. pub fn with_session(mut self, session: Arc) -> Self { self.session = Some(session); self } - /// Set the store params used to open flushed generations. + /// Set the store params used to open SSTables. pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self { self.store_params = Some(store_params); self } - /// Inject a cache of opened flushed-generation datasets, making repeated + /// Inject a cache of opened SSTable datasets, making repeated /// searches against the same generation a pure `Arc::clone`. - pub fn with_flushed_cache(mut self, cache: Arc) -> Self { - self.flushed_cache = Some(cache); + pub fn with_sstable_cache(mut self, cache: Arc) -> Self { + self.sstable_cache = Some(cache); self } - /// Inject the warmer fired on first open of a flushed generation. - pub fn with_warmer(mut self, warmer: Arc) -> Self { + /// Inject the warmer fired on first open of an SSTable. + pub fn with_warmer(mut self, warmer: Arc) -> Self { self.warmer = Some(warmer); self } @@ -193,7 +193,7 @@ impl LsmFtsSearchPlanner { /// /// * `column` — text column to search. /// * `query` — the FTS query (match / phrase / boolean / fuzzy for - /// base/flushed Lance sources; the active memtable currently + /// base/SSTable Lance sources; the active memtable currently /// supports `MatchQuery`). /// * `limit` — optional global top-k to return. /// * `projection` — user columns to project. PK columns are @@ -238,7 +238,7 @@ impl LsmFtsSearchPlanner { &sources, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), )) .await?; @@ -380,12 +380,12 @@ impl LsmFtsSearchPlanner { scanner.full_text_search(bound_query)?; scanner.create_plan().await } - LsmDataSource::FlushedMemTable { path, .. } => { - let dataset = open_flushed_dataset( + LsmDataSource::SsTable { path, .. } => { + let dataset = open_sstable( path, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), self.warmer.as_ref(), ) .await?; @@ -622,7 +622,7 @@ mod tests { #[tokio::test] async fn local_mode_unions_base_and_active_with_consistent_score_schema() { // Regression for the `_score` nullability mismatch between - // FtsIndexExec (active arm) and FTS_SCHEMA (base/flushed). The + // FtsIndexExec (active arm) and FTS_SCHEMA (base/SSTable). The // active-only test below would not catch this — UnionExec rejects // schema-inequality, so we need at least one base + one active // source to exercise that code path. @@ -829,12 +829,12 @@ mod tests { ); } - /// The flushed arm must apply the filter as a true FTS prefilter, and that + /// The SSTable arm must apply the filter as a true FTS prefilter, and that /// prefiltered candidate set must compose with cross-generation block-list /// filtering plus over-fetch. Gen 1's best predicate-matching hit (id=3) is /// superseded by gen 2; with over-fetch, gen 1 should still contribute id=4. #[tokio::test] - async fn prefilter_on_flushed_composes_with_block_list() { + async fn prefilter_on_sstable_composes_with_block_list() { use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::index::DatasetIndexExt; use datafusion::prelude::{col, lit}; @@ -848,7 +848,7 @@ mod tests { // Gen 1: id=1 matches strongly but fails the predicate. id=3 matches // strongly but is stale (blocked by gen 2). id=4 is the next live - // predicate match that only survives if the flushed arm prefilters and + // predicate match that only survives if the SSTable arm prefilters and // over-fetches before the block-list drops id=3. let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let mut gen1 = write_dataset( @@ -886,8 +886,8 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![snapshot]); let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema) @@ -901,7 +901,7 @@ mod tests { None, ) .await - .expect("planner should produce a filtered flushed plan"); + .expect("planner should produce a filtered SSTable plan"); let ctx = datafusion::prelude::SessionContext::new(); let stream = plan.execute(0, ctx.task_ctx()).unwrap(); @@ -922,7 +922,7 @@ mod tests { assert_eq!( ids, vec![4], - "flushed FTS prefilter should return live id=4 after stale id=3 is blocked; got {ids:?}" + "SSTable FTS prefilter should return live id=4 after stale id=3 is blocked; got {ids:?}" ); } diff --git a/rust/lance/src/dataset/mem_wal/scanner/planner.rs b/rust/lance/src/dataset/mem_wal/scanner/planner.rs index 263220acbef..2a83447fb17 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/planner.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/planner.rs @@ -18,17 +18,17 @@ use crate::dataset::mem_wal::TOMBSTONE; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; use super::exec::{MEMTABLE_GEN_COLUMN, MemtableGenTagExec, PkBlockFilterExec, ROW_ADDRESS_COLUMN}; -use super::flushed_cache::{DatasetCache, GenerationWarmer, open_flushed_dataset}; use super::projection::{ build_scanner_projection, canonical_output_schema, null_columns, project_to_canonical, validate_projection_names, }; +use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable}; use crate::session::Session; use lance_io::object_store::ObjectStoreParams; /// Combine the user filter (if any) with `NOT _tombstone` so tombstone rows are /// dropped from a WAL-arm scan. Used only for sources whose schema carries the -/// column (active / flushed generations written since deletes existed). +/// column (active / SSTables written since deletes existed). fn fold_not_tombstone(filter: Option<&Expr>) -> Expr { let live = !col(TOMBSTONE); match filter { @@ -45,14 +45,14 @@ pub struct LsmScanPlanner { pk_columns: Vec, /// Schema of the base table. base_schema: SchemaRef, - /// Session threaded into flushed-generation opens (shared caches). + /// Session threaded into SSTable opens (shared caches). session: Option>, - /// Store params for opening flushed generations, reusing the base dataset's store. + /// Store params for opening SSTables, reusing the base dataset's store. store_params: Option, - /// Cache of opened flushed-generation datasets. - flushed_cache: Option>, - /// Optional warmer fired on first open of a flushed generation. - warmer: Option>, + /// Cache of opened SSTable datasets. + sstable_cache: Option>, + /// Optional warmer fired on first open of an SSTable. + warmer: Option>, } impl LsmScanPlanner { @@ -68,32 +68,32 @@ impl LsmScanPlanner { base_schema, session: None, store_params: None, - flushed_cache: None, + sstable_cache: None, warmer: None, } } - /// Set the session used to open flushed generations. + /// Set the session used to open SSTables. pub fn with_session(mut self, session: Arc) -> Self { self.session = Some(session); self } - /// Set the store params used to open flushed generations. + /// Set the store params used to open SSTables. pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self { self.store_params = Some(store_params); self } - /// Inject a cache of opened flushed-generation datasets, making repeated + /// Inject a cache of opened SSTable datasets, making repeated /// queries against the same generation a pure `Arc::clone`. - pub fn with_flushed_cache(mut self, cache: Arc) -> Self { - self.flushed_cache = Some(cache); + pub fn with_sstable_cache(mut self, cache: Arc) -> Self { + self.sstable_cache = Some(cache); self } - /// Inject the warmer fired on first open of a flushed generation. - pub fn with_warmer(mut self, warmer: Arc) -> Self { + /// Inject the warmer fired on first open of an SSTable. + pub fn with_warmer(mut self, warmer: Arc) -> Self { self.warmer = Some(warmer); self } @@ -154,7 +154,7 @@ impl LsmScanPlanner { &sources, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), )) .await?; @@ -321,12 +321,12 @@ impl LsmScanPlanner { scanner.create_plan().await } - LsmDataSource::FlushedMemTable { path, .. } => { - let dataset = open_flushed_dataset( + LsmDataSource::SsTable { path, .. } => { + let dataset = open_sstable( path, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), self.warmer.as_ref(), ) .await?; @@ -352,7 +352,7 @@ impl LsmScanPlanner { if let Some(expr) = effective { scanner.filter_expr(expr.clone()); } - // Per-source limit pushdown: flushed generations are + // Per-source limit pushdown: SSTables are // within-gen live (dedup-on-flush deletion vectors), so any // `fetch` post-filter rows are valid contributions. if let Some(fetch) = fetch { @@ -456,10 +456,10 @@ mod tests { let shard_id = uuid::Uuid::new_v4(); let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(5) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); - assert_eq!(snapshot.flushed_generations.len(), 2); + assert_eq!(snapshot.sstables.len(), 2); assert_eq!(snapshot.current_generation, 5); } } @@ -519,7 +519,7 @@ mod integration_tests { } /// Create a dataset at the given URI with the provided batches. Also writes - /// the standalone PK sidecar (on `id`) so a flushed-generation source can be + /// the standalone PK sidecar (on `id`) so an SSTable source can be /// probed by the block-list; harmless for a base table (never probed). async fn create_dataset(uri: &str, batches: Vec) -> Dataset { let schema = batches[0].schema(); @@ -552,8 +552,8 @@ mod integration_tests { /// Setup a multi-level LSM structure with: /// - Base table: ids 1-5 with "base" prefix - /// - Flushed gen1: ids 3,4 (updates) with "gen1" prefix - /// - Flushed gen2: ids 4,5 (updates) + id 6 (new) with "gen2" prefix + /// - SSTable gen1: ids 3,4 (updates) with "gen1" prefix + /// - SSTable gen2: ids 4,5 (updates) + id 6 (new) with "gen2" prefix /// - Active memtable: ids 5,6 (updates) + id 7 (new) with "active" prefix /// /// Expected deduplication results: @@ -580,13 +580,13 @@ mod integration_tests { let base_batch = create_test_batch(&schema, &[1, 2, 3, 4, 5], "base"); let base_dataset = Arc::new(create_dataset(&base_uri, vec![base_batch]).await); - // Create flushed gen1 as a separate dataset + // Create SSTable gen1 as a separate dataset let shard_id = Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[3, 4], "gen1"); create_dataset(&gen1_uri, vec![gen1_batch]).await; - // Create flushed gen2 as a separate dataset + // Create SSTable gen2 as a separate dataset let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, shard_id); let gen2_batch = create_test_batch(&schema, &[4, 5, 6], "gen2"); create_dataset(&gen2_uri, vec![gen2_batch]).await; @@ -594,8 +594,8 @@ mod integration_tests { // Build shard snapshot let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); // Create active memtable let (batch_store, index_store) = @@ -819,11 +819,11 @@ mod integration_tests { } /// Regression for the concurrent-read-vs-flush hole: a sealed - /// (frozen-awaiting-flush) memtable is not yet recorded as a flushed - /// generation, but its rows must still be in the scan's read union and + /// (frozen-awaiting-flush) memtable is not yet recorded as an + /// SSTable, but its rows must still be in the scan's read union and /// dedup correctly by generation across the active/frozen seam. /// - /// Layout: base(0) ids 1-5, flushed gen1 ids 3,4, flushed gen2 ids + /// Layout: base(0) ids 1-5, SSTable gen1 ids 3,4, SSTable gen2 ids /// 4,5,6, frozen memtable gen3 ids 6,7, active memtable gen4 ids 7,8. #[tokio::test] async fn test_lsm_scan_frozen_memtable_in_read_union() { @@ -852,8 +852,8 @@ mod integration_tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(4) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); // Frozen gen3 (sealed, NOT in the manifest) and active gen4. let (frozen_store, frozen_index) = @@ -912,7 +912,7 @@ mod integration_tests { assert_eq!(results.get(&3), Some(&"gen1_3".to_string())); assert_eq!(results.get(&4), Some(&"gen2_4".to_string())); assert_eq!(results.get(&5), Some(&"gen2_5".to_string())); - // id=6: in flushed gen2 AND frozen gen3 -> frozen wins. This is the + // id=6: in SSTable gen2 AND frozen gen3 -> frozen wins. This is the // bug: pre-fix the frozen memtable fell out of the read union and // id=6 resolved to "gen2_6". assert_eq!(results.get(&6), Some(&"frozen_6".to_string())); @@ -1129,7 +1129,7 @@ mod integration_tests { } #[tokio::test] - async fn test_lsm_scan_flushed_only_no_active() { + async fn test_lsm_scan_sstable_only_no_active() { let (base_dataset, shard_snapshots, _, pk_columns, _temp_path) = setup_multi_level_lsm().await; @@ -1291,7 +1291,7 @@ mod integration_tests { /// /// Similar to setup_multi_level_lsm but: /// - Active memtable has a BTree index on the `id` column - /// - Flushed datasets have BTree index created (enabling ScalarIndexQuery) + /// - SSTables have BTree index created (enabling ScalarIndexQuery) async fn setup_multi_level_lsm_with_btree_index() -> ( Arc, Vec, @@ -1321,7 +1321,7 @@ mod integration_tests { // Reload dataset to pick up the index let base_dataset = Arc::new(Dataset::open(&base_uri).await.unwrap()); - // Create flushed gen1 with BTree index + // Create SSTable gen1 with BTree index let shard_id = Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[3, 4], "gen1"); @@ -1330,7 +1330,7 @@ mod integration_tests { .await .unwrap(); - // Create flushed gen2 with BTree index + // Create SSTable gen2 with BTree index let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, shard_id); let gen2_batch = create_test_batch(&schema, &[4, 5, 6], "gen2"); let mut gen2_dataset = create_dataset(&gen2_uri, vec![gen2_batch]).await; @@ -1341,8 +1341,8 @@ mod integration_tests { // Build shard snapshot let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); // Create active memtable with BTree index let batch_store = Arc::new(BatchStore::with_capacity(100)); @@ -1677,7 +1677,7 @@ mod integration_tests { let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; - // Use the same base URI the flushed generations were created under, so + // Use the same base URI the SSTables were created under, so // relative `gen_N` folders resolve to real datasets on disk. let base_uri = base_dataset.uri().to_string(); let arrow_schema: arrow_schema::Schema = base_dataset.schema().into(); @@ -1702,7 +1702,7 @@ mod integration_tests { ); assert!( plan_str.contains("gen_1") && plan_str.contains("gen_2"), - "Plan must scan flushed generations, got: {}", + "Plan must scan SSTables, got: {}", plan_str ); assert!( @@ -1809,8 +1809,8 @@ mod integration_tests { } #[tokio::test] - async fn test_lsm_scan_without_base_table_no_flushed_no_active() { - // No base, no flushed, no active → empty result, valid plan. + async fn test_lsm_scan_without_base_table_no_sstable_no_active() { + // No base, no SSTable, no active → empty result, valid plan. let schema = create_pk_schema(); let scanner = LsmScanner::without_base_table( schema, @@ -2184,8 +2184,8 @@ mod integration_tests { } #[tokio::test] - async fn test_lsm_scan_flushed_tombstone_masks_base() { - // A tombstone living in a flushed generation masks the older base row by + async fn test_lsm_scan_sstable_tombstone_masks_base() { + // A tombstone living in an SSTable masks the older base row by // PK presence (block-list) and is itself dropped by the folded predicate. let base_schema = create_pk_schema(); let mem_schema = ts_pk_schema(); @@ -2200,14 +2200,14 @@ mod integration_tests { .await, ); - // Flushed gen 1 holds only a tombstone for id=2 (written with the - // `_tombstone` schema, so the flushed arm folds `NOT _tombstone`). + // SSTable gen 1 holds only a tombstone for id=2 (written with the + // `_tombstone` schema, so the SSTable arm folds `NOT _tombstone`). let shard_id = Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); create_dataset(&gen1_uri, vec![ts_batch(&mem_schema, &[(2, None, true)])]).await; let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let scanner = LsmScanner::new(base, vec![shard_snapshot], vec!["id".to_string()]); let batches: Vec = scanner @@ -2220,13 +2220,13 @@ mod integration_tests { assert_eq!( collect_sorted_ids(&batches), vec![1, 3], - "id=2 deleted via flushed-generation tombstone" + "id=2 deleted via an SSTable tombstone" ); } #[tokio::test] async fn test_lsm_scan_tombstone_does_not_consume_limit() { - // A single (newest) flushed generation holds both tombstones and live + // A single (newest) SSTable holds both tombstones and live // rows. With LIMIT 2 the folded `NOT _tombstone` runs *before* the // per-source pushdown limit, so the limit counts only live rows — we get // 2 live rows, not 0 (which is what a post-limit tombstone filter, or a @@ -2254,7 +2254,7 @@ mod integration_tests { .await; let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let scanner = LsmScanner::without_base_table( base_schema, diff --git a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs index 08afb0345ba..9fc44290209 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs @@ -34,11 +34,11 @@ use crate::dataset::mem_wal::memtable::batch_store::BatchStore; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; use super::exec::{BloomFilterGuardExec, CoalesceFirstExec, compute_pk_hash_from_scalars}; -use super::flushed_cache::{DatasetCache, GenerationWarmer, open_flushed_dataset}; use super::projection::{ DISTANCE_COLUMN, build_scanner_projection, canonical_output_schema, null_columns, project_to_canonical, validate_projection_names, wants_row_address, wants_row_id, }; +use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable}; use crate::session::Session; use lance_io::object_store::ObjectStoreParams; @@ -88,14 +88,14 @@ pub struct LsmPointLookupPlanner { /// Bloom filters for each memtable generation. /// Map: generation -> bloom filter bloom_filters: std::collections::HashMap>, - /// Session threaded into flushed-generation opens (shared caches). + /// Session threaded into SSTable opens (shared caches). session: Option>, - /// Store params for opening flushed generations, reusing the base dataset's store. + /// Store params for opening SSTables, reusing the base dataset's store. store_params: Option, - /// Cache of opened flushed-generation datasets. - flushed_cache: Option>, - /// Optional warmer fired on first open of a flushed generation. - warmer: Option>, + /// Cache of opened SSTable datasets. + sstable_cache: Option>, + /// Optional warmer fired on first open of an SSTable. + warmer: Option>, /// Precomputed canonical output schema for the no-projection case, so the /// hot `lookup(.., None)` path clones an `Arc` instead of rebuilding the /// schema on every call. @@ -128,37 +128,37 @@ impl LsmPointLookupPlanner { bloom_filters: std::collections::HashMap::new(), session: None, store_params: None, - flushed_cache: None, + sstable_cache: None, warmer: None, none_target, task_ctx: SessionContext::new().task_ctx(), } } - /// Set the session used to open flushed generations. + /// Set the session used to open SSTables. pub fn with_session(mut self, session: Arc) -> Self { self.session = Some(session); self } - /// Set the store params used to open flushed generations. + /// Set the store params used to open SSTables. pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self { self.store_params = Some(store_params); self } - /// Inject a cache of opened flushed-generation datasets, making repeated + /// Inject a cache of opened SSTable datasets, making repeated /// lookups against the same generation a pure `Arc::clone`. Populate it up /// front during scan setup via /// [`DatasetMemWalExt::prewarm_mem_wal`](crate::dataset::mem_wal::DatasetMemWalExt::prewarm_mem_wal) /// so the first gen-key lookup does not pay the dataset open. - pub fn with_flushed_cache(mut self, cache: Arc) -> Self { - self.flushed_cache = Some(cache); + pub fn with_sstable_cache(mut self, cache: Arc) -> Self { + self.sstable_cache = Some(cache); self } - /// Inject the warmer fired on first open of a flushed generation. - pub fn with_warmer(mut self, warmer: Arc) -> Self { + /// Inject the warmer fired on first open of an SSTable. + pub fn with_warmer(mut self, warmer: Arc) -> Self { self.warmer = Some(warmer); self } @@ -341,7 +341,7 @@ impl LsmPointLookupPlanner { /// For a single-column primary key this probes the in-memory memtables' /// BTree index directly — no DataFusion plan — newest generation first, and /// returns on the first hit. Only when the lookup must consult an on-disk - /// source (a flushed generation or the base table), a memtable lacks a + /// source (an SSTable or the base table), a memtable lacks a /// BTree on the key, the key is multi-column, or the projection requests /// system columns does it fall back to [`Self::plan_lookup`]. The result is /// identical to executing `plan_lookup` and taking the first row; the fast @@ -416,7 +416,7 @@ impl LsmPointLookupPlanner { None => { // Every in-memory memtable missed. If there is no // on-disk source, the key does not exist; otherwise the - // plan path consults the base table / flushed gens. + // plan path consults the base table / SSTables. if !self.collector.has_on_disk_sources() { return Ok(None); } @@ -660,12 +660,12 @@ impl LsmPointLookupPlanner { // (E0275 downstream). Same for the other arms below. Box::pin(scanner.create_plan()).await? } - LsmDataSource::FlushedMemTable { path, .. } => { - let dataset = open_flushed_dataset( + LsmDataSource::SsTable { path, .. } => { + let dataset = open_sstable( path, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), self.warmer.as_ref(), ) .await?; @@ -1119,7 +1119,7 @@ mod tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); // Create collector let collector = LsmDataSourceCollector::new(base_dataset, vec![shard_snapshot]); @@ -1200,10 +1200,10 @@ mod tests { let base_path = temp_dir.path().to_str().unwrap(); // No base dataset is created. We still need a base URI so the collector - // can resolve flushed-generation paths. + // can resolve SSTable paths. let base_uri = format!("{}/base", base_path); - // Create a flushed generation under {base_uri}/_mem_wal/{shard}/gen_1 + // Create an SSTable under {base_uri}/_mem_wal/{shard}/gen_1 let shard_id = Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[2, 3], "gen1"); @@ -1211,12 +1211,12 @@ mod tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![shard_snapshot]); let planner = LsmPointLookupPlanner::new(collector, vec!["id".to_string()], schema); - // id=3 lives in the flushed generation + // id=3 lives in the SSTable let pk_values = vec![ScalarValue::Int32(Some(3))]; let plan = planner.plan_lookup(&pk_values, None).await.unwrap(); @@ -1540,10 +1540,10 @@ mod tests { } #[tokio::test] - async fn test_point_lookup_flushed_memtable_returns_newest_duplicate() { - // Regression / invariant pin: when a flushed memtable contains two + async fn test_point_lookup_sstable_returns_newest_duplicate() { + // Regression / invariant pin: when an SSTable contains two // rows for the same PK, the lookup must return the newer one. The - // flushed dataset is reverse-written (newest at the smallest + // SSTable dataset is reverse-written (newest at the smallest // physical position), so we simulate that here by writing the // dataset with the new row first. The point-lookup plan today // returns the first match (smallest `_rowid`) under reverse-write, @@ -1564,7 +1564,7 @@ mod tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![shard_snapshot]); let planner = LsmPointLookupPlanner::new(collector, vec!["id".to_string()], schema); @@ -1584,7 +1584,7 @@ mod tests { assert_eq!( name_arr.value(0), "new_1", - "flushed-arm lookup must return the row at the smallest _rowid (newest under reverse-write)" + "SSTable-arm lookup must return the row at the smallest _rowid (newest under reverse-write)" ); } diff --git a/rust/lance/src/dataset/mem_wal/scanner/projection.rs b/rust/lance/src/dataset/mem_wal/scanner/projection.rs index 0ec482aebf8..a39c83a8f1b 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/projection.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/projection.rs @@ -6,8 +6,8 @@ //! //! `MemTableScanner::project()` only special-cases `_rowid`; passing other //! system columns through it errors. And cross-LSM values for system -//! columns aren't comparable (a `_rowid` of 5 in the base and in a flushed -//! memtable refer to different rows). +//! columns aren't comparable (a `_rowid` of 5 in the base and in an +//! SSTable refer to different rows). //! //! - [`build_scanner_projection`] — strips system / `_distance` cols, appends PKs. //! - [`canonical_output_schema`] — final schema honoring user order; system diff --git a/rust/lance/src/dataset/mem_wal/scanner/flushed_cache.rs b/rust/lance/src/dataset/mem_wal/scanner/sstable_cache.rs similarity index 86% rename from rust/lance/src/dataset/mem_wal/scanner/flushed_cache.rs rename to rust/lance/src/dataset/mem_wal/scanner/sstable_cache.rs index 7a011078571..9bb8dadcf02 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/flushed_cache.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/sstable_cache.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -//! Cache of opened flushed-generation datasets for the LSM scanner. +//! Cache of opened SSTable datasets for the LSM scanner. //! -//! Flushed generations are written exactly once to a globally-unique, +//! SSTables are written exactly once to a globally-unique, //! content-addressed path (see `memtable/flush.rs`): a fresh random hash per //! flush invocation means the same path always maps to the same immutable //! bytes. A cached `Arc` therefore can never go stale and needs no @@ -11,11 +11,11 @@ //! optimization driven by the consumer at compaction time. //! //! ```text -//! query ──> open_flushed_dataset(path, session, cache) +//! query ──> open_sstable(path, session, cache) //! │ //! cache.is_some() ──────┤────── cache.is_none() //! │ │ -//! FlushedMemTableCache::get_or_open DatasetBuilder::from_uri +//! SsTableCache::get_or_open DatasetBuilder::from_uri //! (single-flight, shared Arc) (cold open every call) //! ``` @@ -29,15 +29,15 @@ use lance_io::object_store::ObjectStoreParams; use crate::dataset::{Dataset, DatasetBuilder}; use crate::session::Session; -/// Cache of opened flushed-generation datasets, keyed by resolved path. +/// Cache of opened SSTable datasets, keyed by resolved path. /// -/// Flushed generations live at a globally-unique, immutable path, so cached +/// SSTables live at a globally-unique, immutable path, so cached /// entries are never stale and require no TTL. Intended to be held by a /// long-lived owner (one per process or per table) and injected into /// per-request scanners via [`crate::dataset::mem_wal::scanner::LsmScanner`] /// (and the point-lookup / vector-search planners). /// -/// The key is the resolved absolute flushed path +/// The key is the resolved absolute SSTable path /// (`{base}/_mem_wal/{shard}/{folder}`), which is globally unique, so a single /// cache can safely span multiple tables. /// @@ -49,16 +49,16 @@ use crate::session::Session; /// path is only ever served under one store configuration. Serving one table /// through a single cache under two different `ObjectStoreParams` would hand /// every caller the store the first one opened with. -pub struct FlushedMemTableCache { +pub struct SsTableCache { // `moka`'s async cache gives a bounded size plus single-flight // `try_get_with`, so concurrent first-queries on a just-flushed - // generation open the dataset exactly once. The opened dataset carries the + // SSTable open the dataset exactly once. The opened dataset carries the // session index cache, which also backs each generation's standalone PK // dedup index (see `block_list::open_pk_index`) — no separate cache path. inner: moka::future::Cache>, } -impl FlushedMemTableCache { +impl SsTableCache { /// Create a cache holding at most `max_entries` opened datasets. /// /// Eviction is size-only (no TTL): an evicted-then-re-requested generation @@ -119,20 +119,20 @@ impl FlushedMemTableCache { } } -impl std::fmt::Debug for FlushedMemTableCache { +impl std::fmt::Debug for SsTableCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FlushedMemTableCache") + f.debug_struct("SsTableCache") .field("entry_count", &self.inner.entry_count()) .finish() } } -/// Caching of opened flushed-generation datasets, keyed by immutable path. The +/// Caching of opened SSTable datasets, keyed by immutable path. The /// opened dataset carries the session index cache, which also backs each /// generation's secondary indexes and its PK dedup sidecar (see /// `block_list::open_pk_index`) — so a single `get_or_open` is the -/// whole caching surface. Implemented by [`FlushedMemTableCache`]; a -/// [`GenerationWarmer`] composes one to warm through it, and a consumer may +/// whole caching surface. Implemented by [`SsTableCache`]; a +/// [`SsTableWarmer`] composes one to warm through it, and a consumer may /// supply its own implementation. #[async_trait] pub trait DatasetCache: Send + Sync + std::fmt::Debug { @@ -146,12 +146,12 @@ pub trait DatasetCache: Send + Sync + std::fmt::Debug { /// Drop cached entries whose path is not in `live_paths`. Async so an /// implementation can evict retired generations' index objects (e.g. /// `Session::invalidate_index_prefix`) without a later breaking signature - /// change; [`FlushedMemTableCache`]'s own eviction is synchronous. + /// change; [`SsTableCache`]'s own eviction is synchronous. async fn retain_paths(&self, live_paths: &HashSet); } #[async_trait] -impl DatasetCache for FlushedMemTableCache { +impl DatasetCache for SsTableCache { async fn get_or_open( &self, path: &str, @@ -166,7 +166,7 @@ impl DatasetCache for FlushedMemTableCache { } } -/// Proactively warms a flushed generation into the shared caches: open the +/// Proactively warms an SSTable into the shared caches: open the /// dataset and pre-load its secondary indexes and PK dedup sidecar so the first /// query sees no cold reads. This is the **seam** the flush and read paths fire /// — lance defines it; the consumer (e.g. the WAL pod) implements it. `None` => @@ -182,27 +182,27 @@ impl DatasetCache for FlushedMemTableCache { /// and cheap when the path is already warm** (e.g. dedup in-flight and /// completed paths) — a redundant call must not re-do work or fail. #[async_trait] -pub trait GenerationWarmer: Send + Sync + std::fmt::Debug { +pub trait SsTableWarmer: Send + Sync + std::fmt::Debug { async fn warm(&self, path: &str) -> Result<()>; } -/// Open a flushed-generation dataset, shared by all three LSM open sites +/// Open an SSTable dataset, shared by all three LSM open sites /// (scan, point lookup, vector search). /// /// - `cache` present: route through a [`DatasetCache`] (e.g. -/// [`FlushedMemTableCache`]: single-flight, shared `Arc`, manifest read +/// [`SsTableCache`]: single-flight, shared `Arc`, manifest read /// amortized across queries). /// - `cache` absent: cold open via [`DatasetBuilder`]. Passing `session` /// still reuses the shared index / metadata caches; `None`/`None` /// reproduces the original per-query cold-open behavior exactly. /// - `warmer` present: fire a fire-and-forget warm-on-open backstop behind the /// returned handle (the warmer dedups already-warm paths). `None` => no warming. -pub async fn open_flushed_dataset( +pub async fn open_sstable( path: &str, session: Option<&Arc>, store_params: Option<&ObjectStoreParams>, cache: Option<&Arc>, - warmer: Option<&Arc>, + warmer: Option<&Arc>, ) -> Result> { let dataset = match cache { Some(cache) => { @@ -267,7 +267,7 @@ mod tests { let uri = format!("{}/gen_1", temp_dir.path().to_str().unwrap()); write_dataset(&uri, &[1, 2, 3]).await; - let cache = FlushedMemTableCache::new(8); + let cache = SsTableCache::new(8); let first = cache.get_or_open(&uri, None, None).await.unwrap(); let second = cache.get_or_open(&uri, None, None).await.unwrap(); @@ -290,7 +290,7 @@ mod tests { let uri = format!("{}/gen_1", temp_dir.path().to_str().unwrap()); write_dataset(&uri, &[1, 2, 3]).await; - let cache = Arc::new(FlushedMemTableCache::new(8)); + let cache = Arc::new(SsTableCache::new(8)); let calls = Arc::new(AtomicUsize::new(0)); let mut handles = Vec::new(); @@ -327,7 +327,7 @@ mod tests { write_dataset(&keep_uri, &[1]).await; write_dataset(&drop_uri, &[2]).await; - let cache = FlushedMemTableCache::new(8); + let cache = SsTableCache::new(8); cache.get_or_open(&keep_uri, None, None).await.unwrap(); cache.get_or_open(&drop_uri, None, None).await.unwrap(); cache.inner.run_pending_tasks().await; @@ -343,19 +343,15 @@ mod tests { } #[tokio::test] - async fn test_open_flushed_dataset_no_cache_matches_direct_open() { + async fn test_open_sstable_no_cache_matches_direct_open() { // The `None`/`None` path must reproduce a plain cold open: same data, // independent Arc per call (no caching). let temp_dir = tempfile::tempdir().unwrap(); let uri = format!("{}/gen_1", temp_dir.path().to_str().unwrap()); write_dataset(&uri, &[7, 8, 9]).await; - let a = open_flushed_dataset(&uri, None, None, None, None) - .await - .unwrap(); - let b = open_flushed_dataset(&uri, None, None, None, None) - .await - .unwrap(); + let a = open_sstable(&uri, None, None, None, None).await.unwrap(); + let b = open_sstable(&uri, None, None, None, None).await.unwrap(); assert!( !Arc::ptr_eq(&a, &b), "no-cache path must cold-open each call" @@ -363,11 +359,11 @@ mod tests { assert_eq!(a.count_rows(None).await.unwrap(), 3); // With a cache, the second call is a shared clone. - let cache: Arc = Arc::new(FlushedMemTableCache::new(8)); - let c = open_flushed_dataset(&uri, None, None, Some(&cache), None) + let cache: Arc = Arc::new(SsTableCache::new(8)); + let c = open_sstable(&uri, None, None, Some(&cache), None) .await .unwrap(); - let d = open_flushed_dataset(&uri, None, None, Some(&cache), None) + let d = open_sstable(&uri, None, None, Some(&cache), None) .await .unwrap(); assert!(Arc::ptr_eq(&c, &d), "cached path must reuse the Arc"); @@ -381,7 +377,7 @@ mod tests { } #[async_trait] - impl GenerationWarmer for NotifyingWarmer { + impl SsTableWarmer for NotifyingWarmer { async fn warm(&self, _path: &str) -> Result<()> { self.calls.fetch_add(1, Ordering::SeqCst); self.notify.notify_one(); @@ -390,7 +386,7 @@ mod tests { } #[tokio::test] - async fn test_open_flushed_dataset_fires_warm_on_open() { + async fn test_open_sstable_fires_warm_on_open() { // The warm-on-open backstop fires the warmer (fire-and-forget) when a // generation is opened, so generations the flusher never warmed still // get warmed lazily on first read. @@ -400,12 +396,12 @@ mod tests { let calls = Arc::new(AtomicUsize::new(0)); let notify = Arc::new(tokio::sync::Notify::new()); - let warmer: Arc = Arc::new(NotifyingWarmer { + let warmer: Arc = Arc::new(NotifyingWarmer { calls: calls.clone(), notify: notify.clone(), }); - let ds = open_flushed_dataset(&uri, None, None, None, Some(&warmer)) + let ds = open_sstable(&uri, None, None, None, Some(&warmer)) .await .unwrap(); assert_eq!(ds.count_rows(None).await.unwrap(), 3); diff --git a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs index 2705383f0b5..3c314b36a75 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs @@ -28,11 +28,11 @@ use crate::io::exec::TakeExec; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; -use super::flushed_cache::{DatasetCache, GenerationWarmer, open_flushed_dataset}; use super::projection::{ DISTANCE_COLUMN, build_scanner_projection, canonical_output_schema, null_columns, project_to_canonical, validate_projection_names, wants_row_id, }; +use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable}; use crate::session::Session; use lance_io::object_store::ObjectStoreParams; @@ -41,7 +41,7 @@ use lance_io::object_store::ObjectStoreParams; /// Each source is independently newest-per-PK before the union — the active /// memtable via exact brute-force KNN when PK rewrites or a filter require it /// (append-only active data can still use HNSW), -/// flushed generations via their within-generation deletion vector — and the +/// SSTables via their within-generation deletion vector — and the /// cross-generation block-list ([`super::exec::PkBlockFilterExec`]) drops any /// PK superseded by a newer generation. So each PK reaches the union from /// exactly one source and a distance-ordered merge yields the global top-k; no @@ -59,9 +59,9 @@ use lance_io::object_store::ObjectStoreParams; /// MemTableBruteForceVectorExec or VectorIndexExec: active memtable KNN /// ProjectionExec (canonical output schema) /// ProjectionExec (null_columns _rowid) -/// PkBlockFilterExec: block-list (flushed) -/// KNNExec: flushed gen N, fetch=ceil(k*overfetch) (fast_search) -/// … one per flushed gen … +/// PkBlockFilterExec: block-list (SSTable) +/// KNNExec: SSTable gen N, fetch=ceil(k*overfetch) (fast_search) +/// … one per SSTable gen … /// ProjectionExec (canonical output schema) /// PkBlockFilterExec: block-list (base) /// KNNExec: base table, k (fast_search)[.refine()?] @@ -69,11 +69,11 @@ use lance_io::object_store::ObjectStoreParams; /// /// # Index-Only Search (fast_search) /// -/// For base table and flushed memtables we use `fast_search()` to only +/// For base table and SSTables we use `fast_search()` to only /// search indexed data. This is correct because: -/// - Each flushed memtable has its own vector index built during flush. +/// - Each SSTable has its own vector index built during flush. /// - The active memtable covers any unindexed data. -/// - Searching unindexed data in base/flushed would be redundant. +/// - Searching unindexed data in base/SSTable would be redundant. pub struct LsmVectorSearchPlanner { /// Data source collector. collector: LsmDataSourceCollector, @@ -90,17 +90,17 @@ pub struct LsmVectorSearchPlanner { /// the per-source KNN output. Memtable rows already carry all columns; /// the take only fetches additional data for base rows (real `_rowid`). dataset: Option>, - /// Session threaded into flushed-generation opens (shared caches). + /// Session threaded into SSTable opens (shared caches). session: Option>, - /// Store params for opening flushed generations, reusing the base dataset's store. + /// Store params for opening SSTables, reusing the base dataset's store. store_params: Option, - /// Cache of opened flushed-generation datasets. - flushed_cache: Option>, - /// Optional warmer fired on first open of a flushed generation. - warmer: Option>, + /// Cache of opened SSTable datasets. + sstable_cache: Option>, + /// Optional warmer fired on first open of an SSTable. + warmer: Option>, /// Optional prefilter predicate applied to every source arm before its KNN /// search, so rows failing the predicate never enter the top-k. Base and - /// flushed arms use the dataset scanner's native prefilter; memtable arms + /// SSTable arms use the dataset scanner's native prefilter; memtable arms /// route to a filtered brute-force scan. filter: Option, } @@ -131,7 +131,7 @@ impl LsmVectorSearchPlanner { dataset: None, session: None, store_params: None, - flushed_cache: None, + sstable_cache: None, warmer: None, filter: None, } @@ -139,33 +139,33 @@ impl LsmVectorSearchPlanner { /// Attach an optional prefilter predicate. Every source arm restricts its /// KNN to rows matching the predicate (true prefilter), so results match a - /// normal filtered vector scan over base ∪ flushed ∪ in-memory data. + /// normal filtered vector scan over base ∪ SSTables ∪ in-memory data. pub fn with_filter(mut self, filter: Option) -> Self { self.filter = filter; self } - /// Set the session used to open flushed generations. + /// Set the session used to open SSTables. pub fn with_session(mut self, session: Arc) -> Self { self.session = Some(session); self } - /// Set the store params used to open flushed generations. + /// Set the store params used to open SSTables. pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self { self.store_params = Some(store_params); self } - /// Inject a cache of opened flushed-generation datasets, making repeated + /// Inject a cache of opened SSTable datasets, making repeated /// searches against the same generation a pure `Arc::clone`. - pub fn with_flushed_cache(mut self, cache: Arc) -> Self { - self.flushed_cache = Some(cache); + pub fn with_sstable_cache(mut self, cache: Arc) -> Self { + self.sstable_cache = Some(cache); self } - /// Inject the warmer fired on first open of a flushed generation. - pub fn with_warmer(mut self, warmer: Arc) -> Self { + /// Inject the warmer fired on first open of an SSTable. + pub fn with_warmer(mut self, warmer: Arc) -> Self { self.warmer = Some(warmer); self } @@ -245,7 +245,7 @@ impl LsmVectorSearchPlanner { &sources, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), )) .await?; @@ -306,8 +306,8 @@ impl LsmVectorSearchPlanner { // * active: append-only memtables can use HNSW directly; once a // PK rewrite is observed, `MemTableBruteForceVectorExec` drops // superseded versions before the top-k cut. - // * flushed/base: drop cross-gen superseded rows via the - // block-list (within-gen is handled by the flushed DV). + // * SSTable/base: drop cross-gen superseded rows via the + // block-list (within-gen is handled by the SSTable DV). let knn = match blocked { Some(_) if self.pk_columns.is_empty() => knn, Some(set) => Arc::new(super::exec::PkBlockFilterExec::new( @@ -452,12 +452,12 @@ impl LsmVectorSearchPlanner { } scanner.create_plan().await } - LsmDataSource::FlushedMemTable { path, .. } => { - let dataset = open_flushed_dataset( + LsmDataSource::SsTable { path, .. } => { + let dataset = open_sstable( path, self.session.as_ref(), self.store_params.as_ref(), - self.flushed_cache.as_ref(), + self.sstable_cache.as_ref(), self.warmer.as_ref(), ) .await?; @@ -680,7 +680,7 @@ mod tests { let dataset = Dataset::write(reader, uri, Some(WriteParams::default())) .await .unwrap(); - // Also write the standalone PK sidecar (on `id`) so a flushed-generation + // Also write the standalone PK sidecar (on `id`) so an SSTable // source can be probed by the block-list (harmless for a base table). if has_id { crate::dataset::mem_wal::scanner::block_list::write_pk_sidecar(uri, &batches, &["id"]) @@ -858,7 +858,7 @@ mod tests { out_cols ); // Internal columns must not leak: `_rowid` (added by Lance's fast_search - // in the base/flushed arms) and `_memtable_gen` (added by the LSM merge + // in the base/SSTable arms) and `_memtable_gen` (added by the LSM merge // when bloom filters are present) are bookkeeping, not API. assert!( out_schema.field_with_name("_rowid").is_err(), @@ -1226,13 +1226,13 @@ mod tests { ); } - /// The flushed arm must also apply the filter as a true prefilter, and that + /// The SSTable arm must also apply the filter as a true prefilter, and that /// prefiltered candidate set must compose with cross-generation block-list /// filtering plus over-fetch. Gen 1's closest predicate-matching row (id=3) /// is superseded by gen 2; with over-fetch, gen 1 should still contribute /// the next live predicate match (id=4). #[tokio::test] - async fn test_vector_search_flushed_prefilter_composes_with_block_list() { + async fn test_vector_search_sstable_prefilter_composes_with_block_list() { use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::index::DatasetIndexExt; use crate::index::vector::VectorIndexParams; @@ -1271,8 +1271,8 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![snapshot]); let planner = LsmVectorSearchPlanner::new( @@ -1297,7 +1297,7 @@ mod tests { assert_eq!(rows.len(), 1, "expected one result, got {:?}", rows); assert_eq!( rows[0].0, 4, - "flushed prefilter should return live id=4 after stale id=3 is blocked; got {:?}", + "SSTable prefilter should return live id=4 after stale id=3 is blocked; got {:?}", rows ); } @@ -2007,14 +2007,14 @@ mod tests { #[tokio::test] async fn test_vector_search_dedup_across_generations() { // Regression: same primary key inserted into two sources (older - // flushed gen and newer active memtable) with different vectors. - // Without the cross-source PK dedup the older flushed row would + // SSTable gen and newer active memtable) with different vectors. + // Without the cross-source PK dedup the older SSTable row would // still appear in top-k. The newer-generation row must win. // - // We simulate a "flushed gen 1" by writing a tiny Lance dataset + // We simulate a "SSTable gen 1" by writing a tiny Lance dataset // under {base_uri}/_mem_wal/{shard}/gen_1 and pointing the // collector at it. Real flush would reverse-write, but for this - // test we only have one row in the flushed gen so order is moot. + // test we only have one row in the SSTable gen so order is moot. use crate::dataset::mem_wal::scanner::collector::{InMemoryMemTableRef, InMemoryMemTables}; use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::dataset::mem_wal::write::{BatchStore, IndexStore}; @@ -2026,7 +2026,7 @@ mod tests { let base_path = temp_dir.path().to_str().unwrap(); let base_uri = format!("{}/base", base_path); - // Flushed gen 1 holds an older version of pk=1 with a "wrong" vector. + // SSTable gen 1 holds an older version of pk=1 with a "wrong" vector. let shard_id = uuid::Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let old_pk1 = create_test_batch_with_vector(&schema, 1, [9.0, 9.0, 9.0, 9.0]); @@ -2059,7 +2059,7 @@ mod tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![shard_snapshot]) .with_in_memory_memtables( shard_id, @@ -2115,7 +2115,7 @@ mod tests { async fn test_vector_search_system_columns_real_only_for_base() { // Covers three properties of the per-source system columns: // 1. base-hit `_rowid`/`_rowaddr` carry real values - // 2. flushed-memtable arm runs without erroring + // 2. SSTable arm runs without erroring // 3. `_rowaddr` symmetry with `_rowid` (same code path, both are // surfaced when requested and NULL'd outside the base arm) use crate::dataset::mem_wal::scanner::collector::{InMemoryMemTableRef, InMemoryMemTables}; @@ -2142,7 +2142,7 @@ mod tests { .unwrap(); let base_dataset = Arc::new(base_dataset); - // Flushed memtable: id=2 (a separate Lance dataset under + // SSTable: id=2 (a separate Lance dataset under // {base_uri}/_mem_wal/{shard}/gen_1) with its own vector index. let shard_id = uuid::Uuid::new_v4(); let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); @@ -2174,7 +2174,7 @@ mod tests { let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) - .with_flushed_generation(1, "gen_1".to_string()); + .with_sstable(1, "gen_1".to_string()); let collector = LsmDataSourceCollector::new(base_dataset, vec![shard_snapshot]) .with_in_memory_memtables( @@ -2248,10 +2248,10 @@ mod tests { "`_rowaddr` is incompatible with vector_search's fast_search; must be NULL" ); - // id=2 (flushed): both NULL — per-source values would collide with base. - let (rid_null, raddr_null) = seen.get(&2).expect("flushed row id=2 missing"); - assert!(rid_null, "flushed row `_rowid` must be NULL"); - assert!(raddr_null, "flushed row `_rowaddr` must be NULL"); + // id=2 (SSTable): both NULL — per-source values would collide with base. + let (rid_null, raddr_null) = seen.get(&2).expect("SSTable row id=2 missing"); + assert!(rid_null, "SSTable row `_rowid` must be NULL"); + assert!(raddr_null, "SSTable row `_rowaddr` must be NULL"); // id=3 (active): both NULL — BatchStore position is not a Lance row id. let (rid_null, raddr_null) = seen.get(&3).expect("active row id=3 missing"); @@ -2920,9 +2920,9 @@ mod tests { } #[tokio::test] - async fn test_vector_search_flushed_superseded_by_newer_flushed() { - // An older flushed generation's stale row must be suppressed by a newer - // flushed generation (cross-flushed blocking, no base/active involved). + async fn test_vector_search_sstable_superseded_by_newer_sstable() { + // An older SSTable's stale row must be suppressed by a newer + // SSTable (cross-SSTable blocking, no base/active involved). use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::index::DatasetIndexExt; use crate::index::vector::VectorIndexParams; @@ -2957,8 +2957,8 @@ mod tests { let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) - .with_flushed_generation(1, "gen_1".to_string()) - .with_flushed_generation(2, "gen_2".to_string()); + .with_sstable(1, "gen_1".to_string()) + .with_sstable(2, "gen_2".to_string()); let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![snapshot]); let planner = LsmVectorSearchPlanner::new( diff --git a/rust/lance/src/dataset/mem_wal/util.rs b/rust/lance/src/dataset/mem_wal/util.rs index 9dfc019b0b8..dad79f54689 100644 --- a/rust/lance/src/dataset/mem_wal/util.rs +++ b/rust/lance/src/dataset/mem_wal/util.rs @@ -131,7 +131,7 @@ pub fn parse_bit_reversed_filename(filename: &str) -> Option { } /// Adapt the store params a base dataset was opened with for use on a URI -/// *derived* from it (a flushed generation under `_mem_wal/`). +/// *derived* from it (an SSTable under `_mem_wal/`). /// /// The deprecated `object_store` binding pins a store to one location: given /// `Some((store, url))`, both `ObjectStore::from_uri_and_params` and @@ -178,29 +178,24 @@ pub fn shard_manifest_path(base_path: &Path, shard_id: &Uuid) -> Path { shard_base_path(base_path, shard_id).join("manifest") } -/// Path to a flushed MemTable directory. +/// Path to an SSTable directory. /// /// Returns: `{base_path}/_mem_wal/{shard_id}/{random_hash}_gen_{generation}/` -pub fn flushed_memtable_path( - base_path: &Path, - shard_id: &Uuid, - random_hash: &str, - generation: u64, -) -> Path { +pub fn sstable_path(base_path: &Path, shard_id: &Uuid, random_hash: &str, generation: u64) -> Path { shard_base_path(base_path, shard_id).join(format!("{}_gen_{}", random_hash, generation)) } -/// Subdirectory of a flushed generation holding its standalone primary-key +/// Subdirectory of an SSTable holding its standalone primary-key /// dedup index (a sidecar BTree, not registered in the manifest). Both the /// flush writer and the block-list probe join this onto the generation path. pub const PK_INDEX_DIR: &str = "_pk_index"; -/// Path to a flushed generation's standalone primary-key dedup index. +/// Path to an SSTable's standalone primary-key dedup index. pub fn pk_index_path(gen_path: &Path) -> Path { gen_path.clone().join(PK_INDEX_DIR) } -/// Generate an 8-character random hex string for flushed MemTable directories. +/// Generate an 8-character random hex string for SSTable directories. pub fn generate_random_hash() -> String { let bytes: [u8; 4] = rand::random(); format!( @@ -309,7 +304,7 @@ mod tests { ); assert_eq!( - flushed_memtable_path(&base_path, &shard_id, "a1b2c3d4", 5).as_ref(), + sstable_path(&base_path, &shard_id, "a1b2c3d4", 5).as_ref(), "my/dataset/_mem_wal/550e8400-e29b-41d4-a716-446655440000/a1b2c3d4_gen_5" ); diff --git a/rust/lance/src/dataset/mem_wal/wal.rs b/rust/lance/src/dataset/mem_wal/wal.rs index cb2b609d71f..68eb41a03c2 100644 --- a/rust/lance/src/dataset/mem_wal/wal.rs +++ b/rust/lance/src/dataset/mem_wal/wal.rs @@ -1011,7 +1011,7 @@ impl WalEntryData { /// First valid WAL entry position. Positions are 1-based so that a /// `ShardManifest::replay_after_wal_entry_position` of 0 unambiguously means /// "no flush has ever stamped the cursor" — replay then starts at position 1 -/// without needing to consult `flushed_generations`, which an external +/// without needing to consult `sstables`, which an external /// compactor may legitimately drain back to empty. const FIRST_WAL_ENTRY_POSITION: u64 = 1; const MAX_APPEND_CREATE_CONFLICTS: usize = 1024; diff --git a/rust/lance/src/dataset/mem_wal/write.rs b/rust/lance/src/dataset/mem_wal/write.rs index ab87ec68393..943dfb91a45 100644 --- a/rust/lance/src/dataset/mem_wal/write.rs +++ b/rust/lance/src/dataset/mem_wal/write.rs @@ -48,7 +48,7 @@ pub use super::util::{WatchableOnceCell, WatchableOnceCellReader}; pub use super::wal::{WalEntry, WalEntryData, WalFlushFailure, WalFlushResult, WalFlusher}; use super::memtable::flush::TriggerMemTableFlush; -use super::scanner::GenerationWarmer; +use super::scanner::SsTableWarmer; use super::wal::{ BatchDurableWatcher, TriggerIndexApply, TriggerWalFlush, WalAppender, WalFlushSource, WalOnlyState, WalRetryConfig, WalTailer, WriterCursors, apply_index_range, empty_flush_result, @@ -165,14 +165,14 @@ pub struct ShardWriterConfig { pub stats_log_interval: Option, /// How long a frozen memtable lingers in memory after its flush commits, - /// before it is evicted and served only from the on-disk flushed dataset. + /// before it is evicted and served only from the on-disk SSTable dataset. /// /// `Duration::ZERO` (the default) disables retention: evict on commit, no /// sweep ticker. Correct for single-shot queries, which can't observe a /// generation evicted mid-read. /// /// A non-zero value is required only for queries split across reads (e.g. - /// fresh tier and base table read separately, then deduped): the flushed + /// fresh tier and base table read separately, then deduped): the SSTable /// dataset loses the per-batch boundaries that bound as-of membership /// (see [`crate::dataset::mem_wal::scanner::FreshTierWatermark`]), so a /// generation evicted between a query's reads can serve a stale row. Set it @@ -210,7 +210,7 @@ pub struct ShardWriterConfig { /// These control the in-memory HNSW graph this writer builds for its /// MemTable (and, on flush, the on-disk graph serialized from it). They are /// a property of the writer that builds the MemTable, not of the index - /// definition: each flushed generation is independent, so different writers + /// definition: each SSTable is independent, so different writers /// may use different parameters. An index without an entry uses the default /// build parameters. `num_edges` is the HNSW graph degree (level 0 retains /// `2 * num_edges`), equivalent to FAISS's `M`. @@ -221,7 +221,7 @@ pub struct ShardWriterConfig { /// Optional warmer fired pre-commit for each new generation (zero cold reads /// on first query). Wired to the flusher; supplied by the consumer (e.g. the /// WAL pod). Default: `None`. - pub warmer: Option>, + pub warmer: Option>, /// Store params the base dataset was opened with, reused for the flusher's /// opens + writes (base + generations). Injected by `mem_wal_writer`; set @@ -351,7 +351,7 @@ impl ShardWriterConfig { self } - /// Set how long a flushed memtable lingers in memory before eviction. MUST + /// Set how long an SSTable lingers in memory before eviction. MUST /// exceed the maximum query elapsed time — see `frozen_memtable_grace`. pub fn with_frozen_memtable_grace(mut self, grace: Duration) -> Self { self.frozen_memtable_grace = grace; @@ -818,7 +818,7 @@ fn now_millis() -> u64 { start_time().elapsed().as_millis() as u64 } -/// Replay WAL entries written after the last successfully-flushed generation +/// Replay WAL entries written after the last successfully-flushed SSTable /// into the freshly-built MemTable. Updates any in-memory indexes attached to /// the MemTable so replayed rows are immediately searchable. /// @@ -857,7 +857,7 @@ struct ReplayResult { /// `make_memtable(generation, global_offset)` builds a fresh, cursor-bound /// memtable. Rotation happens at WAL-entry boundaries, never mid-entry, so each /// sealed memtable covers a clean range of complete entries and stamps the last -/// one as its flushed generation's `replay_after_wal_entry_position`. +/// one as its SSTable's `replay_after_wal_entry_position`. #[allow(clippy::too_many_arguments)] async fn replay_memtable_from_wal( object_store: Arc, @@ -877,7 +877,7 @@ async fn replay_memtable_from_wal( // starts at position 1. After flushing position N the cursor holds N // and replay starts at N+1. The arithmetic collapses to a single // saturating_add(1) in both cases — we deliberately do not consult - // `flushed_generations` here, since an external compactor may + // `sstables` here, since an external compactor may // legitimately drain that vector back to empty after merging its // contents into the base table. let start_position = manifest.replay_after_wal_entry_position.saturating_add(1); @@ -1742,7 +1742,7 @@ impl ShardWriter { ); // Replay any WAL entries written after the last successfully-flushed - // generation, flushing sealed memtables to Lance generations as the batch + // SSTable, flushing sealed memtables to Lance SSTables as the batch // store fills. Each entry's writer_epoch is checked against ours; an entry // with a strictly greater epoch means a successor claimed the shard // between our `claim_epoch` and replay, so we abort with a fence error. @@ -1794,8 +1794,8 @@ impl ShardWriter { // it is durably reflected in this writer's memtable. We can't // seed from `manifest.wal_entry_position_last_seen` — that field // is bumped on every successful tailer read by other readers, so - // it may sit above what's actually covered by any flushed - // generation. Subtracting 1 from a fresh shard's `next_wal_position` + // it may sit above what's actually covered by any + // SSTable. Subtracting 1 from a fresh shard's `next_wal_position` // of `FIRST_WAL_ENTRY_POSITION` (= 1) yields 0, which correctly // means "no entry covered yet." let initial_covered_wal_entry_position = next_wal_position.saturating_sub(1); @@ -1830,7 +1830,7 @@ impl ShardWriter { )?; // Background MemTable flush handler — frozen memtable to Lance file. - // It rebuilds the same secondary indexes on each flushed generation. + // It rebuilds the same secondary indexes on each SSTable. let memtable_handler = MemTableFlushHandler::new( state.clone(), flusher, @@ -3134,9 +3134,9 @@ struct MemTableFlushHandler { /// covers the whole frozen memtable before it writes a generation. wal_flusher: Arc, epoch: u64, - /// Secondary index configs to rebuild on each flushed generation. When + /// Secondary index configs to rebuild on each SSTable. When /// non-empty the handler flushes via [`MemTableFlusher::flush_with_indexes`] - /// so queries over flushed generations use index lookups instead of full + /// so queries over SSTables use index lookups instead of full /// scans — and so vector search's index-only `fast_search` can see the data /// at all. index_configs: Vec, @@ -3267,7 +3267,7 @@ impl MemTableFlushHandler { let covered_wal_entry_position = wal_flushed_position .or_else(|| memtable.frozen_at_wal_entry_position()) .unwrap_or(0); - // Rebuild secondary indexes on the flushed generation so later + // Rebuild secondary indexes on the SSTable so later // queries hit an index instead of scanning. Skip the extra // dataset open when there are no indexes to build. The indexed // path's future is boxed to keep this async block's nesting @@ -3318,15 +3318,15 @@ impl MemTableFlushHandler { // the read union until a later flush or WAL replay, else a transient // error reopens the hole. if flush_result.is_ok() { - let flushed_generation = memtable.generation(); + let sstable = memtable.generation(); if self.grace.is_zero() { state .frozen_memtables - .retain(|frozen| frozen.memtable.generation() != flushed_generation); + .retain(|frozen| frozen.memtable.generation() != sstable); } else { let now = now_millis(); for frozen in state.frozen_memtables.iter_mut() { - if frozen.memtable.generation() == flushed_generation { + if frozen.memtable.generation() == sstable { frozen.flushed_at_ms = Some(now); } } @@ -3341,7 +3341,7 @@ impl MemTableFlushHandler { info!( "Flushed frozen memtable generation {} ({} rows in {:?})", - result.generation.generation, + result.sstable.generation, result.rows_flushed, start.elapsed() ); @@ -3962,7 +3962,7 @@ mod tests { /// with an optional filter. Mirrors how a query reads a WAL table after a /// flush — the path the wallop fuzz exercised when it caught a deleted row /// resurfacing. - async fn read_flushed_ids_via_lsm( + async fn read_sstable_ids_via_lsm( writer: &ShardWriter, schema: Arc, base_uri: &str, @@ -3975,8 +3975,8 @@ mod tests { let manifest = writer.manifest().await.unwrap().unwrap(); let mut snapshot = ShardSnapshot::new(shard_id).with_current_generation(manifest.current_generation); - for fg in &manifest.flushed_generations { - snapshot = snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + for sstable in &manifest.sstables { + snapshot = snapshot.with_sstable(sstable.generation, sstable.path.clone()); } let mut scanner = LsmScanner::without_base_table( schema, @@ -4018,7 +4018,7 @@ mod tests { } /// Delete a key, then flush: the tombstone and the live row land in the - /// *same* flushed generation, so flush-time dedup must keep the tombstone + /// *same* SSTable, so flush-time dedup must keep the tombstone /// (newest) and the read must fold it away. Regression for the wallop /// phantom (deleted row resurfacing in a filtered read after flush). #[tokio::test] @@ -4047,14 +4047,14 @@ mod tests { writer.wait_for_flush_drain().await.unwrap(); assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, vec![0, 1, 3, 4], - "id=2 deleted before flush; tombstone must not surface in a flushed-gen scan" + "id=2 deleted before flush; tombstone must not surface in an SSTable scan" ); // The filtered read path (folds NOT _tombstone into the predicate) must // also drop it — this is the exact wallop failure shape (`id < 3`). assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 3")) + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 3")) .await, vec![0, 1], "filtered read after flush must not resurface deleted id=2" @@ -4068,7 +4068,7 @@ mod tests { /// mask the older row by PK. This is the wallop scenario (seed flushed, /// then delete, then flush). #[tokio::test] - async fn test_shard_writer_delete_across_flushed_generations() { + async fn test_shard_writer_delete_across_sstables() { let (store, base_path, base_uri, _temp) = create_local_store().await; let schema = create_pk_test_schema(); let shard_id = Uuid::new_v4(); @@ -4097,12 +4097,12 @@ mod tests { writer.wait_for_flush_drain().await.unwrap(); assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, vec![1, 2, 3, 4], "id=0 tombstoned in a newer gen must mask the older gen's live row" ); assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 1")) + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 1")) .await, Vec::::new(), "filtered read 'id < 1' must not resurface cross-gen deleted id=0" @@ -4111,13 +4111,13 @@ mod tests { writer.close().await.unwrap(); } - /// Same as the cross-generation case, but the flushed generations carry a + /// Same as the cross-generation case, but the SSTables carry a /// BTree index on `id` (as every wallop table does). A filtered read /// `id < 1` resolves through the scalar index; the `NOT _tombstone` residual /// must still be applied or the deleted row leaks. This is the exact wallop /// failure (BTree id + `FilteredRead 'id < 1'` resurfacing deleted id=0). #[tokio::test] - async fn test_shard_writer_delete_across_flushed_generations_indexed() { + async fn test_shard_writer_delete_across_sstables_indexed() { let (store, base_path, base_uri, _temp) = create_local_store().await; let schema = create_pk_test_schema(); let shard_id = Uuid::new_v4(); @@ -4149,12 +4149,12 @@ mod tests { writer.wait_for_flush_drain().await.unwrap(); assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, None).await, vec![1, 2, 3, 4], "indexed cross-gen: full scan must mask deleted id=0" ); assert_eq!( - read_flushed_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 1")) + read_sstable_ids_via_lsm(&writer, schema.clone(), &base_uri, shard_id, Some("id < 1")) .await, Vec::::new(), "indexed filtered read 'id < 1' must not resurface deleted id=0 (wallop repro)" @@ -4528,12 +4528,12 @@ mod tests { } /// End-to-end check that the background flush handler rebuilds secondary - /// indexes on every flushed generation. Before this, the handler flushed - /// via plain `flush`, leaving flushed generations unindexed — point + /// indexes on every SSTable. Before this, the handler flushed + /// via plain `flush`, leaving SSTables unindexed — point /// lookups had to full-scan and vector search's index-only `fast_search` /// couldn't see the data at all. #[tokio::test] - async fn test_flushed_generation_is_indexed() { + async fn test_sstable_is_indexed() { use crate::index::DatasetIndexExt; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; @@ -4577,22 +4577,18 @@ mod tests { writer.force_seal_active().await.unwrap(); writer.wait_for_flush_drain().await.unwrap(); - // Resolve the flushed generation recorded in the manifest. + // Resolve the SSTable recorded in the manifest. let manifest = writer.manifest().await.unwrap().unwrap(); - assert_eq!( - manifest.flushed_generations.len(), - 1, - "expected exactly one flushed generation" - ); + assert_eq!(manifest.sstables.len(), 1, "expected exactly one SSTable"); let gen_uri = format!( "{}/_mem_wal/{}/{}", - base_uri, shard_id, manifest.flushed_generations[0].path + base_uri, shard_id, manifest.sstables[0].path ); - // The flushed generation must carry the BTree index built during flush. + // The SSTable must carry the BTree index built during flush. let dataset = crate::Dataset::open(&gen_uri).await.unwrap(); let indices = dataset.load_indices().await.unwrap(); - assert_eq!(indices.len(), 1, "flushed generation should have one index"); + assert_eq!(indices.len(), 1, "SSTable should have one index"); assert_eq!(indices[0].name, "id_idx"); // A PK filter over it must resolve through the index, not a full scan. @@ -5830,13 +5826,13 @@ mod tests { // than writer B's memtable can. } - // Total rows across the active memtable plus every flushed generation. + // Total rows across the active memtable plus every SSTable. // Distinct ids, so no cross-generation dedup — a plain sum is exact. async fn total_rows(writer: &ShardWriter, base_uri: &str, shard_id: Uuid) -> usize { let mut rows = writer.memtable_stats().await.unwrap().row_count; let manifest = writer.manifest().await.unwrap().unwrap(); - for fg in &manifest.flushed_generations { - let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, fg.path); + for sstable in &manifest.sstables { + let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, sstable.path); let dataset = crate::Dataset::open(&gen_uri).await.unwrap(); rows += dataset.count_rows(None).await.unwrap(); } @@ -5859,11 +5855,11 @@ mod tests { // generation rather than holding it in memory or leaving it in the WAL. let manifest = writer_b.manifest().await.unwrap().unwrap(); assert!( - !manifest.flushed_generations.is_empty(), + !manifest.sstables.is_empty(), "replay must have sealed and flushed at least one full memtable" ); - // Every row survived, split between the flushed generations and the + // Every row survived, split between the SSTables and the // active (partial) memtable. assert_eq!( total_rows(&writer_b, &base_uri, shard_id).await as i32, @@ -6396,13 +6392,13 @@ mod tests { /// Regression for the OSS-WAL compactor-drain bug: after a flush /// records its generation in the manifest and an external compactor - /// later drains `flushed_generations` back to empty (the legitimate + /// later drains `sstables` back to empty (the legitimate /// outcome of merging the generation into the base table), reopening /// the writer must not re-replay the already-flushed WAL entry into /// the active memtable. /// /// Under the pre-fix logic, replay disambiguated "fresh shard" from - /// "flushed-then-compacted" with `flushed_generations.is_empty()`, + /// "flushed-then-compacted" with `sstables.is_empty()`, /// which collapsed both cases into start-at-0. With 1-based WAL /// positions and a default cursor of 0 meaning "no flush stamped", /// the flush-then-drain sequence leaves `replay_after_wal_entry_position` @@ -6416,7 +6412,7 @@ mod tests { let shard_id = Uuid::new_v4(); // Writer A: write 5 rows, close (forces a flush of the active - // memtable). The manifest now records a flushed generation and + // memtable). The manifest now records an SSTable and // pins `replay_after_wal_entry_position` to the covered WAL entry. { let writer_a = ShardWriter::open( @@ -6436,14 +6432,14 @@ mod tests { writer_a.close().await.unwrap(); } - // Simulate an external compactor merging the flushed generation - // into the base table: drain `flushed_generations` to empty via a + // Simulate an external compactor merging the SSTable + // into the base table: drain `sstables` to empty via a // direct manifest commit. The cursor stays where the flush put it. let manifest_store = ShardManifestStore::new(store.clone(), &base_path, shard_id, 2); let pre = manifest_store.read_latest().await.unwrap().unwrap(); assert!( - !pre.flushed_generations.is_empty(), - "writer A's close() should have stamped a flushed generation" + !pre.sstables.is_empty(), + "writer A's close() should have stamped an SSTable" ); let cursor_at_flush = pre.replay_after_wal_entry_position; assert!( @@ -6457,22 +6453,22 @@ mod tests { manifest_store .commit_update(compactor_epoch, |current| ShardManifest { version: current.version + 1, - flushed_generations: vec![], + sstables: vec![], ..current.clone() }) .await .unwrap(); let post = manifest_store.read_latest().await.unwrap().unwrap(); assert!( - post.flushed_generations.is_empty(), - "compactor drain should have left flushed_generations empty" + post.sstables.is_empty(), + "compactor drain should have left sstables empty" ); assert_eq!( post.replay_after_wal_entry_position, cursor_at_flush, "compactor must not touch the replay cursor" ); - // Writer B reopens. Pre-fix: replay saw flushed_generations empty, + // Writer B reopens. Pre-fix: replay saw sstables empty, // restarted at WAL position 0, and re-inserted writer A's rows. // Post-fix: replay starts at cursor + 1, finds no entry, and the // memtable stays empty. @@ -6897,7 +6893,7 @@ mod tests { .manifest() .await .unwrap() - .map(|m| m.flushed_generations.len()) + .map(|m| m.sstables.len()) .unwrap_or(0); writer @@ -6916,7 +6912,7 @@ mod tests { .await .unwrap() .expect("manifest should exist after flush"); - assert_eq!(manifest.flushed_generations.len(), flushed_before + 1); + assert_eq!(manifest.sstables.len(), flushed_before + 1); writer.close().await.unwrap(); } @@ -6954,7 +6950,7 @@ mod tests { .manifest() .await .unwrap() - .map(|m| m.flushed_generations.len()) + .map(|m| m.sstables.len()) .unwrap_or(0); writer.abort().await.unwrap(); @@ -6965,7 +6961,7 @@ mod tests { .manifest() .await .unwrap() - .map(|m| m.flushed_generations.len()) + .map(|m| m.sstables.len()) .unwrap_or(0); assert_eq!( flushed_after, flushed_before, @@ -7013,10 +7009,10 @@ mod tests { let manifest = writer.manifest().await.unwrap().expect("manifest exists"); assert!( manifest - .flushed_generations + .sstables .iter() .any(|g| g.generation == initial_gen), - "flushed generation must be recorded in the manifest" + "SSTable must be recorded in the manifest" ); // Still queryable in memory immediately after commit (within grace). @@ -7024,7 +7020,7 @@ mod tests { assert_eq!(refs.active.generation, initial_gen + 1); assert!( refs.frozen.iter().any(|f| f.generation == initial_gen), - "flushed generation must stay queryable during the grace window" + "SSTable must stay queryable during the grace window" ); // After the grace elapses (plus a sweep tick) the handle is evicted. @@ -7071,10 +7067,10 @@ mod tests { let manifest = writer.manifest().await.unwrap().expect("manifest exists"); assert!( manifest - .flushed_generations + .sstables .iter() .any(|g| g.generation == initial_gen), - "flushed generation must be recorded in the manifest" + "SSTable must be recorded in the manifest" ); // ...and the in-memory handle is already gone, no sweep tick needed. @@ -7524,7 +7520,7 @@ mod shard_writer_tests { // The tombstone-only generation still flushed (data without an HNSW index). let manifest = writer.manifest().await.unwrap().expect("manifest exists"); assert_eq!( - manifest.flushed_generations.len(), + manifest.sstables.len(), 1, "the all-tombstone generation must still flush" ); @@ -7591,22 +7587,22 @@ mod shard_writer_tests { .await .expect("Failed to read manifest") .expect("Manifest should exist"); - assert_eq!(manifest.flushed_generations.len(), 1); + assert_eq!(manifest.sstables.len(), 1); - let flushed = &manifest.flushed_generations[0]; - let gen_uri = format!("{}/_mem_wal/{}/{}", uri, shard_id, flushed.path); - let flushed_dataset = Dataset::open(&gen_uri) + let sstable = &manifest.sstables[0]; + let gen_uri = format!("{}/_mem_wal/{}/{}", uri, shard_id, sstable.path); + let sstable = Dataset::open(&gen_uri) .await - .expect("Failed to open flushed generation"); - let flushed_indices = flushed_dataset.load_indices().await.unwrap(); - assert_eq!(flushed_indices.len(), 1); - assert_eq!(flushed_indices[0].name, "text_fts"); + .expect("Failed to open SSTable"); + let sstable_indices = sstable.load_indices().await.unwrap(); + assert_eq!(sstable_indices.len(), 1); + assert_eq!(sstable_indices[0].name, "text_fts"); assert_eq!( - flushed_indices[0].index_version, 1, + sstable_indices[0].index_version, 1, "maintained v1 FTS index must flush as v1" ); - let results = flushed_dataset + let results = sstable .scan() .full_text_search(FullTextSearchQuery::new("Sample".to_owned())) .unwrap() @@ -8130,7 +8126,7 @@ mod shard_writer_tests { /// 2. File system layout is correct (WAL files, manifest, generation directories) /// 3. WAL entries contain expected data /// 4. Data can be read after each flush cycle - /// 5. Manifest tracks flushed generations correctly + /// 5. Manifest tracks SSTables correctly /// /// Run with: cargo test -p lance shard_writer_tests::test_shard_writer_e2e_correctness -- --nocapture #[tokio::test] @@ -8258,24 +8254,24 @@ mod shard_writer_tests { .expect("Failed to read manifest") .expect("Manifest should exist"); - // Verify flushed generations exist on disk + // Verify SSTables exist on disk assert!( - !manifest.flushed_generations.is_empty(), - "Should have at least one flushed generation" + !manifest.sstables.is_empty(), + "Should have at least one SSTable" ); - for flushed_gen in &manifest.flushed_generations { + for sstable in &manifest.sstables { // The path stored in manifest is relative to the shard directory // Construct full path: temp_dir/_mem_wal/shard_id/generation_folder let gen_path = temp_dir .path() .join("_mem_wal") .join(shard_id.to_string()) - .join(&flushed_gen.path); + .join(&sstable.path); // The generation directory should exist assert!( gen_path.exists(), - "Flushed generation directory should exist at {:?}", + "SSTable directory should exist at {:?}", gen_path ); @@ -8396,11 +8392,11 @@ mod shard_writer_tests { .expect("flush must not be redirected at the base table"); let manifest = writer.manifest().await.unwrap().expect("manifest exists"); - assert_eq!(manifest.flushed_generations.len(), 1); - let flushed = manifest.flushed_generations[0].clone(); + assert_eq!(manifest.sstables.len(), 1); + let sstable = manifest.sstables[0].clone(); // The generation landed under `_mem_wal/`, and the base table is untouched. - let gen_uri = format!("{}/_mem_wal/{}/{}", uri, shard_id, flushed.path); + let gen_uri = format!("{}/_mem_wal/{}/{}", uri, shard_id, sstable.path); let generation = Dataset::open(&gen_uri) .await .expect("generation must exist at its own path"); @@ -8416,7 +8412,7 @@ mod shard_writer_tests { // Opening the base instead would dedup back down to 16 rows. let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(manifest.current_generation) - .with_flushed_generation(flushed.generation, flushed.path.clone()); + .with_sstable(sstable.generation, sstable.path.clone()); let scanner = LsmScanner::new(Arc::new(dataset), vec![snapshot], vec!["id".to_string()]); let rows: usize = scanner .try_into_stream() @@ -8489,8 +8485,8 @@ mod shard_writer_tests { writer.wait_for_flush_drain().await.expect("flush failed"); let manifest = writer.manifest().await.unwrap().expect("manifest exists"); - assert_eq!(manifest.flushed_generations.len(), 1); - let flushed = manifest.flushed_generations[0].clone(); + assert_eq!(manifest.sstables.len(), 1); + let sstable = manifest.sstables[0].clone(); // The generation's own Lance manifest is the signal to key on. Keying on // the generation folder alone would pass vacuously: sidecars like @@ -8501,7 +8497,7 @@ mod shard_writer_tests { // `{gen}/data/` never reaches a wrapper under `file://`. The manifest // goes through `put_opts`, and only the flusher's `Dataset::write` / // `open_generation` writes it — both of which must carry the params. - let gen_manifest = format!("{}/_versions", flushed.path); + let gen_manifest = format!("{}/_versions", sstable.path); assert!( controls.wrote_under(&gen_manifest), @@ -8512,7 +8508,7 @@ mod shard_writer_tests { // And the read path must resolve the generation through them too. let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(manifest.current_generation) - .with_flushed_generation(flushed.generation, flushed.path.clone()); + .with_sstable(sstable.generation, sstable.path.clone()); let scanner = LsmScanner::new(Arc::new(dataset), vec![snapshot], vec!["id".to_string()]); let rows: usize = scanner .try_into_stream() @@ -8532,7 +8528,7 @@ mod shard_writer_tests { // fragments are read through it (reads have no local bypass), as is the // generation's standalone PK index. assert!( - controls.read_under(&format!("{}/data/", flushed.path)), + controls.read_under(&format!("{}/data/", sstable.path)), "the scan must read the generation through the base's store params" ); @@ -8581,10 +8577,10 @@ mod shard_writer_tests { writer.wait_for_flush_drain().await.expect("flush failed"); let manifest = writer.manifest().await.unwrap().expect("manifest exists"); - let flushed = manifest.flushed_generations[0].clone(); + let sstable = manifest.sstables[0].clone(); let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(manifest.current_generation) - .with_flushed_generation(flushed.generation, flushed.path.clone()); + .with_sstable(sstable.generation, sstable.path.clone()); // What `DatasetBuilder::with_object_store` leaves on an opened dataset: // a store pinned at the base's own path.