Skip to content

feat: resolve data overlay files on the take (and scan) read path#7536

Merged
wjones127 merged 16 commits into
mainfrom
will/oss-1324-take-can-read-overlays
Jul 15, 2026
Merged

feat: resolve data overlay files on the take (and scan) read path#7536
wjones127 merged 16 commits into
mainfrom
will/oss-1324-take-can-read-overlays

Conversation

@wjones127

@wjones127 wjones127 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Supersedes #7409. Relocated into lance-format/lance and properly stacked on the OSS-1322 branch so the diff is now exactly this PR's change (no spec/1322/1323 commits to scroll past).

Resolves the take random-access path against data overlay files, replacing the temporary "overlays not supported" error from OSS-1322. Implements OSS-1324.

Because take and scan share FragmentReader's read path, the merge is wired there once: each row is addressed by its physical offset (from ReadBatchParams::to_offsets_total) and resolved against the overlays that cover its field. This also enables the scan-path merge that the OSS-1322 PR stubbed out.

How it works

  • Lazy, rank-pushed reads. Each contributing overlay file is opened once (projected to the covered ∩ requested fields) and no value bytes are read up front. Requested offsets are routed to the newest covering overlay at their coverage rank, and only the touched ranks are fetched — via the existing take primitive — so overlay reads are O(requested rows), not O(coverage).
  • Concurrent IO. Base and overlay reads are issued together, then the column is assembled with interleave (rank → fetch-position remap).
  • The merge runs on physical rows in read order, before deletion filtering, so:
    • deletions take precedence (an overlay value computed for a deleted row is dropped with the row),
    • NULL overrides apply (a covered offset with a NULL value resolves to NULL, distinct from fall-through),
    • fields resolve independently.
  • Sparse per-field overlays read each field's value column independently, so unequal-length value columns (OSS-1323) need no rectangular batch. Rank-based addressing only (rank on the coverage bitmap + a value fetch; no offset key column, no binary search).

Overlays on nested (non-top-level) fields are not yet matched and are left for follow-up. There's a TODO(overlay perf) on reader priority to settle with a benchmark.

Tests

take covered/uncovered offsets; multiple overlays (newest wins); per-field coverage with unequal-length columns; NULL override; overlay on a deleted row (inert); multi-fragment scan — each over v2.0 and v2.1. Plus unit tests for the routing/assembly core, and an IO guard (test_take_reads_only_needed_overlay_ranks) asserting a 2-row take over a fully-covering 100k-row overlay reads ~one miniblock, not the whole value column.

Stacking

Stacked on the OSS-1322 branch (will/oss-1322-write-data-overlay-and-scan-data). Merge that first; this PR's base retargets to main automatically when it lands.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for reading dataset fragments that include overlay files.
    • Overlay values are resolved and merged into results during both scans and targeted row reads, honoring newest-wins semantics across nested fields.
    • Optimizes reads by fetching only overlay data needed for the requested rows and projections.
  • Bug Fixes

    • Fragments with overlays are no longer rejected, and overlay resolution remains correct when overlays overlap deleted rows.
  • Tests

    • Added end-to-end overlay read coverage for scan/take, including multi-batch slicing and edge cases.

@wjones127

wjones127 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Read impact of overlay files

What does resolving overlays on read cost, vs. a dataset with none? Measured on a
1M-row int32 column, 1% of cells overlaid, local SSD, warm cache. Each layer is
a separate overlay file all covering the same cells (the worst case — every layer
must be consulted). take is 100 random rows; scan is a full scan of the
column.

coverage overlays take (ms) scan (ms)
0 (none) 0.5 11
contiguous 4 0.4 11
contiguous 16 0.5 21
stride 4 0.5 19
stride 16 0.5 35

take is unaffected by overlays. Rank pushdown reads only the overlaid cells
a take actually touches, so a 100-row take costs the same (~0.5 ms) with 0 or
16 layers.

scan pays a per-layer merge cost. A full scan grows roughly linearly with
the number of layers, since each layer adds one value-column read and one
interleave pass over the output. Scattered (stride) coverage costs more than a
single contiguous run (more pages touched, larger/denser coverage bitmaps): ~1.5
ms/layer strided vs. ~0.6 ms/layer contiguous here. A small number of layers over
contiguous regions is nearly free; many layers — especially scattered — is where
cost accrues, and is exactly what overlay compaction folds back down.

The routing itself (deciding base-vs-overlay per cell) is negligible — under 2 ms
even at 16 scattered layers — so scan cost is dominated by the actual value reads
and merges, not bookkeeping.

@wjones127

Copy link
Copy Markdown
Contributor Author

Read impact of overlay files on a wide value column

Follow-up to the int32 numbers above. That table used a 4-byte int32 column, where the base read is nearly free — but ML overlays usually target wide vectors, where the base read is the whole story. So here is the same take/scan sweep on a 3072-d float32 embedding (12 KiB/row, ~750× an int32 cell). 100K rows (base file ~1.2 GiB), 1% of cells overlaid, single fragment, local SSD, warm cache; each layer covers the same cells (worst case — every layer must be consulted). Both dtypes re-measured on the same machine so they're comparable. Median wall time:

Wide column — 3072-d embedding, 100K rows (~1.2 GiB base)

coverage overlays take (ms) scan (ms)
0 (none) 0.4 ~600
contiguous 4 0.5 595
contiguous 16 0.6 592
stride 4 0.5 856
stride 16 0.6 858

For reference, the narrow int32 column (1M rows) re-run on the same machine: scan 12 / 14 / 27 ms contiguous at 0 / 4 / 16 layers, 13 / 35 / 41 ms stride; take 0.3 – 0.5 ms throughout — same shape as the original table.

Takeaways for a wide column:

take stays ~free regardless of width. A 100-row take is sub-millisecond even at 3072-d, because rank pushdown reads only the ~1.2 MB of cells the take actually touches. Column width barely moves it, and the per-layer cost is the same small additive bump as int32 (0.4 → 0.6 ms from 0 → 16 layers).

scan is dominated by the base column read, not overlay bookkeeping. Reading and decoding the 1.2 GiB base embedding is ~600 ms — that's the floor every scan pays before a single overlay is considered.

Contiguous overlays are effectively free on a wide scan. 0 / 4 / 16 contiguous layers all land at ~590–620 ms, no measurable per-layer cost — the bitmap-major contiguous path from this PR doing exactly what it's for: against a base read that large, the contiguous merge is lost in the noise. (Contrast int32, where the base read is trivial, so each contiguous layer is plainly visible: 12 → 14 → 27 ms.)

