feat(file): v2 writer/reader support columns of unequal length#7406
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
westonpace
left a comment
There was a problem hiding this comment.
Very cool to see this coming together. Some minor nits but nothing blocking. Biggest concern is probably whether we are calling advance_columns in the write spot.
| /// 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<u64> { |
There was a problem hiding this comment.
I think elsewhere we'd just throw an error if the column index was out of bounds. Any particular reason to make this Option instead of Result?
There was a problem hiding this comment.
I'm open to it. I guess option is nice in cases where there's only one reason it wouldn't have a value, but it does mean you don't get an error message. I'll switch to an error with a clear message.
There was a problem hiding this comment.
Done — column_num_rows now returns a Result with an out-of-bounds message instead of Option.
| // `ReaderProjection`'s constructors). Used to partition a projection's flat | ||
| // `column_indices` by top-level field. |
There was a problem hiding this comment.
Used to partition a projection's flat
column_indicesby top-level field.
Maybe I just haven't read far enough in but I don't understand this sentence.
There was a problem hiding this comment.
Removed count_projected_columns — the length walk is now validate_field_length, which should read more clearly, so the confusing sentence is gone.
| 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()); |
There was a problem hiding this comment.
Minor nit: I wonder if we should put these rules in a helper function
There was a problem hiding this comment.
Done — extracted the shape rules into a shared field_column_shape helper, now used by both from_field_ids_helper and the validation walk.
| // The top-level row count of each projected top-level field, paired with the | ||
| // field's name, 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. |
There was a problem hiding this comment.
Do we have any guarantees for nested fields? For example, if I have a struct with two children do both fields need to have the same length?
There was a problem hiding this comment.
That's a good question. I'll look into this. I think I'm inclined to say yes, otherwise it can get especially thorny with lists. But I guess in theory structs could be more flexible. Let me see what is possible and what we need to clear up.
There was a problem hiding this comment.
Yes, and it's now enforced on read. write_column can only target a top-level field, so a struct's children always advance together from a single array and stay equal-length — there's no way to write divergent children today. To guard against a future/foreign writer, I made the reader's length check structure-aware: a struct's children must share its row count, otherwise a clear error (the decoders would otherwise panic in v2.0 / underflow in v2.1). List/map/fixed-size-list items are exempt since their cardinality legitimately differs. Covered by test_validate_struct_child_lengths.
There was a problem hiding this comment.
I think this is probably a reasonable restriction. Things could get very complicated otherwise and the read/write amplification should still be pretty trivial with this requirement. I'm sure I could dream up some counter-example but let's keep it simple until we hit something real.
| if n == 0 { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
I don't even know if we support this in general but I wonder how this handles the empty struct case. It looks like it would return an empty vec (which is probably correct?)
Not important at all, just idle musing
There was a problem hiding this comment.
Maybe worth a simple unit test.
There was a problem hiding this comment.
Added test_validate_length_list_and_empty_struct — an empty struct validates to its single column's length.
| let Some(&root_column) = projection.column_indices.get(cursor) else { | ||
| break; |
There was a problem hiding this comment.
I think it should be an error here and not a break? This means the user supplied a projection with an unequal number of fields / indices which is invalid.
There was a problem hiding this comment.
Done — a projection with the wrong number of column indices now errors instead of silently truncating.
| 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); |
There was a problem hiding this comment.
Super minor nit but seems you could do something like...
for (field_idx, array) in items) {
let new_total = self.field_rows_written[*field_idx] + array.len() as u64;
self.field_rows_written[*field_idx] = new_total;
self.rows_written = self.rows_written.max(new_total);
}
...and save yourself from looping through all the fields on each call.
There was a problem hiding this comment.
Done — advance_columns folds each written field's new total into rows_written directly instead of rescanning.
| 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); |
There was a problem hiding this comment.
Done — write_batch now grows rows_written by num_rows (every field advances uniformly).
| // 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)]) { |
There was a problem hiding this comment.
This method is called in the write_column case but not in the write_batch case (it has its own bookkeeping of self.field_rows_written and self.rows_written. It might be nice to call that out.
Alternatively, does this really need to be a dedicated method? It's only called once and similar logic is inlined in write_batch instead of a dedicated method.
| // column. The returned tasks must be written before the per-field row | ||
| // counters are advanced (see `advance_columns`). |
There was a problem hiding this comment.
This says the returned tasks must be written before we advance the columns. But in write_columns we have...
let encoding_tasks = encoding_tasks
.into_iter()
.flatten()
.collect::<FuturesOrdered<_>>();
self.advance_columns(&columns);
self.write_pages(encoding_tasks).await?;
So it looks like we aren't doing the writing until after advance_columns?
d6f2417 to
08e1d58
Compare
The v2 file writer advanced every column from a single global row counter, so a file could only hold columns of equal length. Sparse data overlay files need columns whose item counts differ within one file. - `FileWriter::write_column(column_index, array)` writes one top-level column, advancing only that field's row counter. `write_batch` is unchanged and still advances all fields together. - `FileReader::column_num_rows(column_index)` exposes a single column's length (sum of its pages' row counts). - Reader projection-length validation: the reader combines a projection's columns into rectangular batches, so they must share a length. A projection whose columns differ in length is rejected up front with a descriptive error instead of panicking inside the decoder, and a struct whose physical children diverge in length is likewise rejected (struct children must be equal-length; list/map/ fixed-size-list items are exempt). `RangeFull`/`RangeFrom` resolve to the projected common length rather than `num_rows`. Rectangular files are unaffected. Validation runs once per read in `FileReadCore`, so it covers both the eager and lazy (indexed) metadata providers. Closes lance-format#7404 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
08e1d58 to
125d96f
Compare
…#7535) > **Supersedes #7407.** Relocated into `lance-format/lance` (head + base in-repo) so the OSS-1324 PR can stack on it directly and show a clean, specific diff. Data model, feature flag, and write/commit foundation for [Data Overlay Files](#7381) (OSS-1322). **Stacked on #7381** (spec/proto). To keep this diff clean, the base is an in-repo mirror of #7381's branch (`will/data-overlay-spec-base`); retarget to `main` once #7381 lands. (#7406 / OSS-1323, needed for the sparse overlay sink, has merged.) ## What's here - **Data model** (`lance-table/src/format/overlay.rs`) — `DataOverlayFile` + `OverlayCoverage` on `Fragment.overlays`, dense (`shared_offset_bitmap`) and sparse (per-field). Lives in its own module: a small public surface (the two types, `dense`/`sparse`, `coverage_for_field`) with the coverage / rank / parse-once / newest-last invariants documented at the module level and the serde/proto/Roaring plumbing kept private. Coverage bitmaps are parsed once on load into `Arc<RoaringBitmap>` (not re-deserialized per access), and a fragment's overlays are stable-sorted by `committed_version` (newest last) on load so resolution can rely on the ordering. Protobuf/serde round-trip + `coverage_for_field` tested. - **Feature flag 64** (`FLAG_DATA_OVERLAY_FILES`) — set when any fragment has overlays. Release-gated: treated as an unknown flag in release builds (so release readers/writers refuse overlay datasets) unless `LANCE_ENABLE_DATA_OVERLAY_FILES` is set; debug builds understand it. Tested (release-gating policy is asserted profile-independently). - **`Operation::DataOverlay` transaction** — appends overlays to a fragment (preserving concurrently-written ones) and stamps `committed_version` at commit, re-stamped on retry. Protobuf round-trip + multi-fragment `build_manifest` (distinct targets + untargeted pass-through) tested. - **Conflict resolution** (v2 `CommitConflictResolver`) — permissive like `DataReplacement`: compatible with append / column-rewrite / index-build / data-replacement / other overlays, and with deletes/updates that leave the overlaid fragment in place. Retryable when a concurrent op row-rewrites or consumes the overlays on an overlaid fragment (`Rewrite`/`Merge`) or *removes* one (`Update`/`Delete` removal); incompatible with whole-dataset `Overwrite`/`Restore` and with `UpdateMemWalState` (matching the spec — both commit directions now agree). Tested (both-direction matrix, including remove-fragment and `Rewrite`×overlay). The fragment read path refuses overlays until the scan merge lands (OSS-1324). ## Follow-ups on this stack - [ ] `DataOverlayFileWriter` — streaming dense+sparse sink, used internally behind `update`/`merge_insert`. - [ ] `validate()` overlay checks — value-column length vs. coverage cardinality, dtype vs. schema (I/O-bound; cheap structural invariants are enforced at parse/commit time). - [ ] Scan/take merge (OSS-1324), resolved at read time fetching only the touched coverage ranks. Blocks OSS-1324 (take/scan), OSS-1325 (index masking), OSS-1326 (compaction). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added experimental data overlay file support with opt-in release gating via an environment variable. * Introduced first-class `DataOverlay` transaction support to append per-fragment overlay updates during commits, including newest-last overlay ordering validation. * **Bug Fixes** * Improved overlay conflict handling across rewrites, data replacements, and row-moving updates, including deferred row-overlap validation. * Prevented reads from proceeding on fragments containing overlay data when overlay merge is in progress. * **Documentation** * Updated transaction compatibility docs with clearer overlay stacking rules, rewrite/replacement overlay interactions, and added conflict scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
The v2 file writer advanced every column from a single global row counter, so a single file could only hold columns of equal length. Sparse data overlay files need columns whose item counts differ within one file (each field covers a different set of rows). This is technically allowed by the file format but unused today.
This PR adds that capability:
FileWriter::write_column(column_index, array)— writes one column, advancing only that field's row counter. Called across multiple calls a field appends; a field never written ends up as a zero-length column; an empty array is a no-op.write_batchis unchanged — it still advances all fields together, so ordinary rectangular files round-trip exactly as before.FileReader::column_num_rows(column_index)— exposes a single column's length (sum of its pages' row counts). Always derivable from page metadata; this just surfaces it.RangeFull/RangeFromresolve to the projected common length rather thanself.num_rows(the file's longest column), so a single short column reads back at its own length. Rectangular files are unaffected (the common length equalsnum_rows).Per-field lengths are computed from each top-level field's root (first) physical column, which equals its top-level row count for primitive, struct, and list fields in both v2.0 and v2.1.
Tests
lance-filelib suite (81 tests) + doctest pass;cargo fmtandcargo clippy --tests -D warningsclean. New/extended, all rstest over V2_0 + V2_1 where relevant:test_write_columns_unequal_lengths: writes a 5-row, a 1-row, and a 0-row column in one file; asserts per-column row counts; reads each back at its own length; random access (sorted indices) within the longer column; empty-array writes are a no-op on both written and unwritten columns.test_read_unequal_length_projection: equal-length projections full-scan; mismatched-length projections error before any batch (error names each column's length); single-columnRangeFull/RangeFrom/RangeToresolve to that column's own length; out-of-range bounds error.test_read_nested_columns_under_validation: struct and list columns (which map to multiple physical columns) still read under the new validation path.test_write_column_validation_errors: missing explicit schema, out-of-bounds field index, and a null written into a non-nullable field are each rejected.test_blocking_read_unequal_length: the blocking read path applies the same validation.test_write_batch_keeps_equal_lengths: rectangular files still produce equal-length columns (backwards compatible).Acceptance criteria (OSS-1323)
column_num_rows)Closes #7404 (OSS-1323). Root of the Data Overlay Files stack; OSS-1322 builds on this.
🤖 Generated with Claude Code