diff --git a/docs/src/format/table/.pages b/docs/src/format/table/.pages index 16c20058608..5b0cb0e95e6 100644 --- a/docs/src/format/table/.pages +++ b/docs/src/format/table/.pages @@ -6,4 +6,5 @@ nav: - Layout: layout.md - Branch & Tag: branch_tag.md - Row ID & Lineage: row_id_lineage.md + - Data Overlay Files: data_overlay_file.md - MemTable & WAL: mem_wal.md diff --git a/docs/src/format/table/data_overlay_file.md b/docs/src/format/table/data_overlay_file.md new file mode 100644 index 00000000000..5540f860018 --- /dev/null +++ b/docs/src/format/table/data_overlay_file.md @@ -0,0 +1,390 @@ +# Data Overlay Files + +!!! note "Overlay files require feature flag 64 (data overlay files)" + + A reader or writer that does not understand overlay files must refuse a + dataset that uses them. Silently ignoring an overlay would return stale base + values, which is a correctness bug rather than a degraded experience. + +Overlay files supply new values for a subset of `(row offset, field)` cells +within a fragment **without rewriting the fragment's base data files**. They make +updates cheap when only a small fraction of rows and/or columns change: instead +of rewriting whole columns or moving rows to a new fragment, a writer appends a +small file carrying just the changed cells. + +This is Lance's third mechanism for changing data in place, alongside +[deletion files](index.md#deletion-files) (which remove rows) and +[data evolution](index.md#data-evolution) (which adds or rewrites whole columns). +An overlay changes individual cells. + +## Concepts + +### Coverage and resolution + +Each overlay declares which cells it provides through a **coverage** bitmap (or, +for sparse overlays, one bitmap per field). The bitmaps index **physical row +offsets** — positions in the base data files, counting deleted rows — so they are +stable across deletions, exactly like deletion vectors. + +To resolve a cell `(offset, field)` on read, walk the fragment's overlays from +**newest to oldest**. The first overlay that covers `(offset, field)` wins; its +value is used. If no overlay covers the cell, the value falls through to the base +data file (or is `NULL` if no base data file holds that field). + +Precedence among overlays is determined by: + +1. `committed_version` — higher wins (see [Versioning](#versioning-and-ordering)). +2. Position in `DataFragment.overlays` as a tiebreaker — a later entry is newer. + +A covered offset whose value is `NULL` overrides the cell **to** `NULL`. This is +distinct from an offset that is simply absent from the bitmap, which falls +through to the base. Coverage, not value-nullness, decides whether an overlay +applies. + +### Interaction with deletions + +Deletions take precedence over overlays. If a row offset is marked deleted in the +fragment's deletion file, any overlay value for that offset is dead and is +ignored, regardless of commit order. This keeps the invariant simple: a deletion +is the final word on a row, so a concurrent overlay against a row that was +deleted needs no special conflict handling — its values are merely inert. + +### Physical layout + +An overlay's data file stores **one value column per field**, in the order of +`data_file.fields`. It does **not** store a row-offset key column. The position of +a covered offset's value within its column is the **rank** of that offset in the +field's coverage bitmap — the number of set bits below it. For a Roaring bitmap +this is an O(1) operation, so random access to any cell is a rank computation +followed by a single value fetch, with no offset column to read and no binary +search. + +Because different fields may cover different offset sets, the value columns of a +single sparse overlay may have **different lengths**. The Lance file format +permits columns of differing item counts within one file, so a sparse overlay is +representable as a single file. (See [Writer support](#writer-support) for the +current implementation status.) + +### Dense vs. sparse overlays + +A single overlay is one of two shapes: + +- **Dense (rectangular).** One `shared_offset_bitmap` applies to every field. Every + covered offset has a value for every field. This is the common case for a plain + `UPDATE`, where one `SET` list is applied to one set of rows. +- **Sparse.** A `FieldCoverage` carries one bitmap per field, used when different + fields cover different offset sets — for example a `MERGE` with multiple + `WHEN MATCHED` branches, where different rows update different columns. A dense + overlay would have to widen to the bounding rectangle and fill the untouched + cells with their current values (post-images), which for wide columns such as + embeddings means re-storing data that did not change. A sparse overlay stores + exactly the changed cells. + +A writer may always express a non-rectangular update as **multiple dense overlays +in one transaction** (one per coverage group) instead of a single sparse overlay. + +## Protobuf + +
+DataOverlayFile protobuf message + +```protobuf +%%% proto.message.DataOverlayFile %%% +``` + +
+ +
+FieldCoverage protobuf message + +```protobuf +%%% proto.message.FieldCoverage %%% +``` + +
+ +## Versioning and ordering + +Overlays reuse the dataset version as their ordering clock rather than +introducing a separate generation counter. + +`committed_version` is the dataset version at which an overlay **became +effective** — the version of the commit that introduced it, **not** the version +it was read from. It is stamped at commit time and re-stamped if the commit is +retried, in the same way as the created-at / last-updated-at version sequences. + +This single value drives every ordering decision: + +- **Overlay vs. overlay** (read precedence): higher `committed_version` wins. +- **Overlay vs. index** (query correctness): an index records the + `dataset_version` it was built from. An index whose `dataset_version >= + committed_version` already incorporates the overlay. An overlay whose + `committed_version > index.dataset_version` is newer than the index and its + cells must be excluded from index results and re-evaluated. +- **Scheduler signal**: the gap between an overlay's `committed_version` and an + index's `dataset_version`, or between an overlay and the base, is a staleness + measure the compaction scheduler can use. + +!!! note "Why effective version, not read version" + + Suppose an overlay reads version 5 and commits at version 6, while an index + is built reading version 5 (before the overlay) and commits at version 7 with + `dataset_version = 5`. If the overlay stored its *read* version (5), the test + `5 > 5` is false, the row would not be excluded, and the index — which never + saw the overlay — would return a stale result. Storing the *effective* + version (6) makes `6 > 5` true, the cell is excluded and re-evaluated, and the + result is correct. + +## Index integration + +Building an index over a fragment that has overlays does **not** require dropping +the fragment from the index's coverage. The fragment stays indexed, and the query +path reconciles overlays at query time using an **exclusion set**. + +The exclusion set for an index on field `F` is the union of the coverage bitmaps, +restricted to field `F`, of every overlay whose `committed_version > +index.dataset_version`. The exclusion is **field-aware**: an overlay that touches +only unrelated columns does not exclude anything from the index on `F`. + +The query then proceeds as: + +1. Run the index search as usual, producing candidate rows. +2. Remove any candidate in the exclusion set. (Its indexed value may be stale.) +3. **Re-evaluate** the excluded rows against their current values — the same flat + path already used for the unindexed tail of fragments. For a scalar predicate + this re-applies the filter; for a vector query it re-scores the row's current + vector. Rows that still match are added back to the result. + +Step 3 is what makes exclusion correct rather than merely safe: removing a row +from index candidates without re-evaluating it would silently drop a row that +should match under its new value. + +### Correctness invariant + +> For every indexed field `F` and every row offset `o` in a fragment the index +> covers, the index's entry for `(o, F)` is trusted unless `o` is excluded. +> `o` is excluded iff some overlay with `committed_version > index.dataset_version` +> covers `(o, F)`. + +The write and compaction paths together preserve this: + +- **Writes** change a cell only by adding an overlay, and that overlay's + `committed_version` exceeds the version of any pre-existing index — so the + change is always covered by an exclusion. +- **Compaction** may remove an overlay only if the index no longer relies on it + (see below). + +## Compaction + +Overlays accumulate read cost — every overlay is a bitmap to test and a possible +file to open. Compaction bounds that cost in two modes: + +- **Overlay → overlay.** Merge several overlays into fewer, computing the + post-image per `(offset, field)` by walking the merged overlays newest-first. + The merged overlay takes the **maximum** `committed_version` of its inputs, so + the exclusion semantics are preserved. Indexes are unaffected. This is cheap and + does not touch the base. +- **Overlay → base.** Fold overlays into a fresh base data file, computing the + post-image for every covered cell, then clear the overlays. The base is + complete, so every post-image is well defined. Overlay offsets are physical, so + they cannot survive a rewrite that reorders rows; folding therefore materializes + values rather than carrying overlays forward. + +!!! warning "Folding an indexed field must update its index" + + An overlay→base fold removes the overlay, which removes the exclusion signal + that kept an index correct. Folding an overlay that covers an indexed field + `F` is therefore equivalent to a column rewrite of `F` and must, in the same + commit, either rebuild the index to a `dataset_version` at least the folded + overlay's `committed_version`, or remove the fragment from the index's + coverage so the rows fall to the flat path. Otherwise the index would serve + stale values with no overlay to exclude them. This is the same rule that + already governs rewriting a column that an index is built on. + +When a fragment with overlays is compacted by a row-rewriting operation +(`RewriteRows`, which produces new fragments with new row addresses), the +overlays are folded into the new base as part of the rewrite, and existing +[fragment-reuse remapping](row_id_lineage.md) handles the row-address changes as +it does today. + +## Row lineage + +An overlay write updates the `last_updated_at_version` of every covered row, so +change-data-feed and time-travel queries observe the update. Because overlays are +addressed by physical offset, they do **not** require stable row IDs to be +enabled; lineage updates apply only when those features are on. + +## Worked example + +A table `users` with stable row IDs enabled and these fields: + +| field id | name | type | +|----------|-----------|-------------------------| +| 1 | id | `int32` (primary key) | +| 2 | name | `utf8` | +| 3 | age | `int32` | +| 4 | embedding | `fixed_size_list`| + +Created at version 1 as a single fragment `0` with one base data file +`data/file0.lance` holding all four columns. `physical_rows = 4`: + +| offset | id | name | age | embedding | +|--------|----|-------|-----|------------------| +| 0 | 1 | Alice | 30 | … | +| 1 | 2 | Bob | 25 | … | +| 2 | 3 | Carol | 40 | … | +| 3 | 4 | Dave | 22 | … | + +A BTree scalar index on `age` is built at version 1, covering fragment `0` +(`dataset_version = 1`). + +### Step 1 — write an overlay + +```sql +UPDATE users SET age = 26 WHERE id = 2; -- Bob, offset 1 +``` + +This touches one field (`age`) for one row, so the writer emits a dense overlay +and commits it as version 2. Fragment `0` gains: + +```text +DataOverlayFile { + data_file: { path: "data/overlay-.lance", fields: [3], column_indices: [0] } + coverage: shared_offset_bitmap = {1} + committed_version: 2 +} +``` + +The overlay file stores a single `age` column with one value, `[26]`, at +rank `{1}.rank(1) = 0`. `last_updated_at_version[1]` is set to 2. + +### Step 2 — read + +`SELECT id, age FROM users` reads base ages `[30, 25, 40, 22]`. For `age` +(field 3), the overlay covers offset 1, so `age[1]` is replaced with the overlay +value at position `{1}.rank(1) = 0` → `26`. Result ages: `[30, 26, 40, 22]`. + +### Step 3 — index query + +```sql +SELECT * FROM users WHERE age = 26; +``` + +The `age` index was built at `dataset_version = 1`; the overlay's +`committed_version` is 2. Since `2 > 1`, the overlay's coverage for `age`, `{1}`, +is the exclusion set for this query. + +- The index (built at v1) holds Bob's *old* `age = 25`, so a lookup for `26` + returns nothing from the index. +- Offset 1 is in the exclusion set, so it is re-evaluated on the flat path. Its + current `age` (26, via the overlay) matches `age = 26`, so Bob is returned. + +The mirror case `WHERE age = 25` shows exclusion preventing a stale hit: the index +returns offset 1 (stale `25`), but offset 1 is excluded, re-evaluated to `26`, and +correctly dropped. + +### Step 4 — a second, non-rectangular write + +```sql +MERGE INTO users USING staged ON users.id = staged.id +WHEN MATCHED AND staged.kind = 'rename' THEN UPDATE SET name = staged.name -- Carol(2), Dave(3) +WHEN MATCHED AND staged.kind = 'revec' THEN UPDATE SET embedding = staged.embedding -- Bob(1) +``` + +`name` is updated for offsets `{2, 3}` and `embedding` for offset `{1}` — different +fields over different rows. This is a sparse overlay, committed as version 3: + +```text +DataOverlayFile { + data_file: { path: "data/overlay-.lance", fields: [2, 4], column_indices: [0, 1] } + coverage: field_coverage { offset_bitmaps: [ {2,3}, {1} ] } + // name (field 2) ^ ^ embedding (field 4) + committed_version: 3 +} +``` + +The file's `name` column has **two** values (`["Caroline", "David"]`, at +ranks 0 and 1 of `{2,3}`) and its `embedding` column has **one** value (at rank 0 +of `{1}`) — columns of different lengths in one file. + +### Step 5 — read after the second write + +`SELECT name, age, embedding FROM users` resolves each field independently, +newest overlay first: + +- `name`: the v3 overlay covers `{2,3}` → `["Alice", "Bob", "Caroline", "David"]`. +- `age`: the v3 overlay does not cover `age`; the v2 overlay still applies at + offset 1 → `[30, 26, 40, 22]`. +- `embedding`: the v3 overlay covers `{1}` → Bob's vector is the new one, others + from base. + +Overlays from different versions coexist and apply per field. + +### Step 6 — compaction (overlay → base) + +The scheduler folds both overlays into fragment `0` at version 4, computing +post-images for `age`, `name`, and `embedding`, and writing a new base data file +`data/file1.lance` with those columns. In the old file, fields 2, 3, and 4 are +tombstoned (`-2`); field 1 (`id`) remains. The fragment's `overlays` list is +cleared. Row addresses are preserved (a column rewrite, not a row rewrite), so +stable row IDs and the deletion vector are untouched. + +Because the fold removed the overlay that was excluding offset 1 from the `age` +index, the same commit must reconcile that index: either rebuild it at +`dataset_version >= 2`, or drop fragment `0` from its coverage so `age` queries +fall to the flat path. After a rebuild at version 4, no overlay remains and the +`age` index directly returns `26` for Bob with no exclusion needed. + +## Guidance + +!!! note "This section is a stub." + + The following are implementation considerations, not part of the on-disk + specification. + +### When to overlay vs. rewrite a column vs. move rows + +*(To be expanded.)* The choice between appending an overlay, rewriting a full +column (data evolution), and moving updated rows to a new fragment depends on the +fraction of rows changed, the fraction of columns changed, column width, the +presence of indexes on the changed columns, and the accumulated overlay read +cost. Roughly: few rows changed favors overlays; most rows in a few columns +favors a column rewrite; most columns changed favors moving rows to a new +fragment. + +### Writer support + +*(To be expanded.)* Dense (rectangular) overlays write with the existing +equal-length file writer today. Sparse overlays stored as a **single** file +require the writer to emit columns of independent lengths, which the current v2 +writer does not yet do (it advances all columns from one global row counter). +Until that support lands, a writer can express a sparse update as multiple dense +overlays in one transaction. + +### Scheduling compaction + +*(To be expanded.)* The overlay→overlay and overlay→base modes have very +different costs; a cost/benefit scheduler decides when each is worthwhile, using +the version gap as a staleness signal. + +### Open questions + +*(To be resolved.)* + +- **Per-fragment vs. per-table overlays.** Overlays are attached per fragment. + Should there be a table-level overlay concept, and how would it interact with + fragment-level row addressing? +- **Relationship to LSM.** Overlays plus compaction resemble an LSM tree (newest + layer wins, periodic merge). How far should that analogy be taken, and what do + we deliberately do differently given Lance's random-access requirements? +- **Coverage bitmap spill.** Coverage bitmaps live inline in the manifest. Very + large coverage (an overlay touching many rows) may warrant external spill, as + the row-ID and last-updated-at sequences already do above a size threshold. + +## Related specifications + +- [Table format overview](index.md) +- [Transactions](transaction.md) +- [Row ID & Lineage](row_id_lineage.md) +- [Index Formats](../index/index.md) +- [Format Versioning](versioning.md) diff --git a/docs/src/format/table/index.md b/docs/src/format/table/index.md index 94ea4b90dc9..ce4d0b26613 100644 --- a/docs/src/format/table/index.md +++ b/docs/src/format/table/index.md @@ -168,6 +168,35 @@ However, this invalidates row addresses and requires rebuilding indices, which c +## Data Overlay Files + +!!! note "Overlay files require feature flag 64 (data overlay files)" + +Overlay files supply new values for a subset of `(row offset, field)` cells within +a fragment without rewriting the base data files. They make updates cheap when only +a small percentage of rows and/or columns change: a writer appends a small file +carrying just the changed cells instead of rewriting whole columns or moving rows +to a new fragment. + +On read, each cell is resolved by consulting the fragment's overlays from newest to +oldest; the first overlay covering that `(offset, field)` wins, otherwise the value +falls through to the base data file. Indices keep covering the fragment and reconcile +overlays at query time through a field-aware exclusion set. + +For the full specification — coverage and resolution rules, dense vs. sparse layout, +versioning, index integration, compaction, and a worked example — see the +[Data Overlay Files Specification](data_overlay_file.md). + +
+DataOverlayFile protobuf message + +```protobuf +%%% proto.message.DataOverlayFile %%% +``` + +
+ + ## Related Specifications ### Storage Layout diff --git a/protos/table.proto b/protos/table.proto index d298809d5d8..cc8b477a6a6 100644 --- a/protos/table.proto +++ b/protos/table.proto @@ -113,6 +113,11 @@ message Manifest { // * 2: row ids are stable and stored as part of the fragment metadata. // * 4: use v2 format (deprecated) // * 8: table config is present + // * 16: data files use multiple base paths (shallow clone / multi-base) + // * 32: the transaction file under _transactions is not written (inline only) + // * 64: data overlay files are present (see DataOverlayFile). Readers that do + // not understand overlays must refuse the dataset, since ignoring an overlay + // would silently return stale base values. uint64 reader_feature_flags = 9; // Feature flags for writers. @@ -311,6 +316,15 @@ message DataFragment { repeated DataFile files = 2; + // Optional overlay files for this fragment, which supply new values for a + // subset of cells without rewriting the base data files. This MUST be empty + // if the data overlay files feature flag (64) is not set in the manifest. + // + // Order is significant: a later entry is newer than an earlier one. When two + // overlays cover the same (offset, field) and share a `committed_version`, the + // later entry wins. See DataOverlayFile for the full resolution rules. + repeated DataOverlayFile overlays = 11; + // File that indicates which rows, if any, should be considered deleted. DeletionFile deletion_file = 3; @@ -433,6 +447,66 @@ message DataFile { optional uint32 base_id = 7; } // DataFile +// An overlay file supplies new values for a subset of (row offset, field) cells +// within a fragment, without rewriting the fragment's base data files. It is +// used for efficient updates when only a small fraction of rows and/or columns +// change. +// +// On read, a cell is resolved by consulting the fragment's overlays from newest +// to oldest: the first overlay that covers that (offset, field) wins; if none +// cover it, the value falls through to the base data file. Because deletions +// take precedence over overlays, an overlay value for an offset that is also +// marked deleted is dead and is ignored. +// +// The overlay's data file does NOT store a row-offset key column. Within a value +// column, the position of a covered offset's value is the rank (0-based count of +// set bits below it) of that offset within the field's coverage bitmap. Because +// fields may cover different offset sets, the value columns of a single overlay +// data file may have different lengths (which the Lance file format permits). +message DataOverlayFile { + // The data file storing the overlay's new cell values, one value column per + // field in `data_file.fields`. No row-offset key column is stored. + DataFile data_file = 1; + + // Which (offset, field) cells this overlay provides values for. + oneof coverage { + // A single 32-bit Roaring bitmap of physical row offsets that applies to + // every field in `data_file.fields` (a "dense" / rectangular overlay). + // Every covered offset has a value for every field. This is the common case + // for a plain UPDATE, where one SET list is applied to one set of rows. + bytes shared_offset_bitmap = 2; + // Per-field coverage for a "sparse" overlay, used when different fields cover + // different offset sets (e.g. a MERGE with multiple WHEN MATCHED branches). + FieldCoverage field_coverage = 4; + } + + // The dataset version at which this overlay became effective: the version of + // the commit that introduced it, NOT the version it was read from. It is + // stamped at commit time and re-stamped if the commit is retried, in the same + // way as the created-at / last-updated-at version sequences. + // + // This drives two orderings: + // * Versus index builds: an index whose `dataset_version` >= this value + // already incorporates this overlay. Otherwise the overlay's covered cells + // are excluded from index results for the affected fields and re-evaluated + // against their current values (see the Data Overlay Files specification). + // * Versus other overlays: when two overlays cover the same (offset, field), + // the one with the higher `committed_version` wins. Overlays that share a + // `committed_version` are ordered by their position in + // `DataFragment.overlays`, where a later entry is newer and wins. + uint64 committed_version = 3; +} + +// Per-field coverage for a sparse overlay. +message FieldCoverage { + // One entry per field in the overlay's `data_file.fields`, in the same order. + // Each is a 32-bit Roaring bitmap of the physical row offsets covered for that + // field. An offset present in a field's bitmap but mapped to a NULL value + // means the cell is overridden to NULL (distinct from an offset that is absent, + // which falls through to the base data file). + repeated bytes offset_bitmaps = 1; +} + // Deletion File // // The path of the deletion file is constructed as: diff --git a/protos/transaction.proto b/protos/transaction.proto index e72e95025a4..bfc0eee354b 100644 --- a/protos/transaction.proto +++ b/protos/transaction.proto @@ -315,6 +315,44 @@ message Transaction { repeated DataReplacementGroup replacements = 1; } + // Overlay files to append to a single fragment, in order (the last entry is + // newest). The overlays are appended to the fragment's existing `overlays` + // list; they do not replace it, so overlays written by concurrent commits are + // preserved. + message DataOverlayGroup { + uint64 fragment_id = 1; + // Each DataOverlayFile.committed_version is left 0 by the writer and stamped + // to the new dataset version at commit time (re-stamped on retry), in the + // same way as the created-at / last-updated-at version sequences. The fields + // touched are read from each overlay's `data_file.fields`. + repeated DataOverlayFile overlays = 2; + } + + // Attach overlay files to fragments, supplying new values for a subset of + // (row offset, field) cells without rewriting the fragments' base data files. + // See the DataOverlayFile message in table.proto and the Data Overlay Files + // specification for resolution, coverage, and versioning rules. + // + // Conflict semantics (intentionally permissive, like DataReplacement). Against + // a concurrent operation that touches one of the same fragments: + // * Another DataOverlay (any fields): COMPATIBLE. Overlays stack; when two + // overlays cover the same (offset, field) the one with the higher + // `committed_version` wins, so independent backfills never conflict. + // * Append / new fragments: COMPATIBLE. + // * Delete: COMPATIBLE. A deletion takes precedence over an overlay, so an + // overlay value for a deleted offset is inert (no special handling needed). + // * DataReplacement or column-rewrite (Update with REWRITE_COLUMNS) of the + // same field: COMPATIBLE. Both preserve physical row addresses, so overlay + // offsets stay valid; the overlay is newer and wins its covered cells, and + // the version gate excludes those cells from any rebuilt index. + // * Row-rewrite, compaction, or an overlay->base fold of the fragment: + // CONFLICT. These change physical row addresses or consume the overlays, so + // the overlay's offsets are no longer valid. The writer must re-read the new + // fragment, recompute, and retry. + message DataOverlay { + repeated DataOverlayGroup groups = 1; + } + // Update the merged generations in MemWAL index. // This operation is used during merge-insert to atomically record which // generations have been merged to the base table. @@ -346,6 +384,7 @@ message Transaction { UpdateMemWalState update_mem_wal_state = 112; Clone clone = 113; UpdateBases update_bases = 114; + DataOverlay data_overlay = 115; } // Fields 200/202 (`blob_append` / `blob_overwrite`) previously represented blob dataset ops. diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index c454f73819e..98135b60f7e 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -491,6 +491,87 @@ impl FileReader { self.num_rows } + /// The number of rows stored in a single physical column. + /// + /// For ordinary (rectangular) files every column has the same length, equal + /// to [`num_rows`](Self::num_rows). Files written with + /// [`FileWriter::write_column`](crate::writer::FileWriter::write_column) + /// may have columns of differing lengths; this returns the length of one + /// such column, derived by summing its pages' row counts. Returns `None` if + /// `column_index` is out of bounds. + pub fn column_num_rows(&self, column_index: usize) -> Option { + self.metadata + .column_metadatas + .get(column_index) + .map(|col| col.pages.iter().map(|page| page.length).sum()) + } + + // Number of physical columns a field (and its descendants) contributes to a + // projection, mirroring `ReaderProjection::from_field_ids_helper`. Used to + // partition a projection's flat `column_indices` by top-level field. + fn count_projected_columns(field: &Field, is_structural: bool) -> usize { + let contributes = !is_structural + || field.children.is_empty() + || field.is_blob() + || field.is_packed_struct(); + let recurse = !is_structural || (!field.is_blob() && !field.is_packed_struct()); + let mut count = contributes as usize; + if recurse { + for child in &field.children { + count += Self::count_projected_columns(child, is_structural); + } + } + count + } + + // The top-level row count of each projected top-level field, in projection + // order. A field's length is the page-row sum of its root (first) physical + // column, which equals the field's top-level row count for primitive, + // struct, and list fields in both v2.0 and v2.1. Ordinary files have equal + // lengths across columns; files written with `write_column` may not. + fn projected_field_lengths(&self, projection: &ReaderProjection) -> Vec { + let is_structural = self.metadata.version() >= LanceFileVersion::V2_1; + let mut lengths = Vec::with_capacity(projection.schema.fields.len()); + let mut cursor = 0usize; + for field in &projection.schema.fields { + let n = Self::count_projected_columns(field, is_structural); + if n == 0 { + continue; + } + let Some(&root_column) = projection.column_indices.get(cursor) else { + break; + }; + lengths.push(self.column_num_rows(root_column as usize).unwrap_or(0)); + cursor += n; + } + lengths + } + + // The reader combines a projection's columns into rectangular batches, so + // they must all have the same length. Returns that common length, or a + // descriptive error (naming the per-column lengths) when they differ. + // Ordinary files always pass; only files written with `write_column` whose + // columns ended up unequal can fail, and those must be read separately. + fn verify_uniform_lengths(projection: &ReaderProjection, field_lengths: &[u64]) -> Result { + let first = field_lengths.first().copied().unwrap_or(0); + if field_lengths.iter().all(|&len| len == first) { + return Ok(first); + } + let columns = projection + .schema + .fields + .iter() + .map(|f| f.name.as_str()) + .zip(field_lengths.iter().copied()) + .map(|(name, len)| format!("{name}={len}")) + .collect::>() + .join(", "); + Err(Error::invalid_input(format!( + "cannot read columns of differing lengths together ({columns}); \ + read each column (or equal-length group) separately" + ))) + } + pub fn metadata(&self) -> &Arc { &self.metadata } @@ -1221,11 +1302,18 @@ impl FileReader { ) -> Result + Send>>> { let projection = projection.unwrap_or_else(|| self.base_projection.clone()); Self::validate_projection(&projection, &self.metadata)?; + // All projected columns must share a length: the reader combines them + // into rectangular batches. Ordinary files satisfy this (every column + // has `self.num_rows` rows); files written with `FileWriter::write_column` + // may not, and such columns must be read separately. `read_len` is that + // common length, which `RangeFull`/`RangeFrom` resolve against (rather + // than `self.num_rows`, the file's longest column). + let field_lengths = self.projected_field_lengths(&projection); + let read_len = Self::verify_uniform_lengths(&projection, &field_lengths)?; let verify_bound = |params: &ReadBatchParams, bound: u64, inclusive: bool| { - if bound > self.num_rows || bound == self.num_rows && inclusive { + if bound > read_len || (bound == read_len && inclusive) { Err(Error::invalid_input(format!( - "cannot read {:?} from file with {} rows", - params, self.num_rows + "cannot read {params:?} from columns with {read_len} rows" ))) } else { Ok(()) @@ -1267,13 +1355,8 @@ impl FileReader { } ReadBatchParams::RangeFrom(range) => { verify_bound(¶ms, range.start as u64, true)?; - self.read_range( - range.start as u64..self.num_rows, - batch_size, - projection, - filter, - ) - .await + self.read_range(range.start as u64..read_len, batch_size, projection, filter) + .await } ReadBatchParams::RangeTo(range) => { verify_bound(¶ms, range.end as u64, false)?; @@ -1281,7 +1364,7 @@ impl FileReader { .await } ReadBatchParams::RangeFull => { - self.read_range(0..self.num_rows, batch_size, projection, filter) + self.read_range(0..read_len, batch_size, projection, filter) .await } } @@ -1476,11 +1559,18 @@ impl FileReader { ) -> Result> { let projection = projection.unwrap_or_else(|| self.base_projection.clone()); Self::validate_projection(&projection, &self.metadata)?; + // All projected columns must share a length: the reader combines them + // into rectangular batches. Ordinary files satisfy this (every column + // has `self.num_rows` rows); files written with `FileWriter::write_column` + // may not, and such columns must be read separately. `read_len` is that + // common length, which `RangeFull`/`RangeFrom` resolve against (rather + // than `self.num_rows`, the file's longest column). + let field_lengths = self.projected_field_lengths(&projection); + let read_len = Self::verify_uniform_lengths(&projection, &field_lengths)?; let verify_bound = |params: &ReadBatchParams, bound: u64, inclusive: bool| { - if bound > self.num_rows || bound == self.num_rows && inclusive { + if bound > read_len || (bound == read_len && inclusive) { Err(Error::invalid_input(format!( - "cannot read {:?} from file with {} rows", - params, self.num_rows + "cannot read {params:?} from columns with {read_len} rows" ))) } else { Ok(()) @@ -1521,7 +1611,7 @@ impl FileReader { ReadBatchParams::RangeFrom(range) => { verify_bound(¶ms, range.start as u64, true)?; self.read_range_blocking( - range.start as u64..self.num_rows, + range.start as u64..read_len, batch_size, projection, filter, @@ -1532,7 +1622,7 @@ impl FileReader { self.read_range_blocking(0..range.end as u64, batch_size, projection, filter) } ReadBatchParams::RangeFull => { - self.read_range_blocking(0..self.num_rows, batch_size, projection, filter) + self.read_range_blocking(0..read_len, batch_size, projection, filter) } } } diff --git a/rust/lance-file/src/writer.rs b/rust/lance-file/src/writer.rs index 12bd50df6fe..96d282e978b 100644 --- a/rust/lance-file/src/writer.rs +++ b/rust/lance-file/src/writer.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use arrow_array::RecordBatch; +use arrow_array::{ArrayRef, RecordBatch}; use arrow_data::ArrayData; use bytes::{Buf, BufMut, Bytes, BytesMut}; @@ -221,6 +221,11 @@ pub struct FileWriter { field_id_to_column_indices: Vec<(u32, u32)>, num_columns: u32, rows_written: u64, + // The number of rows written for each top-level field (i.e. each entry in + // `column_writers`). With `write_batch` every field advances together and + // these are all equal, but `write_column` advances one field at a time, so + // a single file may end up with columns of differing item counts. + field_rows_written: Vec, global_buffers: Vec<(u64, u64)>, schema_metadata: HashMap, options: FileWriterOptions, @@ -277,6 +282,7 @@ impl FileWriter { column_metadata: Vec::new(), num_columns: 0, rows_written: 0, + field_rows_written: Vec::new(), field_id_to_column_indices: Vec::new(), global_buffers: Vec::new(), schema_metadata: HashMap::new(), @@ -467,6 +473,7 @@ impl FileWriter { BatchEncoder::try_new(&schema, encoding_strategy.as_ref(), &encoding_options)?; self.num_columns = encoder.num_columns(); + self.field_rows_written = vec![0; encoder.field_encoders.len()]; self.column_writers = encoder.field_encoders; self.column_metadata = vec![initial_column_metadata(); self.num_columns as usize]; self.field_id_to_column_indices = encoder.field_id_to_column_index; @@ -490,13 +497,14 @@ impl FileWriter { batch: &RecordBatch, external_buffers: &mut OutOfLineBuffers, ) -> Result>> { - self.schema + let items = self + .schema .as_ref() .unwrap() .fields .iter() - .zip(self.column_writers.iter_mut()) - .map(|(field, column_writer)| { + .enumerate() + .map(|(field_idx, field)| { let array = batch .column_by_name(&field.name) @@ -507,19 +515,53 @@ impl FileWriter { ) .into(), ))?; + Ok((field_idx, array.clone())) + }) + .collect::>>()?; + self.encode_columns(&items, external_buffers) + } + + // Encode a set of `(field index, array)` pairs, each advancing only its own + // column. The returned tasks must be written before the per-field row + // counters are advanced (see `advance_columns`). + fn encode_columns( + &mut self, + items: &[(usize, ArrayRef)], + external_buffers: &mut OutOfLineBuffers, + ) -> Result>> { + // Snapshot the starting row number of each field before borrowing the + // column writers mutably below. + let row_numbers = items + .iter() + .map(|(field_idx, _)| self.field_rows_written[*field_idx]) + .collect::>(); + items + .iter() + .zip(row_numbers) + .map(|((field_idx, array), row_number)| { let repdef = RepDefBuilder::default(); let num_rows = array.len() as u64; - column_writer.maybe_encode( + self.column_writers[*field_idx].maybe_encode( array.clone(), external_buffers, repdef, - self.rows_written, + row_number, num_rows, ) }) .collect::>>() } + // Advance the per-field row counters after a set of columns has been + // written, keeping `rows_written` (the file's logical length) in sync as the + // maximum column length. + fn advance_columns(&mut self, items: &[(usize, ArrayRef)]) { + for (field_idx, array) in items { + self.field_rows_written[*field_idx] += array.len() as u64; + } + self.rows_written = self.field_rows_written.iter().copied().max().unwrap_or(0); + } + /// Schedule a batch of data to be written to the file /// /// Note: the future returned by this method may complete before the data has been fully @@ -557,18 +599,93 @@ impl FileWriter { .flatten() .collect::>(); - self.rows_written = match self.rows_written.checked_add(batch.num_rows() as u64) { - Some(rows_written) => rows_written, - None => { - return Err(Error::invalid_input_source(format!("cannot write batch with {} rows because {} rows have already been written and Lance files cannot contain more than 2^64 rows", num_rows, self.rows_written).into())); - } - }; + // `write_batch` advances every field by the same amount, keeping all + // columns equal length. Guard against overflowing the row counter. + if self.rows_written.checked_add(num_rows).is_none() { + return Err(Error::invalid_input_source(format!("cannot write batch with {} rows because {} rows have already been written and Lance files cannot contain more than 2^64 rows", num_rows, self.rows_written).into())); + } + for field_rows in self.field_rows_written.iter_mut() { + *field_rows += num_rows; + } + self.rows_written = self.field_rows_written.iter().copied().max().unwrap_or(0); self.write_pages(encoding_tasks).await?; Ok(()) } + /// Write a single column, advancing only that column's row counter. + /// + /// Unlike [`write_batch`](Self::write_batch), which advances every column + /// from a single shared row counter, this method advances one column + /// independently. Used across calls it produces a single file whose columns + /// may have different item counts — the physical layout used by sparse data + /// overlay files, where each field covers a different set of rows. + /// + /// `column_index` refers to a top-level field in the writer's schema (the + /// same order as the schema's fields). A column may be written across + /// multiple calls; its values are appended. A field that is never written + /// ends up as a zero-length column. The writer must have been created with + /// an explicit schema (via [`try_new`](Self::try_new)); a lazy schema cannot + /// be inferred here because individual calls need not cover every field. + /// + /// ``` + /// # use arrow_array::{ArrayRef, Int32Array}; + /// # use std::sync::Arc; + /// # use lance_file::writer::FileWriter; + /// # async fn example(writer: &mut FileWriter) -> lance_core::Result<()> { + /// // Field 0 gets three values, field 1 gets one — a non-rectangular file. + /// writer.write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3]))).await?; + /// writer.write_column(1, Arc::new(Int32Array::from(vec![10]))).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn write_column(&mut self, column_index: usize, array: ArrayRef) -> Result<()> { + let schema = self.schema.as_ref().ok_or_else(|| { + Error::invalid_input_source( + "write_column requires the writer to be created with an explicit schema".into(), + ) + })?; + let field = schema.fields.get(column_index).ok_or_else(|| { + Error::invalid_input_source( + format!( + "write_column: field index {} is out of bounds (schema has {} fields)", + column_index, + schema.fields.len() + ) + .into(), + ) + })?; + if array.len() as u64 > u32::MAX as u64 { + return Err(Error::invalid_input_source( + "cannot write Lance files with more than 2^32 rows".into(), + )); + } + Self::verify_field_nullability(&array.to_data(), field)?; + + // A never-advanced field simply remains a zero-length column, which the + // encoders handle at `finish` time. + if array.is_empty() { + return Ok(()); + } + + let columns = [(column_index, array)]; + let mut external_buffers = + OutOfLineBuffers::new(self.tell().await?, PAGE_BUFFER_ALIGNMENT as u64); + let encoding_tasks = self.encode_columns(&columns, &mut external_buffers)?; + for external_buffer in external_buffers.take_buffers() { + Self::do_write_buffer(&mut self.writer, &external_buffer).await?; + } + let encoding_tasks = encoding_tasks + .into_iter() + .flatten() + .collect::>(); + + self.advance_columns(&columns); + self.write_pages(encoding_tasks).await?; + Ok(()) + } + async fn write_column_metadata( &mut self, metadata: pbfile::ColumnMetadata, @@ -974,11 +1091,11 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use crate::reader::{FileReader, FileReaderOptions, describe_encoding}; + use crate::reader::{FileReader, FileReaderOptions, ReaderProjection, describe_encoding}; use crate::testing::FsFixture; use crate::writer::{ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES, FileWriter, FileWriterOptions}; use arrow_array::builder::{Float32Builder, Int32Builder}; - use arrow_array::{Int32Array, RecordBatch, UInt64Array}; + use arrow_array::{ArrayRef, Int32Array, RecordBatch, UInt64Array}; use arrow_array::{RecordBatchReader, StringArray, types::Float64Type}; use arrow_schema::{DataType, Field, Field as ArrowField, Schema, Schema as ArrowSchema}; use lance_core::cache::LanceCache; @@ -990,6 +1107,7 @@ mod tests { use lance_encoding::version::LanceFileVersion; use lance_io::object_store::ObjectStore; use lance_io::utils::CachedFileSize; + use rstest::rstest; #[tokio::test] async fn test_basic_write() { @@ -1040,6 +1158,326 @@ mod tests { file_writer.finish().await.unwrap(); } + // Read a single column back at an explicit range/index set, returning its + // `Int32` values. Reading one column (or an equal-length group) at a time is + // how unequal-length files are consumed: a full scan across columns of + // differing lengths cannot form a single rectangular batch. + async fn read_int32_column( + reader: &FileReader, + schema: &LanceSchema, + version: LanceFileVersion, + name: &str, + params: lance_io::ReadBatchParams, + ) -> Vec> { + use futures::TryStreamExt; + use lance_encoding::decoder::FilterExpression; + + let projection = ReaderProjection::from_column_names(version, schema, &[name]).unwrap(); + let batches: Vec = reader + .read_stream_projected(params, 1024, 16, projection, FilterExpression::no_filter()) + .await + .unwrap() + .try_collect() + .await + .unwrap(); + batches + .iter() + .flat_map(|b| { + b.column(0) + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .collect::>() + }) + .collect() + } + + /// A single file may hold columns of differing item counts (no shared global + /// row counter). This is the physical layout used by sparse data overlay + /// files, where each field covers a different set of rows. + #[rstest] + #[tokio::test] + async fn test_write_columns_unequal_lengths( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + use lance_io::ReadBatchParams; + + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, true), + ArrowField::new("b", DataType::Int32, true), + ArrowField::new("c", DataType::Int32, true), + ])); + let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap(); + + let fs = FsFixture::default(); + let options = FileWriterOptions { + format_version: Some(version), + ..Default::default() + }; + let mut writer = FileWriter::try_new( + fs.object_store.create(&fs.tmp_path).await.unwrap(), + lance_schema.clone(), + options, + ) + .unwrap(); + + // Field "a" gets 5 values across two calls (appending), field "b" gets a + // single value, and field "c" is never written (a zero-length column). + let a1: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + let b: ArrayRef = Arc::new(Int32Array::from(vec![10])); + writer.write_column(0, a1).await.unwrap(); + writer.write_column(1, b).await.unwrap(); + let a2: ArrayRef = Arc::new(Int32Array::from(vec![4, 5])); + writer.write_column(0, a2).await.unwrap(); + // A zero-length array for an otherwise-unwritten field is a no-op. + let c_empty: ArrayRef = Arc::new(Int32Array::from(Vec::::new())); + writer.write_column(2, c_empty).await.unwrap(); + + let summary = writer.finish().await.unwrap(); + // The file's logical length is the longest column. + assert_eq!(summary.num_rows, 5); + + let file_scheduler = fs + .scheduler + .open_file(&fs.tmp_path, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + + // Per-column row counts are recorded in / derivable from file metadata. + assert_eq!(reader.num_rows(), 5); + assert_eq!(reader.column_num_rows(0), Some(5)); + assert_eq!(reader.column_num_rows(1), Some(1)); + assert_eq!(reader.column_num_rows(2), Some(0)); + assert_eq!(reader.column_num_rows(3), None); + + // Each column reads back independently at its own length. + assert_eq!( + read_int32_column( + &reader, + &lance_schema, + version, + "a", + ReadBatchParams::Range(0..5) + ) + .await, + vec![Some(1), Some(2), Some(3), Some(4), Some(5)], + ); + assert_eq!( + read_int32_column( + &reader, + &lance_schema, + version, + "b", + ReadBatchParams::Range(0..1) + ) + .await, + vec![Some(10)], + ); + + // Random access by position within the longer column returns the right + // value even though other columns are shorter. (The take path requires + // strictly increasing indices.) + assert_eq!( + read_int32_column( + &reader, + &lance_schema, + version, + "a", + ReadBatchParams::Indices(arrow_array::UInt32Array::from(vec![0, 2, 4])), + ) + .await, + vec![Some(1), Some(3), Some(5)], + ); + } + + /// Reading an unequal-length file: + /// - a projection whose columns are equal length full-scans normally; + /// - a full scan across columns of differing length is rejected up front, + /// before any batch is produced (even though a prefix would be rectangular); + /// - a bounded read is valid as long as every projected column covers it; + /// - a single-column `RangeFull` resolves to that column's own length, not + /// the file's (maximum) length. + #[rstest] + #[tokio::test] + async fn test_read_unequal_length_projection( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + use futures::TryStreamExt; + use lance_encoding::decoder::FilterExpression; + use lance_io::ReadBatchParams; + + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, true), + ArrowField::new("b", DataType::Int32, true), + ArrowField::new("c", DataType::Int32, true), + ])); + let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap(); + let fs = FsFixture::default(); + let options = FileWriterOptions { + format_version: Some(version), + ..Default::default() + }; + let mut writer = FileWriter::try_new( + fs.object_store.create(&fs.tmp_path).await.unwrap(), + lance_schema.clone(), + options, + ) + .unwrap(); + // "a" and "b" are equal length (5); "c" is shorter (1). + writer + .write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5]))) + .await + .unwrap(); + writer + .write_column(1, Arc::new(Int32Array::from(vec![6, 7, 8, 9, 10]))) + .await + .unwrap(); + writer + .write_column(2, Arc::new(Int32Array::from(vec![100]))) + .await + .unwrap(); + writer.finish().await.unwrap(); + + let file_scheduler = fs + .scheduler + .open_file(&fs.tmp_path, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + + let read = |names: &'static [&'static str], params: ReadBatchParams| { + let projection = + ReaderProjection::from_column_names(version, &lance_schema, names).unwrap(); + async { + match reader + .read_stream_projected( + params, + 1024, + 16, + projection, + FilterExpression::no_filter(), + ) + .await + { + Ok(stream) => stream.try_collect::>().await, + Err(e) => Err(e), + } + } + }; + let col_values = |batches: &[RecordBatch], idx: usize| -> Vec> { + batches + .iter() + .flat_map(|b| { + b.column(idx) + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .collect::>() + }) + .collect() + }; + + // Equal-length projection [a, b] full-scans into rectangular batches. + let batches = read(&["a", "b"], ReadBatchParams::RangeFull).await.unwrap(); + assert_eq!( + col_values(&batches, 0), + vec![Some(1), Some(2), Some(3), Some(4), Some(5)] + ); + assert_eq!( + col_values(&batches, 1), + vec![Some(6), Some(7), Some(8), Some(9), Some(10)] + ); + + // A mismatched-length projection [a, c] (5 vs 1) is rejected before any + // batch is yielded, regardless of the read params — its columns cannot + // be combined into rectangular batches. + assert!( + read(&["a", "c"], ReadBatchParams::RangeFull).await.is_err(), + "full scan across unequal-length columns must error" + ); + assert!( + read(&["a", "c"], ReadBatchParams::Range(0..1)) + .await + .is_err(), + "even a common-prefix read of unequal-length columns must error" + ); + + // A single-column RangeFull resolves to that column's own length. + let batches = read(&["c"], ReadBatchParams::RangeFull).await.unwrap(); + assert_eq!(col_values(&batches, 0), vec![Some(100)]); + let batches = read(&["a"], ReadBatchParams::RangeFull).await.unwrap(); + assert_eq!( + col_values(&batches, 0), + vec![Some(1), Some(2), Some(3), Some(4), Some(5)] + ); + } + + /// Files written the ordinary (rectangular) way keep equal column lengths, + /// so the unequal-length support is backwards compatible. + #[tokio::test] + async fn test_write_batch_keeps_equal_lengths() { + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, true), + ArrowField::new("b", DataType::Int32, true), + ])); + let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap(); + + let fs = FsFixture::default(); + let mut writer = FileWriter::try_new( + fs.object_store.create(&fs.tmp_path).await.unwrap(), + lance_schema, + FileWriterOptions::default(), + ) + .unwrap(); + let batch = RecordBatch::try_new( + arrow_schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new(Int32Array::from(vec![4, 5, 6])), + ], + ) + .unwrap(); + writer.write_batch(&batch).await.unwrap(); + let summary = writer.finish().await.unwrap(); + assert_eq!(summary.num_rows, 3); + + let file_scheduler = fs + .scheduler + .open_file(&fs.tmp_path, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + assert_eq!(reader.column_num_rows(0), Some(3)); + assert_eq!(reader.column_num_rows(1), Some(3)); + } + #[tokio::test] async fn test_max_page_bytes_enforced() { let arrow_field = Field::new("data", DataType::UInt64, false); diff --git a/rust/lance-table/benches/manifest_intern.rs b/rust/lance-table/benches/manifest_intern.rs index 78b7e352207..81bd57c1a22 100644 --- a/rust/lance-table/benches/manifest_intern.rs +++ b/rust/lance-table/benches/manifest_intern.rs @@ -59,6 +59,7 @@ fn make_uniform_pb_fragments(n: u64, num_fields: usize) -> Vec file_size_bytes: 0, base_id: None, }], + overlays: vec![], deletion_file: None, row_id_sequence: None, physical_rows: 1000, @@ -135,6 +136,7 @@ fn make_diverse_pb_fragments( file_size_bytes: 0, base_id: None, }], + overlays: vec![], deletion_file: None, row_id_sequence: None, physical_rows: 1000, diff --git a/rust/lance-table/src/feature_flags.rs b/rust/lance-table/src/feature_flags.rs index 096f0da79e5..369c880c062 100644 --- a/rust/lance-table/src/feature_flags.rs +++ b/rust/lance-table/src/feature_flags.rs @@ -20,8 +20,22 @@ pub const FLAG_TABLE_CONFIG: u64 = 8; pub const FLAG_BASE_PATHS: u64 = 16; /// Disable writing transaction file under _transaction/, this flag is set when we only want to write inline transaction in manifest pub const FLAG_DISABLE_TRANSACTION_FILE: u64 = 32; +/// Fragments contain data overlay files, which supply new values for a subset of +/// cells without rewriting base data files. A reader that does not understand +/// overlays must refuse the dataset, since ignoring an overlay would silently +/// return stale base values. +/// +/// Data overlay files are not yet a released feature: in release builds this flag +/// is treated as unknown (so a release reader/writer refuses an overlay dataset) +/// unless [`ENABLE_DATA_OVERLAY_FILES_ENV`] is set, which lets benchmarks opt in. +/// Debug builds always understand it so tests exercise the path. +pub const FLAG_DATA_OVERLAY_FILES: u64 = 64; /// The first bit that is unknown as a feature flag -pub const FLAG_UNKNOWN: u64 = 64; +pub const FLAG_UNKNOWN: u64 = 128; + +/// Environment variable that opts a release build into reading and writing data +/// overlay files before the feature is generally released. +pub const ENABLE_DATA_OVERLAY_FILES_ENV: &str = "LANCE_ENABLE_DATA_OVERLAY_FILES"; /// Set the reader and writer feature flags in the manifest based on the contents of the manifest. pub fn apply_feature_flags( @@ -71,18 +85,51 @@ pub fn apply_feature_flags( manifest.writer_feature_flags |= FLAG_BASE_PATHS; } + // Overlay files change cell values on read, so a reader that ignores them + // would return stale base values. Both readers and writers must understand + // them. + let has_overlays = manifest + .fragments + .iter() + .any(|frag| !frag.overlays.is_empty()); + if has_overlays { + manifest.reader_feature_flags |= FLAG_DATA_OVERLAY_FILES; + manifest.writer_feature_flags |= FLAG_DATA_OVERLAY_FILES; + } + if disable_transaction_file { manifest.writer_feature_flags |= FLAG_DISABLE_TRANSACTION_FILE; } Ok(()) } +/// Whether this build understands data overlay files: always in debug builds, +/// and in release builds only when [`ENABLE_DATA_OVERLAY_FILES_ENV`] is set. +fn data_overlay_files_enabled() -> bool { + cfg!(debug_assertions) || std::env::var_os(ENABLE_DATA_OVERLAY_FILES_ENV).is_some() +} + +/// The feature-flag bits this build understands, given whether overlay support +/// is enabled. Split out from [`supported_flags`] so the policy is testable +/// without toggling the build profile or environment. +fn supported_flags_when(overlay_enabled: bool) -> u64 { + let mut supported = FLAG_UNKNOWN - 1; + if !overlay_enabled { + supported &= !FLAG_DATA_OVERLAY_FILES; + } + supported +} + +fn supported_flags() -> u64 { + supported_flags_when(data_overlay_files_enabled()) +} + pub fn can_read_dataset(reader_flags: u64) -> bool { - reader_flags < FLAG_UNKNOWN + reader_flags & !supported_flags() == 0 } pub fn can_write_dataset(writer_flags: u64) -> bool { - writer_flags < FLAG_UNKNOWN + writer_flags & !supported_flags() == 0 } pub fn has_deprecated_v2_feature_flag(writer_flags: u64) -> bool { @@ -103,6 +150,7 @@ mod tests { assert!(can_read_dataset(super::FLAG_TABLE_CONFIG)); assert!(can_read_dataset(super::FLAG_BASE_PATHS)); assert!(can_read_dataset(super::FLAG_DISABLE_TRANSACTION_FILE)); + assert!(can_read_dataset(super::FLAG_DATA_OVERLAY_FILES)); assert!(can_read_dataset( super::FLAG_DELETION_FILES | super::FLAG_STABLE_ROW_IDS @@ -111,6 +159,53 @@ mod tests { assert!(!can_read_dataset(super::FLAG_UNKNOWN)); } + #[test] + fn test_data_overlay_flag_release_gating() { + // Release default (overlays disabled): the overlay flag is treated as + // unknown so the dataset is refused, while other known flags still pass. + let supported = supported_flags_when(false); + assert_eq!(supported & FLAG_DATA_OVERLAY_FILES, 0); + assert_eq!(FLAG_DELETION_FILES & !supported, 0); + assert_ne!(FLAG_DATA_OVERLAY_FILES & !supported, 0); + // Enabled (debug or env opt-in): the overlay flag is understood. + let supported = supported_flags_when(true); + assert_eq!(FLAG_DATA_OVERLAY_FILES & !supported, 0); + } + + #[test] + fn test_apply_feature_flags_sets_overlay_flag() { + use crate::format::{ + DataFile, DataOverlayFile, DataStorageFormat, Fragment, OverlayCoverage, + }; + use arrow_schema::{Field as ArrowField, Schema as ArrowSchema}; + use lance_core::datatypes::Schema; + use roaring::RoaringBitmap; + use std::collections::HashMap; + use std::sync::Arc; + + let arrow_schema = ArrowSchema::new(vec![ArrowField::new( + "id", + arrow_schema::DataType::Int64, + false, + )]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + let mut fragment = Fragment::new(0); + fragment.overlays = vec![DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![0], None), + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + committed_version: 1, + }]; + let mut manifest = Manifest::new( + schema, + Arc::new(vec![fragment]), + DataStorageFormat::default(), + HashMap::new(), + ); + apply_feature_flags(&mut manifest, false, false).unwrap(); + assert_ne!(manifest.reader_feature_flags & FLAG_DATA_OVERLAY_FILES, 0); + assert_ne!(manifest.writer_feature_flags & FLAG_DATA_OVERLAY_FILES, 0); + } + #[test] fn test_write_check() { assert!(can_write_dataset(0)); @@ -120,12 +215,14 @@ mod tests { assert!(can_write_dataset(super::FLAG_TABLE_CONFIG)); assert!(can_write_dataset(super::FLAG_BASE_PATHS)); assert!(can_write_dataset(super::FLAG_DISABLE_TRANSACTION_FILE)); + assert!(can_write_dataset(super::FLAG_DATA_OVERLAY_FILES)); assert!(can_write_dataset( super::FLAG_DELETION_FILES | super::FLAG_STABLE_ROW_IDS | super::FLAG_USE_V2_FORMAT_DEPRECATED | super::FLAG_TABLE_CONFIG | super::FLAG_BASE_PATHS + | super::FLAG_DATA_OVERLAY_FILES )); assert!(!can_write_dataset(super::FLAG_UNKNOWN)); } diff --git a/rust/lance-table/src/format/fragment.rs b/rust/lance-table/src/format/fragment.rs index 431e466dbd4..ef5166728ab 100644 --- a/rust/lance-table/src/format/fragment.rs +++ b/rust/lance-table/src/format/fragment.rs @@ -11,6 +11,7 @@ use lance_file::format::{MAJOR_VERSION, MINOR_VERSION}; use lance_file::version::LanceFileVersion; use lance_io::utils::CachedFileSize; use object_store::path::Path; +use roaring::RoaringBitmap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::format::pb; @@ -233,6 +234,210 @@ impl TryFrom for DataFile { } } +/// Which `(physical offset, field)` cells a [`DataOverlayFile`] provides values +/// for. +/// +/// The coverage bitmaps index **physical** row offsets (positions in the base +/// data files, counting deleted rows), so they are stable across deletions, like +/// deletion vectors. Bitmaps are parsed from their 32-bit Roaring encoding once +/// when the fragment is loaded and held behind an `Arc` so cloning a fragment is +/// cheap; use [`DataOverlayFile::coverage_for_field`] to obtain the one that +/// applies to a given field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "OverlayCoverageBytes", try_from = "OverlayCoverageBytes")] +pub enum OverlayCoverage { + /// A single bitmap that applies to every field in the overlay's + /// `data_file.fields` (a dense / rectangular overlay): every covered offset + /// has a value for every field. + Shared(Arc), + /// One bitmap per field, in the same order as the overlay's + /// `data_file.fields` (a sparse overlay): different fields may cover + /// different offset sets. + PerField(Vec>), +} + +/// Serialized form of [`OverlayCoverage`] — each bitmap as its 32-bit Roaring +/// byte encoding. The in-memory form parses these once at load. +#[derive(Debug, Clone, Serialize, Deserialize)] +enum OverlayCoverageBytes { + Shared(Vec), + PerField(Vec>), +} + +fn deserialize_roaring(bytes: &[u8]) -> Result { + RoaringBitmap::deserialize_from(bytes).map_err(|e| { + Error::invalid_input(format!( + "failed to deserialize overlay coverage bitmap: {e}" + )) + }) +} + +fn serialize_roaring(bitmap: &RoaringBitmap) -> Vec { + let mut bytes = Vec::with_capacity(bitmap.serialized_size()); + // Writing to a Vec is infallible. + bitmap.serialize_into(&mut bytes).unwrap(); + bytes +} + +impl From for OverlayCoverageBytes { + fn from(coverage: OverlayCoverage) -> Self { + match coverage { + OverlayCoverage::Shared(bitmap) => Self::Shared(serialize_roaring(&bitmap)), + OverlayCoverage::PerField(bitmaps) => { + Self::PerField(bitmaps.iter().map(|b| serialize_roaring(b)).collect()) + } + } + } +} + +impl TryFrom for OverlayCoverage { + type Error = Error; + + fn try_from(bytes: OverlayCoverageBytes) -> Result { + Ok(match bytes { + OverlayCoverageBytes::Shared(b) => Self::Shared(Arc::new(deserialize_roaring(&b)?)), + OverlayCoverageBytes::PerField(bs) => Self::PerField( + bs.iter() + .map(|b| deserialize_roaring(b).map(Arc::new)) + .collect::>()?, + ), + }) + } +} + +impl DeepSizeOf for OverlayCoverage { + fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize { + // RoaringBitmap does not expose its allocation size; its serialized size + // is a cheap, close proxy for the heap it holds. + let bitmap_heap = |bitmap: &RoaringBitmap| { + std::mem::size_of::() + bitmap.serialized_size() + }; + match self { + Self::Shared(bitmap) => bitmap_heap(bitmap), + Self::PerField(bitmaps) => { + bitmaps.capacity() * std::mem::size_of::>() + + bitmaps.iter().map(|b| bitmap_heap(b)).sum::() + } + } + } +} + +impl OverlayCoverage { + /// Build a dense coverage from a single bitmap shared across every field. + pub fn dense(bitmap: RoaringBitmap) -> Self { + Self::Shared(Arc::new(bitmap)) + } + + /// Build a sparse coverage from one bitmap per field. + pub fn sparse(bitmaps: Vec) -> Self { + Self::PerField(bitmaps.into_iter().map(Arc::new).collect()) + } +} + +/// An overlay file supplies new values for a subset of `(physical offset, field)` +/// cells within a fragment, without rewriting the fragment's base data files. See +/// the Data Overlay Files specification for the full resolution, coverage, and +/// versioning rules. +/// +/// The overlay's `data_file` stores one value column per field in +/// `data_file.fields`, with **no** row-offset key column. Within a value column, +/// the position of a covered offset's value is the **rank** (0-based count of set +/// bits below it) of that offset in the field's coverage bitmap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] +pub struct DataOverlayFile { + /// The data file storing the overlay's new cell values. + pub data_file: DataFile, + /// Which cells this overlay provides values for. + pub coverage: OverlayCoverage, + /// The dataset version at which this overlay became effective (the version of + /// the commit that introduced it, stamped at commit time and re-stamped on + /// retry). Higher wins when two overlays cover the same `(offset, field)`. + pub committed_version: u64, +} + +impl DataOverlayFile { + /// The parsed coverage bitmap that applies to the field stored at + /// `field_pos` within `data_file.fields`. + /// + /// For a dense overlay the same shared bitmap is returned for every field; + /// for a sparse overlay the per-field bitmap at `field_pos` is returned. The + /// bitmap is already parsed, so this is a cheap `Arc` clone. + pub fn coverage_for_field(&self, field_pos: usize) -> Result> { + match &self.coverage { + OverlayCoverage::Shared(bitmap) => Ok(bitmap.clone()), + OverlayCoverage::PerField(bitmaps) => { + bitmaps.get(field_pos).cloned().ok_or_else(|| { + Error::invalid_input(format!( + "overlay field_coverage has {} bitmaps but field position {} was requested", + bitmaps.len(), + field_pos + )) + }) + } + } + } +} + +/// Overlays are stored newest-last: a later list position is newer, and ties in +/// `committed_version` are broken by position. Loading stable-sorts by +/// `committed_version` so resolution can rely on the ordering without +/// re-checking; the stable sort preserves the position tiebreak for equal +/// versions. +fn sort_overlays_newest_last(overlays: &mut [DataOverlayFile]) { + overlays.sort_by_key(|overlay| overlay.committed_version); +} + +impl From<&DataOverlayFile> for pb::DataOverlayFile { + fn from(overlay: &DataOverlayFile) -> Self { + let coverage = match &overlay.coverage { + OverlayCoverage::Shared(bitmap) => { + pb::data_overlay_file::Coverage::SharedOffsetBitmap(serialize_roaring(bitmap)) + } + OverlayCoverage::PerField(bitmaps) => { + pb::data_overlay_file::Coverage::FieldCoverage(pb::FieldCoverage { + offset_bitmaps: bitmaps.iter().map(|b| serialize_roaring(b)).collect(), + }) + } + }; + Self { + data_file: Some(pb::DataFile::from(&overlay.data_file)), + coverage: Some(coverage), + committed_version: overlay.committed_version, + } + } +} + +impl TryFrom for DataOverlayFile { + type Error = Error; + + fn try_from(proto: pb::DataOverlayFile) -> Result { + let data_file = proto + .data_file + .ok_or_else(|| Error::invalid_input("DataOverlayFile is missing its data_file"))?; + let coverage = match proto.coverage { + Some(pb::data_overlay_file::Coverage::SharedOffsetBitmap(bytes)) => { + OverlayCoverage::Shared(Arc::new(deserialize_roaring(&bytes)?)) + } + Some(pb::data_overlay_file::Coverage::FieldCoverage(fc)) => OverlayCoverage::PerField( + fc.offset_bitmaps + .iter() + .map(|b| deserialize_roaring(b).map(Arc::new)) + .collect::>()?, + ), + None => { + return Err(Error::invalid_input( + "DataOverlayFile is missing its coverage", + )); + } + }; + Ok(Self { + data_file: DataFile::try_from(data_file)?, + coverage, + committed_version: proto.committed_version, + }) + } +} + /// Interns repeated data so that fragments with identical content share a /// single heap allocation via `Arc`. /// @@ -375,6 +580,15 @@ impl DataFileFieldInterner { .into_iter() .map(|f| self.intern_data_file(f)) .collect::>()?, + overlays: { + let mut overlays = p + .overlays + .into_iter() + .map(DataOverlayFile::try_from) + .collect::>>()?; + sort_overlays_newest_last(&mut overlays); + overlays + }, deletion_file: p.deletion_file.map(DeletionFile::try_from).transpose()?, row_id_meta: p.row_id_sequence.map(RowIdMeta::try_from).transpose()?, physical_rows, @@ -483,6 +697,12 @@ pub struct Fragment { /// Files within the fragment. pub files: Vec, + /// Overlay files supplying new values for a subset of cells without + /// rewriting the base data files. Order is significant: a later entry is + /// newer than an earlier one. See [`DataOverlayFile`] for resolution rules. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub overlays: Vec, + /// Optional file with deleted local row offsets. #[serde(skip_serializing_if = "Option::is_none")] pub deletion_file: Option, @@ -510,6 +730,7 @@ impl Fragment { Self { id, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -549,6 +770,7 @@ impl Fragment { Self { id, files: vec![DataFile::new_legacy(path, schema, None, None)], + overlays: vec![], deletion_file: None, physical_rows, row_id_meta: None, @@ -669,6 +891,15 @@ impl TryFrom for Fragment { .into_iter() .map(DataFile::try_from) .collect::>()?, + overlays: { + let mut overlays = p + .overlays + .into_iter() + .map(DataOverlayFile::try_from) + .collect::>>()?; + sort_overlays_newest_last(&mut overlays); + overlays + }, deletion_file: p.deletion_file.map(DeletionFile::try_from).transpose()?, row_id_meta: p.row_id_sequence.map(RowIdMeta::try_from).transpose()?, physical_rows, @@ -716,6 +947,7 @@ impl From<&Fragment> for pb::DataFragment { Self { id: f.id, files: f.files.iter().map(pb::DataFile::from).collect(), + overlays: f.overlays.iter().map(pb::DataOverlayFile::from).collect(), deletion_file, row_id_sequence, physical_rows: f.physical_rows.unwrap_or_default() as u64, @@ -734,6 +966,161 @@ mod tests { use object_store::path::Path; use serde_json::{Value, json}; + #[test] + fn test_data_overlay_roundtrip() { + // A fragment carrying a dense overlay round-trips through protobuf and + // back, and the parsed coverage bitmap is recovered per field. + let mut bitmap = RoaringBitmap::new(); + bitmap.insert(1); + bitmap.insert(3); + + let overlay = DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("overlay-0.lance", vec![3], None), + coverage: OverlayCoverage::dense(bitmap.clone()), + committed_version: 7, + }; + let mut fragment = Fragment::new(0); + fragment.files = vec![DataFile::new_legacy_from_fields( + "base.lance", + vec![1, 3], + None, + )]; + fragment.overlays = vec![overlay]; + + let proto = pb::DataFragment::from(&fragment); + assert_eq!(proto.overlays.len(), 1); + let round_tripped = Fragment::try_from(proto).unwrap(); + assert_eq!(round_tripped, fragment); + + // Dense coverage applies to every field. + let recovered = round_tripped.overlays[0].coverage_for_field(0).unwrap(); + assert_eq!(*recovered, bitmap); + assert_eq!( + *round_tripped.overlays[0].coverage_for_field(5).unwrap(), + bitmap + ); + } + + #[test] + fn test_data_overlay_sparse_per_field_coverage() { + // A sparse overlay carries one bitmap per field, recovered by position. + let name_coverage = RoaringBitmap::from_iter([2u32, 3]); + let embedding_coverage = RoaringBitmap::from_iter([1u32]); + let overlay = DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("overlay-1.lance", vec![2, 4], None), + coverage: OverlayCoverage::sparse(vec![ + name_coverage.clone(), + embedding_coverage.clone(), + ]), + committed_version: 3, + }; + let mut fragment = Fragment::new(1); + fragment.overlays = vec![overlay]; + + let round_tripped = Fragment::try_from(pb::DataFragment::from(&fragment)).unwrap(); + assert_eq!( + *round_tripped.overlays[0].coverage_for_field(0).unwrap(), + name_coverage + ); + assert_eq!( + *round_tripped.overlays[0].coverage_for_field(1).unwrap(), + embedding_coverage + ); + } + + #[test] + fn test_data_overlay_missing_fields_error() { + // A DataOverlayFile proto missing its coverage or data_file is rejected. + let no_coverage = pb::DataOverlayFile { + data_file: Some(pb::DataFile::from(&DataFile::new_legacy_from_fields( + "overlay.lance", + vec![3], + None, + ))), + coverage: None, + committed_version: 1, + }; + let err = DataOverlayFile::try_from(no_coverage).unwrap_err(); + assert!(err.to_string().contains("missing its coverage"), "{err}"); + + let no_data_file = pb::DataOverlayFile { + data_file: None, + coverage: Some(pb::data_overlay_file::Coverage::SharedOffsetBitmap( + serialize_roaring(&RoaringBitmap::from_iter([0u32])), + )), + committed_version: 1, + }; + let err = DataOverlayFile::try_from(no_data_file).unwrap_err(); + assert!(err.to_string().contains("missing its data_file"), "{err}"); + } + + #[test] + fn test_overlays_sorted_newest_last_on_load() { + // Overlays load stable-sorted by committed_version (newest last), with + // list position preserved as the tiebreak for equal versions. + let mk = |version: u64, field: i32| DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![field], None), + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + committed_version: version, + }; + let mut fragment = Fragment::new(0); + // Written out of order: v5, v2, v2 (second), v3. + fragment.overlays = vec![mk(5, 1), mk(2, 2), mk(2, 3), mk(3, 4)]; + + let loaded = Fragment::try_from(pb::DataFragment::from(&fragment)).unwrap(); + let versions: Vec = loaded + .overlays + .iter() + .map(|o| o.committed_version) + .collect(); + assert_eq!(versions, vec![2, 2, 3, 5]); + // Stable: the two v2 overlays keep their original relative order (field 2 + // before field 3). + assert_eq!( + loaded.overlays[0].data_file.fields.as_ref(), + [2i32].as_slice() + ); + assert_eq!( + loaded.overlays[1].data_file.fields.as_ref(), + [3i32].as_slice() + ); + } + + #[test] + fn test_overlay_coverage_serde_json_roundtrip() { + // The custom serde impl round-trips through JSON for dense/sparse, + // including empty bitmaps and a zero-bitmap sparse coverage. + for coverage in [ + OverlayCoverage::dense(RoaringBitmap::from_iter([1u32, 5, 100])), + OverlayCoverage::dense(RoaringBitmap::new()), + OverlayCoverage::sparse(vec![ + RoaringBitmap::from_iter([2u32, 3]), + RoaringBitmap::new(), + ]), + OverlayCoverage::sparse(vec![]), + ] { + let json = serde_json::to_string(&coverage).unwrap(); + let back: OverlayCoverage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, coverage); + } + } + + #[test] + fn test_coverage_for_field_out_of_bounds() { + let overlay = DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![2, 4], None), + coverage: OverlayCoverage::sparse(vec![ + RoaringBitmap::from_iter([1u32]), + RoaringBitmap::from_iter([2u32]), + ]), + committed_version: 1, + }; + assert!(overlay.coverage_for_field(0).is_ok()); + assert!(overlay.coverage_for_field(1).is_ok()); + let err = overlay.coverage_for_field(5).unwrap_err(); + assert!(err.to_string().contains("field position"), "{err}"); + } + #[test] fn test_new_fragment() { let path = "foobar.lance"; diff --git a/rust/lance-table/src/format/manifest.rs b/rust/lance-table/src/format/manifest.rs index 9845061b7e4..cd0a403621f 100644 --- a/rust/lance-table/src/format/manifest.rs +++ b/rust/lance-table/src/format/manifest.rs @@ -1316,6 +1316,7 @@ mod tests { vec![0, 1, 2], None, )], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -1328,6 +1329,7 @@ mod tests { DataFile::new_legacy_from_fields("path2", vec![0, 1, 43], None), DataFile::new_legacy_from_fields("path3", vec![2], None), ], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 3e0d77704da..448feb961d7 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -79,6 +79,7 @@ pub mod index; pub mod mem_wal; mod metadata; pub mod optimize; +pub(crate) mod overlay; pub mod progress; pub mod refs; pub(crate) mod rowids; diff --git a/rust/lance/src/dataset/files.rs b/rust/lance/src/dataset/files.rs index 848add7e4a8..2214822ed90 100644 --- a/rust/lance/src/dataset/files.rs +++ b/rust/lance/src/dataset/files.rs @@ -1036,6 +1036,7 @@ mod tests { // No base_id -> falls back to the dataset base_uri. mk_file("c.lance", None), ], + overlays: vec![], // Deletion files also carry a base_id when they originate from a // shallow clone, and must resolve against base_paths too. deletion_file: Some(DeletionFile { diff --git a/rust/lance/src/dataset/fragment.rs b/rust/lance/src/dataset/fragment.rs index eb165e5f612..108c2debcf9 100644 --- a/rust/lance/src/dataset/fragment.rs +++ b/rust/lance/src/dataset/fragment.rs @@ -15,7 +15,8 @@ use arrow::compute::concat_batches; use arrow_array::cast::as_primitive_array; use arrow_array::types::UInt64Type; use arrow_array::{ - Array, RecordBatch, RecordBatchReader, StructArray, UInt32Array, UInt64Array, new_null_array, + Array, ArrayRef, RecordBatch, RecordBatchReader, StructArray, UInt32Array, UInt64Array, + new_null_array, }; use arrow_schema::Schema as ArrowSchema; use datafusion::logical_expr::Expr; @@ -47,7 +48,7 @@ use lance_table::format::{DataFile, DeletionFile, Fragment}; use lance_table::io::deletion::{deletion_file_path, write_deletion_file}; use lance_table::rowids::RowIdSequence; use lance_table::utils::stream::{ - ReadBatchFutStream, ReadBatchTask, ReadBatchTaskStream, RowIdAndDeletesConfig, + ReadBatchFut, ReadBatchFutStream, ReadBatchTask, ReadBatchTaskStream, RowIdAndDeletesConfig, wrap_with_row_id_and_delete, }; use roaring::RoaringBitmap; @@ -62,6 +63,9 @@ use super::updater::Updater; use super::{NewColumnTransform, WriteParams, schema_evolution}; use crate::dataset::Dataset; use crate::dataset::fragment::session::FragmentSession; +use crate::dataset::overlay::{ + assemble_overlay_column, overlay_indices_newest_first, route_overlays, +}; use crate::io::deletion::read_dataset_deletion_file; /// Result of [`FileFragment::update_columns_with_offsets`]: updated fragment metadata, modified field ids, @@ -911,11 +915,24 @@ impl FileFragment { futures::future::Either::Right(futures::future::ready(Ok(None))) }; - let (opened_files, deletion_vec, row_id_sequence) = - join!(open_files, deletion_vec_load, row_id_load); + // Open overlay value-column readers concurrently with the base files + // (no value bytes are read yet — that happens per batch on read). + let overlay_plan_load = if self.metadata.overlays.is_empty() { + futures::future::Either::Left(futures::future::ready(Ok(Vec::new()))) + } else { + futures::future::Either::Right(self.load_overlay_plan(projection, &read_config)) + }; + + let (opened_files, deletion_vec, row_id_sequence, overlay_plans) = join!( + open_files, + deletion_vec_load, + row_id_load, + overlay_plan_load + ); let opened_files = opened_files?; let deletion_vec = deletion_vec?; let row_id_sequence = row_id_sequence?; + let overlay_plans = overlay_plans?; if opened_files.is_empty() && !read_config.has_system_cols() { return Err(Error::not_found(format!( @@ -938,6 +955,10 @@ impl FileFragment { Arc::new(self.metadata.clone()), )?; + if !overlay_plans.is_empty() { + reader.overlay_plans = Arc::new(overlay_plans); + } + if read_config.with_row_id { reader.with_row_id(); } @@ -1113,6 +1134,91 @@ impl FileFragment { Ok(opened_files) } + /// Open the overlay value-column readers for the projected fields, ordered + /// newest-first, ready to be merged into base batches on read. + /// + /// Each contributing overlay *file* is opened once (its metadata loaded), + /// projected to the fields it covers that the read also projects. The value + /// columns themselves are **not** read here — the per-batch merge fetches + /// only the coverage ranks it needs (see [`FragmentReader::merge_overlays`]), + /// so a `take` of a few rows no longer reads a whole overlay column. + /// + /// For each projected (top-level) field, the fragment's overlays are walked + /// newest-first; an overlay contributes if its `data_file.fields` includes + /// the field. Overlays on nested (non-top-level) fields are not yet supported + /// and are simply not matched here. + async fn load_overlay_plan( + &self, + projection: &Schema, + read_config: &FragReadConfig, + ) -> Result> { + let order = overlay_indices_newest_first(&self.metadata.overlays); + + // Open each contributing overlay file once, concurrently. The reader is + // shared (via `Arc`) by every field that file covers. + // + // TODO(overlay perf): these reads use the default reader priority. Once + // we benchmark take/scan over overlays, decide whether overlay value + // reads should inherit `read_config.reader_priority` (or get a dedicated + // priority) so they schedule alongside the base reads. + let opened: Vec>> = + futures::future::try_join_all(order.iter().map(|&overlay_idx| async move { + let overlay = &self.metadata.overlays[overlay_idx]; + let covered: Vec = projection + .fields + .iter() + .filter(|f| overlay.data_file.fields.contains(&f.id)) + .cloned() + .collect(); + if covered.is_empty() { + return Ok::<_, Error>(None); + } + let schema = Schema { + fields: covered, + metadata: Default::default(), + }; + Ok(self + .open_reader(&overlay.data_file, Some(&schema), read_config) + .await? + .map(Arc::from)) + })) + .await?; + + let mut plans = Vec::new(); + for field in &projection.fields { + let mut overlays_newest_first = Vec::new(); + for (slot, &overlay_idx) in order.iter().enumerate() { + let overlay = &self.metadata.overlays[overlay_idx]; + let Some(field_pos) = overlay + .data_file + .fields + .iter() + .position(|&id| id == field.id) + else { + continue; + }; + let Some(reader) = &opened[slot] else { + continue; + }; + overlays_newest_first.push(LoadedFieldOverlay { + coverage: overlay.coverage_for_field(field_pos)?, + reader: reader.clone(), + field_projection: Arc::new(Schema { + fields: vec![field.clone()], + metadata: Default::default(), + }), + }); + } + if !overlays_newest_first.is_empty() { + plans.push(FieldOverlayPlan { + field_name: field.name.clone(), + overlays_newest_first, + }); + } + } + Ok(plans) + } + /// Count the rows in this fragment. pub async fn count_rows(&self, filter: Option) -> Result { match filter { @@ -1989,6 +2095,106 @@ impl From for Fragment { } } +/// One overlay's contribution to one projected field: which physical offsets it +/// covers, and an opened (but unread) reader over the overlay file from which the +/// field's value column is fetched by coverage rank at merge time. +#[derive(Debug, Clone)] +struct LoadedFieldOverlay { + /// Physical offsets this overlay covers for the field. + coverage: Arc, + /// Reader over the overlay data file, projected to this field; shared across + /// the fields that the same file covers. + reader: Arc, + /// Single-field projection used when fetching the value column. + field_projection: Arc, +} + +/// The overlays that apply to a single projected field, ordered newest-first. +/// `field_name` is the top-level read-batch column name the plan applies to. +#[derive(Debug, Clone)] +struct FieldOverlayPlan { + field_name: String, + overlays_newest_first: Vec, +} + +/// Resolve overlays for one base batch: route each projected field against the +/// batch's physical `offsets`, fetch only the coverage ranks the batch touches +/// (concurrently with the base read), and assemble the merged columns. Fields +/// with no plan, and the row-id/row-address system columns, pass through. +async fn merge_overlay_batch( + base: ReadBatchFut, + offsets: &[u32], + plans: &[FieldOverlayPlan], +) -> Result { + let field_work = futures::future::try_join_all(plans.iter().map(|plan| async move { + let coverages: Vec<&RoaringBitmap> = plan + .overlays_newest_first + .iter() + .map(|overlay| overlay.coverage.as_ref()) + .collect(); + let routing = route_overlays(offsets, &coverages); + if routing.all_fall_through() { + return Ok::<_, Error>((plan.field_name.as_str(), None)); + } + let fetched = futures::future::try_join_all( + plan.overlays_newest_first + .iter() + .zip(routing.needed_ranks()) + .map(|(overlay, ranks)| { + fetch_overlay_ranks( + overlay.reader.as_ref(), + overlay.field_projection.clone(), + ranks, + ) + }), + ) + .await?; + Ok((plan.field_name.as_str(), Some((routing, fetched)))) + })); + + // The base read and every overlay value read proceed concurrently. + let (batch, resolved) = futures::future::try_join(base, field_work).await?; + + let schema = batch.schema(); + let mut columns = batch.columns().to_vec(); + for (field_name, work) in resolved { + let Some((routing, fetched)) = work else { + continue; + }; + let Some(idx) = schema.index_of(field_name).ok() else { + // The plan's field is not in this batch's projection; skip it. + continue; + }; + columns[idx] = assemble_overlay_column(&columns[idx], &routing, &fetched)?; + } + Ok(RecordBatch::try_new(schema, columns)?) +} + +/// Fetch one overlay's value column at the given coverage `ranks` (sorted and +/// unique). Returns a column of `ranks.len()` values aligned with `ranks`; an +/// empty `ranks` reads nothing and yields an empty column. +async fn fetch_overlay_ranks( + reader: &dyn GenericFileReader, + projection: Arc, + ranks: &[u32], +) -> Result { + if ranks.is_empty() { + return Ok(arrow_array::new_empty_array( + &projection.fields[0].data_type(), + )); + } + let mut tasks = reader + .take_all_tasks(ranks, ranks.len() as u32, projection, None) + .await?; + let mut chunks: Vec = Vec::new(); + while let Some(task) = tasks.next().await { + let batch = task.task.await?; + chunks.push(batch.column(0).clone()); + } + let chunk_refs: Vec<&dyn arrow_array::Array> = chunks.iter().map(|a| a.as_ref()).collect(); + Ok(arrow_select::concat::concat(&chunk_refs)?) +} + /// [`FragmentReader`] is an abstract reader for a [`FileFragment`]. /// /// It opens the data files that contains the columns of the projection schema, and @@ -2042,6 +2248,11 @@ pub struct FragmentReader { // total number of physical rows in the fragment (all rows, ignoring deletions) num_physical_rows: usize, + + /// Overlay value columns for the projected fields, loaded newest-first. + /// Empty when the fragment has no data overlay files. Merged into base + /// batches (by physical offset) before deletion filtering on every read. + overlay_plans: Arc>, } // Custom clone impl needed because it is not easy to clone Box @@ -2070,6 +2281,7 @@ impl Clone for FragmentReader { created_at_sequence: self.created_at_sequence.clone(), num_rows: self.num_rows, num_physical_rows: self.num_physical_rows, + overlay_plans: self.overlay_plans.clone(), } } } @@ -2137,6 +2349,7 @@ impl FragmentReader { created_at_sequence: None, num_rows, num_physical_rows, + overlay_plans: Arc::new(Vec::new()), }) } @@ -2394,6 +2607,50 @@ impl FragmentReader { Ok(result.project_by_schema(&output_schema)?) } + /// Merge data overlay values into a stream of base batches. + /// + /// Runs on physical rows in read order, *before* deletion filtering, so each + /// row can be addressed by its physical offset (from `params`) and deletions + /// take precedence naturally (an overlay value for a deleted row is dropped + /// with the row downstream). A no-op when the fragment has no overlays. + /// + /// Each batch's physical offsets are known from `params` and the task's + /// `num_rows` without reading any data, so the overlay value reads (only the + /// coverage ranks the batch actually touches) are issued concurrently with + /// the base read rather than after it. + fn merge_overlays( + &self, + merged: ReadBatchTaskStream, + params: &ReadBatchParams, + total_num_rows: u32, + ) -> ReadBatchTaskStream { + if self.overlay_plans.is_empty() { + return merged; + } + let offsets: Arc> = + Arc::new(params.to_offsets_total(total_num_rows).values().to_vec()); + let plans = self.overlay_plans.clone(); + let mut row_start = 0usize; + merged + .map(move |task| { + let num_rows = task.num_rows; + let start = row_start; + row_start += num_rows as usize; + let offsets = offsets.clone(); + let plans = plans.clone(); + let inner = task.task; + ReadBatchTask { + num_rows, + task: async move { + let slice = &offsets[start..start + num_rows as usize]; + merge_overlay_batch(inner, slice, &plans).await + } + .boxed(), + } + }) + .boxed() + } + async fn new_read_impl<'a, F>( &'a self, params: ReadBatchParams, @@ -2461,6 +2718,8 @@ impl FragmentReader { lance_table::utils::stream::merge_streams(read_streams) }; + let merged = self.merge_overlays(merged, ¶ms, total_num_rows); + // Add the row id column (if needed) and delete rows (if a deletion // vector is present). let config = RowIdAndDeletesConfig { @@ -2631,6 +2890,9 @@ impl FragmentReader { lance_table::utils::stream::merge_streams(read_streams) }; + let params = ReadBatchParams::Ranges(ranges); + let merged_stream = self.merge_overlays(merged_stream, ¶ms, total_num_rows); + // Add the row id column (if needed) and delete rows (if a deletion // vector is present). let config = RowIdAndDeletesConfig { @@ -2643,7 +2905,7 @@ impl FragmentReader { with_row_created_at_version: self.with_row_created_at_version, last_updated_at_sequence: self.last_updated_at_sequence.clone(), created_at_sequence: self.created_at_sequence.clone(), - params: ReadBatchParams::Ranges(ranges), + params, total_num_rows, }; let output_schema = Arc::new(self.output_schema.clone()); @@ -2839,6 +3101,656 @@ mod tests { Dataset::open(test_uri).await.unwrap() } + /// End-to-end tests for reading data overlay files (OSS-1324): overlays are + /// written, committed via the `DataOverlay` transaction, and then resolved on + /// the `take` and scan read paths. + mod overlay_read { + use std::sync::Arc; + + use arrow_array::{ + Array, ArrayRef, Int32Array, RecordBatch, RecordBatchIterator, UInt64Array, + }; + use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use lance_core::datatypes::Schema; + use lance_file::version::LanceFileVersion; + use lance_file::writer::{FileWriter, FileWriterOptions}; + use lance_io::utils::CachedFileSize; + use lance_table::format::{DataFile, DataOverlayFile, OverlayCoverage}; + use object_store::path::Path; + use roaring::RoaringBitmap; + use rstest::rstest; + + use crate::dataset::transaction::{DataOverlayGroup, Operation}; + use crate::dataset::{Dataset, WriteDestination, WriteParams}; + + fn bitmap(offsets: impl IntoIterator) -> RoaringBitmap { + RoaringBitmap::from_iter(offsets) + } + + fn i32_array(values: impl IntoIterator>) -> ArrayRef { + Arc::new(Int32Array::from_iter(values)) + } + + /// Two-fragment Int32 dataset: `id` (field 0) = 0..12 and `val` (field 1) + /// = id * 10, written 6 rows per file (fragments 0 and 1). + /// + /// Uses an in-memory store so the test can write overlay files with a + /// store-relative `data/.lance` path and commit against the returned + /// dataset directly. + async fn create_base_dataset(version: LanceFileVersion) -> Dataset { + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, true), + ArrowField::new("val", DataType::Int32, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..12)), + Arc::new(Int32Array::from_iter_values((0..12).map(|v| v * 10))), + ], + ) + .unwrap(); + let write_params = WriteParams { + max_rows_per_file: 6, + max_rows_per_group: 6, + data_storage_version: Some(version), + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + Dataset::write(reader, "memory://", Some(write_params)) + .await + .unwrap() + } + + /// Write an overlay file covering `fields` (dataset field ids) of + /// `fragment_id` with the given coverage and per-field value columns, then + /// commit it as a `DataOverlay` transaction. `name` makes the file unique. + #[allow(clippy::too_many_arguments)] + async fn commit_overlay( + dataset: Dataset, + name: &str, + fragment_id: u64, + fields: &[i32], + coverage: OverlayCoverage, + columns: Vec, + version: LanceFileVersion, + ) -> Dataset { + let read_version = dataset.version().version; + let overlay_schema = dataset.schema().project_by_ids(fields, true); + + let filename = format!("{name}.lance"); + let path = Path::from(format!("data/{filename}")); + let obj_writer = dataset.object_store.create(&path).await.unwrap(); + let mut writer = FileWriter::try_new( + obj_writer, + overlay_schema, + FileWriterOptions { + format_version: Some(version), + ..Default::default() + }, + ) + .unwrap(); + let (major, minor) = writer.version().to_numbers(); + for (column_index, array) in columns.into_iter().enumerate() { + writer.write_column(column_index, array).await.unwrap(); + } + let summary = writer.finish().await.unwrap(); + + let mut data_file = DataFile::new_unstarted(filename, major, minor); + data_file.fields = writer + .field_id_to_column_indices() + .iter() + .map(|(field_id, _)| *field_id as i32) + .collect::>() + .into(); + data_file.column_indices = writer + .field_id_to_column_indices() + .iter() + .map(|(_, column_index)| *column_index as i32) + .collect::>() + .into(); + data_file.file_size_bytes = CachedFileSize::new(summary.size_bytes); + + let overlay = DataOverlayFile { + data_file, + coverage, + committed_version: 0, + }; + Dataset::commit( + WriteDestination::Dataset(Arc::new(dataset)), + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id, + overlays: vec![overlay], + }], + }, + Some(read_version), + None, + None, + Arc::new(Default::default()), + false, + ) + .await + .unwrap() + } + + fn full_schema(dataset: &Dataset) -> Schema { + dataset.schema().clone() + } + + fn col(batch: &RecordBatch, name: &str) -> Int32Array { + let idx = batch.schema().index_of(name).unwrap(); + batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap() + .clone() + } + + #[rstest] + #[tokio::test] + async fn test_take_covered_and_uncovered( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + // Overlay fragment 0's `val` at physical offsets {1, 4}. + let dataset = commit_overlay( + dataset, + "ov", + 0, + &[1], + OverlayCoverage::dense(bitmap([1, 4])), + vec![i32_array([Some(111), Some(444)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag + .take(&[0, 1, 2, 4], &full_schema(&dataset)) + .await + .unwrap(); + // Offsets 1 and 4 take overlay values; 0 and 2 fall through to base. + assert_eq!(col(&batch, "val").values(), &[0, 111, 20, 444]); + // The unrelated `id` column is untouched. + assert_eq!(col(&batch, "id").values(), &[0, 1, 2, 4]); + } + + #[rstest] + #[tokio::test] + async fn test_take_newest_overlay_wins( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "older", + 0, + &[1], + OverlayCoverage::dense(bitmap([1, 4])), + vec![i32_array([Some(111), Some(444)])], + version, + ) + .await; + // A newer overlay (later commit -> higher committed_version) re-covers + // offset 1. + let dataset = commit_overlay( + dataset, + "newer", + 0, + &[1], + OverlayCoverage::dense(bitmap([1])), + vec![i32_array([Some(999)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[1, 4], &full_schema(&dataset)).await.unwrap(); + // Offset 1 -> newest overlay (999); offset 4 -> only older covers it. + assert_eq!(col(&batch, "val").values(), &[999, 444]); + } + + #[rstest] + #[tokio::test] + async fn test_take_per_field_coverage( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + // Sparse overlay: `id` covers {2}, `val` covers {2, 3} — different + // offset sets and therefore unequal-length value columns. + let dataset = commit_overlay( + dataset, + "sparse", + 0, + &[0, 1], + OverlayCoverage::sparse(vec![bitmap([2]), bitmap([2, 3])]), + vec![i32_array([Some(777)]), i32_array([Some(220), Some(330)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[2, 3], &full_schema(&dataset)).await.unwrap(); + // id: offset 2 covered (777), offset 3 falls through (3). + assert_eq!(col(&batch, "id").values(), &[777, 3]); + // val: both offsets covered (220, 330). + assert_eq!(col(&batch, "val").values(), &[220, 330]); + } + + #[rstest] + #[tokio::test] + async fn test_take_null_override( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "nullov", + 0, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([None])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[0, 1], &full_schema(&dataset)).await.unwrap(); + let val = col(&batch, "val"); + // Offset 0 is covered with a NULL value -> resolves to NULL; offset 1 + // falls through to the base value. + assert!(val.is_null(0)); + assert_eq!(val.value(1), 10); + } + + #[rstest] + #[tokio::test] + async fn test_overlay_on_deleted_row_is_inert( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let mut dataset = create_base_dataset(version).await; + // Delete global row 1 (fragment 0, physical offset 1). + dataset.delete("id = 1").await.unwrap(); + // Overlay covers the deleted offset 1 and the live offset 4. + let dataset = commit_overlay( + dataset, + "delov", + 0, + &[1], + OverlayCoverage::dense(bitmap([1, 4])), + vec![i32_array([Some(111), Some(444)])], + version, + ) + .await; + + // Scan fragment 0: row 1 is gone, and offset 4's overlay value survives + // even though the deletion shifts logical positions — coverage is keyed + // by physical offset. + let frag = dataset.get_fragment(0).unwrap(); + let mut scanner = frag.scan(); + let batch = scanner + .project(&["id", "val"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(col(&batch, "id").values(), &[0, 2, 3, 4, 5]); + assert_eq!(col(&batch, "val").values(), &[0, 20, 30, 444, 50]); + } + + #[rstest] + #[tokio::test] + async fn test_scan_multi_fragment_overlays( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + // Overlay fragment 0 at offset 0 and fragment 1 at offset 0 (global + // row 6). Each fragment's coverage is independent. + let dataset = commit_overlay( + dataset, + "frag0", + 0, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([Some(1000)])], + version, + ) + .await; + let dataset = commit_overlay( + dataset, + "frag1", + 1, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([Some(6000)])], + version, + ) + .await; + + let batch = dataset + .scan() + .project(&["id", "val"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(batch.num_rows(), 12); + let expected: Vec = (0..12) + .map(|i| match i { + 0 => 1000, + 6 => 6000, + other => other * 10, + }) + .collect(); + assert_eq!(col(&batch, "val").values(), &expected); + } + + /// A `take` of a few rows must read only the overlay value-column ranks + /// those rows touch — not the whole column. Uses v2.1 (which slices pages + /// on read) and an incompressible, all-covering overlay, so reading the + /// full column would be far more bytes than reading a couple of ranks. + /// This is the regression guard for the lazy, rank-pushdown overlay read. + #[tokio::test] + async fn test_take_reads_only_needed_overlay_ranks() { + let version = LanceFileVersion::V2_1; + const N: usize = 100_000; + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, true), + ArrowField::new("val", DataType::Int32, true), + ])); + let base = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..N as i32)), + Arc::new(Int32Array::from_iter_values((0..N as i32).map(|v| v * 10))), + ], + ) + .unwrap(); + let write_params = WriteParams { + max_rows_per_file: N, + max_rows_per_group: N, + data_storage_version: Some(version), + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(base)], schema.clone()); + let dataset = Dataset::write(reader, "memory://", Some(write_params)) + .await + .unwrap(); + + // Overlay `val` over ALL N offsets with incompressible values, so the + // value column is ~N*4 bytes on disk. + let values: Vec = (0..N as u64) + .map(|i| { + let mut x = i; + x ^= x >> 33; + x = x.wrapping_mul(0xff51_afd7_ed55_8ccd); + x ^= x >> 33; + x as i32 + }) + .collect(); + let dataset = commit_overlay( + dataset, + "big", + 0, + &[1], + OverlayCoverage::dense(bitmap(0..N as u32)), + vec![Arc::new(Int32Array::from(values.clone())) as ArrayRef], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let val_only = dataset.schema().project_by_ids(&[1], true); + + // Measure only the reads that resolve the take. + dataset.object_store.io_stats_incremental(); + let batch = frag.take(&[0, 1], &val_only).await.unwrap(); + let io = dataset.object_store.io_stats_incremental(); + + // The overlay's `val` column alone is N*4 bytes; resolving two adjacent + // offsets must read only a small fraction of it. + let full_column_bytes = (N * std::mem::size_of::()) as u64; + assert!( + io.read_bytes > 0 && io.read_bytes < full_column_bytes / 4, + "take read {} bytes; expected far less than the {}-byte overlay \ + column (a take must not read the whole value column)", + io.read_bytes, + full_column_bytes, + ); + + // ...and it still resolves correctly. + let val = col(&batch, "val"); + assert_eq!(val.value(0), values[0]); + assert_eq!(val.value(1), values[1]); + } + + /// The overlay merge runs before `wrap_with_row_id_and_delete`, so the + /// `_rowid` system column must coexist with overlay-resolved data columns: + /// the row ids are unaffected by the merge and the overlay value still wins. + #[rstest] + #[tokio::test] + async fn test_scan_with_row_id_alongside_overlay( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "rowidov", + 0, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([Some(1000)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag + .scan() + .with_row_id() + .project(&["id", "val"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + // Overlay value resolves... + assert_eq!(col(&batch, "val").values()[0], 1000); + assert_eq!(&col(&batch, "val").values()[1..], &[10, 20, 30, 40, 50]); + // ...and the row ids for fragment 0 are the untouched physical offsets. + let row_ids = batch + .column(batch.schema().index_of("_rowid").unwrap()) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(row_ids.values(), &[0, 1, 2, 3, 4, 5]); + } + + /// When the newest overlay covers every requested offset, an older overlay + /// in the same plan is routed zero ranks and its value column must not be + /// read (the empty-ranks branch of `fetch_overlay_ranks`). The result still + /// resolves to the newest overlay. + #[rstest] + #[tokio::test] + async fn test_take_older_overlay_contributes_no_ranks( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + // Older covers {1, 4}; newer re-covers {1}. A take of only offset 1 + // routes entirely to the newer overlay, leaving the older one with no + // ranks to fetch even though it is part of the field's plan. + let dataset = commit_overlay( + dataset, + "older", + 0, + &[1], + OverlayCoverage::dense(bitmap([1, 4])), + vec![i32_array([Some(111), Some(444)])], + version, + ) + .await; + let dataset = commit_overlay( + dataset, + "newer", + 0, + &[1], + OverlayCoverage::dense(bitmap([1])), + vec![i32_array([Some(999)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[1], &full_schema(&dataset)).await.unwrap(); + assert_eq!(col(&batch, "val").values(), &[999]); + } + + /// A newest overlay whose value is NULL must shadow an older overlay's + /// non-null value at the same offset — the merge resolves to NULL, it does + /// not fall back to the older overlay. + #[rstest] + #[tokio::test] + async fn test_take_newest_null_shadows_older( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "older", + 0, + &[1], + OverlayCoverage::dense(bitmap([1])), + vec![i32_array([Some(111)])], + version, + ) + .await; + let dataset = commit_overlay( + dataset, + "newer_null", + 0, + &[1], + OverlayCoverage::dense(bitmap([1])), + vec![i32_array([None])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[1], &full_schema(&dataset)).await.unwrap(); + let val = col(&batch, "val"); + assert!(val.is_null(0), "newest NULL must win over older 111"); + } + + /// Newest-wins is resolved independently per field across multiple sparse + /// overlays: for the same offset, `id` can resolve to one overlay while + /// `val` resolves to the other, depending on which overlay newly covers + /// that field at that offset. + #[rstest] + #[tokio::test] + async fn test_take_multi_sparse_per_field_newest_wins( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + // Older: id covers {3}, val covers {2}. + let dataset = commit_overlay( + dataset, + "older", + 0, + &[0, 1], + OverlayCoverage::sparse(vec![bitmap([3]), bitmap([2])]), + vec![i32_array([Some(7773)]), i32_array([Some(2772)])], + version, + ) + .await; + // Newer: id covers {2}, val covers {3} — the mirror image. + let dataset = commit_overlay( + dataset, + "newer", + 0, + &[0, 1], + OverlayCoverage::sparse(vec![bitmap([2]), bitmap([3])]), + vec![i32_array([Some(9992)]), i32_array([Some(9993)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + let batch = frag.take(&[2, 3], &full_schema(&dataset)).await.unwrap(); + // id: offset 2 -> newer (9992), offset 3 -> older (7773). + assert_eq!(col(&batch, "id").values(), &[9992, 7773]); + // val: offset 2 -> older (2772), offset 3 -> newer (9993). + assert_eq!(col(&batch, "val").values(), &[2772, 9993]); + } + + /// A fragment with an overlay plan, but a take that touches only uncovered + /// offsets, must fall entirely through to the base values (the + /// `routing.all_fall_through()` early-return with a plan present). + #[rstest] + #[tokio::test] + async fn test_take_plan_present_all_offsets_uncovered( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "ov", + 0, + &[1], + OverlayCoverage::dense(bitmap([1, 4])), + vec![i32_array([Some(111), Some(444)])], + version, + ) + .await; + + let frag = dataset.get_fragment(0).unwrap(); + // None of {0, 2, 5} are covered: the plan exists but contributes nothing. + let batch = frag.take(&[0, 2, 5], &full_schema(&dataset)).await.unwrap(); + assert_eq!(col(&batch, "val").values(), &[0, 20, 50]); + assert_eq!(col(&batch, "id").values(), &[0, 2, 5]); + } + + /// A dataset-level `take` spanning multiple fragments, each with its own + /// overlay, routes every global row index to the right fragment's overlay. + #[rstest] + #[tokio::test] + async fn test_dataset_take_multi_fragment_overlays( + #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion, + ) { + let dataset = create_base_dataset(version).await; + let dataset = commit_overlay( + dataset, + "frag0", + 0, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([Some(1000)])], + version, + ) + .await; + let dataset = commit_overlay( + dataset, + "frag1", + 1, + &[1], + OverlayCoverage::dense(bitmap([0])), + vec![i32_array([Some(6000)])], + version, + ) + .await; + + // Global rows 0 and 6 are the overlaid offset-0 rows of fragments 0 and + // 1; rows 1 and 7 fall through to base. + let batch = dataset + .take(&[0, 1, 6, 7], full_schema(&dataset)) + .await + .unwrap(); + assert_eq!(col(&batch, "id").values(), &[0, 1, 6, 7]); + assert_eq!(col(&batch, "val").values(), &[1000, 10, 6000, 70]); + } + } + #[rstest] #[tokio::test] async fn test_fragment_scan( diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index 87dda8e7e57..25168b54780 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -2125,6 +2125,7 @@ mod tests { let fragment = Fragment { id: 0, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(0), diff --git a/rust/lance/src/dataset/overlay.rs b/rust/lance/src/dataset/overlay.rs new file mode 100644 index 00000000000..0da44fa8c91 --- /dev/null +++ b/rust/lance/src/dataset/overlay.rs @@ -0,0 +1,596 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Resolution of data overlay files on read. +//! +//! An overlay supplies new values for a subset of `(physical offset, field)` +//! cells. To resolve a field's values for a set of physical row offsets, the +//! overlays that cover that field are walked **newest to oldest**: the first +//! overlay that covers an offset wins, and its value is taken at the offset's +//! **rank** (the 0-based count of set bits below it) in the field's coverage +//! bitmap. An offset that no overlay covers falls through to the base value. +//! +//! The offsets are supplied explicitly (one per base row), so a single code path +//! serves both the scan (a contiguous physical range) and `take` (arbitrary +//! physical offsets) read paths. +//! +//! Deletions take precedence over overlays, but that is handled downstream: the +//! merge runs on physical rows *before* the deletion filter, so an overlay value +//! for a deleted offset is computed and then dropped with the row — making it +//! inert, exactly as the specification requires, with no special handling here. + +use std::collections::{BTreeSet, HashMap}; + +use arrow_array::{Array, ArrayRef}; +use arrow_select::interleave::interleave; +use lance_core::{Error, Result}; +use roaring::RoaringBitmap; + +use lance_table::format::DataOverlayFile; + +/// Order a fragment's overlays from newest to oldest for read resolution. +/// +/// Precedence is by `committed_version` (higher is newer); ties are broken by +/// position in the fragment's `overlays` list, where a later entry is newer. +/// Returns indices into `overlays`. +pub fn overlay_indices_newest_first(overlays: &[DataOverlayFile]) -> Vec { + let mut indices: Vec = (0..overlays.len()).collect(); + indices.sort_by(|&a, &b| { + overlays[b] + .committed_version + .cmp(&overlays[a].committed_version) + .then(b.cmp(&a)) + }); + indices +} + +/// The physical offsets within a fragment whose value for an indexed field may be +/// stale relative to an index built at `index_version`, and so must be excluded +/// from that index's results and re-evaluated against current values on the flat +/// path. +/// +/// The set is the union, over every overlay whose `committed_version` is newer +/// than `index_version`, of that overlay's coverage **restricted to the indexed +/// fields**. The restriction makes exclusion field-aware: an overlay that touches +/// only non-indexed fields contributes nothing. An overlay whose +/// `committed_version <= index_version` is already incorporated by the index and +/// is ignored. +pub fn overlay_exclusion_offsets( + overlays: &[DataOverlayFile], + indexed_field_ids: &[i32], + index_version: u64, +) -> Result { + let mut excluded = RoaringBitmap::new(); + for overlay in overlays { + if overlay.committed_version <= index_version { + continue; + } + for (field_pos, field_id) in overlay.data_file.fields.iter().enumerate() { + if indexed_field_ids.contains(field_id) { + excluded |= &*overlay.coverage_for_field(field_pos)?; + } + } + } + Ok(excluded) +} + +/// Resolve a single field's values for the rows whose physical offsets are given +/// by `offsets` (one per base row, in the same order as `base`), merging the +/// overlays that cover the field (which must be supplied newest-first). +/// +/// Produced by [`route_overlays`] from the coverage bitmaps alone — before any +/// value column is read — so the caller can fetch only the ranks it actually +/// needs (see [`OverlayRouting::needed_ranks`]) instead of the whole column, and +/// then assemble the merged column with [`assemble_overlay_column`]. +pub struct OverlayRouting { + /// `interleave` source/position pairs, one per output row. Source `0` is the + /// base column (position = the row's index); source `k + 1` is overlay `k`'s + /// fetched values (position = the row's index within `needed_ranks[k]`). + indices: Vec<(usize, usize)>, + /// `needed_ranks[k]` is the sorted, deduplicated set of coverage ranks that + /// overlay `k` must supply for this batch — the indices to fetch from its + /// value column. + needed_ranks: Vec>, + /// Whether any row routes to an overlay at all (false ⇒ pure fall-through). + any_overlay: bool, +} + +impl OverlayRouting { + /// The ranks each overlay (newest-first) must fetch from its value column. + pub fn needed_ranks(&self) -> &[Vec] { + &self.needed_ranks + } + + /// True when no row is covered by any overlay, so the base column is the + /// answer unchanged and no value-column reads are needed. + pub fn all_fall_through(&self) -> bool { + !self.any_overlay + } +} + +/// Decide, for each physical offset in `offsets`, which source supplies its +/// value: the newest overlay whose coverage contains it (taken at the offset's +/// 0-based rank in that coverage), or the base column if none covers it. +/// +/// Reads only the coverage bitmaps (newest-first), so it can run before the +/// value columns are fetched and tells the caller exactly which ranks to fetch. +/// +/// A scan reads a contiguous physical range, so when `offsets` is contiguous +/// ascending we take a bitmap-major fast path that visits only each coverage's +/// in-range bits — `O(covered + K)` — instead of probing every offset against +/// every coverage. `take` supplies arbitrary offsets and uses the general path. +pub fn route_overlays( + offsets: &[u32], + coverages_newest_first: &[&RoaringBitmap], +) -> OverlayRouting { + match contiguous_base(offsets) { + Some(base) => route_contiguous(base, offsets.len(), coverages_newest_first), + None => route_arbitrary(offsets, coverages_newest_first), + } +} + +/// The starting offset if `offsets` is a contiguous ascending run +/// `[base, base + 1, ...]`, else `None` (including when empty). +fn contiguous_base(offsets: &[u32]) -> Option { + let base = *offsets.first()?; + offsets + .iter() + .enumerate() + .all(|(i, &offset)| offset as u64 == base as u64 + i as u64) + .then_some(base) +} + +/// Fast path for a contiguous batch: offset `o` is output row `o - base`, so a +/// coverage's bits route to rows directly without per-offset probing. +/// +/// For each coverage we intersect with the batch's offset range, which is a +/// container-level operation that drops a non-overlapping batch (e.g. a scan +/// batch past a contiguous coverage's bits) in `O(containers)` without touching +/// individual cells. The in-range bits then carry **consecutive** coverage ranks +/// starting at the count of bits below `base` (one `rank` lookup) — no bits lie +/// between them by construction — so ranks need no running count. Coverages are +/// processed newest-first with a "first claim wins" guard for precedence. +fn route_contiguous( + base: u32, + len: usize, + coverages_newest_first: &[&RoaringBitmap], +) -> OverlayRouting { + let mut needed_ranks: Vec> = vec![Vec::new(); coverages_newest_first.len()]; + let mut routed: Vec> = vec![None; len]; + let range_end = (base as u64 + len as u64).min(u32::MAX as u64) as u32; + let mut batch_range = RoaringBitmap::new(); + batch_range.insert_range(base..range_end); + + for (k, coverage) in coverages_newest_first.iter().enumerate() { + let in_range = *coverage & &batch_range; + if in_range.is_empty() { + continue; + } + // 0-based rank of the first in-range cell: the coverage bits below `base`. + let base_rank = if base == 0 { + 0 + } else { + coverage.rank(base - 1) as u32 + }; + for (i, offset) in in_range.iter().enumerate() { + let row = (offset - base) as usize; + if routed[row].is_none() { + routed[row] = Some((k, needed_ranks[k].len())); + needed_ranks[k].push(base_rank + i as u32); + } + } + } + + let mut any_overlay = false; + let indices = routed + .into_iter() + .enumerate() + .map(|(i, routed)| match routed { + None => (0, i), + Some((k, pos)) => { + any_overlay = true; + (k + 1, pos) + } + }) + .collect(); + + OverlayRouting { + indices, + needed_ranks, + any_overlay, + } +} + +/// General path for arbitrary (e.g. `take`) offsets: probe each offset against +/// the coverages newest-first. `take` batches are small, so the `O(N * K)` +/// probing here is not a bottleneck. +fn route_arbitrary(offsets: &[u32], coverages_newest_first: &[&RoaringBitmap]) -> OverlayRouting { + let mut rank_sets: Vec> = vec![BTreeSet::new(); coverages_newest_first.len()]; + let mut raw: Vec> = Vec::with_capacity(offsets.len()); + for &offset in offsets { + let mut routed = None; + for (k, coverage) in coverages_newest_first.iter().enumerate() { + if coverage.contains(offset) { + // 0-based rank: number of set bits strictly below `offset`. + let rank = coverage.rank(offset) as u32 - 1; + rank_sets[k].insert(rank); + routed = Some((k, rank)); + break; + } + } + raw.push(routed); + } + + let needed_ranks: Vec> = rank_sets + .iter() + .map(|ranks| ranks.iter().copied().collect()) + .collect(); + let rank_positions: Vec> = needed_ranks + .iter() + .map(|ranks| ranks.iter().enumerate().map(|(pos, &r)| (r, pos)).collect()) + .collect(); + + let mut any_overlay = false; + let indices = raw + .into_iter() + .enumerate() + .map(|(i, routed)| match routed { + None => (0, i), + Some((k, rank)) => { + any_overlay = true; + (k + 1, rank_positions[k][&rank]) + } + }) + .collect(); + + OverlayRouting { + indices, + needed_ranks, + any_overlay, + } +} + +/// Assemble the merged column from `base` and the per-overlay values fetched for +/// the ranks [`route_overlays`] asked for. +/// +/// `fetched_newest_first[k]` holds overlay `k`'s values for `routing`'s +/// `needed_ranks[k]`, in that order. The result has the same length and data +/// type as `base`; a covered offset whose overlay value is NULL resolves **to** +/// NULL (distinct from a fall-through, which keeps its base value). +pub fn assemble_overlay_column( + base: &ArrayRef, + routing: &OverlayRouting, + fetched_newest_first: &[ArrayRef], +) -> Result { + if routing.all_fall_through() { + return Ok(base.clone()); + } + if fetched_newest_first.len() != routing.needed_ranks.len() { + return Err(Error::invalid_input(format!( + "overlay assembly got {} value columns but routing expects {}", + fetched_newest_first.len(), + routing.needed_ranks.len() + ))); + } + for (k, values) in fetched_newest_first.iter().enumerate() { + if values.len() != routing.needed_ranks[k].len() { + return Err(Error::invalid_input(format!( + "overlay value column {} has {} values but {} ranks were requested", + k, + values.len(), + routing.needed_ranks[k].len() + ))); + } + } + + let mut sources: Vec<&dyn Array> = Vec::with_capacity(fetched_newest_first.len() + 1); + sources.push(base.as_ref()); + for values in fetched_newest_first { + sources.push(values.as_ref()); + } + interleave(&sources, &routing.indices).map_err(Error::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_array::{Int32Array, StringArray, UInt32Array}; + use std::sync::Arc; + + fn i32_array(values: impl IntoIterator>) -> ArrayRef { + Arc::new(Int32Array::from_iter(values)) + } + + fn bitmap(offsets: impl IntoIterator) -> RoaringBitmap { + RoaringBitmap::from_iter(offsets) + } + + /// Physical offsets for a contiguous range `[start, start + len)`. + fn offsets(start: u32, len: usize) -> Vec { + (start..start + len as u32).collect() + } + + /// Drive the production flow purely in memory: route against the coverage + /// bitmaps, then fetch just the requested ranks from each overlay's *full* + /// value column (exactly what the rank-pushdown `take` does on disk), then + /// assemble. `overlays_newest_first` holds each overlay's `(coverage, full + /// value column indexed by rank)`. + fn resolve( + base: &ArrayRef, + offsets: &[u32], + overlays_newest_first: &[(RoaringBitmap, ArrayRef)], + ) -> ArrayRef { + let coverages: Vec<&RoaringBitmap> = overlays_newest_first.iter().map(|(c, _)| c).collect(); + let routing = route_overlays(offsets, &coverages); + let fetched: Vec = overlays_newest_first + .iter() + .zip(routing.needed_ranks()) + .map(|((_, full), ranks)| { + let indices = UInt32Array::from(ranks.clone()); + arrow_select::take::take(full.as_ref(), &indices, None).unwrap() + }) + .collect(); + assemble_overlay_column(base, &routing, &fetched).unwrap() + } + + fn assert_i32_eq(actual: &ArrayRef, expected: impl IntoIterator>) { + let actual = actual.as_any().downcast_ref::().unwrap(); + assert_eq!(actual, &Int32Array::from_iter(expected)); + } + + #[test] + fn test_no_overlays_returns_base() { + let base = i32_array([Some(1), Some(2), Some(3)]); + let resolved = resolve(&base, &offsets(0, 3), &[]); + assert_i32_eq(&resolved, [Some(1), Some(2), Some(3)]); + } + + #[test] + fn test_single_overlay_rank_addressing() { + // Base ages [30, 25, 40, 22]; overlay sets offset 1 -> 26 (rank 0). + let base = i32_array([Some(30), Some(25), Some(40), Some(22)]); + let overlay = (bitmap([1]), i32_array([Some(26)])); + let resolved = resolve(&base, &offsets(0, 4), &[overlay]); + assert_i32_eq(&resolved, [Some(30), Some(26), Some(40), Some(22)]); + } + + #[test] + fn test_rank_addressing_multiple_offsets() { + // Coverage {0, 2, 3} -> values at ranks 0,1,2. + let base = i32_array([Some(10), Some(11), Some(12), Some(13)]); + let overlay = ( + bitmap([0, 2, 3]), + i32_array([Some(100), Some(120), Some(130)]), + ); + let resolved = resolve(&base, &offsets(0, 4), &[overlay]); + assert_i32_eq(&resolved, [Some(100), Some(11), Some(120), Some(130)]); + } + + #[test] + fn test_newest_overlay_wins() { + // Two overlays both cover offset 1; the newest (first in the slice) wins. + let base = i32_array([Some(0), Some(1), Some(2)]); + let newest = (bitmap([1]), i32_array([Some(999)])); + let older = (bitmap([1, 2]), i32_array([Some(111), Some(222)])); + let resolved = resolve(&base, &offsets(0, 3), &[newest, older]); + // offset 1 -> newest (999); offset 2 -> only older covers it (222). + assert_i32_eq(&resolved, [Some(0), Some(999), Some(222)]); + } + + #[test] + fn test_null_override_vs_fall_through() { + // A covered offset with a NULL value overrides the cell to NULL; an + // absent offset falls through to the base. + let base = i32_array([Some(1), Some(2), Some(3)]); + let overlay = (bitmap([0]), i32_array([None])); + let resolved = resolve(&base, &offsets(0, 3), &[overlay]); + assert_i32_eq(&resolved, [None, Some(2), Some(3)]); + } + + #[test] + fn test_physical_start_offset() { + // The batch covers physical rows [10, 13); the overlay covers offset 11. + let base = i32_array([Some(0), Some(0), Some(0)]); + let overlay = (bitmap([11]), i32_array([Some(7)])); + let resolved = resolve(&base, &offsets(10, 3), &[overlay]); + assert_i32_eq(&resolved, [Some(0), Some(7), Some(0)]); + } + + #[test] + fn test_string_column_merge() { + let base: ArrayRef = Arc::new(StringArray::from(vec!["a", "b", "c"])); + let overlay = ( + bitmap([0, 2]), + Arc::new(StringArray::from(vec!["A", "C"])) as ArrayRef, + ); + let resolved = resolve(&base, &offsets(0, 3), &[overlay]); + let expected: ArrayRef = Arc::new(StringArray::from(vec!["A", "b", "C"])); + assert_eq!(&resolved, &expected); + } + + #[test] + fn test_non_contiguous_offsets() { + // `take` supplies arbitrary, non-contiguous physical offsets. The base + // rows correspond to offsets 5, 1, 8 (in that order); the overlay covers + // offsets {1, 8} with values at ranks 0, 1. + let base = i32_array([Some(50), Some(10), Some(80)]); + let overlay = (bitmap([1, 8]), i32_array([Some(11), Some(88)])); + let resolved = resolve(&base, &[5, 1, 8], &[overlay]); + // offset 5 uncovered -> base 50; offset 1 -> rank 0 (11); offset 8 -> rank 1 (88). + assert_i32_eq(&resolved, [Some(50), Some(11), Some(88)]); + } + + #[test] + fn test_routing_dedups_repeated_ranks() { + // A `take` may request the same offset twice; both rows must route to the + // same rank, and that rank is fetched only once. + let coverage = bitmap([2, 5]); + let routing = route_overlays(&[5, 2, 5], &[&coverage]); + // Offset 5 is rank 1, offset 2 is rank 0: distinct ranks {0, 1}, sorted. + assert_eq!(routing.needed_ranks(), &[vec![0, 1]]); + let full = i32_array([Some(20), Some(50)]); // values at ranks 0, 1 + let fetched = vec![ + arrow_select::take::take( + full.as_ref(), + &UInt32Array::from(routing.needed_ranks()[0].clone()), + None, + ) + .unwrap(), + ]; + let base = i32_array([Some(0), Some(0), Some(0)]); + let resolved = assemble_overlay_column(&base, &routing, &fetched).unwrap(); + assert_i32_eq(&resolved, [Some(50), Some(20), Some(50)]); + } + + #[test] + fn test_assemble_value_count_mismatch_errors() { + let coverage = bitmap([0, 1]); + let routing = route_overlays(&[0, 1], &[&coverage]); + let base = i32_array([Some(1), Some(2)]); + // One value supplied for two requested ranks is a caller bug. + let fetched = vec![i32_array([Some(9)])]; + assert!(assemble_overlay_column(&base, &routing, &fetched).is_err()); + } + + #[test] + fn test_contiguous_fast_path_matches_general() { + // The contiguous fast path must produce byte-for-byte identical routing + // to the general offset-major path for any contiguous batch. Fuzz a range + // of bases, lengths, overlay counts, and coverage densities — including + // bits outside the batch range — and compare both fields. + let mut state = 0x9e3779b97f4a7c15u64; + let mut next = || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as u32 + }; + for _ in 0..500 { + let base = next() % 64; + let len = (next() % 48 + 1) as usize; + let num_overlays = (next() % 5) as usize; + let coverages: Vec = (0..num_overlays) + .map(|_| { + let density = next() % 101; + let mut b = RoaringBitmap::new(); + for off in base.saturating_sub(3)..base + len as u32 + 3 { + if next() % 100 < density { + b.insert(off); + } + } + b + }) + .collect(); + let refs: Vec<&RoaringBitmap> = coverages.iter().collect(); + let contiguous_offsets: Vec = (base..base + len as u32).collect(); + + let fast = route_contiguous(base, len, &refs); + let general = route_arbitrary(&contiguous_offsets, &refs); + assert_eq!(fast.indices, general.indices, "indices differ"); + assert_eq!( + fast.needed_ranks, general.needed_ranks, + "needed_ranks differ" + ); + assert_eq!(fast.any_overlay, general.any_overlay, "any_overlay differs"); + } + } + + #[test] + fn test_overlay_ordering_newest_first() { + use lance_table::format::{DataFile, OverlayCoverage}; + let mk = |version: u64| DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![1], None), + coverage: OverlayCoverage::dense(RoaringBitmap::new()), + committed_version: version, + }; + // List order [v2, v5, v3]; newest-first should be v5(idx1), v3(idx2), v2(idx0). + let overlays = vec![mk(2), mk(5), mk(3)]; + assert_eq!(overlay_indices_newest_first(&overlays), vec![1, 2, 0]); + + // Equal versions: later list position is newer. + let overlays = vec![mk(4), mk(4)]; + assert_eq!(overlay_indices_newest_first(&overlays), vec![1, 0]); + } + + /// A dense overlay covering `offsets` for `field_ids`, committed at `version`. + fn dense_overlay( + field_ids: Vec, + offsets: impl IntoIterator, + version: u64, + ) -> lance_table::format::DataOverlayFile { + use lance_table::format::{DataFile, OverlayCoverage}; + lance_table::format::DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", field_ids, None), + coverage: OverlayCoverage::dense(bitmap(offsets)), + committed_version: version, + } + } + + #[test] + fn test_exclusion_offsets_version_gate() { + // index built at version 5; only overlays committed > 5 are excluded. + let overlays = vec![ + dense_overlay(vec![3], [0, 1], 4), + dense_overlay(vec![3], [2, 7], 6), + ]; + let excluded = overlay_exclusion_offsets(&overlays, &[3], 5).unwrap(); + assert_eq!(excluded, bitmap([2, 7])); + // An overlay exactly at the index version is already incorporated. + let overlays = vec![dense_overlay(vec![3], [9], 5)]; + assert!( + overlay_exclusion_offsets(&overlays, &[3], 5) + .unwrap() + .is_empty() + ); + } + + #[test] + fn test_exclusion_offsets_is_field_aware() { + // An overlay touching only an unrelated field excludes nothing. + let overlays = vec![dense_overlay(vec![2], [0, 1, 2], 9)]; + assert!( + overlay_exclusion_offsets(&overlays, &[3], 1) + .unwrap() + .is_empty() + ); + // The union spans only the indexed fields the overlay actually carries. + let overlays = vec![dense_overlay(vec![2, 3], [4], 9)]; + assert_eq!( + overlay_exclusion_offsets(&overlays, &[3], 1).unwrap(), + bitmap([4]) + ); + } + + #[test] + fn test_exclusion_offsets_sparse_per_field() { + use lance_table::format::{DataFile, OverlayCoverage}; + // Sparse overlay: field 2 covers {2,3}, field 4 covers {1}. + let overlay = DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![2, 4], None), + coverage: OverlayCoverage::sparse(vec![bitmap([2, 3]), bitmap([1])]), + committed_version: 9, + }; + let overlays = vec![overlay]; + // Only the bitmap for the indexed field (4) contributes. + assert_eq!( + overlay_exclusion_offsets(&overlays, &[4], 1).unwrap(), + bitmap([1]) + ); + assert_eq!( + overlay_exclusion_offsets(&overlays, &[2], 1).unwrap(), + bitmap([2, 3]) + ); + } + + #[test] + fn test_exclusion_offsets_unions_multiple_overlays() { + let overlays = vec![ + dense_overlay(vec![3], [1], 6), + dense_overlay(vec![3], [4, 5], 7), + ]; + assert_eq!( + overlay_exclusion_offsets(&overlays, &[3], 1).unwrap(), + bitmap([1, 4, 5]) + ); + } +} diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index d4b58e4783f..ff1cc52f59d 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -12,12 +12,12 @@ use std::task::{Context, Poll}; use crate::index::DatasetIndexExt; use arrow::array::AsArray; -use arrow_array::{Array, Float32Array, Int64Array, RecordBatch}; +use arrow_array::{Array, Float32Array, Int64Array, RecordBatch, UInt64Array}; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef, SortOptions}; use arrow_select::concat::concat_batches; use async_recursion::async_recursion; use chrono::Utc; -use datafusion::common::{DFSchema, JoinType, NullEquality, SchemaExt, exec_datafusion_err}; +use datafusion::common::{DFSchema, JoinType, NullEquality, exec_datafusion_err}; use datafusion::functions_aggregate; use datafusion::logical_expr::{Expr, ScalarUDF, col, lit}; use datafusion::physical_expr::PhysicalSortExpr; @@ -83,11 +83,12 @@ use tracing::{Span, info_span, instrument}; use uuid::Uuid; use super::Dataset; +use crate::dataset::overlay::overlay_exclusion_offsets; use crate::dataset::row_offsets_to_row_addresses; use crate::dataset::utils::SchemaAdapter; use crate::index::DatasetIndexInternalExt; use crate::index::scalar::inverted::{load_segment_details, load_segments}; -use crate::index::scalar_logical::scalar_index_fragment_bitmap; +use crate::index::scalar_logical::{load_named_scalar_segments, scalar_index_fragment_bitmap}; use crate::index::vector::utils::{ default_distance_type_for, get_vector_dim, get_vector_type, validate_distance_type_for, }; @@ -2897,6 +2898,23 @@ impl Scanner { read_options = read_options.with_only_indexed_fragments(); } + // Mask data overlay files: a fragment with an overlay committed after an index it relies + // on touched an indexed field can no longer be trusted to that index. Drop such fragments + // from the index's covered set so they are re-evaluated on the flat path (OSS-1325). + if let Some(index_query) = filter_plan.index_query.as_ref() { + let candidate_frags = read_options + .fragments + .clone() + .unwrap_or_else(|| self.dataset.fragments().clone()); + let stale_rows = self + .overlay_stale_index_rows(index_query, &candidate_frags) + .await?; + if !stale_rows.is_empty() { + let stale_frags: RoaringBitmap = stale_rows.keys().copied().collect(); + read_options = read_options.with_overlay_stale_fragments(stale_frags); + } + } + let result_format = self.index_expr_result_format(); let index_input = filter_plan.index_query.clone().map(|index_query| { Arc::new(ScalarIndexExec::new( @@ -3277,6 +3295,10 @@ impl Scanner { self.fragments_covered_by_fts_query(&query).await?, ) .await?; + // TODO(OSS-1325): FTS does not yet mask data overlay files. A fragment with an overlay + // on an FTS-indexed field committed after the index was built may return stale hits. The + // fix requires identifying stale FTS segments (analogous to `overlay_stale_index_frags`) + // and routing their fragments to the flat-text fallback path. let fts_exec = self .plan_fts(&query, ¶ms, filter_plan, &prefilter_source) .await?; @@ -3509,30 +3531,62 @@ impl Scanner { let unindexed_fragments = self .retain_target_fragments(self.dataset.unindexed_fragments(&index.name).await?); - // If all target fragments are unindexed, skip index entirely - if unindexed_fragments.len() == target_fragments.len() { + // Fragments whose FTS index entries may be stale due to a newer data overlay. + // These are excluded from the indexed path and re-evaluated on the flat path. + let (stale_flat_frag_ids, fresh_segments) = self + .fts_stale_frags_and_fresh_segments(&column, &target_fragments) + .await?; + + // Fragments that need flat evaluation: unindexed + stale (deduplicated). + let flat_fragments: Vec = { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut frags = Vec::new(); + for f in unindexed_fragments.iter().chain( + target_fragments + .iter() + .filter(|f| stale_flat_frag_ids.contains(f.id as u32)), + ) { + if seen.insert(f.id as u32) { + frags.push(f.clone()); + } + } + frags + }; + + // If all target fragments need flat evaluation, skip the indexed path. + if flat_fragments.len() == target_fragments.len() { if self.fast_search { return Ok(Arc::new(EmptyExec::new(FTS_SCHEMA.clone()))); } let flat_match_plan = self - .plan_flat_match_query(unindexed_fragments, query, params, filter_plan) + .plan_flat_match_query(flat_fragments, query, params, filter_plan) .await?; return Ok(flat_match_plan); } - // Mixed case: use index + flat search for unindexed - let match_plan: Arc = Arc::new(MatchQueryExec::new( - self.dataset.clone(), - query.clone(), - params.clone(), - prefilter_source.clone(), - )); + // Build the indexed path. When overlays made some segments stale we use + // `new_with_segments` to restrict the search to fresh segments only. + let match_plan: Arc = match fresh_segments { + Some(segs) => Arc::new(MatchQueryExec::new_with_segments( + self.dataset.clone(), + query.clone(), + params.clone(), + prefilter_source.clone(), + segs, + )), + None => Arc::new(MatchQueryExec::new( + self.dataset.clone(), + query.clone(), + params.clone(), + prefilter_source.clone(), + )), + }; - if self.fast_search || unindexed_fragments.is_empty() { + if self.fast_search || flat_fragments.is_empty() { (Some(match_plan), None) } else { let flat_match_plan = self - .plan_flat_match_query(unindexed_fragments, query, params, filter_plan) + .plan_flat_match_query(flat_fragments, query, params, filter_plan) .await?; (Some(match_plan), Some(flat_match_plan)) } @@ -3787,9 +3841,32 @@ impl Scanner { "Refine factor cannot be zero".to_string(), )); } + // Mask data overlay files: compute which row addresses within each segment have + // been updated by a newer overlay so their ANN entries may be stale (OSS-1325). + // These stale rows are blocked from ANN results via the prefilter and re-scored + // on the targeted flat path below — only the specific stale rows, not the whole + // fragment, so sparse overlays incur near-zero overhead. + let stale_rows = self.mask_overlay_stale_rows(&index_segments)?; + // Build a prefilter block mask for stale rows (empty = no-op fast path). + let overlay_block: Option = if stale_rows.is_empty() { + None + } else { + let mut tree_map = RowAddrTreeMap::new(); + for (&frag_id, offsets) in &stale_rows { + tree_map.insert_bitmap(frag_id, offsets.clone()); + } + Some(RowAddrMask::from_block(tree_map)) + }; + let ann_node = match vector_type { - DataType::FixedSizeList(_, _) => self.ann(&q, &index_segments, filter_plan).await?, - DataType::List(_) => self.multivec_ann(&q, &index_segments, filter_plan).await?, + DataType::FixedSizeList(_, _) => { + self.ann(&q, &index_segments, filter_plan, overlay_block.clone()) + .await? + } + DataType::List(_) => { + self.multivec_ann(&q, &index_segments, filter_plan, overlay_block.clone()) + .await? + } _ => unreachable!(), }; @@ -3807,7 +3884,14 @@ impl Scanner { if !self.fast_search { knn_node = self - .knn_combined(&q, &index_name, &index_segments, knn_node, filter_plan) + .knn_combined( + &q, + &index_name, + &index_segments, + &stale_rows, + knn_node, + filter_plan, + ) .await?; } @@ -3942,6 +4026,7 @@ impl Scanner { q: &Query, index_name: &str, indexed_segments: &[IndexMetadata], + stale_rows: &std::collections::HashMap, mut knn_node: Arc, filter_plan: &ExprFilterPlan, ) -> Result> { @@ -3958,30 +4043,42 @@ impl Scanner { self.dataset.unindexed_fragments(index_name).await? }; - if !fallback_fragments.is_empty() { - let q = q.clone(); - debug_assert!(q.metric_type.is_some()); + let has_fallback = !fallback_fragments.is_empty(); + let has_stale = !stale_rows.is_empty(); - // If the vector column is not present, we need to take the vector column, so - // that the distance value is comparable with the flat search ones. - if knn_node.schema().column_with_name(&q.column).is_none() { - let vector_projection = self - .dataset - .empty_projection() - .union_column(&q.column, OnMissing::Error) - .unwrap(); - knn_node = self.take(knn_node, vector_projection)?; - } + if !has_fallback && !has_stale { + return Ok(knn_node); + } - let mut columns = vec![q.column.clone()]; - if let Some(expr) = filter_plan.full_expr.as_ref() { - let filter_columns = Planner::column_names_in_expr(expr); - columns.extend(filter_columns); - } + let q = q.clone(); + debug_assert!(q.metric_type.is_some()); + + // Ensure the vector column is present for distance computation. + if knn_node.schema().column_with_name(&q.column).is_none() { + let vector_projection = self + .dataset + .empty_projection() + .union_column(&q.column, OnMissing::Error) + .unwrap(); + knn_node = self.take(knn_node, vector_projection)?; + } + + let mut columns = vec![q.column.clone()]; + if let Some(expr) = filter_plan.full_expr.as_ref() { + let filter_columns = Planner::column_names_in_expr(expr); + columns.extend(filter_columns); + } + + // Collect flat-path plans; union order matches original (flat before ANN) so test snapshots + // and downstream plan analyses remain stable. + let mut flat_inputs: Vec> = Vec::new(); + + // Flat KNN for unindexed (new-data) fragments. + if has_fallback { let vector_scan_projection = Arc::new(self.dataset.schema().project(&columns).unwrap()); - // Note: we could try and use the scalar indices here to reduce the scope of this scan but the - // most common case is that fragments that are newer than the vector index are going to be newer - // than the scalar indices anyways + // Note: we could try and use the scalar indices here to reduce the scope of this scan + // but the most common case is that fragments newer than the vector index are also + // newer than the scalar indices. let mut scan_node = self.scan_fragments( true, false, @@ -3990,41 +4087,67 @@ impl Scanner { false, vector_scan_projection, Arc::new(fallback_fragments), - // Can't pushdown limit/offset in an ANN search None, - // We are re-ordering anyways, so no need to get data in data - // in a deterministic order. false, ); - if let Some(expr) = filter_plan.full_expr.as_ref() { - // If there is a prefilter we need to manually apply it to the new data scan_node = Arc::new(LanceFilterExec::try_new(expr.clone(), scan_node)?); } - // first we do flat search on just the new data - let topk_appended = self.flat_knn(scan_node, &q)?; + let topk_fallback = self.flat_knn(scan_node, &q)?; + let topk_fallback: Arc = + Arc::new(project(topk_fallback, knn_node.schema().as_ref())?); + flat_inputs.push(topk_fallback); + } - // To do a union, we need to make the schemas match. Right now - // knn_node: _distance, _rowid, vector - // topk_appended: vector, , _rowid, _distance - let topk_appended = project(topk_appended, knn_node.schema().as_ref())?; - assert!( - topk_appended - .schema() - .equivalent_names_and_types(&knn_node.schema()) - ); - // union - let unioned = UnionExec::try_new(vec![Arc::new(topk_appended), knn_node])?; - // Enforce only 1 partition. - let unioned = RepartitionExec::try_new( - unioned, - datafusion::physical_plan::Partitioning::RoundRobinBatch(1), + // Flat KNN for stale rows only (row-level precision, OSS-1325). + // Only specific row addresses need re-scoring, not the whole fragment, so sparse overlays + // incur near-zero overhead. + if has_stale { + let stale_addrs: Vec = stale_rows + .iter() + .flat_map(|(&frag_id, offsets)| { + offsets + .iter() + .map(move |offset| ((frag_id as u64) << 32) | offset as u64) + }) + .collect(); + let batch = RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ArrowField::new( + ROW_ID, + DataType::UInt64, + true, + )])), + vec![Arc::new(UInt64Array::from(stale_addrs))], )?; - // then we do a flat search on KNN(new data) + ANN(indexed data) - return self.flat_knn(Arc::new(unioned), &q); + let stale_id_plan = Arc::new(OneShotExec::from_batch(batch)); + + // Fetch vector + filter columns for the stale rows. + let mut take_proj = self + .dataset + .empty_projection() + .union_column(&q.column, OnMissing::Error)?; + if let Some(expr) = filter_plan.full_expr.as_ref() { + let filter_columns = Planner::column_names_in_expr(expr); + take_proj = take_proj.union_columns(filter_columns, OnMissing::Error)?; + } + let mut stale_node = self.take(stale_id_plan, take_proj)?; + if let Some(expr) = filter_plan.full_expr.as_ref() { + stale_node = Arc::new(LanceFilterExec::try_new(expr.clone(), stale_node)?); + } + let topk_stale = self.flat_knn(stale_node, &q)?; + let topk_stale: Arc = + Arc::new(project(topk_stale, knn_node.schema().as_ref())?); + flat_inputs.push(topk_stale); } - Ok(knn_node) + // Union: flat paths first (matching original order), then ANN results. + flat_inputs.push(knn_node); + let unioned = UnionExec::try_new(flat_inputs)?; + let unioned = RepartitionExec::try_new( + unioned, + datafusion::physical_plan::Partitioning::RoundRobinBatch(1), + )?; + self.flat_knn(Arc::new(unioned), &q) } #[async_recursion] @@ -4055,29 +4178,177 @@ impl Scanner { } } - /// Given an index query, split the fragments into two sets + /// Given an index query, split the fragments into two groups and collect per-row stale data. /// - /// The first set is the relevant fragments, which are covered by ALL indices in the query - /// The second set is the missing fragments, which are missed by at least one index + /// - `relevant_frags`: covered by ALL indices. Stale rows within them are returned separately + /// so callers can block them from `MaterializeIndexExec` and re-score via a targeted take. + /// - `missing_frags`: not covered by at least one index; fall back to full scan + filter. + /// - `stale_rows`: per-fragment row offsets whose indexed values are stale due to a data + /// overlay committed after the index was built (field-aware, version-gated). Empty when no + /// overlays are present. /// - /// There is no point in handling the case where a fragment is covered by some (but not all) - /// of the indices. If we have to do a full scan of the fragment then we do it + /// There is no point in partially indexing a fragment (some indices cover it, others do not). + /// If we have to do a full scan of a fragment for any reason, we do it entirely. async fn partition_frags_by_coverage( &self, index_expr: &ScalarIndexExpr, fragments: Arc>, - ) -> Result<(Vec, Vec)> { + ) -> Result<( + Vec, + Vec, + std::collections::HashMap, + )> { let covered_frags = self.fragments_covered_by_index_query(index_expr).await?; + let stale_rows = self + .overlay_stale_index_rows(index_expr, &fragments) + .await?; let mut relevant_frags = Vec::with_capacity(fragments.len()); let mut missing_frags = Vec::with_capacity(fragments.len()); for fragment in fragments.iter() { if covered_frags.contains(fragment.id as u32) { + // Indexed fragments stay on the indexed path. Stale rows within them are blocked + // from the index result and re-evaluated separately via a targeted take. relevant_frags.push(fragment.clone()); } else { missing_frags.push(fragment.clone()); } } - Ok((relevant_frags, missing_frags)) + Ok((relevant_frags, missing_frags, stale_rows)) + } + + /// Per-row stale offsets for each fragment whose indexed values may be stale because an + /// overlay committed *after* an index was built touches a field that index covers. + /// + /// The check is field-aware (an overlay touching only unindexed fields excludes nothing) and + /// version-gated (an overlay with `committed_version <= index.dataset_version` is already + /// incorporated by the index), via [`overlay_exclusion_offsets`]. + async fn overlay_stale_index_rows( + &self, + index_expr: &ScalarIndexExpr, + fragments: &[Fragment], + ) -> Result> { + // Overlays are rare; skip all index loading when none of the candidate fragments has one. + if fragments + .iter() + .all(|fragment| fragment.overlays.is_empty()) + { + return Ok(std::collections::HashMap::new()); + } + let frag_by_id: std::collections::HashMap = + fragments.iter().map(|f| (f.id as u32, f)).collect(); + + // Walk the (boolean) index expression tree to collect leaf searches. + let mut searches = Vec::new(); + let mut stack = vec![index_expr]; + while let Some(expr) = stack.pop() { + match expr { + ScalarIndexExpr::Not(inner) => stack.push(inner), + ScalarIndexExpr::And(lhs, rhs) | ScalarIndexExpr::Or(lhs, rhs) => { + stack.push(lhs); + stack.push(rhs); + } + ScalarIndexExpr::Query(search) => searches.push(search), + } + } + + // `load_named_scalar_segments` returns cached index metadata — no disk I/O on the hot + // path. Even without the cache, this code is only reached when at least one fragment has + // overlays (rare), so the per-leaf cost is acceptable. + let mut stale: std::collections::HashMap = + std::collections::HashMap::new(); + for search in searches { + let segments = load_named_scalar_segments( + self.dataset.as_ref(), + &search.column, + &search.index_name, + ) + .await?; + for segment in &segments { + collect_overlay_stale_rows_for_segment(segment, &frag_by_id, &mut stale)?; + } + } + Ok(stale) + } + + /// Compute per-row stale data for a vector index's segments. + /// + /// Returns a map from fragment_id to the set of row offsets within that fragment that are stale + /// (their vector values have been updated by a newer overlay since the index was built). An + /// empty map means no stale rows — the fast path where no masking is needed. See OSS-1325. + fn mask_overlay_stale_rows( + &self, + segments: &[IndexMetadata], + ) -> Result> { + let fragments = self.dataset.fragments(); + if fragments + .iter() + .all(|fragment| fragment.overlays.is_empty()) + { + return Ok(std::collections::HashMap::new()); + } + let frag_by_id: std::collections::HashMap = + fragments.iter().map(|f| (f.id as u32, f)).collect(); + let mut stale: std::collections::HashMap = + std::collections::HashMap::new(); + for segment in segments { + collect_overlay_stale_rows_for_segment(segment, &frag_by_id, &mut stale)?; + } + Ok(stale) + } + + /// Compute which FTS segments are stale due to data overlay files committed after the + /// index was built, and which fragments must therefore fall back to the flat text path. + /// + /// Returns `(flat_frag_ids, Some(fresh_segments))` when overlays are present: + /// - `flat_frag_ids`: fragment IDs that must be scanned flat (stale fragments, plus any + /// other fragments co-located in a segment that covers a stale one — the whole segment is + /// excluded, so all fragments it covered must move to flat). + /// - `fresh_segments`: the subset of FTS segments that cover no stale fragment; safe to + /// pass to `MatchQueryExec::new_with_segments`. + /// + /// Returns `(empty, None)` on the fast path (no overlays, or no segments load). + async fn fts_stale_frags_and_fresh_segments( + &self, + column: &str, + target_fragments: &[Fragment], + ) -> Result<(RoaringBitmap, Option>)> { + // Fast path: no overlays on any target fragment. + if target_fragments.iter().all(|f| f.overlays.is_empty()) { + return Ok((RoaringBitmap::new(), None)); + } + + let Some(segments) = load_segments(&self.dataset, column).await? else { + return Ok((RoaringBitmap::new(), None)); + }; + + let frag_by_id: std::collections::HashMap = + target_fragments.iter().map(|f| (f.id as u32, f)).collect(); + let mut stale_frag_ids = RoaringBitmap::new(); + for seg in &segments { + collect_stale_overlay_frags(seg, &frag_by_id, &mut stale_frag_ids)?; + } + + if stale_frag_ids.is_empty() { + // Overlays exist but none are on this FTS column or predate the index. + return Ok((stale_frag_ids, None)); + } + + // Any segment covering a stale fragment is excluded from the indexed path. + // All fragments covered by that segment (stale + co-located fresh ones) must + // fall to the flat path, since the indexed path no longer covers them. + let mut flat_frag_ids = stale_frag_ids.clone(); + let mut fresh_segments = Vec::with_capacity(segments.len()); + for seg in segments { + match &seg.fragment_bitmap { + Some(bm) if !bm.is_disjoint(&stale_frag_ids) => { + flat_frag_ids |= bm; + // exclude this segment from the indexed path + } + _ => fresh_segments.push(seg), + } + } + + Ok((flat_frag_ids, Some(fresh_segments))) } // First perform a lookup in a scalar index for ids and then perform a take on the @@ -4099,16 +4370,29 @@ impl Scanner { let needs_recheck = index_expr.needs_recheck(); - // Figure out which fragments are covered by ALL indices - let (relevant_frags, missing_frags) = self + // Figure out which fragments are covered by ALL indices, and which rows within + // covered fragments are stale due to data overlay files (OSS-1325). + let (relevant_frags, missing_frags, stale_rows) = self .partition_frags_by_coverage(index_expr, fragments) .await?; - let mut plan: Arc = Arc::new(MaterializeIndexExec::new( + // Build the MaterializeIndexExec, blocking stale row addresses so the index never + // emits them. Stale rows are re-scored separately via a targeted take below. + let mat_exec = MaterializeIndexExec::new( self.dataset.clone(), index_expr.clone(), Arc::new(relevant_frags), - )); + ); + let mat_exec = if stale_rows.is_empty() { + mat_exec + } else { + let mut tree_map = RowAddrTreeMap::new(); + for (&frag_id, offsets) in &stale_rows { + tree_map.insert_bitmap(frag_id, offsets.clone()); + } + mat_exec.with_overlay_block(RowAddrMask::from_block(tree_map)) + }; + let mut plan: Arc = Arc::new(mat_exec); let refine_expr = filter_plan.refine_expr.as_ref(); @@ -4154,6 +4438,21 @@ impl Scanner { plan = Arc::new(AddRowAddrExec::try_new(plan, self.dataset.clone(), 0)?); } + // Both the missing-fragments path (full scan) and the stale-rows path (targeted take) + // need the user's projection extended with any filter columns. Compute it once. + let fallback_projection: Option = + if !missing_frags.is_empty() || !stale_rows.is_empty() { + let filter = filter_plan.full_expr.as_ref().unwrap(); + let filter_cols = Planner::column_names_in_expr(filter); + Some( + projection + .clone() + .union_columns(filter_cols, OnMissing::Error)?, + ) + } else { + None + }; + let new_data_path: Option> = if !missing_frags.is_empty() { log::trace!( "scalar_indexed_scan will need full scan of {} missing fragments", @@ -4172,10 +4471,8 @@ impl Scanner { // If there were no extra columns then we still need the project // because Materialize -> Take puts the row id at the left and // Scan puts the row id at the right + let scan_projection = fallback_projection.clone().unwrap(); let filter = filter_plan.full_expr.as_ref().unwrap(); - let filter_cols = Planner::column_names_in_expr(filter); - let scan_projection = projection.union_columns(filter_cols, OnMissing::Error)?; - let scan_schema = Arc::new(scan_projection.to_bare_schema()); let scan_arrow_schema = Arc::new(scan_schema.as_ref().into()); let planner = Planner::new(scan_arrow_schema); @@ -4204,16 +4501,54 @@ impl Scanner { None }; - if let Some(new_data_path) = new_data_path { - let unioned = UnionExec::try_new(vec![plan, new_data_path])?; - // Enforce only 1 partition. - let unioned = Arc::new(RepartitionExec::try_new( - unioned, - datafusion::physical_plan::Partitioning::RoundRobinBatch(1), - )?); - Ok(unioned) + // Stale-Take path: re-evaluate only the stale row addresses against the full filter + // (OSS-1325 row-level optimization). These rows were blocked from the index result above; + // here we take their current (overlay-merged) values and re-apply the predicate. + // The schema matches `plan` via `project(…, plan.schema())`. + let stale_take_path: Option> = if stale_rows.is_empty() { + None } else { + let filter = filter_plan.full_expr.as_ref().unwrap(); + let take_projection = fallback_projection.unwrap(); + + let stale_addrs: Vec = stale_rows + .iter() + .flat_map(|(&frag_id, offsets)| { + offsets + .iter() + .map(move |offset| ((frag_id as u64) << 32) | offset as u64) + }) + .collect(); + let batch = RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ArrowField::new( + ROW_ID, + DataType::UInt64, + true, + )])), + vec![Arc::new(UInt64Array::from(stale_addrs))], + )?; + let stale_id_plan = Arc::new(OneShotExec::from_batch(batch)); + let stale_node = self.take(stale_id_plan, take_projection)?; + + let planner = Planner::new(stale_node.schema()); + let optimized_filter = planner.optimize_expr(filter.clone())?; + let filtered = Arc::new(LanceFilterExec::try_new(optimized_filter, stale_node)?); + Some(Arc::new(project(filtered, plan.schema().as_ref())?)) + }; + + let extra_paths: Vec> = [new_data_path, stale_take_path] + .into_iter() + .flatten() + .collect(); + if extra_paths.is_empty() { Ok(plan) + } else { + let all_paths = std::iter::once(plan).chain(extra_paths).collect(); + let unioned = UnionExec::try_new(all_paths)?; + Ok(Arc::new(RepartitionExec::try_new( + unioned, + datafusion::physical_plan::Partitioning::RoundRobinBatch(1), + )?)) } } @@ -4645,11 +4980,18 @@ impl Scanner { q: &Query, index: &[IndexMetadata], filter_plan: &ExprFilterPlan, + overlay_block: Option, ) -> Result> { let prefilter_source = self .prefilter_source(filter_plan, self.get_indexed_frags(index)) .await?; - let inner_fanout_search = new_knn_exec(self.dataset.clone(), index, q, prefilter_source)?; + let inner_fanout_search = new_knn_exec( + self.dataset.clone(), + index, + q, + prefilter_source, + overlay_block, + )?; let sort_expr = PhysicalSortExpr { expr: expressions::col(DIST_COL, inner_fanout_search.schema().as_ref())?, options: SortOptions { @@ -4676,6 +5018,7 @@ impl Scanner { q: &Query, index: &[IndexMetadata], filter_plan: &ExprFilterPlan, + overlay_block: Option, ) -> Result> { // we split the query procedure into two steps: // 1. collect the candidates by vector searching on each query vector @@ -4708,6 +5051,7 @@ impl Scanner { index, &query, prefilter_source.clone(), + overlay_block.clone(), )?; let sort_expr = PhysicalSortExpr { expr: expressions::col(DIST_COL, ann_node.schema().as_ref())?, @@ -4788,7 +5132,7 @@ impl Scanner { // are not in the fragments we are scanning. if filter_plan.is_exact_index_search() && self.fragments.is_none() { let index_query = filter_plan.index_query.as_ref().expect_ok()?; - let (_, missing_frags) = self + let (_, missing_frags, _) = self .partition_frags_by_coverage(index_query, fragments.clone()) .await?; @@ -12594,3 +12938,985 @@ full_filter=name LIKE Utf8(\"test%2\"), refine_filter=name LIKE Utf8(\"test%2\") .await; } } + +/// Insert into `stale` the ids of fragments covered by `segment` whose index entries may be +/// stale because an overlay committed after the segment was built touches a field the segment +/// indexes. Field-aware and version-gated via [`overlay_exclusion_offsets`]. See OSS-1325. +fn collect_stale_overlay_frags( + segment: &IndexMetadata, + frag_by_id: &std::collections::HashMap, + stale: &mut RoaringBitmap, +) -> Result<()> { + let Some(coverage) = segment.fragment_bitmap.as_ref() else { + return Ok(()); + }; + for frag_id in coverage.iter() { + if stale.contains(frag_id) { + continue; + } + let Some(fragment) = frag_by_id.get(&frag_id) else { + continue; + }; + if fragment.overlays.is_empty() { + continue; + } + // Cheap version gate: skip the field/bitmap work if every overlay on this fragment + // predates the segment (already incorporated by the index). + if fragment + .overlays + .iter() + .all(|o| o.committed_version <= segment.dataset_version) + { + continue; + } + if !overlay_exclusion_offsets(&fragment.overlays, &segment.fields, segment.dataset_version)? + .is_empty() + { + stale.insert(frag_id); + } + } + Ok(()) +} + +/// Like [`collect_stale_overlay_frags`] but with row-level granularity: instead of marking the +/// whole fragment stale, it computes exactly which row offsets within each covered fragment are +/// stale and accumulates them into `stale` (fragment_id → stale row offsets). +/// +/// Used by the vector ANN path (OSS-1325) to block only the affected rows from ANN results and +/// re-score only those rows on the flat path, keeping overhead proportional to the number of +/// overlaid rows rather than the whole fragment size. +fn collect_overlay_stale_rows_for_segment( + segment: &IndexMetadata, + frag_by_id: &std::collections::HashMap, + stale: &mut std::collections::HashMap, +) -> Result<()> { + let Some(coverage) = segment.fragment_bitmap.as_ref() else { + return Ok(()); + }; + for frag_id in coverage.iter() { + let Some(fragment) = frag_by_id.get(&frag_id) else { + continue; + }; + if fragment.overlays.is_empty() { + continue; + } + if fragment + .overlays + .iter() + .all(|o| o.committed_version <= segment.dataset_version) + { + continue; + } + let excluded = overlay_exclusion_offsets( + &fragment.overlays, + &segment.fields, + segment.dataset_version, + )?; + if !excluded.is_empty() { + *stale.entry(frag_id).or_default() |= &excluded; + } + } + Ok(()) +} + +/// End-to-end tests for OSS-1325: a scalar index masks data overlay files so that +/// queries stay correct while overlays remain (stale index hits are dropped and new +/// matches are added by re-evaluating overlay-covered rows on the flat path). +#[cfg(test)] +mod overlay_index_masking { + use std::sync::Arc; + + use arrow_array::cast::AsArray; + use arrow_array::types::Int32Type; + use arrow_array::{ArrayRef, Int32Array, RecordBatch, RecordBatchIterator}; + use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use lance_index::IndexType; + use lance_index::scalar::FullTextSearchQuery; + use lance_index::scalar::ScalarIndexParams; + use lance_io::utils::CachedFileSize; + use lance_table::format::{DataFile, DataOverlayFile, OverlayCoverage}; + use roaring::RoaringBitmap; + + use lance_file::writer::{FileWriter, FileWriterOptions}; + + use crate::Dataset; + use crate::dataset::transaction::{DataOverlayGroup, Operation}; + use crate::dataset::{WriteDestination, WriteParams}; + use crate::index::DatasetIndexExt; + + /// Two-fragment Int32 dataset: `id` (field 0) = 0..12 and `age` (field 1) = id * 10, + /// six rows per file (fragments 0 and 1). In-memory store so overlay files can be written + /// with a store-relative `data/.lance` path and committed against the dataset. + async fn create_base_dataset() -> Dataset { + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, true), + ArrowField::new("age", DataType::Int32, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..12)), + Arc::new(Int32Array::from_iter_values((0..12).map(|v| v * 10))), + ], + ) + .unwrap(); + let write_params = WriteParams { + max_rows_per_file: 6, + max_rows_per_group: 6, + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + Dataset::write(reader, "memory://", Some(write_params)) + .await + .unwrap() + } + + async fn build_age_index(dataset: &mut Dataset) { + dataset + .create_index( + &["age"], + IndexType::BTree, + None, + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + } + + /// Write an overlay file covering `fields` of `fragment_id` with `coverage` and the given + /// per-field value columns, then commit it as a `DataOverlay` transaction. `name` makes + /// the overlay file unique. + async fn commit_overlay( + dataset: Dataset, + name: &str, + fragment_id: u64, + fields: &[i32], + coverage: OverlayCoverage, + columns: Vec, + ) -> Dataset { + let read_version = dataset.version().version; + let overlay_schema = dataset.schema().project_by_ids(fields, true); + + let filename = format!("{name}.lance"); + // Use dataset.base so the path is absolute for file:// stores. + // to_local_path() prepends '/' to the object_store path, so a bare + // "data/foo.lance" would resolve to /data/foo.lance (root fs). With + // base we get e.g. tmp/lance-bench/data/foo.lance → /tmp/lance-bench/data/foo.lance. + // For memory:// stores base is empty so the result is the same as before. + let path = dataset.base.clone().join("data").join(filename.as_str()); + let obj_writer = dataset.object_store.create(&path).await.unwrap(); + let mut writer = + FileWriter::try_new(obj_writer, overlay_schema, FileWriterOptions::default()).unwrap(); + let (major, minor) = writer.version().to_numbers(); + for (i, array) in columns.into_iter().enumerate() { + writer.write_column(i, array).await.unwrap(); + } + let summary = writer.finish().await.unwrap(); + + let mut data_file = DataFile::new_unstarted(filename, major, minor); + data_file.fields = writer + .field_id_to_column_indices() + .iter() + .map(|(field_id, _)| *field_id as i32) + .collect::>() + .into(); + data_file.column_indices = writer + .field_id_to_column_indices() + .iter() + .map(|(_, column_index)| *column_index as i32) + .collect::>() + .into(); + data_file.file_size_bytes = CachedFileSize::new(summary.size_bytes); + + let overlay = DataOverlayFile { + data_file, + coverage, + committed_version: 0, + }; + Dataset::commit( + WriteDestination::Dataset(Arc::new(dataset)), + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id, + overlays: vec![overlay], + }], + }, + Some(read_version), + None, + None, + Arc::new(Default::default()), + false, + ) + .await + .unwrap() + } + + /// Sorted `id` values returned by a filtered scan. + async fn ids_matching(dataset: &Dataset, filter: &str) -> Vec { + let batch = dataset + .scan() + .filter(filter) + .unwrap() + .project(&["id"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + if batch.num_rows() == 0 { + return Vec::new(); + } + let mut ids = batch + .column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec(); + ids.sort_unstable(); + ids + } + + fn i32_array(values: impl IntoIterator>) -> ArrayRef { + Arc::new(Int32Array::from_iter(values)) + } + + fn fsl(rows: Vec>, dim: i32) -> ArrayRef { + let flat: Vec = rows.into_iter().flatten().collect(); + let item = Arc::new(ArrowField::new("item", DataType::Float32, true)); + Arc::new( + arrow_array::FixedSizeListArray::try_new( + item, + dim, + Arc::new(arrow_array::Float32Array::from(flat)), + None, + ) + .unwrap(), + ) + } + + /// A newer overlay on the indexed field drops stale index hits (the old value no longer + /// matches) and surfaces new matches (the new value is found even though the index never + /// saw it). Mirrors the spec's Bob 25 -> 26 worked example. + #[tokio::test] + async fn test_overlay_stale_drop_and_new_match() { + let mut dataset = create_base_dataset().await; + build_age_index(&mut dataset).await; + + // Fragment 0, offset 1 is id=1, age=10. The overlay (committed after the index) + // changes its age to 999. + let dataset = commit_overlay( + dataset, + "age_overlay", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(999)])], + ) + .await; + + // Stale-drop: the index still holds age=10 for id=1, but its current value is 999, + // so it must not be returned. + assert_eq!(ids_matching(&dataset, "age = 10").await, Vec::::new()); + // New-match: the index never saw age=999, but re-evaluation finds it. + assert_eq!(ids_matching(&dataset, "age = 999").await, vec![1]); + // An untouched indexed value is unaffected. + assert_eq!(ids_matching(&dataset, "age = 20").await, vec![2]); + } + + /// Row-level BTree precision: when one row in a covered fragment is stale, only that row is + /// blocked from the index result and re-evaluated on the stale-Take path. Non-stale rows in + /// the same fragment (including one that matches the predicate) remain on the indexed path. + /// + /// Setup: fragment 0 has id=5 → age=50 (not stale). Overlay id=1 → age=50 (stale). + /// After the overlay two rows in fragment 0 have age=50. The row-level optimization must + /// return both: id=5 from the index and id=1 from the stale-Take path. + #[tokio::test] + async fn test_btree_overlay_row_level_precision() { + let mut dataset = create_base_dataset().await; + build_age_index(&mut dataset).await; + + // Fragment 0: ids 0-5, ages 0,10,20,30,40,50. Overlay offset 1 (id=1): age 10→50. + // After this both id=1 and id=5 have age=50, in the same fragment. + let dataset = commit_overlay( + dataset, + "age_row_level", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(50)])], + ) + .await; + + // Stale drop: id=1's old age=10 entry must not appear. + assert_eq!(ids_matching(&dataset, "age = 10").await, Vec::::new()); + + // id=5 via index + id=1 via stale-Take path — both in fragment 0. + assert_eq!(ids_matching(&dataset, "age = 50").await, vec![1, 5]); + + // Non-stale rows in the same fragment still return correctly. + assert_eq!(ids_matching(&dataset, "age = 20").await, vec![2]); + assert_eq!(ids_matching(&dataset, "age = 30").await, vec![3]); + } + + /// An overlay touching only a non-indexed field excludes nothing from the index on `age`. + #[tokio::test] + async fn test_overlay_on_unrelated_field_excludes_nothing() { + let mut dataset = create_base_dataset().await; + build_age_index(&mut dataset).await; + + // Overlay field 0 (`id`), not the indexed `age`. The age index stays fully trusted. + let dataset = commit_overlay( + dataset, + "id_overlay", + 0, + &[0], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(777)])], + ) + .await; + + // The age index is still trusted: age=10 finds the offset-1 row, whose id now reads + // through the overlay as 777. The fragment was not routed to the flat path on account + // of an overlay that touches no indexed field. + assert_eq!(ids_matching(&dataset, "age = 10").await, vec![777]); + // An untouched row is unaffected. + assert_eq!(ids_matching(&dataset, "age = 20").await, vec![2]); + // The overlaid id is the new value on read, and the old one is gone. + assert_eq!(ids_matching(&dataset, "id = 777").await, vec![777]); + assert_eq!(ids_matching(&dataset, "id = 1").await, Vec::::new()); + } + + /// An overlay whose `committed_version <= index.dataset_version` is already incorporated by + /// the index (the index was built reading merged values) and is not excluded. + #[tokio::test] + async fn test_overlay_older_than_index_not_excluded() { + let dataset = create_base_dataset().await; + + // Commit the overlay first (age of id=1 becomes 999), then build the index on top. + let mut dataset = commit_overlay( + dataset, + "age_overlay_old", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(999)])], + ) + .await; + build_age_index(&mut dataset).await; + + // The index incorporates the overlay, so it returns the merged value directly. + assert_eq!(ids_matching(&dataset, "age = 999").await, vec![1]); + assert_eq!(ids_matching(&dataset, "age = 10").await, Vec::::new()); + } + + /// A covered offset whose overlay value is NULL overrides the cell to NULL, so the stale + /// index hit for its old value is dropped. + #[tokio::test] + async fn test_overlay_null_override() { + let mut dataset = create_base_dataset().await; + build_age_index(&mut dataset).await; + + // id=1 (age=10) is overridden to NULL. + let dataset = commit_overlay( + dataset, + "age_overlay_null", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([None])], + ) + .await; + + assert_eq!(ids_matching(&dataset, "age = 10").await, Vec::::new()); + assert_eq!(ids_matching(&dataset, "age IS NULL").await, vec![1]); + } + + /// Overlays on a non-first fragment are masked correctly, and a query spanning both + /// fragments returns the right rows. + #[tokio::test] + async fn test_overlay_multi_fragment() { + let mut dataset = create_base_dataset().await; + build_age_index(&mut dataset).await; + + // Fragment 1 holds ids 6..12 (ages 60..110). Offset 2 within fragment 1 is id=8, + // age=80; change it to 60 (a value that also legitimately exists at id=6). + let dataset = commit_overlay( + dataset, + "age_overlay_frag1", + 1, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([2])), + vec![i32_array([Some(60)])], + ) + .await; + + // id=8 no longer has age=80 (stale-drop on fragment 1). + assert_eq!(ids_matching(&dataset, "age = 80").await, Vec::::new()); + // Both id=6 (base) and id=8 (overlay) now have age=60 (new-match added to base hit). + assert_eq!(ids_matching(&dataset, "age = 60").await, vec![6, 8]); + // A value in the untouched fragment 0 is still served correctly. + assert_eq!(ids_matching(&dataset, "age = 30").await, vec![3]); + } + + /// A vector index masks overlays: a row whose vector was moved (by a newer overlay) away + /// from the query is dropped from results, and a row moved *onto* the query is found by + /// re-scoring its current vector on the flat path — even though the index never saw it. + #[tokio::test] + async fn test_vector_index_rescore_on_overlay() { + use arrow_array::cast::AsArray; + use futures::TryStreamExt; + use lance_index::IndexType; + use lance_linalg::distance::MetricType; + + use crate::index::vector::VectorIndexParams; + + const DIM: i32 = 8; + let query = vec![1.0_f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let far = vec![0.0_f32, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + + // 64 rows over two fragments. Every base vector is orthogonal to the query except id=35, + // which equals the query (so a stale index ranks it first). All sit in fragment 1's range + // (32..64) for the rows we overlay; fragment 0 (0..32) holds far, never-overlaid rows. + let mut vectors: Vec> = Vec::with_capacity(64); + for i in 0..64 { + if i == 35 { + vectors.push(query.clone()); + } else { + let mut v = vec![0.0_f32; DIM as usize]; + v[1] = (i + 2) as f32; // orthogonal to the query, distinct, far + vectors.push(v); + } + } + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, true), + ArrowField::new( + "vec", + DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + DIM, + ), + true, + ), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..64)), + fsl(vectors, DIM), + ], + ) + .unwrap(); + let write_params = WriteParams { + max_rows_per_file: 32, + max_rows_per_group: 32, + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write(reader, "memory://", Some(write_params)) + .await + .unwrap(); + + // Single-partition IVF_FLAT: the ANN searches every indexed row with exact distances. + let params = VectorIndexParams::ivf_flat(1, MetricType::L2); + dataset + .create_index(&["vec"], IndexType::Vector, None, ¶ms, true) + .await + .unwrap(); + + // Overlay fragment 1 (ids 32..64): move id=35 (offset 3) onto `far`, and id=40 + // (offset 8) onto the query. The index, built before the overlay, still believes id=35 + // is the query and has never seen id=40 near it. + let dataset = commit_overlay( + dataset, + "vec_overlay", + 1, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([3, 8])), + vec![fsl(vec![far.clone(), query.clone()], DIM)], + ) + .await; + + let results = dataset + .scan() + .nearest("vec", &arrow_array::Float32Array::from(query.clone()), 3) + .unwrap() + .minimum_nprobes(1) + .project(&["id"]) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + let ids: Vec = results + .iter() + .flat_map(|b| { + b.column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec() + }) + .collect(); + + // id=40 was moved onto the query and is found by re-scoring (new-match recall). + assert!( + ids.contains(&40), + "expected id=40 (re-scored to query) in {ids:?}" + ); + // id=35's stale index entry (the query) must not resurface: its current vector is far. + assert!( + !ids.contains(&35), + "stale vector for id=35 should be dropped, got {ids:?}" + ); + } + + /// A compound boolean predicate (age AND id) exercises the ScalarIndexExpr tree-walk in + /// `overlay_stale_index_frags`. An overlay on `age` marks fragment 0 stale from the `age` + /// index's perspective, so the compound query must re-evaluate fragment 0 on the flat path. + #[tokio::test] + async fn test_overlay_stale_with_compound_index_expression() { + let mut dataset = create_base_dataset().await; + // Build BTree indexes on both columns so a compound filter can use both. + build_age_index(&mut dataset).await; + dataset + .create_index( + &["id"], + IndexType::BTree, + None, + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + // Fragment 0 covers id=0..5, age=0..50. Overlay changes id=1's age from 10 to 999. + let dataset = commit_overlay( + dataset, + "age_compound", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(999)])], + ) + .await; + + // Compound query: both the `age` and `id` index are involved. The overlay on `age` + // makes fragment 0 stale for the `age` index; it falls to the flat path, which uses + // the merged (overlay) value. Result: the stale age=10 hit is gone, age=999 appears. + assert_eq!(ids_matching(&dataset, "age = 10").await, Vec::::new()); + assert_eq!(ids_matching(&dataset, "age = 999").await, vec![1]); + // A pure `id` query on an unaffected fragment still works correctly. + assert_eq!(ids_matching(&dataset, "id = 2").await, vec![2]); + } + + /// Text dataset: two fragments, 6 rows each. Schema: id (Int32), text (Utf8). + /// Texts are unique tokens so each row can be identified by its term. + async fn create_text_dataset() -> Dataset { + use arrow_array::StringArray; + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, true), + ArrowField::new("text", DataType::Utf8, true), + ])); + let texts: Vec<&str> = vec![ + "apple pie", + "apple banana", // row 1, fragment 0 — will be overlaid in tests + "cherry cake", + "banana split", + "orange juice", + "grape vine", + "mango sorbet", // fragment 1 starts here + "pear tart", + "lemon curd", + "peach cobbler", + "plum pudding", + "fig newton", + ]; + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..12)), + Arc::new(StringArray::from(texts)), + ], + ) + .unwrap(); + let write_params = WriteParams { + max_rows_per_file: 6, + max_rows_per_group: 6, + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + Dataset::write(reader, "memory://", Some(write_params)) + .await + .unwrap() + } + + async fn build_text_fts_index(dataset: &mut Dataset) { + use lance_index::scalar::inverted::InvertedIndexParams; + + dataset + .create_index( + &["text"], + IndexType::Inverted, + None, + &InvertedIndexParams::default(), + true, + ) + .await + .unwrap(); + } + + /// Collect sorted IDs of rows returned by an FTS query on `text`. + async fn fts_ids_matching(dataset: &Dataset, term: &str) -> Vec { + use arrow_array::cast::AsArray; + use futures::TryStreamExt; + + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new(term.to_owned())) + .unwrap() + .project(&["id"]) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let mut ids: Vec = results + .iter() + .flat_map(|b| { + b.column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec() + }) + .collect(); + ids.sort_unstable(); + ids + } + + /// An overlay committed after the FTS index is built replaces a row's text. Searching for + /// the old term must not return the stale row; searching for the new term must find it. + #[tokio::test] + async fn test_fts_overlay_stale_drop_and_new_match() { + use arrow_array::StringArray; + + let mut dataset = create_text_dataset().await; + build_text_fts_index(&mut dataset).await; + + // fragment 0, row offset 1 (id=1): "apple banana" → "cherry mango" + // field ID 1 is the `text` column. + let dataset = commit_overlay( + dataset, + "text_overlay", + 0, + &[1], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![Arc::new(StringArray::from(vec![Some("cherry mango")]))], + ) + .await; + + // "apple" now matches only id=0 ("apple pie"); id=1's stale index entry must be dropped. + assert_eq!(fts_ids_matching(&dataset, "apple").await, vec![0]); + + // "banana" matched id=1 and id=3 before; after overlay id=1's stale entry must be gone. + assert_eq!(fts_ids_matching(&dataset, "banana").await, vec![3]); + + // "cherry" now matches id=1 (via flat path on stale fragment) and id=2 ("cherry cake"). + let cherry_ids = fts_ids_matching(&dataset, "cherry").await; + assert!( + cherry_ids.contains(&1), + "id=1 overlay→cherry mango should be found: {cherry_ids:?}" + ); + assert!( + cherry_ids.contains(&2), + "id=2 cherry cake should still be found: {cherry_ids:?}" + ); + + // "mango" now matches id=1 (overlay) and id=6 ("mango sorbet" in fragment 1). + let mango_ids = fts_ids_matching(&dataset, "mango").await; + assert!( + mango_ids.contains(&1), + "id=1 overlay→cherry mango should be found: {mango_ids:?}" + ); + assert!( + mango_ids.contains(&6), + "id=6 mango sorbet should still be found: {mango_ids:?}" + ); + } + + /// An overlay on a field the FTS index does NOT cover must not exclude anything. + #[tokio::test] + async fn test_fts_overlay_unrelated_field_not_excluded() { + let mut dataset = create_text_dataset().await; + build_text_fts_index(&mut dataset).await; + + // Overlay field 0 (id) — not covered by the FTS index on `text`. + let dataset = commit_overlay( + dataset, + "id_overlay_for_fts", + 0, + &[0], + OverlayCoverage::dense(RoaringBitmap::from_iter([1])), + vec![i32_array([Some(999)])], + ) + .await; + + // FTS coverage must be unchanged — both rows containing "apple" are still returned. + // The `id` overlay changes row offset 1's id from 1 to 999, so the projected id column + // reflects the overlay even though the FTS index correctly returned that row. + assert_eq!(fts_ids_matching(&dataset, "apple").await, vec![0, 999]); + assert_eq!(fts_ids_matching(&dataset, "banana").await, vec![3, 999]); + } + + /// Benchmark: measure query latency for BTree, FTS, and vector ANN with 0/4/16 overlay layers. + /// + /// Run with: cargo test -p lance --lib --release -- overlay_index_masking::bench --ignored --nocapture + #[tokio::test] + #[ignore = "benchmark"] + #[allow(clippy::print_stdout)] + async fn bench_index_query_overlay_overhead() { + use std::time::Instant; + + use arrow_array::Float32Array; + use lance_linalg::distance::MetricType; + + use crate::index::vector::VectorIndexParams; + + const DIM: i32 = 32; + const ROWS: i32 = 1_000_000; + const ROWS_PER_FRAG: i32 = 100_000; // 10 fragments + const ITERS: u32 = 10; // large scans — 10 is enough for stable averages + + // Fixed disk path so timings are comparable across runs. Deleted and recreated fresh. + let uri = "/tmp/lance-bench-overlay-oss1325"; + if std::path::Path::new(uri).exists() { + std::fs::remove_dir_all(uri).unwrap(); + } + + // --- Build 1M-row dataset on local disk -------------------------------- + // Schema: id(0), age(1), vec(2) — 3 top-level fields. + // Lance field IDs (depth-first): id=0, age=1, vec=2, vec.item=3. + + println!("Building {ROWS}-row dataset at {uri} (this takes ~30 s)..."); + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("age", DataType::Int32, false), + ArrowField::new( + "vec", + DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + DIM, + ), + false, + ), + ])); + + let row_ids: Vec = (0..ROWS).collect(); + let ages: Vec = row_ids.iter().map(|&i| i * 10).collect(); + // Build the 128 MB flat float array directly (avoids 1M per-row Vec allocations). + let flat_vecs: Vec = (0..(ROWS as usize * DIM as usize)) + .map(|j| (j / DIM as usize) as f32 % 1000.0) + .collect(); + let vec_col = Arc::new( + arrow_array::FixedSizeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + DIM, + Arc::new(Float32Array::from(flat_vecs)), + None, + ) + .unwrap(), + ); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(row_ids)), + Arc::new(Int32Array::from(ages)), + vec_col, + ], + ) + .unwrap(); + + let write_params = WriteParams { + max_rows_per_file: ROWS_PER_FRAG as usize, + max_rows_per_group: ROWS_PER_FRAG as usize, + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write(reader, uri, Some(write_params)) + .await + .unwrap(); + + println!("Building BTree index on age..."); + dataset + .create_index( + &["age"], + IndexType::BTree, + None, + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + println!("Building IVF_FLAT(1 partition) index on vec..."); + dataset + .create_index( + &["vec"], + IndexType::Vector, + None, + &VectorIndexParams::ivf_flat(1, MetricType::L2), + true, + ) + .await + .unwrap(); + + println!("Indexes built.\n"); + + // --- Timing helper --------------------------------------------------- + + async fn timeit(iters: u32, mut f: F) -> f64 + where + F: FnMut() -> Fut, + Fut: std::future::Future, + { + f().await; // warmup + let t0 = Instant::now(); + for _ in 0..iters { + f().await; + } + t0.elapsed().as_secs_f64() * 1000.0 / iters as f64 + } + + // === Scenario A: BTree query overhead ================================ + // + // Overlay on `age` (field 1), covering only offset 0 of fragment 0. + // Fragment granularity: the entire fragment 0 (100k rows) falls to flat-scan. + // + // btree_cold: `age = 420` → id=42 → in fragment 0 (rows 0..99999). + // With overlays: 100k-row flat scan + per-overlay merge instead of index lookup. + // Without overlays: O(log n) BTree lookup. + // + // btree_warm: `age = 1000420` → id=100042 → in fragment 1 (rows 100000..199999). + // Always served by the BTree index regardless of overlay count on fragment 0. + // This isolates the index-lookup baseline. + println!("=== Scenario A: BTree (overlay on `age`, fragment 0 becomes stale) ==="); + println!( + "{:>10} {:>14} {:>14}", + "overlays", "cold_frag0_ms", "warm_frag1_ms" + ); + + let mut committed_a = 0u32; + for num_overlays in [0u32, 1, 4, 16] { + // Commit only the delta since the last iteration. + for layer in committed_a..num_overlays { + dataset = commit_overlay( + dataset, + &format!("age_ol{layer}"), + 0, // fragment 0 + &[1], // field 1 = age + OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + vec![i32_array([Some(999)])], + ) + .await; + } + committed_a = num_overlays; + + let ds = Arc::new(dataset.clone()); + + // Cold path: stale fragment falls to flat scan when overlays > 0. + let ds2 = ds.clone(); + let cold_ms = timeit(ITERS, || { + let ds = ds2.clone(); + async move { + ds.scan() + .filter("age = 420") + .unwrap() + .project(&["age"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + } + }) + .await; + + // Warm path: fragment 1 never stale, always index-served. + let ds2 = ds.clone(); + let warm_ms = timeit(ITERS, || { + let ds = ds2.clone(); + async move { + ds.scan() + .filter("age = 1000420") + .unwrap() + .project(&["age"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + } + }) + .await; + + println!("{num_overlays:>10} {cold_ms:>14.1} {warm_ms:>14.1}"); + } + + // === Scenario B: Vector ANN overhead ================================= + // + // Overlay on `vec` (field 2), covering only offset 0 of fragment 0. + // The field-aware check means the 16 age overlays from Scenario A do NOT affect + // the vector index (they touch field 1, not field 2). Only a vec overlay (field 2) + // marks fragment 0 stale for the vector index. + // + // With a vec overlay: 100k rows of fragment 0 are excluded from ANN prefilter + // bitmaps and re-scored brute-force (O(100k × DIM) distance computations). + println!("\n=== Scenario B: Vector ANN (overlay on `vec`, 100k rows brute-forced) ==="); + println!("{:>12} {:>10}", "vec_overlays", "ann_ms"); + + let query_vec = Float32Array::from(vec![0.5f32; DIM as usize]); + + for num_vec_overlays in [0u32, 1] { + if num_vec_overlays == 1 { + dataset = commit_overlay( + dataset, + "vec_ol0", + 0, // fragment 0 + &[2], // field 2 = vec (FixedSizeList top-level field) + OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + vec![fsl(vec![vec![0.0f32; DIM as usize]], DIM)], + ) + .await; + } + + let ds = Arc::new(dataset.clone()); + let ds2 = ds.clone(); + let qv = query_vec.clone(); + let ann_ms = timeit(ITERS, || { + let ds = ds2.clone(); + let q = qv.clone(); + async move { + ds.scan() + .nearest("vec", &q, 10) + .unwrap() + .minimum_nprobes(1) + .project(&["id"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + } + }) + .await; + + println!("{num_vec_overlays:>12} {ann_ms:>10.1}"); + } + } +} diff --git a/rust/lance/src/dataset/schema_evolution.rs b/rust/lance/src/dataset/schema_evolution.rs index 5ef35a33ab7..8c1595c4daa 100644 --- a/rust/lance/src/dataset/schema_evolution.rs +++ b/rust/lance/src/dataset/schema_evolution.rs @@ -1941,6 +1941,7 @@ mod test { Ok(Some(Fragment { files: vec![], id: 0, + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(50), diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index 4555cd7ee6c..33248c2b68d 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -31,8 +31,9 @@ use lance_table::feature_flags::{FLAG_STABLE_ROW_IDS, apply_feature_flags}; use lance_table::rowids::read_row_ids; use lance_table::{ format::{ - BasePath, DataFile, DataStorageFormat, Fragment, IndexFile, IndexMetadata, Manifest, - RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, RowIdMeta, pb, + BasePath, DataFile, DataOverlayFile, DataStorageFormat, Fragment, IndexFile, IndexMetadata, + Manifest, RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, + RowIdMeta, pb, }, io::{ commit::CommitHandler, @@ -258,6 +259,17 @@ pub struct Transaction { #[derive(Debug, Clone, DeepSizeOf, PartialEq)] pub struct DataReplacementGroup(pub u64, pub DataFile); +/// Overlay files to append to a single fragment, in order (the last entry is +/// newest). The overlays are appended to the fragment's existing `overlays` +/// list rather than replacing it, so overlays written by concurrent commits are +/// preserved. Each overlay's `committed_version` is stamped to the new dataset +/// version at commit time (re-stamped on retry). +#[derive(Debug, Clone, DeepSizeOf, PartialEq)] +pub struct DataOverlayGroup { + pub fragment_id: u64, + pub overlays: Vec, +} + /// An entry for a map update. If value is None, the key will be removed from the map. #[derive(Debug, Clone, DeepSizeOf, PartialEq)] pub struct UpdateMapEntry { @@ -367,6 +379,11 @@ pub enum Operation { DataReplacement { replacements: Vec, }, + /// Attach overlay files to fragments, supplying new values for a subset of + /// `(row offset, field)` cells without rewriting the fragments' base data + /// files. See [`DataOverlayFile`] and the Data Overlay Files specification + /// for resolution, coverage, and versioning rules. + DataOverlay { groups: Vec }, /// Merge a new column in /// 'fragments' is the final fragments include all data files, the new fragments must align with old ones at rows. /// 'schema' is not forced to include existed columns, which means we could use Merge to drop column data @@ -499,6 +516,7 @@ impl std::fmt::Display for Operation { Self::Project { .. } => write!(f, "Project"), Self::UpdateConfig { .. } => write!(f, "UpdateConfig"), Self::DataReplacement { .. } => write!(f, "DataReplacement"), + Self::DataOverlay { .. } => write!(f, "DataOverlay"), Self::Clone { .. } => write!(f, "Clone"), Self::UpdateMemWalState { .. } => write!(f, "UpdateMemWalState"), Self::UpdateBases { .. } => write!(f, "UpdateBases"), @@ -1345,6 +1363,8 @@ impl PartialEq for Operation { (Self::Clone { .. }, Self::UpdateBases { .. }) => { std::mem::discriminant(self) == std::mem::discriminant(other) } + (Self::DataOverlay { groups: a }, Self::DataOverlay { groups: b }) => compare_vec(a, b), + (Self::DataOverlay { .. }, _) | (_, Self::DataOverlay { .. }) => false, } } } @@ -1521,6 +1541,7 @@ impl Operation { Self::Project { .. } => "Project", Self::UpdateConfig { .. } => "UpdateConfig", Self::DataReplacement { .. } => "DataReplacement", + Self::DataOverlay { .. } => "DataOverlay", Self::UpdateMemWalState { .. } => "UpdateMemWalState", Self::Clone { .. } => "Clone", Self::UpdateBases { .. } => "UpdateBases", @@ -2300,6 +2321,49 @@ impl Transaction { &replaced_fields, ); } + Operation::DataOverlay { groups } => { + // Stamp each overlay with the version this commit is producing. + // build_manifest re-runs on every retry with an updated + // current_manifest, so this is naturally re-stamped on retry. + let new_version = current_manifest.map_or(1, |m| m.version + 1); + + let existing_fragments = maybe_existing_fragments?; + // Multiple groups may target the same fragment; merge them in + // order rather than letting a HashMap collapse drop all but the + // last group's overlays. + let mut overlays_by_fragment: HashMap> = HashMap::new(); + for group in groups { + overlays_by_fragment + .entry(group.fragment_id) + .or_default() + .extend(group.overlays.iter()); + } + + // Every group must target an existing fragment. + for fragment_id in overlays_by_fragment.keys() { + if !existing_fragments.iter().any(|f| f.id == *fragment_id) { + return Err(Error::invalid_input(format!( + "DataOverlay targets fragment {fragment_id}, which does not exist" + ))); + } + } + + for fragment in existing_fragments { + let mut fragment = fragment.clone(); + if let Some(new_overlays) = overlays_by_fragment.get(&fragment.id) { + // Appended (not replaced) so concurrently-written overlays + // survive; later entries are newer. + fragment + .overlays + .extend(new_overlays.iter().map(|&overlay| { + let mut overlay = overlay.clone(); + overlay.committed_version = new_version; + overlay + })); + } + final_fragments.push(fragment); + } + } Operation::UpdateMemWalState { merged_generations } => { update_mem_wal_index_merged_generations( &mut final_indices, @@ -3007,6 +3071,34 @@ impl TryFrom for DataReplacementGroup { } } +impl From<&DataOverlayGroup> for pb::transaction::DataOverlayGroup { + fn from(group: &DataOverlayGroup) -> Self { + Self { + fragment_id: group.fragment_id, + overlays: group + .overlays + .iter() + .map(pb::DataOverlayFile::from) + .collect(), + } + } +} + +impl TryFrom for DataOverlayGroup { + type Error = Error; + + fn try_from(message: pb::transaction::DataOverlayGroup) -> Result { + Ok(Self { + fragment_id: message.fragment_id, + overlays: message + .overlays + .into_iter() + .map(DataOverlayFile::try_from) + .collect::>>()?, + }) + } +} + impl TryFrom for Transaction { type Error = Error; @@ -3289,6 +3381,14 @@ impl TryFrom for Transaction { })) => Operation::UpdateBases { new_bases: new_bases.into_iter().map(BasePath::from).collect(), }, + Some(pb::transaction::Operation::DataOverlay(pb::transaction::DataOverlay { + groups, + })) => Operation::DataOverlay { + groups: groups + .into_iter() + .map(DataOverlayGroup::try_from) + .collect::>>()?, + }, None => { return Err(Error::internal( "Transaction message did not contain an operation".to_string(), @@ -3559,6 +3659,14 @@ impl From<&Transaction> for pb::Transaction { .collect(), }) } + Operation::DataOverlay { groups } => { + pb::transaction::Operation::DataOverlay(pb::transaction::DataOverlay { + groups: groups + .iter() + .map(pb::transaction::DataOverlayGroup::from) + .collect(), + }) + } Operation::UpdateMemWalState { merged_generations } => { pb::transaction::Operation::UpdateMemWalState(pb::transaction::UpdateMemWalState { merged_generations: merged_generations @@ -3838,7 +3946,8 @@ mod tests { use lance_file::version::LanceFileVersion; use lance_io::utils::CachedFileSize; use lance_table::format::{ - RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, RowIdMeta, + OverlayCoverage, RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, + RowIdMeta, }; use lance_table::rowids::segment::U64Segment; use lance_table::rowids::write_row_ids; @@ -4148,6 +4257,7 @@ mod tests { physical_rows: Some(100), row_id_meta: None, files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4180,6 +4290,7 @@ mod tests { physical_rows: Some(50), row_id_meta: Some(RowIdMeta::Inline(serialized)), files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4212,6 +4323,7 @@ mod tests { physical_rows: Some(50), // More physical rows than existing row IDs row_id_meta: Some(RowIdMeta::Inline(serialized)), files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4247,6 +4359,7 @@ mod tests { physical_rows: Some(50), // Less physical rows than existing row IDs row_id_meta: Some(RowIdMeta::Inline(serialized)), files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4275,6 +4388,7 @@ mod tests { physical_rows: Some(30), // No existing row IDs row_id_meta: None, files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4284,6 +4398,7 @@ mod tests { physical_rows: Some(25), // Partial existing row IDs row_id_meta: Some(RowIdMeta::Inline(serialized)), files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4328,6 +4443,7 @@ mod tests { physical_rows: None, row_id_meta: None, files: vec![], + overlays: vec![], deletion_file: None, last_updated_at_version_meta: None, created_at_version_meta: None, @@ -4835,6 +4951,7 @@ mod tests { let fragment = Fragment { id: 1, files: vec![data_file], + overlays: vec![], deletion_file: None, row_id_meta, physical_rows: Some(5), @@ -5104,6 +5221,7 @@ mod tests { None, )], physical_rows: Some(10), + overlays: vec![], deletion_file: None, row_id_meta: None, last_updated_at_version_meta: None, @@ -5195,6 +5313,7 @@ mod tests { let prev_fragment = Fragment { id: 0, files: vec![mk_file("before.lance")], + overlays: vec![], deletion_file: None, row_id_meta, physical_rows: Some(5), @@ -5267,6 +5386,7 @@ mod tests { let prev_fragment = Fragment { id: 0, files: vec![data_file.clone()], + overlays: vec![], deletion_file: None, row_id_meta: row_id_meta.clone(), physical_rows: Some(5), @@ -5286,6 +5406,7 @@ mod tests { let merged_fragment = Fragment { id: 0, files: vec![data_file], + overlays: vec![], deletion_file: None, row_id_meta, physical_rows: Some(5), @@ -5335,6 +5456,7 @@ mod tests { let prev_fragment = Fragment { id: 0, files: vec![mk_file("before.lance")], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(5), @@ -5399,6 +5521,7 @@ mod tests { let existing_fragment = Fragment { id: 0, files: vec![mk_file("existing.lance")], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&row_ids_0))), physical_rows: Some(3), @@ -5421,6 +5544,7 @@ mod tests { let new_fragment = Fragment { id: 1, files: vec![mk_file("new.lance")], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&row_ids_1))), physical_rows: Some(4), @@ -5483,6 +5607,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(3), @@ -5496,6 +5621,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -5538,6 +5664,7 @@ mod tests { Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&frag_a_seq))), physical_rows: Some(2), @@ -5549,6 +5676,7 @@ mod tests { Fragment { id: 2, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&frag_b_seq))), physical_rows: Some(3), @@ -5564,6 +5692,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -5605,6 +5734,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(2), @@ -5619,6 +5749,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -5663,6 +5794,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(2), @@ -5676,6 +5808,7 @@ mod tests { let new_fragment = Fragment { id: 20, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(4), @@ -5709,6 +5842,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(2), @@ -5720,6 +5854,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(1), @@ -5748,6 +5883,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(2), @@ -5758,6 +5894,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(3), @@ -5788,6 +5925,7 @@ mod tests { let existing_fragment = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&existing_seq))), physical_rows: Some(2), @@ -5801,6 +5939,7 @@ mod tests { let new_fragment = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(1), @@ -5842,6 +5981,7 @@ mod tests { let in_range_frag = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&in_range_seq))), physical_rows: Some(2), @@ -5862,6 +6002,7 @@ mod tests { let out_of_range_frag = Fragment { id: 2, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&out_of_range_seq))), physical_rows: Some(2), @@ -5876,6 +6017,7 @@ mod tests { let new_frag = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -5914,6 +6056,7 @@ mod tests { let existing = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&seq))), physical_rows: Some(3), @@ -5926,6 +6069,7 @@ mod tests { let new_frag = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -5974,6 +6118,7 @@ mod tests { let src_frag = Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&src_seq))), physical_rows: Some(100), @@ -5988,6 +6133,7 @@ mod tests { let new_frag = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(100), @@ -6035,6 +6181,7 @@ mod tests { Fragment { id: 1, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&seq_a))), physical_rows: Some(3), @@ -6046,6 +6193,7 @@ mod tests { Fragment { id: 2, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&seq_b))), physical_rows: Some(3), @@ -6061,6 +6209,7 @@ mod tests { let new_frag = Fragment { id: 10, files: vec![], + overlays: vec![], deletion_file: None, row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&new_seq))), physical_rows: Some(2), @@ -6127,4 +6276,181 @@ mod tests { assert!(!left.modifies_same_metadata(&different_key)); assert!(left.modifies_same_metadata(&replace)); } + + #[test] + fn test_data_overlay_operation_roundtrips() { + // A DataOverlay operation survives the protobuf round-trip, preserving + // the target fragment, the overlay's coverage, and its committed_version. + use lance_table::format::{DataOverlayFile, OverlayCoverage}; + + let mut bitmap = roaring::RoaringBitmap::new(); + bitmap.insert(1); + bitmap.insert(4); + let overlay = DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("overlay-0.lance", vec![3], None), + coverage: OverlayCoverage::dense(bitmap.clone()), + committed_version: 6, + }; + let pb_overlay = pb::DataOverlayFile::from(&overlay); + + let message = pb::Transaction { + read_version: 1, + uuid: Uuid::new_v4().to_string(), + operation: Some(pb::transaction::Operation::DataOverlay( + pb::transaction::DataOverlay { + groups: vec![pb::transaction::DataOverlayGroup { + fragment_id: 7, + overlays: vec![pb_overlay], + }], + }, + )), + ..Default::default() + }; + + let txn = Transaction::try_from(message).unwrap(); + match txn.operation { + Operation::DataOverlay { groups } => { + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].fragment_id, 7); + assert_eq!(groups[0].overlays.len(), 1); + assert_eq!(groups[0].overlays[0].committed_version, 6); + assert_eq!( + *groups[0].overlays[0].coverage_for_field(0).unwrap(), + bitmap + ); + } + other => panic!("expected DataOverlay, got {other:?}"), + } + } + + fn overlay_with_field(field: i32, committed_version: u64) -> DataOverlayFile { + DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("o.lance", vec![field], None), + coverage: OverlayCoverage::dense(roaring::RoaringBitmap::from_iter([0u32])), + committed_version, + } + } + + #[test] + fn test_data_overlay_build_manifest_appends_and_stamps() { + // A fragment already carrying an overlay (committed at v3) gets a new + // overlay appended and stamped to the new dataset version; the existing + // overlay is preserved with its version. + let mut fragment = Fragment::new(0); + fragment.overlays = vec![overlay_with_field(1, 3)]; + let schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]); + let manifest = Manifest::new( + LanceSchema::try_from(&schema).unwrap(), + Arc::new(vec![fragment]), + lance_table::format::DataStorageFormat::new(LanceFileVersion::V2_0), + HashMap::new(), + ); + + let txn = Transaction::new( + manifest.version, + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id: 0, + overlays: vec![overlay_with_field(2, 0)], + }], + }, + None, + ); + + let (result, _) = txn + .build_manifest( + Some(&manifest), + vec![], + "txn", + &ManifestWriteConfig::default(), + ) + .unwrap(); + + let frag = &result.fragments[0]; + assert_eq!(frag.overlays.len(), 2); + assert_eq!(frag.overlays[0].committed_version, 3); + assert_eq!(frag.overlays[1].committed_version, result.version); + assert!(result.version > manifest.version); + } + + #[test] + fn test_data_overlay_build_manifest_merges_duplicate_groups() { + // Two groups targeting the same fragment must both survive (a HashMap + // collapse would have dropped the first). + let manifest = sample_manifest(); + let txn = Transaction::new( + manifest.version, + Operation::DataOverlay { + groups: vec![ + DataOverlayGroup { + fragment_id: 0, + overlays: vec![overlay_with_field(1, 0)], + }, + DataOverlayGroup { + fragment_id: 0, + overlays: vec![overlay_with_field(2, 0)], + }, + ], + }, + None, + ); + + let (result, _) = txn + .build_manifest( + Some(&manifest), + vec![], + "txn", + &ManifestWriteConfig::default(), + ) + .unwrap(); + + let overlays = &result.fragments[0].overlays; + assert_eq!(overlays.len(), 2); + assert_eq!(overlays[0].data_file.fields.as_ref(), [1i32].as_slice()); + assert_eq!(overlays[1].data_file.fields.as_ref(), [2i32].as_slice()); + } + + #[test] + fn test_data_overlay_build_manifest_rejects_unknown_fragment() { + let manifest = sample_manifest(); + let txn = Transaction::new( + manifest.version, + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id: 99, + overlays: vec![overlay_with_field(1, 0)], + }], + }, + None, + ); + let err = txn + .build_manifest( + Some(&manifest), + vec![], + "txn", + &ManifestWriteConfig::default(), + ) + .unwrap_err(); + assert!(err.to_string().contains("does not exist"), "{err}"); + } + + #[test] + fn test_data_overlay_operation_eq() { + let overlay = |field: i32| Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id: 0, + overlays: vec![overlay_with_field(field, 1)], + }], + }; + // Reflexive and value-based (the arm previously returned false for self). + assert_eq!(overlay(1), overlay(1)); + assert_ne!(overlay(1), overlay(2)); + // Not equal to a different operation kind (previously returned true vs Rewrite). + let rewrite = Operation::Rewrite { + groups: vec![], + rewritten_indices: vec![], + frag_reuse_index: None, + }; + assert_ne!(overlay(1), rewrite); + } } diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index ff0a119158c..47c4add18bd 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -3562,6 +3562,7 @@ mod tests { let fragments = vec![Fragment { id: 0, files: vec![external_file, local_file], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(0), diff --git a/rust/lance/src/dataset/write/commit.rs b/rust/lance/src/dataset/write/commit.rs index baad71b3e39..7aed72a2d71 100644 --- a/rust/lance/src/dataset/write/commit.rs +++ b/rust/lance/src/dataset/write/commit.rs @@ -551,6 +551,7 @@ mod tests { file_size_bytes: CachedFileSize::new(100), base_id: None, }], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(10), diff --git a/rust/lance/src/index/prefilter.rs b/rust/lance/src/index/prefilter.rs index 071f1b8893d..d8a02c1aa27 100644 --- a/rust/lance/src/index/prefilter.rs +++ b/rust/lance/src/index/prefilter.rs @@ -52,6 +52,10 @@ pub struct DatasetPreFilter { // Fragment IDs whose data is still in the index but has been removed from the dataset. // Used by FTS merge-on-read to prune stale fragments at search time. pub(super) deleted_fragments: Option, + // Row addresses whose index entries are stale due to a newer data overlay committed after + // the index was built. Computed synchronously at plan time and ANDead into the final mask + // so the index never returns those rows. See OSS-1325. + pub(super) overlay_block: Option, // When the tasks are finished this is the combined filter pub(super) final_mask: Mutex>>, } @@ -83,6 +87,7 @@ impl DatasetPreFilter { deleted_ids, filtered_ids, deleted_fragments: None, + overlay_block: None, final_mask: Mutex::new(OnceCell::new()), } } @@ -226,6 +231,13 @@ impl DatasetPreFilter { self.deleted_fragments = Some(fragments); } + /// Block specific row addresses from index results because their index entries are stale + /// due to a data overlay committed after the index was built. See OSS-1325. + pub fn with_overlay_block(mut self, block: RowAddrMask) -> Self { + self.overlay_block = Some(block); + self + } + /// Creates a task to load a mask that filters out deleted rows and, /// when `restrict_to_fragments` is true, also restricts results to only /// the given `fragments`. @@ -383,6 +395,9 @@ impl PreFilter for DatasetPreFilter { } combined = combined & RowAddrMask::from_block(block_list); } + if let Some(overlay_block) = &self.overlay_block { + combined = combined & overlay_block.clone(); + } Arc::new(combined) }); @@ -393,6 +408,7 @@ impl PreFilter for DatasetPreFilter { self.deleted_ids.is_none() && self.filtered_ids.is_none() && self.deleted_fragments.is_none() + && self.overlay_block.is_none() } /// Get the row id mask for this prefilter diff --git a/rust/lance/src/index/vector/fixture_test.rs b/rust/lance/src/index/vector/fixture_test.rs index 1b82a7f6941..9e5ca542688 100644 --- a/rust/lance/src/index/vector/fixture_test.rs +++ b/rust/lance/src/index/vector/fixture_test.rs @@ -278,6 +278,7 @@ mod test { deleted_ids: None, filtered_ids: None, deleted_fragments: None, + overlay_block: None, final_mask: Mutex::new(OnceCell::new()), }), &NoOpMetricsCollector, diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index ce0d29d550b..8980dd4f1ed 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -1687,6 +1687,7 @@ mod tests { DataFile::new_legacy_from_fields("path1", vec![0, 1, 2], None), DataFile::new_legacy_from_fields("unused", vec![9], None), ], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -1699,6 +1700,7 @@ mod tests { DataFile::new_legacy_from_fields("path2", vec![0, 1, 2], None), DataFile::new_legacy_from_fields("path3", vec![2], None), ], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -1736,6 +1738,7 @@ mod tests { vec![0, 1, 10], None, )], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -1748,6 +1751,7 @@ mod tests { DataFile::new_legacy_from_fields("path2", vec![0, 1, 2], None), DataFile::new_legacy_from_fields("path3", vec![10], None), ], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: None, @@ -1838,6 +1842,7 @@ mod tests { let fragment = Fragment { id: 0, files: vec![data_file], + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(100), diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index dc898534c89..1e46c4acffa 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -137,6 +137,21 @@ impl<'a> TransactionRebase<'a> { conflicting_mem_wal_merged_gens: Vec::new(), }) } + Operation::DataOverlay { groups } => { + let modified_fragment_ids = + groups.iter().map(|g| g.fragment_id).collect::>(); + let initial_fragments = + initial_fragments_for_rebase(dataset, &transaction, &modified_fragment_ids) + .await; + Ok(Self { + transaction, + affected_rows, + initial_fragments, + modified_fragment_ids, + conflicting_frag_reuse_indices: Vec::new(), + conflicting_mem_wal_merged_gens: Vec::new(), + }) + } Operation::Merge { fragments, .. } => { let modified_fragment_ids = fragments.iter().map(|f| f.id).collect::>(); let initial_fragments = @@ -203,6 +218,9 @@ impl<'a> TransactionRebase<'a> { Operation::DataReplacement { .. } => { self.check_data_replacement_txn(other_transaction, other_version) } + Operation::DataOverlay { .. } => { + self.check_data_overlay_txn(other_transaction, other_version) + } Operation::Merge { .. } => self.check_merge_txn(other_transaction, other_version), Operation::Restore { .. } => self.check_restore_txn(other_transaction, other_version), Operation::ReserveFragments { .. } => { @@ -235,6 +253,10 @@ impl<'a> TransactionRebase<'a> { | Operation::Project { .. } | Operation::Append { .. } | Operation::UpdateConfig { .. } + // A concurrent overlay is inert against the rows we delete + // (deletions take precedence over overlays) and otherwise + // preserves physical offsets, so it never conflicts. + | Operation::DataOverlay { .. } | Operation::UpdateBases { .. } => Ok(()), Operation::Rewrite { groups, .. } => { if groups @@ -382,6 +404,9 @@ impl<'a> TransactionRebase<'a> { | Operation::Project { .. } | Operation::Clone { .. } | Operation::UpdateConfig { .. } + // A concurrent overlay preserves physical offsets and is newer + // than this update, so it wins its covered cells without conflict. + | Operation::DataOverlay { .. } | Operation::UpdateBases { .. } => Ok(()), Operation::Append { .. } => { // If current transaction has primary key conflict detection, @@ -498,6 +523,10 @@ impl<'a> TransactionRebase<'a> { match &other_transaction.operation { Operation::Append { .. } | Operation::Clone { .. } + // An overlay committed after this index's version is newer than + // the index; the query path excludes its covered cells via the + // version gate, so the build does not conflict. + | Operation::DataOverlay { .. } | Operation::UpdateBases { .. } => Ok(()), Operation::CreateIndex { new_indices: created_indices, @@ -679,6 +708,20 @@ impl<'a> TransactionRebase<'a> { Ok(()) } } + Operation::DataOverlay { groups } => { + // Rewriting a fragment changes its physical row addresses, so + // an overlay addressed by physical offset on that fragment is + // invalidated and must be re-applied against the new base. + if groups + .iter() + .map(|g| g.fragment_id) + .any(|id| self.modified_fragment_ids.contains(&id)) + { + Err(self.retryable_conflict_err(other_transaction, other_version)) + } else { + Ok(()) + } + } Operation::Rewrite { groups, frag_reuse_index: committed_fri, @@ -858,6 +901,7 @@ impl<'a> TransactionRebase<'a> { | Operation::CreateIndex { .. } | Operation::Rewrite { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::Restore { .. } | Operation::ReserveFragments { .. } @@ -891,7 +935,8 @@ impl<'a> TransactionRebase<'a> { | Operation::Merge { .. } | Operation::UpdateConfig { .. } | Operation::Clone { .. } - | Operation::DataReplacement { .. } => Ok(()), + | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } => Ok(()), } } @@ -907,6 +952,9 @@ impl<'a> TransactionRebase<'a> { | Operation::UpdateConfig { .. } | Operation::ReserveFragments { .. } | Operation::Project { .. } + // Both a column replacement and an overlay preserve physical row + // addresses; the overlay is newer and wins its covered cells. + | Operation::DataOverlay { .. } | Operation::UpdateBases { .. } => Ok(()), Operation::Merge { .. } => { // Merge rewrites the whole fragment list; always conflict @@ -1009,6 +1057,78 @@ impl<'a> TransactionRebase<'a> { } } + /// Conflict checks for our DataOverlay transaction against a concurrent one. + /// + /// Overlays are intentionally permissive (see the Data Overlay Files spec): + /// they stack with other overlays and tolerate appends, deletes, column + /// rewrites, and index builds, because overlay coverage is addressed by + /// physical offset and the version gate keeps indexes correct. The only + /// concurrent operations that invalidate an overlay are those that rewrite + /// rows or consume the overlays on one of our fragments (Rewrite / Merge), + /// and the whole-dataset replacements (Overwrite / Restore). + fn check_data_overlay_txn( + &mut self, + other_transaction: &Transaction, + other_version: u64, + ) -> Result<()> { + match &other_transaction.operation { + Operation::Append { .. } + | Operation::CreateIndex { .. } + | Operation::ReserveFragments { .. } + | Operation::Project { .. } + | Operation::UpdateConfig { .. } + | Operation::UpdateBases { .. } + | Operation::Clone { .. } + | Operation::UpdateMemWalState { .. } + | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } => Ok(()), + // A concurrent Update or Delete that *removes* one of our overlaid + // fragments leaves the overlay orphaned — it is addressed by physical + // offset into a fragment that would no longer exist — so conflict and + // retry against the new fragment list. Fragments merely updated in + // place (deletion vectors, column rewrites) preserve physical offsets, + // so the overlay stays valid and does not conflict. + Operation::Update { + removed_fragment_ids, + .. + } + | Operation::Delete { + deleted_fragment_ids: removed_fragment_ids, + .. + } => { + if removed_fragment_ids + .iter() + .any(|id| self.modified_fragment_ids.contains(id)) + { + Err(self.retryable_conflict_err(other_transaction, other_version)) + } else { + Ok(()) + } + } + Operation::Rewrite { groups, .. } => { + // A rewrite (compaction / fold) of a fragment we are overlaying + // changes its physical row addresses, so our offsets would be + // invalid. Conflict only if it touches one of our fragments. + let touches_our_fragment = groups + .iter() + .flat_map(|g| g.old_fragments.iter()) + .any(|f| self.modified_fragment_ids.contains(&f.id)); + if touches_our_fragment { + Err(self.retryable_conflict_err(other_transaction, other_version)) + } else { + Ok(()) + } + } + Operation::Merge { .. } => { + // Merge rewrites the whole fragment list; always conflict. + Err(self.retryable_conflict_err(other_transaction, other_version)) + } + Operation::Overwrite { .. } | Operation::Restore { .. } => { + Err(self.incompatible_conflict_err(other_transaction, other_version)) + } + } + } + fn check_merge_txn( &mut self, other_transaction: &Transaction, @@ -1026,7 +1146,8 @@ impl<'a> TransactionRebase<'a> { | Operation::Delete { .. } | Operation::Rewrite { .. } | Operation::Merge { .. } - | Operation::DataReplacement { .. } => { + | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } => { Err(self.retryable_conflict_err(other_transaction, other_version)) } Operation::Overwrite { .. } @@ -1050,6 +1171,7 @@ impl<'a> TransactionRebase<'a> { | Operation::CreateIndex { .. } | Operation::Rewrite { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::Restore { .. } | Operation::ReserveFragments { .. } @@ -1078,6 +1200,7 @@ impl<'a> TransactionRebase<'a> { | Operation::CreateIndex { .. } | Operation::Rewrite { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::ReserveFragments { .. } | Operation::Update { .. } @@ -1102,6 +1225,7 @@ impl<'a> TransactionRebase<'a> { | Operation::UpdateConfig { .. } | Operation::CreateIndex { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Rewrite { .. } | Operation::Clone { .. } | Operation::ReserveFragments { .. } @@ -1166,6 +1290,7 @@ impl<'a> TransactionRebase<'a> { | Operation::CreateIndex { .. } | Operation::Rewrite { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::Restore { .. } | Operation::ReserveFragments { .. } @@ -1238,6 +1363,7 @@ impl<'a> TransactionRebase<'a> { | Operation::Overwrite { .. } | Operation::Delete { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::Restore { .. } | Operation::Clone { .. } @@ -1331,6 +1457,7 @@ impl<'a> TransactionRebase<'a> { Operation::Append { .. } | Operation::Overwrite { .. } | Operation::DataReplacement { .. } + | Operation::DataOverlay { .. } | Operation::Merge { .. } | Operation::Restore { .. } | Operation::ReserveFragments { .. } @@ -2734,6 +2861,129 @@ mod tests { } } + #[test] + fn test_data_overlay_conflicts() { + use crate::dataset::transaction::DataOverlayGroup; + use ConflictResult::*; + use lance_table::format::{DataOverlayFile, OverlayCoverage}; + use roaring::RoaringBitmap; + + // Our transaction overlays fragment 1. + let overlay_op = |fragment_id: u64| Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id, + overlays: vec![DataOverlayFile { + data_file: DataFile::new_legacy_from_fields("overlay.lance", vec![0], None), + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + committed_version: 0, + }], + }], + }; + let update_removing = |removed_fragment_ids: Vec| Operation::Update { + removed_fragment_ids, + updated_fragments: vec![], + new_fragments: vec![], + fields_modified: vec![], + merged_generations: Vec::new(), + fields_for_preserving_frag_bitmap: vec![], + update_mode: None, + inserted_rows_filter: None, + updated_fragment_offsets: None, + }; + let delete = |updated: Vec, deleted: Vec| Operation::Delete { + updated_fragments: updated, + deleted_fragment_ids: deleted, + predicate: "x > 2".to_string(), + }; + let rewrite_of = |old: &Fragment| Operation::Rewrite { + groups: vec![RewriteGroup { + old_fragments: vec![old.clone()], + new_fragments: vec![], + }], + rewritten_indices: vec![], + frag_reuse_index: None, + }; + + let fragment0 = Fragment::new(0); + let fragment1 = Fragment::new(1); + + // Each case is checked against our overlay on fragment 1. + let cases: Vec<(Operation, ConflictResult)> = vec![ + // Permissive: preserves physical offsets / leaves fragment 1 in place. + ( + Operation::Append { + fragments: vec![fragment0.clone()], + }, + Compatible, + ), + ( + Operation::CreateIndex { + new_indices: vec![], + removed_indices: vec![], + }, + Compatible, + ), + ( + Operation::DataReplacement { + replacements: vec![DataReplacementGroup( + 1, + DataFile::new_legacy_from_fields("r.lance", vec![0], None), + )], + }, + Compatible, + ), + // Another overlay on the same fragment stacks rather than conflicts. + (overlay_op(1), Compatible), + // Delete/Update that only updates fragment 1 in place is compatible. + (delete(vec![fragment1.clone()], vec![]), Compatible), + (update_removing(vec![2]), Compatible), + // ...but removing our overlaid fragment 1 orphans the overlay -> conflict. + (delete(vec![], vec![1]), Retryable), + (update_removing(vec![1]), Retryable), + // Rewriting fragment 1 invalidates its physical offsets -> conflict; + // a rewrite of a different fragment does not. + (rewrite_of(&fragment1), Retryable), + (rewrite_of(&fragment0), Compatible), + // Merge rewrites the whole fragment list; Restore replaces the dataset. + ( + Operation::Merge { + fragments: vec![fragment1.clone()], + schema: lance_core::datatypes::Schema::default(), + }, + Retryable, + ), + (Operation::Restore { version: 1 }, NotCompatible), + ]; + + for (other, expected) in cases { + let mut rebase = TransactionRebase { + transaction: Transaction::new(0, overlay_op(1), None), + initial_fragments: HashMap::new(), + modified_fragment_ids: modified_fragment_ids(&overlay_op(1)) + .collect::>(), + affected_rows: None, + conflicting_frag_reuse_indices: Vec::new(), + conflicting_mem_wal_merged_gens: Vec::new(), + }; + let other_txn = Transaction::new(0, other.clone(), None); + let result = rebase.check_txn(&other_txn, 1); + match expected { + Compatible => assert!( + result.is_ok(), + "overlay should be compatible with {other:?}, got {result:?}" + ), + Retryable => assert!( + matches!(result, Err(Error::RetryableCommitConflict { .. })), + "overlay should retryably conflict with {other:?}, got {result:?}" + ), + NotCompatible => assert!( + matches!(result, Err(Error::IncompatibleTransaction { .. })), + "overlay should be incompatible with {other:?}, got {result:?}" + ), + } + } + } + #[test] fn test_create_index_conflicts_only_on_same_name() { let index0 = IndexMetadata { @@ -3208,6 +3458,7 @@ mod tests { Operation::DataReplacement { replacements } => { Box::new(replacements.iter().map(|r| r.0)) } + Operation::DataOverlay { groups } => Box::new(groups.iter().map(|g| g.fragment_id)), } } diff --git a/rust/lance/src/io/exec/filtered_read.rs b/rust/lance/src/io/exec/filtered_read.rs index b50cba1f9ce..b548c04d04f 100644 --- a/rust/lance/src/io/exec/filtered_read.rs +++ b/rust/lance/src/io/exec/filtered_read.rs @@ -86,6 +86,15 @@ impl EvaluatedIndex { applicable_fragments, }) } + + /// Drop `fragments` from the covered set so they are read in full and re-filtered on the flat + /// path rather than trusting the (possibly stale) index result for them. See OSS-1325. + fn without_fragments(mut self, fragments: &RoaringBitmap) -> Self { + if !fragments.is_empty() { + self.applicable_fragments -= fragments; + } + self + } } /// A fragment along with ranges of row offsets to read @@ -1295,6 +1304,10 @@ pub struct FilteredReadOptions { pub io_buffer_size_bytes: Option, /// If true, skip fragments that are not covered by the scalar index result. pub only_indexed_fragments: bool, + /// Fragments whose index entries may be stale because an overlay committed after the index + /// was built touches an indexed field. They are dropped from the index's covered set so they + /// fall to the flat path and are re-evaluated against their current (overlay-merged) values. + pub overlay_stale_fragments: RoaringBitmap, } impl FilteredReadOptions { @@ -1324,12 +1337,22 @@ impl FilteredReadOptions { full_filter: None, io_buffer_size_bytes: None, only_indexed_fragments: false, + overlay_stale_fragments: RoaringBitmap::new(), threading_mode: FilteredReadThreadingMode::OnePartitionMultipleThreads( get_num_compute_intensive_cpus(), ), } } + /// Drop the given fragments from the scalar index's covered set, forcing them onto the flat + /// path where the full filter is re-evaluated against their current (overlay-merged) values. + /// Used to mask data overlay files: a fragment with a newer overlay on an indexed field can + /// no longer be trusted to the index. See OSS-1325. + pub fn with_overlay_stale_fragments(mut self, fragments: RoaringBitmap) -> Self { + self.overlay_stale_fragments = fragments; + self + } + /// Include deleted rows in the scan /// /// This is currently only supported if there is no scan_range specified @@ -1675,9 +1698,10 @@ impl FilteredReadExec { let index_search_result = index_search.next().await.ok_or_else(|| { Error::internal("Index search did not yield any results".to_string()) })??; - evaluated_index = Some(Arc::new(EvaluatedIndex::try_from_arrow( - &index_search_result, - )?)); + evaluated_index = Some(Arc::new( + EvaluatedIndex::try_from_arrow(&index_search_result)? + .without_fragments(&options.overlay_stale_fragments), + )); } // Load fragments to compute the plan diff --git a/rust/lance/src/io/exec/knn.rs b/rust/lance/src/io/exec/knn.rs index 01125ac1617..c084f0aec53 100644 --- a/rust/lance/src/io/exec/knn.rs +++ b/rust/lance/src/io/exec/knn.rs @@ -59,6 +59,8 @@ use lance_table::format::IndexMetadata; use tokio::sync::Notify; use uuid::Uuid; +use lance_select::RowAddrMask; + use crate::dataset::Dataset; use crate::index::DatasetIndexInternalExt; use crate::index::prefilter::{DatasetPreFilter, FilterLoader}; @@ -1102,11 +1104,17 @@ pub static KNN_PARTITION_SCHEMA: LazyLock = LazyLock::new(|| { ])) }); +/// Create a new ANN execution node, optionally blocking stale row addresses from index results. +/// +/// `overlay_block`: when `Some`, rows whose addresses are in the block list are excluded from +/// ANN results (their index entries may be stale due to a newer data overlay). Pass `None` +/// when no overlay masking is needed. See OSS-1325. pub fn new_knn_exec( dataset: Arc, indices: &[IndexMetadata], query: &Query, prefilter_source: PreFilterSource, + overlay_block: Option, ) -> Result> { let ivf_node = ANNIvfPartitionExec::try_new( dataset.clone(), @@ -1114,12 +1122,13 @@ pub fn new_knn_exec( query.clone(), )?; - let sub_index = ANNIvfSubIndexExec::try_new( + let sub_index = ANNIvfSubIndexExec::try_new_with_overlay( Arc::new(ivf_node), dataset, indices.to_vec(), query.clone(), prefilter_source, + overlay_block, )?; Ok(Arc::new(sub_index)) @@ -1384,6 +1393,10 @@ pub struct ANNIvfSubIndexExec { /// Prefiltering input prefilter_source: PreFilterSource, + /// Row addresses whose index entries are stale due to a newer data overlay. Blocked from + /// index results at execution time via [`DatasetPreFilter::with_overlay_block`]. See OSS-1325. + overlay_block: Option, + /// Datafusion Plan Properties properties: Arc, @@ -1397,6 +1410,17 @@ impl ANNIvfSubIndexExec { indices: Vec, query: Query, prefilter_source: PreFilterSource, + ) -> Result { + Self::try_new_with_overlay(input, dataset, indices, query, prefilter_source, None) + } + + pub fn try_new_with_overlay( + input: Arc, + dataset: Arc, + indices: Vec, + query: Query, + prefilter_source: PreFilterSource, + overlay_block: Option, ) -> Result { if input.schema().field_with_name(PART_ID_COLUMN).is_err() { return Err(Error::index(format!( @@ -1416,6 +1440,7 @@ impl ANNIvfSubIndexExec { indices, query, prefilter_source, + overlay_block, properties, metrics: ExecutionPlanMetricsSet::new(), }) @@ -1891,6 +1916,7 @@ impl ExecutionPlan for ANNIvfSubIndexExec { indices: self.indices.clone(), query: self.query.clone(), prefilter_source, + overlay_block: self.overlay_block.clone(), properties: self.properties.clone(), metrics: ExecutionPlanMetricsSet::new(), } @@ -1971,11 +1997,13 @@ impl ExecutionPlan for ANNIvfSubIndexExec { PreFilterSource::None => None, }; - let pre_filter = Arc::new(DatasetPreFilter::new( - ds.clone(), - &indices, - prefilter_loader, - )); + let pre_filter = { + let mut pf = DatasetPreFilter::new(ds.clone(), &indices, prefilter_loader); + if let Some(block) = self.overlay_block.clone() { + pf = pf.with_overlay_block(block); + } + Arc::new(pf) + }; let state = Arc::new(ANNIvfEarlySearchResults::new(indices.len(), query.k)); diff --git a/rust/lance/src/io/exec/scalar_index.rs b/rust/lance/src/io/exec/scalar_index.rs index ee05ce7a86f..0c62ec82632 100644 --- a/rust/lance/src/io/exec/scalar_index.rs +++ b/rust/lance/src/io/exec/scalar_index.rs @@ -561,6 +561,10 @@ pub struct MaterializeIndexExec { dataset: Arc, expr: ScalarIndexExpr, fragments: Arc>, + /// Row addresses blocked from the index result due to data overlay files committed after the + /// index was built. ANDead into the candidate mask before row ID materialisation so that stale + /// index entries never reach downstream operators. See OSS-1325. + overlay_block: Option, properties: Arc, metrics: ExecutionPlanMetricsSet, } @@ -633,16 +637,25 @@ impl MaterializeIndexExec { dataset, expr, fragments, + overlay_block: None, properties, metrics: ExecutionPlanMetricsSet::new(), } } + /// Block specific row addresses from the index result. Used to exclude rows whose indexed + /// values are stale because a data overlay was committed after the index was built. + pub fn with_overlay_block(mut self, block: RowAddrMask) -> Self { + self.overlay_block = Some(block); + self + } + #[instrument(name = "materialize_scalar_index", skip_all, level = "debug")] async fn do_execute( expr: ScalarIndexExpr, dataset: Arc, fragments: Arc>, + overlay_block: Option, metrics: Arc, ) -> Result { let expr_result = expr.evaluate(dataset.as_ref(), metrics.as_ref()); @@ -670,12 +683,15 @@ impl MaterializeIndexExec { } Ok(result.upper) }; - let mask = if let Some(prefilter) = prefilter { + let mut mask = if let Some(prefilter) = prefilter { let (expr_result, prefilter) = futures::try_join!(expr_result, prefilter)?; take_upper(expr_result)? & (*prefilter).clone() } else { take_upper(expr_result.await?)? }; + if let Some(block) = overlay_block { + mask = mask & block; + } let ids = row_ids_for_mask(mask, &dataset, &fragments).await?; let ids = UInt64Array::from(ids); Ok(RecordBatch::try_new( @@ -811,6 +827,7 @@ impl ExecutionPlan for MaterializeIndexExec { self.expr.clone(), self.dataset.clone(), self.fragments.clone(), + self.overlay_block.clone(), metrics, ); let stream = futures::stream::iter(vec![batch_fut]) diff --git a/rust/lance/src/utils/test.rs b/rust/lance/src/utils/test.rs index 3338eee07a8..f804a7cc38a 100644 --- a/rust/lance/src/utils/test.rs +++ b/rust/lance/src/utils/test.rs @@ -243,6 +243,7 @@ impl TestDatasetGenerator { Fragment { id: 0, files, + overlays: vec![], deletion_file: None, row_id_meta: None, physical_rows: Some(batch.num_rows()),