Scattered (stride) coverage adds a bounded ~45% (~270 ms) that — unlike int32 — does not grow with layer count. 4 and 16 stride layers are indistinguishable (both ~856 ms). Once a scan is on the scattered-merge path, the extra cost is a one-time scattered gather over wide rows; stacking more layers over the same cells only unions cheap coverage bitmaps while the base read still dominates. On int32 the per-layer merge is the whole cost, so it keeps climbing (35 → 41 ms).

Cold-cache, every wide scan reads the full ~1.2 GiB column regardless of layer count (16 layers at 1% coverage add <5% IO); a wide take reads ~1.2 MB.

Net: for wide vector columns the overlay read tax is smaller in relative terms than the int32 microbenchmark suggests — contiguous layers are essentially free, and even scattered layers cost a bounded ~45% that doesn't compound with depth. The int32 sweep is close to a worst case for relative overhead precisely because its base read is so cheap there's nothing to amortize against.

Benchmark added in #7544 (test_overlay_read_wide).

@wjones127
wjones127 force-pushed the will/oss-1322-write-data-overlay-and-scan-data branch from e2f98f5 to efab63a Compare July 6, 2026 20:44
@wjones127
wjones127 force-pushed the will/oss-1324-take-can-read-overlays branch from 3ba338b to a7d4508 Compare July 6, 2026 20:44
@wjones127
wjones127 force-pushed the will/oss-1322-write-data-overlay-and-scan-data branch from efab63a to 6a39624 Compare July 7, 2026 22:25
@wjones127
wjones127 force-pushed the will/oss-1324-take-can-read-overlays branch 2 times, most recently from 4487c79 to a7cfcf0 Compare July 7, 2026 23:49
Comment thread rust/lance/src/dataset/fragment.rs Outdated
Comment thread rust/lance/src/dataset/overlay.rs Outdated
Comment thread rust/lance/src/dataset/overlay.rs Outdated
@wjones127
wjones127 force-pushed the will/oss-1324-take-can-read-overlays branch from a7cfcf0 to 72cc6d2 Compare July 8, 2026 17:13
Comment thread rust/lance/src/dataset/overlay.rs Outdated
Comment thread rust/lance/src/dataset/overlay.rs Outdated
@wjones127
wjones127 marked this pull request as ready for review July 8, 2026 21:42
@wjones127
wjones127 requested a review from westonpace July 8, 2026 21:43
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9908fdd3-b036-4fa2-b589-ec3f825409b6

📥 Commits

Reviewing files that changed from the base of the PR and between f660232 and a1bce9a.

📒 Files selected for processing (1)
  • rust/lance/src/dataset/overlay.rs

📝 Walkthrough

Walkthrough

Changes

Overlay-aware fragment reads now plan coverage from metadata, lazily load required overlay values, merge them into projected batches, and apply this flow to take and scan reads with nested-type and edge-case tests.

Overlay read support

Layer / File(s) Summary
Overlay planning and routing
rust/lance/src/dataset.rs, rust/lance/src/dataset/overlay.rs
Adds the overlay module, atomic-field planning, coverage routing, nested-column traversal/splicing, and routing validation.
Lazy overlay resolution
rust/lance/src/dataset/overlay.rs
Prunes overlay files and values to requested rows, fetches them asynchronously, and merges resolved fields into base batches.
Fragment read integration
rust/lance/src/dataset/fragment.rs
Stores overlay state during fragment opening and merges overlay values before row-ID and deletion processing for single- and multi-range reads.
End-to-end overlay validation
rust/lance/src/dataset/fragment.rs, rust/lance/src/dataset/overlay.rs
Tests scans, takes, nested fields, nulls, deletions, row IDs, coverage, ordering, routing, assembly errors, and multi-batch reads.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FragmentReader
  participant plan_overlays
  participant resolve_overlays
  participant merge_overlay_batch
  participant RowIdAndDeletes
  FragmentReader->>plan_overlays: plan overlay coverage from fragment metadata
  FragmentReader->>resolve_overlays: resolve requested fragment offsets
  resolve_overlays-->>merge_overlay_batch: loaded overlay atom values
  FragmentReader->>merge_overlay_batch: provide base batch
  merge_overlay_batch-->>FragmentReader: merged batch
  FragmentReader->>RowIdAndDeletes: apply row IDs and deletion filtering
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: resolving data overlay files on take and scan read paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch will/oss-1324-take-can-read-overlays

Comment @coderabbitai help to get the list of available commands.

@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.

Clean design. I think the I/O could probably be optimized better in the future (I dropped a comment about the holes) but we can cross that bridge when we come to it.

Comment thread rust/lance/src/dataset/overlay.rs Outdated
Comment on lines +373 to +379
/// An overlay contributes to a projected field when any id in its `data_file.fields`
/// belongs to that top-level field's subtree. Overlays are written against the field
/// ids the file actually stores, which for a struct or list column is its *leaf*
/// child ids (the V2_1 structural encoding does not record the parent id), so a leaf
/// is mapped back to its top-level projected ancestor — the whole column is then
/// fetched and replaced as a unit. Each contributing overlay *file* appears once in
/// `files`, shared by every top-level field it covers.

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.

The wording here is confusing.

An overlay contributes to a projected field when any id in its data_file.fields belongs to that top-level field's subtree

What "top-level field" is the emphasized 'that' referring to? I think it is referring to the projected field but projected fields are not necessarily top-level fields.

Comment on lines +409 to +415
for (field_pos, &field_id) in overlay.data_file.fields.iter().enumerate() {
if let Some(&top) = top_level_of.get(&field_id)
&& seen.insert(top.id)
{
contributions.push((top, field_pos));
}
}

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.

Let say I have a struct as so:

my_struct(field_id=7)
my_struct.foo(field_id=8)
my_struct.bar(field_id=9)

If my projection targets field 8 and the overlay targets field 9 then I will not find it here and the overlay will not be applied. I think this is correct but all this talk of top-level fields made it seem like an overlay should be applied whenever there is a common top-level field (which there is in this case).

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.

Actually, here's a more concerning case. Let's say I have:

outer_struct(field_id=11)
outer_struct.middle_struct(field_id=12)
outer_struct.middle_struct.inner_struct(field_id=13)

Let's say my projection asks for field_id=12 (middle_struct). Then top_level_of will be 12->11. Then my overlay will have field_id=13 which is not present in top_level_of and so I will not apply that overlay but I should!

