Repository navigation
Conversation
80b8bf4 to
b2b38d4
Compare
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
Shuffled stable row IDs retain both their source arrays and a padded inverse mapping. When the resulting RowIdIndex cannot enter the cache, small requests reconstruct the complete map. This change compacts the prepared representation and allows verified unchanged fragments to share lookup data across snapshots and independently cached units. - Use bitmap/rank offsets for dense IDs, packed relative-ID/offset words for narrower sparse IDs, and 12-byte entries for wide IDs. Release redundant source arrays and skip inverse preparation when the existing cost model selects merging. - Share immutable prepared fragments and merged chunks through `new_reusing` and `try_reuse_fragments`. The caller verifies content, deletion and physical-address identity; partial merged groups or representation changes retain an explicit rebuild fallback. - Add `try_combine` to compose independently prepared probe indices after original arrays are released. It shares prepared allocations, rebuilds routing metadata, rejects duplicate fragment namespaces and preserves the global probe/merge cost gate. Merged or mixed inputs return `None` for the caller to rebuild. It never retains the input indices themselves. - Keep `FragmentRowIdIndex::get_many` available for explicit direct lookup of native encodings. Dataset loading and prepared-fragment cache admission are integrated in #10800; this core API does not provide routing metadata for unknown spilled candidates. Shared allocations are counted once per DeepSizeOf context. No on-disk format change. Validation on this source: - 64 RowIdIndex tests passed, including independent physical-offset expectations, deletion and duplicate cases, released original arrays, input-index lifetimes, shared accounting and the 64-to-65 overlap fallback. - All four row-ID API doctests passed. - `cargo fmt --all` and `git diff --check` passed. - Full workspace `cargo clippy --profile ci --all --tests --benches -- -D warnings` passed with pinned Rust 1.98.1. Combined acceptance with #10800 at `23aceccd1fe83a44ba48b3d52028b264ef2803e8` is complete; its five core files match this PR at `7786af74faca4103f801cce0d929b336c04b6ebe`. The integration passed 116 row-ID tests (including 11 resolver cases), 56 take tests, 6 compaction tests, the cache-key test and full workspace Clippy. Real-S3 validation covered 34 matched jobs with equal cache and process-memory limits, verified executable/source identities and independent row-address/payload oracles. Every current arm completed; the sole OOM was the core-only parent while retaining an old reader during 300M-row append under a 4 GiB cap. The current arm completed once with a cgroup peak near that cap, so this is a feasibility observation, not an arbitrary-input memory guarantee. The loader now caches prepared fragment indices rather than individual ID answers. #10800 reports the current child-versus-this-core-parent measurements, including improved cold/distinct-ID and append behavior, cache-pressure behavior, and regressions in warm high-fragment-count and point-to-bulk workloads. `try_combine` deliberately rejects merged/mixed or cost-rejected unions; the loader then rereads/rebuilds. Unknown overlapping spilled candidates still require inspection. Earlier core-only measurements apply to `e3d3a2869`, not this complete head; no current-head core-only speedup is claimed here. Separate S3 mutation checks validated update, deleted IDs, missing IDs and retained snapshots on the same product source. The fixture's hidden persistent-row-ID index metadata prevented UpdateBuilder discovery on both parent and current; the mutation helper removed that metadata only on an owned shallow clone. This does not validate coexistence with that persistent index or modify the retained input dataset. No BTree performance claim is made.
23acecc to
588903f
Compare
wjones127
left a comment
There was a problem hiding this comment.
Thanks for this. Content-addressing the per-fragment units is a sound way to reuse work across snapshots. The tests are strong: the fixtures check results against oracles computed from the fixture's own arithmetic, and they cover duplicates, deletions, eviction, admission rejection and old snapshots. The append result (8s to 39ms for old-key takes) is a real win.
Blocking: the single current entry means handles on different versions evict each other's view, and that likely forces full rebuilds when a delete lands between them. I've suggested a way around it inline: cache only the parts, and make the per-version view lightweight.
Also, could you trim the PR description? It will become the commit message. It references local paths (/Users/yang/Documents/Codex/...) and internal terms ("frozen v7", "mbx", "wake9", rejected v4/v5) that won't mean anything to future readers. A short paragraph on the design (three tiers: complete view, prepared fragment units, span metadata) plus the benchmark table would be enough.
| /// Without them, only overlapping ID spans require deletion files to | ||
| /// disambiguate copies produced by updates. |
There was a problem hiding this comment.
question: why do we cache without knowing the deleted rows? Should we just switch to always knowing deletions when we cache?
| self.with_deletions, | ||
| self.e_tag.unwrap_or("") | ||
| )) | ||
| Cow::Owned(format!("row_id_index/current/{}", self.with_deletions)) |
There was a problem hiding this comment.
question(blocking): With one current entry per dataset, two handles on different versions keep evicting each other's view. The PR's own numbers show old-snapshot revisit going 0.002 ms -> 2.1 ms in the append case. If a delete lands between the versions, reuse_merged can't keep the changed group, so build() reloads every fragment's sequence on each alternation. Why one entry rather than per-version entries that share the prepared allocations? To clear this: either keep per-version keys with shared allocations, or add a test where two handles alternate across a delete and assert bounded read_bytes on the second round trip. One way to unblock this is the suggestion on resolve.rs:535: if the per-version view didn't own the shared allocations, per-version entries would be cheap.
| .session() | ||
| .row_id_index_builds | ||
| .for_dataset(&dataset.uri)?; | ||
| let guard = lock.lock().await; |
There was a problem hiding this comment.
issue(non-blocking): When there is no complete view, every call takes this dataset-wide lock, including warm calls answered entirely from resident units. The lock is then held across the cold prepare_fragment reads below. A cold lookup on fragment A blocks an unrelated warm lookup on fragment B, which probably explains the C=4 regression in the description. Could the resident-hit pass run before taking the lock, with cold preparation deduplicated per fragment (e.g. get_or_insert_with_key on PreparedRowIdFragmentKey) instead of per dataset?
| }; | ||
| // LanceCache intentionally has no per-key remove. Metadata-only replacement | ||
| // releases matching prepared values without clearing unrelated cache entries. | ||
| for (key, cached) in retire { |
There was a problem hiding this comment.
suggestion: The retirement pass (and, I think, the single current entry) exists because the complete view and the per-fragment units are both cache entries charged for the same shared allocations. Could we avoid the overlap instead? Cache only the parts — per-fragment units, plus merged chunks keyed by their source group — each charged once, and make the per-version view a lightweight value (probes/end_tree plus Arcs into the parts) charged only for its own overhead. That would remove the need to retire units (including the fallback case where they're retired but no view is admitted), and per-version entries would be cheap enough to avoid the thrashing above.
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
126c533 to
b5ee153
Compare
wjones127
left a comment
There was a problem hiding this comment.
Thanks for reworking this. Going fragment-only removes the single current entry, the retirement pass, and the dataset-wide lock, so my earlier blocking concern about handles on different versions evicting each other no longer applies. The tests are strong: oracles come from fixture arithmetic or physical scans, and they cover inline and spilled lineage, duplicates, deletions, seven snapshots, compaction, restore, eviction, and concurrent preparation.
Nothing here is blocking. The changes are crate-internal and easy to revisit, so I'm approving. Follow-ups worth considering:
- Deletions out of the lookup: building each fragment's lookup without deletions and filtering after the lookup would mean a delete never invalidates or rereads a fragment, and snapshots that differ only in deletions would share one entry.
- Warm path: every call recomputes two identities and probes the cache for every fragment, which likely explains the 1.38x slower warm p50 and lower C=4 throughput.
- Machinery:
RowIdIndexBuildLocksduplicates the single-flight behaviorLanceCachealready provides. - Identity drift with #8880: both PRs define a fragment's row-id/deletion content identity, and they disagree on which fields matter.
- The e-tag /
ignore_deletionsfallback still routes to the complete index.
Smaller nits are inline.
| #[derive(Clone, Copy, Debug)] | ||
| pub(crate) struct PreparedRowIdFragmentKey { | ||
| pub source: InternalCacheKey, | ||
| pub deletion: Option<InternalCacheKey>, |
There was a problem hiding this comment.
question: Could the per-fragment lookup leave deletions out? Right now the deletion vector goes into RowIdIndex::new and into the key, so a delete on a fragment invalidates its entry and rereads its row-id source (read_row_id_sequence doesn't go through the sequence cache). Every deletion state of a fragment is a separate entry competing for capacity, which the description calls out.
If the lookup were built without deletions and keyed by the row-id source only, deletions could be applied after the lookup: the result is already an address, so it's a cheap deletion_vector.contains(offset) check, and record_matches still catches an id that's live in two fragments. That would also keep the merged representation on its O(1) Range path, since any deletion currently forces per-row decomposition. In the seven-snapshot workload, all seven snapshots would share one entry per fragment.
Is there a reason the deletions need to be built in?
| let mut resident = Vec::new(); | ||
| // Consume every hit before admitting misses: a scan under cache pressure | ||
| // must not evict a useful resident entry before this request has queried it. | ||
| for fragment in dataset.manifest.fragments.iter() { |
There was a problem hiding this comment.
question: On a warm call this loop still does work for every fragment in the manifest: it builds two KeyBuilder identities (the deletion one rehashes inline deletion bytes, which aren't memoized like the row-id digest), then an index_cache probe, then a metadata_cache probe on a miss. With 1000 fragments that's thousands of hashes and cache lookups before any ID is resolved. That seems like the likely source of the 1.38x slower seven-snapshot warm p50 and the C=4 throughput drop. Earlier I suggested keeping a per-version view as a lightweight value: sorted spans plus Arcs into the cached parts, charged only for its own overhead. That would make the warm path a binary search over spans. Did you try that and drop it, or would it fit on top of this?
| /// Locks live only while a caller builds or waits. The registry holds no indices | ||
| /// and prunes expired entries, including datasets no longer used by the session. | ||
| #[derive(Default)] | ||
| pub struct RowIdIndexBuildLocks { |
There was a problem hiding this comment.
question: What does this registry buy over LanceCache::get_or_insert_with_key? The cache already coalesces concurrent loads for the same key. As far as I can tell, the registry exists for two cases: (1) a load that only learns the span and finds no candidates, and (2) sharing an oversized lookup the cache refuses to admit. For (1), the span could go through metadata_cache.get_or_insert_with_key(RowIdFragmentSpanKey, ..), followed by index_cache.get_or_insert_with_key(PreparedRowIdFragmentKey, ..) only when the span overlaps. (2) seems rare enough not to need its own mechanism. That would drop RowIdIndexBuildLocks, its Session field, and its DeepSizeOf impl, and leave one place that manages these entries.
| Error::internal(format!("row id fragment build registry poisoned: {error}")) | ||
| })?; | ||
| fragments | ||
| .retain(|_, entry| entry.build.strong_count() > 0 || entry.active.strong_count() > 0); |
There was a problem hiding this comment.
issue: This retain scans the whole map on every for_fragment call. Entries survive while active is upgradable, i.e. while the index cache holds the PreparedFragment, so the map grows to about the number of resident fragments, not just in-flight builds. A cold query with M misses then does M full scans under a std::sync::Mutex, so O(M·N), and each call also allocates uri.to_owned() (here and in prepared). Moot if the registry goes away, otherwise prune lazily (e.g. only when an upgrade fails, or every Nth insert).
| Ok(()) | ||
| } | ||
|
|
||
| pub(super) fn sequence_identity( |
There was a problem hiding this comment.
question: This identity is RowIdSequenceKey plus physical_rows, the resolved base path, and the row-id column index. #8880 (in review now) factors the same identity into a shared helper without those extras, and uses it for its per-fragment allow-list pieces:
lance/rust/lance/src/session/caches.rs
Lines 349 to 373 in 0c0698e
Which is right? If a base_id can be remapped to a different path, or the same lineage file can be projected through a different column, then RowIdSequenceKey can already serve a stale sequence, and the fix belongs there so every per-fragment cache benefits. If not, could this reuse the shared helper so the two caches can't drift apart?
| deletion_file: file, | ||
| } | ||
| .write_key(&mut builder); | ||
| if let Some(inline) = &file.inline { |
There was a problem hiding this comment.
suggestion: #8880 fixes the same gap at the source: it adds the inline payload to DeletionFileKey itself (schema v2) via write_deletion_file_identity:
lance/rust/lance/src/session/caches.rs
Lines 130 to 149 in 0c0698e
Whichever lands second should drop its own copy, or the inline bytes get hashed twice per fragment per call. It'd be nice if deletion_identity here were just DeletionFileKey plus the base path, once that's in.
| if with_deletions && row_ids.iter().all(|id| *id >= dataset.manifest.next_row_id) { | ||
| return Ok(Some(vec![None; row_ids.len()])); | ||
| } | ||
| if !with_deletions || dataset.manifest_location.e_tag.is_none() { |
There was a problem hiding this comment.
question: Why is the fragment path gated on an e-tag? That gate comes from the version-keyed complete index:
lance/rust/lance/src/dataset/rowids.rs
Lines 231 to 239 in 4266ce5
The fragment keys here are content-addressed (the same reasoning as #7645), so a dataset recreated at the same URI can't collide with them. As written, a dataset without an e-tag gets a private, uncached complete build on every take, which is the slowest path. Separately, ignore_deletions takes still use the complete index. So both representations of the same lookup can be resident at once (the ANN test now asserts a RowIdIndex entry). Is the plan to move those callers to fragments too?
| vec![Some(expected_31)] | ||
| ); | ||
| let after_distinct = current.object_store.io_stats_snapshot(); | ||
| assert!(after_distinct.read_bytes >= before.read_bytes); |
There was a problem hiding this comment.
nitpick: This assertion is always true. If this call is expected to be warm (fragment 3's source and deletion state are unchanged), could it be an assert_eq! like the ones below? Otherwise I'd drop it.
| assert!(after_distinct.read_bytes >= before.read_bytes); | |
| assert_eq!(after_distinct.read_bytes, before.read_bytes); |
| } | ||
|
|
||
| #[cfg(test)] | ||
| pub(super) fn entry_bytes(value: &impl DeepSizeOf) -> usize { |
There was a problem hiding this comment.
nitpick: This hardcodes the cache's per-entry overhead (two AtomicUsize plus the key). If the entry layout changes, the eviction test's budget silently stops meaning "admits one but not both". Could the test measure the weight from the cache instead, e.g. insert one entry and read size_bytes()?
| } | ||
| } | ||
|
|
||
| /// Resolve all unknown candidates; cached fragment lookups answer different IDs |
There was a problem hiding this comment.
nitpick: This doc doesn't say what the function returns. Also on naming: comments and test names call PreparedFragment a "graph" (bounds_survive_graph_eviction...), and RowIdIndexBuildLocks also holds weak refs to finished lookups, so it's more than locks. Could we pick one name for each and stick to it? For the doc (lines 52-53), something like:
/// Map stable row ids to row addresses, preserving input order and duplicates.
/// With `with_deletions`, deleted or unknown ids map to `None`. Lookups are
/// prepared and cached per fragment, keyed by its row-id and deletion content,
/// so snapshots that leave a fragment unchanged share them.
There was a problem hiding this comment.
Amortized registry cleanup preserves live preparation and lookup sharing without scanning every resident entry on each miss. Shared fragment mappings and snapshot coverage checks retain safe reuse across deletion and restored snapshots.
Warm end-to-end latency remains workload dependent. Measured ancestor 2663559 reported 1.10–1.11× slower warm full takes and a 1.19× slowdown under cache pressure versus b5ee153, despite faster row-ID lookups and lower heavy-delete RSS. The latest cleanup has not been benchmarked, and neither revision has a matched main comparison. Use warm full-take latency and throughput for workload sizing, and allow memory headroom for cold preparation and separately loaded complete indices.
Cache stable-row-ID lookups per immutable fragment so append and snapshot changes reuse unchanged work. Lookup mappings and snapshot deletion visibility are charged separately. Sparse mappings rebuild when deletions remove at least half of the retained rows; compact range mappings stay reusable, and pruned mappings are reused only when they cover the requested snapshot. Batched sparse lookups use interleaved search while preserving physical addresses, input order, duplicates and deletion visibility.
Resident candidates are checked before bounded source reads. The demand path keeps fragment-level lookups; existing complete-index callers and no-e-tag/ignore-deletions fallbacks retain their behavior. Persisted formats are unchanged. The fragment registry now amortizes dead-entry cleanup and releases sparse high-water allocations; regression coverage checks live builders and unadmitted lookups survive cleanup.
b5ee1532663559Current-head performance is not yet measured. The table preserves the accepted tradeoffs of measured ancestor
2663559, before the registry cleanup, test and documentation fixes in61cdfcd9; it is not a performance claim for the latest head. Matched real-S3 measurements compare the previous PR headb5ee153064583d39019ca15939d4de9dfeeef815with266355944c437cad88c4a2bbedc0ab48f6057908: 100M shuffled/spilled IDs, 1000 initial fragments, five UInt64 columns, c7i.8xlarge, Rust 1.98.1, release-with-debug. Both arms use the same helper, inputs and limits: index 4 GiB, metadata 1 GiB, total 4 GiB (8 GiB for snapshot full takes), swap 0 B. The pressure case uses a 128 MiB index cache and 91% deletions. Values average two fresh processes with reversed order; the final two rows are separate 32-warm-call follow-ups, not pooled with the initial 8-call runs. There is no matched main comparison for either revision.Full-take gains vary by run and workload; warm latency can regress despite lower lookup CPU and snapshot memory. High overlap still probes candidate fragments, directory work remains O(F), and cold preparation or held readers need memory beyond cache accounting. The deletion-rebuild policy supplies the memory savings; batch search adds no resident index. Preserve the latency regressions above when evaluating latency-sensitive workloads.
Validation at measured ancestor
2663559: 395 targeted core/rowids/public-take test executions, full x86 workspace Clippy, and 48 S3 processes with 1920 independent physical-address/payload checks. For prior head61cdfcd9, project CI passed, including Linux x86/ARM Rust tests, workspace Clippy, Python compatibility tests and Java tests. Prior integration head2581cf909also passed applicable CI. Current headc537979afmerges maine0aacd774; the only manual resolution preserves the combined imports for fragment lookup and quantized refinement cache keys. Main's take-address overflow fix is retained. Formatting, diff checks and commit hooks passed; exact-head Rust, Python and Java CI passed. Gatekeeper skipped duplicate review because Will's approving review is still active; this is not a new review of the merge commit. Benchmark results above remain specific to measured ancestor2663559.