Skip to content

feat(file): v2 writer/reader support columns of unequal length#7406

Merged
wjones127 merged 1 commit into
lance-format:mainfrom
wjones127:will/oss-1323-v2-unequal-length-columns
Jun 25, 2026
Merged

feat(file): v2 writer/reader support columns of unequal length#7406
wjones127 merged 1 commit into
lance-format:mainfrom
wjones127:will/oss-1323-v2-unequal-length-columns

Conversation

@wjones127

@wjones127 wjones127 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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_batch is 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.
  • 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 now rejected up front with a descriptive error (naming each column's length) instead of panicking inside the decoder. RangeFull/RangeFrom resolve to the projected common length rather than self.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 equals num_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-file lib suite (81 tests) + doctest pass; cargo fmt and cargo clippy --tests -D warnings clean. 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-column RangeFull/RangeFrom/RangeTo resolve 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)

  • v2 writer can write a single file with columns of differing item counts (no shared global row counter)
  • v2 reader reads each column back independently at its own length
  • Random access by position within a shorter/longer column returns correct values
  • Per-column row counts derivable from file metadata (column_num_rows)
  • Round-trip tests incl. zero-length and single-row columns alongside longer ones
  • Existing equal-length files round-trip unchanged

Closes #7404 (OSS-1323). Root of the Data Overlay Files stack; OSS-1322 builds on this.

🤖 Generated with Claude Code

@github-actions github-actions Bot added A-encoding Encoding, IO, file reader/writer enhancement New feature or request labels Jun 23, 2026
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.68836% with 32 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-file/src/reader.rs 92.08% 17 Missing and 5 partials ⚠️
rust/lance-file/src/writer.rs 95.61% 5 Missing and 5 partials ⚠️

📢 Thoughts on this report? Let us know!

Comment thread rust/lance-file/src/writer.rs Outdated
Comment thread rust/lance-file/src/writer.rs Outdated
Comment thread rust/lance-file/src/writer.rs
@wjones127
wjones127 marked this pull request as ready for review June 24, 2026 02:48
@wjones127
wjones127 requested review from Xuanwo and westonpace June 24, 2026 02:48

@westonpace westonpace left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rust/lance-file/src/reader.rs Outdated
/// 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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — column_num_rows now returns a Result with an out-of-bounds message instead of Option.

Comment thread rust/lance-file/src/reader.rs Outdated
Comment on lines +513 to +514
// `ReaderProjection`'s constructors). Used to partition a projection's flat
// `column_indices` by top-level field.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used to partition a projection's flat column_indices by top-level field.

Maybe I just haven't read far enough in but I don't understand this sentence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed count_projected_columns — the length walk is now validate_field_length, which should read more clearly, so the confusing sentence is gone.

Comment thread rust/lance-file/src/reader.rs Outdated
Comment on lines +516 to +520
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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit: I wonder if we should put these rules in a helper function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — extracted the shape rules into a shared field_column_shape helper, now used by both from_field_ids_helper and the validation walk.

Comment thread rust/lance-file/src/reader.rs Outdated
Comment on lines +530 to +535
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rust/lance-file/src/reader.rs Outdated
Comment on lines +542 to +544
if n == 0 {
continue;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth a simple unit test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_validate_length_list_and_empty_struct — an empty struct validates to its single column's length.

Comment thread rust/lance-file/src/reader.rs Outdated
Comment on lines +545 to +546
let Some(&root_column) = projection.column_indices.get(cursor) else {
break;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — a projection with the wrong number of column indices now errors instead of silently truncating.

Comment thread rust/lance-file/src/writer.rs Outdated
Comment on lines +559 to +562
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — advance_columns folds each written field's new total into rows_written directly instead of rescanning.

Comment thread rust/lance-file/src/writer.rs Outdated
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — write_batch now grows rows_written by num_rows (every field advances uniformly).

Comment thread rust/lance-file/src/writer.rs Outdated
// 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)]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rust/lance-file/src/writer.rs Outdated
Comment on lines +525 to +526
// column. The returned tasks must be written before the per-field row
// counters are advanced (see `advance_columns`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@wjones127
wjones127 force-pushed the will/oss-1323-v2-unequal-length-columns branch 2 times, most recently from d6f2417 to 08e1d58 Compare June 24, 2026 22:46
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>
@wjones127
wjones127 force-pushed the will/oss-1323-v2-unequal-length-columns branch from 08e1d58 to 125d96f Compare June 24, 2026 23:38
@wjones127
wjones127 merged commit def65b1 into lance-format:main Jun 25, 2026
30 checks passed
wjones127 added a commit that referenced this pull request Jul 13, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-encoding Encoding, IO, file reader/writer enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

V2 File writers and readers can support columns of varying lengths

2 participants