Comment thread rust/lance/src/dataset/fragment.rs Outdated
Ok(result.project_by_schema(&output_schema)?)
}

/// Merge data overlay values into a stream of base batches.

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.

Suggested change
/// Merge data overlay values into a stream of base batches.
/// Merge data overlay values onto a stream of base batches.

Super trivial and possibly incorrect nit.

Comment on lines +2654 to +2659
// The offset_in_frag of every row this read will return, materialized once.
// Cost is one u32 per output row (a whole-fragment scan is 4 bytes/row), and
// it lets us both prune overlays to the read and slice each batch's offsets
// below without reading any data. Only paid when the fragment has overlays.
let offsets_in_frag: Arc<Vec<u32>> =
Arc::new(params.to_offsets_total(total_num_rows).values().to_vec());

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 wonder if we can avoid this materialization? In resolve_overlays it is immediately converted into a bitmap. In the map function it is sliced. These seem like things we could do without materializing. I think slicing support already exists for ReadBatchParams and we could add conversion-to-bitmap support (or just convert to bitmap immediately and then slice the bitmap)

Alternatively, maybe ReadBatchParams should change to a roaring bitmap/treemap in the first place 🤔

But I guess that would be a larger refactor 😛

Comment thread rust/lance/src/dataset/overlay.rs Outdated
// 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.

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.

It's more than just a performance concern. An invalid priority can lead to deadlock. For example, the default prioirty is 0 (highest priority), if we issue a bunch of overlay reads for a batch that we're not ready to read at all then they can clog up the backpressure queue.

However, I think we are ok. I don't think we initiate the fetch until the task is polled for the first time. That won't happen unless we have a dedicated consumer for the data. A priority of 0 is probably the correct answer in this case (we are initiating the reads quite late in the process)

The potential performance optimization would be to start fetching the overlay data earlier. For example, imagine we are in a compute constrained workload. The current approach probably gives us bubbles:

Image

So, in the end, I guess it is a perf question. Still, let's update the comment to say we are using priority 0 because we've already indicated we are ready to consume this batch and so it is safe to consider it highest priority.

/// `offset_in_overlay = <cells this coverage has before the batch>` (a single
/// `rank` lookup), and each following cell is one more. Coverages are applied
/// newest-first, and the first overlay to claim a row wins.
fn route_contiguous(

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.

Nice fast path

@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.

Actually, sorry, switching back to request changes until the projection mapping comment (#7536 (comment)) is resolved.

@wjones127
wjones127 force-pushed the will/oss-1322-write-data-overlay-and-scan-data branch from dec272a to b82ac8d Compare July 10, 2026 22:38
@github-actions

Copy link
Copy Markdown
Contributor

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@github-actions github-actions Bot added the A-format On-disk format: protos and format spec docs label Jul 13, 2026
Base automatically changed from will/oss-1322-write-data-overlay-and-scan-data to main July 13, 2026 18:24
wjones127 and others added 3 commits July 13, 2026 11:28
Wire overlay resolution into FragmentReader so `take` and scan reads merge
data overlay cells over the base data, removing the temporary guard that
refused reads of fragments carrying overlays.

Reads are lazy and rank-pushed: each overlay file is opened once (no value
bytes read up front), requested offsets are routed to the newest covering
overlay at their coverage rank, and only the touched ranks are fetched via
the existing `take` primitive. Base and overlay IO run concurrently, then
the column is assembled with `interleave`. This makes overlay reads
O(requested rows) rather than O(coverage).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adds tests for combinations the initial read-path suite missed: row-id
pass-through alongside an overlay, an older overlay routed zero ranks
(empty-ranks fetch branch), a newest NULL value shadowing an older
non-null, per-field newest-wins across two sparse overlays, a plan
present but all requested offsets uncovered (all-fall-through), and a
dataset-level take spanning multiple overlaid fragments.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`route_overlays` was offset-major: for every physical offset it probed
each coverage bitmap newest-first. On a full scan that is
`O(N_rows * K_overlays)` roaring `contains` probes — ~16M for a 1M-row,
16-overlay scan — of which 99% only confirm a fall-through to base. It
dominated scan CPU (~87% of the scan thread) and made scan cost grow
linearly with overlay count, badly so for scattered (stride) coverage.

A scan reads a contiguous physical range, so offset `o` is output row
`o - base`. The fast path now intersects each coverage with the batch's
offset range (a container-level op that drops a non-overlapping batch in
`O(containers)`) and routes only the in-range bits directly to rows.
Those bits carry consecutive coverage ranks starting at the count of bits
below `base`, so ranks need a single `rank` lookup rather than a probe per
cell. `take` still supplies arbitrary offsets and keeps the general path.

Microbenchmark of `route_overlays` over a 1M-row scan (ms/scan):

    overlays   stride old -> new      contiguous old -> new
    1          13.3  -> 0.67          2.8  -> 0.55
    4          46.3  -> 0.86          3.5  -> 0.66
    16        178.5  -> 1.48          9.4  -> 0.96
    64        829.0  -> 4.07         33.3  -> 2.09

A fuzz test asserts the fast path produces byte-identical routing to the
general path across random bases, lengths, overlay counts, and densities.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
wjones127 and others added 10 commits July 13, 2026 11:28
Review feedback: the "rank" language in the overlay read path is hard to
follow. Name the three coordinate spaces explicitly and use them everywhere:

- offset_in_frag: a row's physical position in the fragment
- offset_in_batch: a row's position within the batch being assembled
- offset_in_overlay: a value's position in an overlay's dense value column
  (what a roaring bitmap calls "rank")

Renames `OverlayRouting::needed_ranks` -> `offsets_in_overlay`,
`fetch_overlay_ranks` -> `fetch_overlay_values`, and the contiguous-path
`base` -> `frag_start` (it was colliding with "base column"). Rewrites the
module and function docs in plainer language. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review feedback: prune overlay readers by row selection, not just projection.

Overlay readers were opened eagerly in `FileFragment::open`, before the read's
rows were known, so a `take` that misses an overlay's cells still paid to open
its file. Split the work:

- `plan_overlays` (at open): build the coverage plan from metadata only — no
  files opened, no IO.
- `resolve_overlays` (at read, in `merge_overlays`): the read's offsets are
  known here, so open only the overlay files whose coverage intersects the
  requested rows; overlays disjoint from the read are dropped and never opened.

Projection pruning (skip overlays covering no projected field) is unchanged.

Trade-off: a reused `FragmentReader` reopens overlay readers per read rather
than once at open. Acceptable — overlays are resolved per read operation, and
the plan/state is shared via `Arc` so cloning a reader stays cheap.

Test: `test_take_prunes_overlays_outside_row_selection` deletes an overlay's
data file and shows a take missing its coverage still succeeds (never opened),
while a take hitting it fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`plan_overlays` walked every projected field against every overlay,
which is `O(num_fields x num_overlays)` — costly when a fragment has
thousands of fields. Rebuild it as a single newest-first pass over the
overlays that indexes each covered field into a `field_id -> overlays`
map, making planning `O(total overlay fields + projected fields)`.

Overlays are already sorted newest-last on load
(`sort_overlays_newest_last`), so the pass just iterates the slice in
reverse for newest-first precedence — dropping the redundant
`overlay_indices_newest_first` sort helper (its ordering rule stays
covered by `test_overlays_sorted_newest_last_on_load`). A `debug_assert`
documents the load-time invariant the reverse walk relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`OverlayRouting` (with its methods), `route_overlays`, and
`assemble_overlay_column` were `pub` but are only used within the
`overlay` module — its sibling helpers (`route_contiguous`,
`route_arbitrary`, `fetch_overlay_values`, ...) are already private.
Narrow them to match, so the module's real entry points
(`plan_overlays`, `resolve_overlays`, `merge_overlay_batch`,
`OverlayReadPlanner`, `FieldOverlayPlan`) are clear at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`merge_overlays` slices each batch out of the read's `offsets_in_frag`
with a running `rows_seen` accumulator, but every existing scan test
used single-batch 6-row fragments, so the cross-batch (`start > 0`) path
was never exercised. Add a scan over a 10-row fragment at batch_size 4
with overlays in the 1st, 2nd, and 3rd batches; the test asserts the
read actually spans multiple batches before checking alignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add three read-path cases that were untested: an empty `take` (plan
present, no offsets to route), an end-to-end overlay on a variable-width
`Utf8` column (exercises the string value-pushdown path through the real
reader, plus a NULL override), and projection pruning doing zero IO —
proven by deleting the overlay's data file and confirming a read that
projects only the unrelated column still succeeds while projecting the
overlaid column fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
An overlay is written against the field ids the file stores, which for a
struct or list column under the V2_1 structural encoding are its *leaf*
child ids — the parent's top-level id is never recorded. `plan_overlays`
matched only top-level ids, so a struct/list overlay was silently skipped
on read (V2_1) and returned stale base values, while V2_0 — which does
record the parent id — resolved it. The mismatch was untested.

Map every id in the projection subtree (top-level fields and their nested
descendants) to its top-level projected field, so a leaf id resolves to
the column it belongs to; the whole column is then fetched and replaced as
a unit. Add end-to-end tests for struct and list overlays on both
formats.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The struct/list fix mapped an overlay's stored fields to their top-level
projected column and replaced the whole column, which panicked whenever an
overlay's fields did not equal that column's full leaf set — e.g. an
overlay on sub-field `s.a` while the read projects the whole struct `s`
(it fetched `s` from a file holding only `a`). Reported by review.

Resolve per *atom* instead: a per-row field an overlay replaces as a unit
(a primitive leaf, or a whole list/map; structs are recursed through). An
overlay contributes to an atom when its stored leaf ids fall in the atom's
leaf set; the atom's value is fetched (projected to exactly the atom) and
spliced back into its output column, rebuilding the struct spine along the
path. Overlays on different sub-fields of one struct resolve
independently, newest-wins is per atom, and an overlay on a non-projected
field is skipped (its file never opened). Adds `enumerate_atoms`,
`collect_leaf_ids`, `descend_by_ids`, `splice_by_ids`.

Also per review: clarify the reader-priority comment (priority 0 is
correct because overlay reads are issued late, only when a ready consumer
polls the batch), note the offsets-materialization follow-up, and fix a
doc typo ("into" -> "onto").

Tests: sub-field overlay under a parent-struct projection (the panic),
non-projected sibling skipped, three-level nesting, multiple sub-field
overlays with per-atom newest-wins, and descend/splice unit tests
(including struct-null preservation).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ng accessors

Self-review cleanups. `AtomOverlayPlan` is the opened counterpart of
`PlannedAtom`, so name it `LoadedAtom` to match the `PlannedAtomOverlay` ->
`LoadedAtomOverlay` pairing and stop it reading like a planning-phase type.

Drop the `OverlayRouting::offsets_in_overlay`/`all_fall_through` getters: every
caller is in-module and some already read the fields directly, so the thin
forwarders only created a mixed convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add two end-to-end cases surfaced by self-review:

- A NULL *base* cell (distinct from a NULL overlay value): a covered NULL base
  is overridden to the overlay's value, an uncovered NULL base falls through
  and stays NULL.
- A top-level Map column, exercising a multi-leaf atom (key + value leaf ids
  both mapping back to the one Map atom). Maps require the 2.2+ file format, so
  this case runs at V2_2 rather than the V2_0/V2_1 parametrization.

Also hoist the test module's StructArray/Fields imports to the top of
`overlay_read` and update a stale reference to the removed `all_fall_through`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@wjones127
wjones127 force-pushed the will/oss-1324-take-can-read-overlays branch from 4bb5e39 to 5ff87ce Compare July 13, 2026 19:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/dataset/fragment.rs`:
- Around line 3749-3752: The pruning tests at rust/lance/src/dataset/fragment.rs
lines 3749-3752 and 4189-4192 must validate the specific missing-file errors
instead of only calling is_err(): match each result as the Error variant and
assert its message references miss.lance at the first site and valov.lance at
the second.

In `@rust/lance/src/dataset/overlay.rs`:
- Around line 796-854: Consolidate the six input-only tests around resolve into
one parametrized #[rstest], with a named #[case::{name}(...)] for each existing
scenario. Pass each case’s base array, offsets, overlays, and expected values as
parameters, then invoke resolve and assert_i32_eq once in the shared test body;
preserve all current cases and expectations.
- Around line 297-308: Restrict the visibility of the crate-internal overlay API
by changing LoadedAtom, OverlayReadPlanner, is_empty, plan_overlays,
resolve_overlays, and merge_overlay_batch from pub to pub(crate). Leave public
re-exports unchanged so the intended external API remains available only through
those re-exports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d182d7ad-f36f-4f35-a04e-3925b0e94945

📥 Commits

Reviewing files that changed from the base of the PR and between a3d4719 and 5ff87ce.

📒 Files selected for processing (3)
  • rust/lance/src/dataset.rs
  • rust/lance/src/dataset/fragment.rs
  • rust/lance/src/dataset/overlay.rs

Comment thread rust/lance/src/dataset/fragment.rs
Comment thread rust/lance/src/dataset/overlay.rs Outdated
Comment on lines +297 to +308
pub struct LoadedAtom {
/// The top-level output column the atom lives in (its name locates the batch
/// column; its field tree drives the descend/splice into that column).
top_field: Arc<Field>,
/// Child field ids from `top_field` down to the atom (empty when the atom *is*
/// the top-level column). Drives descending to, and splicing back, the atom.
ancestor_ids: Vec<i32>,
/// Projection of exactly the atom (its ancestor path pruned to the atom subtree),
/// used to fetch the atom's values from the overlay file.
fetch_projection: Arc<Schema>,
overlays_newest_first: Vec<LoadedAtomOverlay>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer pub(crate) over pub for these crate-internal items.

overlay is declared pub(crate) mod overlay;, so LoadedAtom, OverlayReadPlanner (Line 343), is_empty (Line 350), plan_overlays (Line 372), resolve_overlays (Line 570), and merge_overlay_batch (Line 656) are only reachable within the crate regardless. Marking them pub overstates the intended surface; use pub(crate).

As per coding guidelines: "Prefer pub(crate) over pub for crate-internal items, and use pub use re-exports for the actual public API surface."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/overlay.rs` around lines 297 - 308, Restrict the
visibility of the crate-internal overlay API by changing LoadedAtom,
OverlayReadPlanner, is_empty, plan_overlays, resolve_overlays, and
merge_overlay_batch from pub to pub(crate). Leave public re-exports unchanged so
the intended external API remains available only through those re-exports.

Source: Coding guidelines

Comment on lines +796 to +854
#[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_value_offset() {
// Base ages [30, 25, 40, 22]; overlay sets offset_in_frag 1 -> 26, whose
// value sits at offset_in_overlay 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_value_offsets_multiple_cells() {
// Coverage {0, 2, 3} -> values at offset_in_overlay 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_in_frag 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)]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate these input-only variants into a single parametrized rstest.

test_no_overlays_returns_base, test_single_overlay_value_offset, test_value_offsets_multiple_cells, test_newest_overlay_wins, test_null_override_vs_fall_through, and test_physical_start_offset differ only by (base, offsets, overlays, expected) and all funnel through resolve + assert_i32_eq. A single #[rstest] with #[case::{name}(...)] per scenario keeps them readable and removes the boilerplate.

As per coding guidelines: "Use rstest for Rust tests ... when cases differ only by inputs; use #[case::{name}(...)] for readable Rust case names."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/overlay.rs` around lines 796 - 854, Consolidate the
six input-only tests around resolve into one parametrized #[rstest], with a
named #[case::{name}(...)] for each existing scenario. Pass each case’s base
array, offsets, overlays, and expected values as parameters, then invoke resolve
and assert_i32_eq once in the shared test body; preserve all current cases and
expectations.

Source: Coding guidelines

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.34274% with 35 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/dataset/overlay.rs 94.72% 19 Missing and 14 partials ⚠️
rust/lance/src/dataset/fragment.rs 99.39% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

…intermediate struct

Tighten the two pruning tests to assert the failure is a not-found error naming
the deleted overlay file, not a bare is_err() that any unrelated failure would
satisfy.

Extend test_overlay_deeply_nested_subfield to also project the intermediate
struct by id while the overlay targets a deeper leaf, covering the case where a
top-level-only mapping would miss the overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
rust/lance/src/dataset/fragment.rs (2)

2657-2713: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Unchecked slice indexing risks a panic if the row-count invariant ever breaks.

batch_offsets = &offsets_in_frag[start..start + num_rows as usize] (line 2705) has no bounds check. Correctness today relies on rows_seen never exceeding offsets_in_frag.len(), i.e. that the sum of task.num_rows across all batches from merged always equals params.to_offsets_total(total_num_rows).len(). That invariant holds by construction in the reviewed callers, but a future change to either side (e.g. a caller passing mismatched params/total_num_rows, or a reader producing a different row count) would panic here instead of surfacing a clear error — this crashes the read task rather than propagating a Result::Err.

Per coding guidelines, prefer debug_assert! for this kind of internal invariant, or return an explicit error, rather than leaving it unguarded.

🛡️ Suggested guard
                 task: async move {
                     let batch_offsets = &offsets_in_frag[start..start + num_rows as usize];
+                    debug_assert!(
+                        start + num_rows as usize <= offsets_in_frag.len(),
+                        "overlay merge: batch rows_seen ({}) exceed offsets_in_frag len ({}) — \
+                         params/total_num_rows mismatch with the merged batch stream",
+                        start + num_rows as usize,
+                        offsets_in_frag.len()
+                    );
                     merge_overlay_batch(inner, batch_offsets, &plans).await
                 }

As per coding guidelines: "Prefer debug_assert! over assert! for non-safety invariants" and "Do not silently guard against impossible conditions; use debug_assert!, return an explicit error, or remove the check."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/fragment.rs` around lines 2657 - 2713, Guard the batch
slice in merge_overlays by validating that start + num_rows stays within
offsets_in_frag before indexing, using debug_assert! for the internal row-count
invariant or returning an explicit error that propagates through the task.
Preserve the existing merge_overlay_batch flow for valid ranges and avoid
leaving the unchecked slice as the only failure path.

Source: Coding guidelines


912-967: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Redundant full-fragment clone on every overlay-planning open().

Line 962 does Arc::new(self.clone()), cloning the whole FileFragment — including a fresh copy of self.metadata, which was already deep-cloned one line earlier at line 952 (Arc::new(self.metadata.clone())) for the reader's own field. For a fragment with overlays, this doubles the metadata clone cost on every open() call.

Consider reusing the metadata Arc (or storing only what OverlayReadState actually needs — e.g. fragment_id + a lighter handle) instead of cloning the entire FileFragment again.

♻️ Possible approach
-        let mut reader = FragmentReader::try_new(
-            self.id(),
-            deletion_vec,
-            row_id_sequence,
-            opened_files,
-            ArrowSchema::from(projection),
-            self.count_rows(None).await?,
-            num_physical_rows,
-            Arc::new(self.metadata.clone()),
-        )?;
+        let metadata = Arc::new(self.metadata.clone());
+        let mut reader = FragmentReader::try_new(
+            self.id(),
+            deletion_vec,
+            row_id_sequence,
+            opened_files,
+            ArrowSchema::from(projection),
+            self.count_rows(None).await?,
+            num_physical_rows,
+            metadata,
+        )?;
 
         // Plan overlay resolution from coverage metadata (no files opened here); the
         // readers are opened lazily on read, pruned to the rows each read touches.
         if !self.metadata.overlays.is_empty() {
             let planner = plan_overlays(self, projection)?;
             if !planner.is_empty() {
                 reader.overlay = Some(OverlayReadState {
                     planner: Arc::new(planner),
-                    fragment: Arc::new(self.clone()),
+                    fragment: Arc::new(self.clone()), // still needs the full FileFragment (dataset handle), just avoid re-cloning metadata separately if possible
                     read_config: Arc::new(read_config.clone()),
                 });
             }
         }

Whether this is worth doing depends on how expensive FileFragment::clone() actually is (i.e. whether metadata is the bulk of the cost) — worth confirming against the actual struct definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/fragment.rs` around lines 912 - 967, Remove the
full-fragment clone in the overlay setup within Fragment::open: replace
OverlayReadState’s Arc::new(self.clone()) usage with a representation containing
only the data overlay reads require, reusing the metadata Arc already created
for FragmentReader::try_new where applicable. Update OverlayReadState and its
consumers accordingly while preserving lazy overlay planning and reads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance/src/dataset/fragment.rs`:
- Around line 2657-2713: Guard the batch slice in merge_overlays by validating
that start + num_rows stays within offsets_in_frag before indexing, using
debug_assert! for the internal row-count invariant or returning an explicit
error that propagates through the task. Preserve the existing
merge_overlay_batch flow for valid ranges and avoid leaving the unchecked slice
as the only failure path.
- Around line 912-967: Remove the full-fragment clone in the overlay setup
within Fragment::open: replace OverlayReadState’s Arc::new(self.clone()) usage
with a representation containing only the data overlay reads require, reusing
the metadata Arc already created for FragmentReader::try_new where applicable.
Update OverlayReadState and its consumers accordingly while preserving lazy
overlay planning and reads.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d6c4c2d5-294c-4cf5-b402-b037e76a3dd3

📥 Commits

Reviewing files that changed from the base of the PR and between 5ff87ce and 2198b53.

📒 Files selected for processing (1)
  • rust/lance/src/dataset/fragment.rs

"atom" was coined terminology. Rename it to "atomic field", matching the existing
`atomic layout` concept in lance-core (field.rs), which already denotes a field
treated as one indivisible per-row unit whose interior children aren't addressed
individually. Types: LoadedAtom -> LoadedAtomicField, PlannedAtom ->
PlannedAtomicField, and the *Overlay/enumerate_/*_id counterparts. No behavior
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/dataset/overlay.rs`:
- Line 684: Rename the local variable assigned from
plan.fetch_projection.fields[0] from atomic_field to fetch_top_field, reflecting
that it is the pruned top-level projection field. Update the corresponding
descend_by_ids call to pass fetch_top_field, leaving the traversal behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4193f83f-c2fb-4d27-852c-b00f8559110f

📥 Commits

Reviewing files that changed from the base of the PR and between 2198b53 and f660232.

📒 Files selected for processing (2)
  • rust/lance/src/dataset/fragment.rs
  • rust/lance/src/dataset/overlay.rs

// Fetch each overlay's values and descend to the atomic field array. The fetch is
// projected to the atomic field's ancestor path, so the fetched column is the pruned
// top-level column; `descend_by_ids` walks it down to the atomic field.
let atomic_field = &plan.fetch_projection.fields[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Misleading variable name: this is the fetch projection's top-level field, not the atomic field.

plan.fetch_projection.fields[0] is the pruned top-level column (the root of the ancestor spine), which is exactly what descend_by_ids(&column, atomic_field, &plan.ancestor_ids) needs as its field root — it then walks down ancestor_ids to the atomic field. Naming it atomic_field invites the opposite reading. Consider fetch_top_field for parity with plan.top_field used on the base side at Line 716.

♻️ Suggested rename
-        let atomic_field = &plan.fetch_projection.fields[0];
+        let fetch_top_field = &plan.fetch_projection.fields[0];

Then update the descend_by_ids(&column, atomic_field, ...) call site accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/overlay.rs` at line 684, Rename the local variable
assigned from plan.fetch_projection.fields[0] from atomic_field to
fetch_top_field, reflecting that it is the pruned top-level projection field.
Update the corresponding descend_by_ids call to pass fetch_top_field, leaving
the traversal behavior unchanged.

…c fields

enumerate_atomic_fields tested `matches!(field.data_type(), DataType::Struct(_))`
at every node. Field::data_type() rebuilds the whole arrow DataType of a struct's
subtree on each call, so enumerating a wide/deeply-nested projection was
superlinear (~O(N * depth)) with heavy transient allocation. Use the O(1)
`logical_type.is_struct()` check instead; same semantics, no allocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@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.

Thanks for testing the extra cases. This covers the possibilities I can think of. Any others we can address as we find them.

// Projecting the *intermediate* struct `outer.middle` (field id 2) while
// the overlay targets a deeper field (id 3) must still apply: the
// overlay's leaf id falls inside the projected subtree, so it maps to a
// projected atomic field. (This is the case wjones127/westonpace flagged where a

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.

Some of these test comments are probably a little superfluous but I know how hard it is to avoid generating them 😆

@wjones127
wjones127 merged commit 234fc2a into main Jul 15, 2026
35 checks passed
@wjones127
wjones127 deleted the will/oss-1324-take-can-read-overlays branch July 15, 2026 17:35
wjones127 added a commit that referenced this pull request Jul 15, 2026
A minimal, first slice of overlay-aware compaction (OSS-1326), stacked
on #7536 (OSS-1324, `will/oss-1324-take-can-read-overlays`) — review
against that base.

## What it does

Adds a `max_overlays_per_fragment: Option<usize>` option to
`CompactionOptions`. When a fragment carries **more than** this many
data overlay files, the planner marks it `CompactItself`, so
`compact_files` fully rewrites it into a fresh fragment with its
overlays **and** deletions materialized into the base data. A fragment
at or below the limit is not a candidate on this basis.

The read side needs no new machinery: compaction already scans each
fragment through the normal read path, which (per #7536) resolves
overlays and applies deletions, so the merged post-image is what gets
written to the new base file.

### Index correctness

A full rewrite removes the overlays, and with them the staleness signal
the query path uses to mask an index older than an overlay. So the
`Rewrite` commit now drops the rewritten fragment from the coverage of
any index left stale by those overlays — **field-aware** (only indices
covering a field an overlay actually supplied) and **version-gated**
(`overlay.committed_version > index.dataset_version`). Those rows fall
back to a flat scan until reindex; an index is never left serving stale
values. Non-stale indices (built at/after the overlay, or on un-overlaid
fields) are remapped normally.

This reuses the existing `Operation::Rewrite` path — no new persisted
operation or conflict-matrix change.

## Scope / follow-ups

Only the "fully compact the fragment" strategy is implemented.
Overlay→overlay merge, in-place overlay→base folds (column rewrite
preserving row addresses), and rebuild-in-place index reconciliation are
left as follow-ups.

## Tests

- Planner/e2e (`optimize.rs`): overlay count over the limit triggers a
full compaction (fresh single-file fragment, overlays cleared, values
materialized); at-or-below limit is a no-op; deletions are materialized
alongside overlays; a stale scalar index is dropped from the compacted
fragment's coverage and the indexed query stays correct.
- Unit (`transaction.rs`): `prune_overlay_stale_fields_from_indices` is
field-aware and version-gated (stale index dropped; index at/after the
overlay kept; index on an un-overlaid field untouched).

🤖 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 a configurable overlay-count trigger for compaction; fragments
exceeding the limit are compacted into standalone form.
* Default limit is 10 overlays per fragment, configurable via
`lance.compaction.max_overlays_per_fragment` (set to `none` to disable).

* **Bug Fixes**
* Prevented stale index coverage from returning outdated values after
overlay/materialization compaction.
* Improved accuracy by pruning rewritten fragment coverage from older
index versions, causing queries to fall back to scans where needed.

* **Tests**
* Added coverage for the overlay trigger and index-staleness pruning
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
wjones127 added a commit that referenced this pull request Jul 15, 2026
Exposes the `DataOverlay` commit operation to Python so overlays can be
created and committed from Python (needed to benchmark and use data
overlay files without dropping into Rust). Mirrors the existing
`DataReplacement` binding.

## What's here
- `LanceOperation.DataOverlay` with `DataOverlayFile` and
`DataOverlayGroup`. A `DataOverlayFile` carries the value `DataFile`
plus **exactly one** of `shared_offsets` (dense coverage shared by every
field) or `field_offsets` (sparse, one offset set per field). The commit
stamps `committed_version`; passing both/neither coverage is rejected
with a clear error.
- PyO3 conversions (both directions) in `python/src/transaction.rs`.
- Fills in the `overlays` field on the Python `FragmentMetadata ->
Fragment` conversion, which OSS-1322 left unset (Python metadata doesn't
carry overlays — they're committed via `DataOverlay`).

## Tests
`test_data_overlay_*` in `test_dataset.py`: dense round-trip resolves on
read; newest overlay wins; sparse per-field coverage resolves fields
independently; and the exactly-one-coverage validation rejects
both/neither.

## Stacking
Stacked on **#7536** (OSS-1324 read path) — base retargets automatically
when it lands. Next PR (benchmarks) stacks on this.

> Note: the `python/src/fragment.rs` one-liner arguably belongs in the
OSS-1322 PR (#7535), which added `Fragment.overlays` without updating
the binding; included here so the stack compiles.

🤖 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 cell-level `DataOverlay` operations to append fragment overlays
without rewriting entire fragments.
- Supports dense and sparse overlays, with newest overlapping values
taking precedence.
- Persists overlay metadata on fragments and carries optional committed
version stamps.
- **Bug Fixes**
- Preserves overlay metadata correctly through JSON serialization and
Python↔Rust round-trips.
- **Tests**
- Added end-to-end coverage for dense/sparse overlays, precedence
behavior, metadata round-tripping, and offset validation.
- **Documentation/Chores**
- Updated fragment metadata `repr` expectations and improved default
`max_bytes_per_file` for fragment writing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
wjones127 added a commit that referenced this pull request Jul 21, 2026
A minimal, first slice of overlay-aware compaction (OSS-1326), stacked
on #7536 (OSS-1324, `will/oss-1324-take-can-read-overlays`) — review
against that base.

## What it does

Adds a `max_overlays_per_fragment: Option<usize>` option to
`CompactionOptions`. When a fragment carries **more than** this many
data overlay files, the planner marks it `CompactItself`, so
`compact_files` fully rewrites it into a fresh fragment with its
overlays **and** deletions materialized into the base data. A fragment
at or below the limit is not a candidate on this basis.

The read side needs no new machinery: compaction already scans each
fragment through the normal read path, which (per #7536) resolves
overlays and applies deletions, so the merged post-image is what gets
written to the new base file.

### Index correctness

A full rewrite removes the overlays, and with them the staleness signal
the query path uses to mask an index older than an overlay. So the
`Rewrite` commit now drops the rewritten fragment from the coverage of
any index left stale by those overlays — **field-aware** (only indices
covering a field an overlay actually supplied) and **version-gated**
(`overlay.committed_version > index.dataset_version`). Those rows fall
back to a flat scan until reindex; an index is never left serving stale
values. Non-stale indices (built at/after the overlay, or on un-overlaid
fields) are remapped normally.

This reuses the existing `Operation::Rewrite` path — no new persisted
operation or conflict-matrix change.

## Scope / follow-ups

Only the "fully compact the fragment" strategy is implemented.
Overlay→overlay merge, in-place overlay→base folds (column rewrite
preserving row addresses), and rebuild-in-place index reconciliation are
left as follow-ups.

## Tests

- Planner/e2e (`optimize.rs`): overlay count over the limit triggers a
full compaction (fresh single-file fragment, overlays cleared, values
materialized); at-or-below limit is a no-op; deletions are materialized
alongside overlays; a stale scalar index is dropped from the compacted
fragment's coverage and the indexed query stays correct.
- Unit (`transaction.rs`): `prune_overlay_stale_fields_from_indices` is
field-aware and version-gated (stale index dropped; index at/after the
overlay kept; index on an un-overlaid field untouched).

🤖 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 a configurable overlay-count trigger for compaction; fragments
exceeding the limit are compacted into standalone form.
* Default limit is 10 overlays per fragment, configurable via
`lance.compaction.max_overlays_per_fragment` (set to `none` to disable).

* **Bug Fixes**
* Prevented stale index coverage from returning outdated values after
overlay/materialization compaction.
* Improved accuracy by pruning rewritten fragment coverage from older
index versions, causing queries to fall back to scans where needed.

* **Tests**
* Added coverage for the overlay trigger and index-staleness pruning
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
(cherry picked from commit 6b15a8e)
wjones127 added a commit that referenced this pull request Jul 21, 2026
Exposes the `DataOverlay` commit operation to Python so overlays can be
created and committed from Python (needed to benchmark and use data
overlay files without dropping into Rust). Mirrors the existing
`DataReplacement` binding.

## What's here
- `LanceOperation.DataOverlay` with `DataOverlayFile` and
`DataOverlayGroup`. A `DataOverlayFile` carries the value `DataFile`
plus **exactly one** of `shared_offsets` (dense coverage shared by every
field) or `field_offsets` (sparse, one offset set per field). The commit
stamps `committed_version`; passing both/neither coverage is rejected
with a clear error.
- PyO3 conversions (both directions) in `python/src/transaction.rs`.
- Fills in the `overlays` field on the Python `FragmentMetadata ->
Fragment` conversion, which OSS-1322 left unset (Python metadata doesn't
carry overlays — they're committed via `DataOverlay`).

## Tests
`test_data_overlay_*` in `test_dataset.py`: dense round-trip resolves on
read; newest overlay wins; sparse per-field coverage resolves fields
independently; and the exactly-one-coverage validation rejects
both/neither.

## Stacking
Stacked on **#7536** (OSS-1324 read path) — base retargets automatically
when it lands. Next PR (benchmarks) stacks on this.

> Note: the `python/src/fragment.rs` one-liner arguably belongs in the
OSS-1322 PR (#7535), which added `Fragment.overlays` without updating
the binding; included here so the stack compiles.

🤖 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 cell-level `DataOverlay` operations to append fragment overlays
without rewriting entire fragments.
- Supports dense and sparse overlays, with newest overlapping values
taking precedence.
- Persists overlay metadata on fragments and carries optional committed
version stamps.
- **Bug Fixes**
- Preserves overlay metadata correctly through JSON serialization and
Python↔Rust round-trips.
- **Tests**
- Added end-to-end coverage for dense/sparse overlays, precedence
behavior, metadata round-tripping, and offset validation.
- **Documentation/Chores**
- Updated fragment metadata `repr` expectations and improved default
`max_bytes_per_file` for fragment writing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
(cherry picked from commit 9681621)
wjones127 added a commit that referenced this pull request Jul 22, 2026
…es (#7549)

Index masking for data overlay files. Builds on the overlay read path
(#7536), now merged.

## Problem

An index built before an overlay does not reflect the overlay's values,
so its entries for overlay-covered cells may be stale. `WHERE age = 25`
after an overlay sets a row's age to 26 must not return that row from
the index; `WHERE age = 26` must find it. Queries must stay correct
while overlays remain.

## Approach

For each index a query relies on, compute which row addresses within
each covered fragment carry an overlay committed *after* the index
(`committed_version > index.dataset_version`) and touching a field the
index covers. The check is:

- **field-aware** — an overlay touching only non-indexed fields excludes
nothing;
- **version-gated** — an overlay already incorporated by the index
(`committed_version <= index.dataset_version`) is ignored.

This is the new `overlay_exclusion_offsets` helper in
`dataset/overlay.rs`.

Such rows are excluded from the index result and re-evaluated against
their current (overlay-merged) values:

- **Scalar (BTree)** (row-level): stale row addresses are blocked from
`MaterializeIndexExec` output via `overlay_block: Option<RowAddrMask>`.
Only those rows are re-evaluated via a targeted `TakeExec` + full-filter
path. Non-stale rows in the same fragment remain on the indexed path.
- **Vector ANN** (row-level): stale row addresses are passed to
`DatasetPreFilter.overlay_block`, blocking them from ANN results. Only
those rows are re-scored via a targeted `TakeExec` + flat-KNN path.
Non-stale rows in the same fragment stay in ANN coverage.
- **FTS** (fragment-level): FTS segments covering stale fragments are
excluded from `MatchQueryExec` (via `new_with_segments`). Stale
fragments fall to the existing `FlatMatchQueryExec` path, which reads
current overlay-merged values. Fragment granularity is sufficient
because flat text matching is row-precise and cheap relative to
flat-KNN.

This drops stale index hits and surfaces new matches the index never
saw.

## Performance

Benchmark: 1M rows, 100k rows/fragment (10 frags), 32-dim vec, NVMe,
release build.

### Scalar (BTree) — `age = <value>`

| overlays | stale_frag_ms | clean_frag_ms |
|---|---:|---:|
| 0 | 0.4 | 0.4 |
| 1 | 0.9 | 0.6 |
| 4 | 0.5 | 0.6 |
| 16 | 0.6 | 0.6 |

The overhead when overlays exist comes from the stale-check itself
(segment metadata load), not from flat-scanning — only stale rows are
taken, not the whole fragment.

### Vector ANN — fragment 0 with 1 overlaid row

| vec_overlays | ann_ms | notes |
|---|---:|---|
| 0 | 9.6 | no overlays |
| 1 | 10.4 | row 0 blocked, re-scored via targeted take |

Previously at fragment-level: 10.0ms → 11.9ms (+1.9ms scanning 100k rows
flat).
With row-level: +0.8ms for targeted take of 1 row — overhead is
O(stale_rows), not O(fragment_size).

Fast-path summary:
- **No overlays** (common case): O(num_fragments) boolean check, zero
allocations.
- **Overlays predate the index**: per-fragment version gate skips all
field/bitmap work.
- **Stale overlays exist**: index metadata from cache;
`overlay_exclusion_offsets` called once per covered fragment with
overlays.

## Tests

`dataset::tests::dataset_overlay_index_masking` (e2e) and
`dataset::overlay` (unit):

- BTree: stale-drop and new-match; row-level precision (non-stale row in
same fragment returned alongside stale row via Take path)
- Compound AND expression: both leaf index searches collect stale rows
- Overlay on an unrelated field excludes nothing
- Overlay with `committed_version <= index.dataset_version` not excluded
- NULL override
- Multi-fragment
- Vector index: stale row dropped, overlay-updated row re-scored back
into the top-k
- FTS: stale term not returned; new term found via flat path; overlay on
non-FTS field excludes nothing
- `overlay_exclusion_offsets` unit tests: version gate, field-awareness,
sparse per-field coverage, multi-overlay union

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-format On-disk format: protos and format spec docs enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants