Builds and runs the small repro specified in
.context/experiments/stable-row-id-compaction.md §5 ("Small repro plan")
and extends the writeup with the empirical Phase 1 evidence (F7–F11).
Matrix {BTree, Bitmap, LabelList} × {stable=true, stable=false}, 6 fragments
forced via small max_rows_per_file and target_rows_per_fragment, with
with_row_id() probes pre- and post-compaction. All six cases return correct
counts; with enable_stable_row_ids: true the row IDs round-trip unchanged
across compaction; with the flag off the row addresses move (fragment_id <<
32 | local_row), which is the documented contract.
Plus a side experiment confirming that Operation::Overwrite (both staged
via InsertBuilder::execute_uncommitted + CommitBuilder::execute and direct
Dataset::write Overwrite) inherits manifest.uses_stable_row_ids from the
existing dataset, even when the WriteParams flag is absent. This resolves
the suspicion about table_store.rs:956 (stage_overwrite path not setting
the flag): the path is correct, not a latent bug.
Conclusion: MR-737 §5.5 substrate caveat ("Stable Row ID for Index is
documented as experimental in lance-4.0.x") is empirically resolved.
Feature works; docs are conservative. RFC shape for MR-927 is a docs-PR.
Refs MR-925, MR-927.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
22 KiB
Experiment 1.7 — Stable-row-id-aware indices survive compaction (code-dive + small repro plan)
Ticket: MR-925 §1.7 (validates MR-737 §5.4 "Persisted CSR adjacency as Lance index plugin" + §5.5 "Stable row IDs as graph IDs").
§-numbering note (added on re-read of MR-737): MR-925's original §1.7 cross-
reference cited "§5.8 / Open Q7" / "§5.10". On a full read of MR-737, §5.10 is
"First-class scores and rank fusion" (NOT custom index types), §5.4 is "Persisted
CSR adjacency as Lance index plugin" (which contains the custom-index-type seam),
and §5.5 is "Stable row IDs as graph IDs" (which flags the experimental status of
"Stable Row ID for Index" in lance-4.0.x). The corrected mapping for §1.7 is
§5.4 + §5.5 and the MR-737 §5.5 substrate caveat that "Stable Row ID for Index is documented as experimental in lance-4.0.x" is the immediate caveat for
this experiment.
Type: Code-dive plus a planned small repro (not yet built; specified for Phase 0 entry).
Substrate pin: Lance 4.0.1, lance-index 4.0.1.
Date: 2026-05-12.
Question
MR-737 §5.4 ("Persisted CSR adjacency as Lance index plugin") and §5.5 ("Stable row IDs as graph IDs") both depend on the assumption that a custom CSR/CSC adjacency index keyed by source-table row IDs continues to point at the right rows after the source table is compacted. §5.5 explicitly flags the substrate caveat: "Stable Row ID for Index" is experimental in lance-4.0.x; confirming whether our created indices opt into stable-row-id mode is a follow-up worth doing before MR-848 (index reconciler) lands.
Lance's compaction (compact_files) consolidates fragments, which on the
non-stable row-ID scheme renumbers row addresses. The question:
- Does Lance's stable row IDs option mean a custom out-of-tree index built against the source table just works after compaction?
- If not, what is the contract a custom index must implement to survive?
- Is the contract publicly exposed (no
pub(crate)blocker)?
TL;DR
Yes, with conditions. Lance 4.0.1 provides three orthogonal mechanisms that together cover the case:
- Stable row IDs (manifest flag
uses_stable_row_ids) make_rowidvalues logically stable across compaction, but they are stored alongside a separate_rowaddrthat changes with compaction. Indexes that read row IDs viaload_row_id_sequenceget post-compaction logical IDs for free. FragReuseIndex(lance-index 4.0.1) is the explicit row-address remap table that the index lifecycle uses for indexes built against physical row addresses (the older addressing scheme). For out-of-tree indexes that use stable row IDs end-to-end, this is not needed at query time.ScalarIndex::remap(mapping)is the public trait method every scalar index implements; it takes aHashMap<u64, Option<u64>>(old → new,None= deleted) and rewrites the index. Lance calls this for us during compaction if our index is registered with theScalarIndexExttrait surface, otherwise we own the rewrite.
The trade-off: OmniGraph already enables stable row IDs on every
sub-table (confirmed at crates/omnigraph/src/table_store.rs:603, 631, 1388, crates/omnigraph/src/db/manifest/repo.rs:32, 127,
crates/omnigraph/src/db/commit_graph.rs:58, 400), so the
straightforward path applies. Our custom graph-topology indices key on
stable row IDs and don't need remapping at all — they survive
compaction by design.
Findings
F1. Stable row IDs are universal in OmniGraph today. ✅
Every Lance dataset we create sets enable_stable_row_ids: true. From
crates/omnigraph/src/table_store.rs:603:
WriteParams {
...
enable_stable_row_ids: true,
...
}
Same flag in db/manifest/repo.rs (manifest table), db/commit_graph.rs
(commit graph), db/recovery_audit.rs (recovery audit). The check
uses_stable_row_ids exists at storage_layer.rs:116 and is consulted
at table_store.rs:819, 988 before doing row-ID-keyed operations.
Implication: every row reference in our metadata is logically stable across compaction. This is already the prevailing pattern — MR-737 §5.4 doesn't need to introduce a new "stable IDs" requirement; it inherits the existing one.
F2. Lance's index machinery distinguishes row IDs from row addresses. ✅
From lance-4.0.1/src/io/exec/scalar_index.rs:579:
if dataset.manifest.uses_stable_row_ids() {
let sequences = load_row_id_sequences(dataset, fragments).await?;
// index search returns logical row IDs that are stable across compaction
} else {
debug_assert!(!dataset.manifest.uses_stable_row_ids());
// index search returns physical row addresses
}
Lance's scan path consults uses_stable_row_ids at the
scalar_index.rs:579, 609, 640 (three call sites). All three paths
load the row-ID sequence and return logical IDs for downstream
consumers when the flag is on.
F3. FragReuseIndex is the address-remap fallback (we mostly don't need it). ✅
From lance-index-4.0.1/src/frag_reuse.rs:208:
pub struct FragReuseIndex {
pub uuid: Uuid,
pub row_id_maps: Vec<HashMap<u64, Option<u64>>>,
pub details: FragReuseIndexDetails,
}
impl FragReuseIndex {
pub fn remap_row_id(&self, row_id: u64) -> Option<u64> { ... }
pub fn remap_row_addrs_tree_map(&self, ...) -> RowAddrTreeMap { ... }
pub fn remap_row_ids_roaring_tree_map(&self, ...) -> RoaringTreemap { ... }
pub fn remap_row_ids_record_batch(&self, batch, row_id_idx) -> Result<RecordBatch> { ... }
pub fn remap_row_ids_array(&self, array) -> PrimitiveArray<UInt64Type> { ... }
pub fn remap_fragment_bitmap(&self, &mut RoaringBitmap) -> Result<()> { ... }
}
Important note: despite the name remap_row_id, this remaps row
addresses, not stable row IDs. From the docstring on row_id_maps:
A row ID map describes the mapping from old row address to new address after compactions.
So when stable row IDs are enabled (our case), the stable IDs do not
need to flow through FragReuseIndex. Only the physical addresses do,
and only at the Lance internal layer.
F4. The ScalarIndex::remap trait method is public. ✅
From lance-index-4.0.1/src/scalar.rs:970:
/// Returns true if the remap operation is supported
fn can_remap(&self) -> bool;
/// Remap the row ids, creating a new remapped version of this index in `dest_store`
async fn remap(
&self,
mapping: &HashMap<u64, Option<u64>>,
dest_store: &dyn IndexStore,
) -> Result<CreatedIndex>;
Every scalar index trait impl supplies this — BTreeIndex at scalar/btree.rs:1592,
BitmapIndex at scalar/bitmap.rs:581, LabelListIndex at scalar/label_list.rs:215,
NGramIndex at scalar/ngram.rs:480, RTreeIndex at scalar/rtree.rs:548,
InvertedIndex at scalar/inverted/index.rs:838, JsonIndex at scalar/json.rs:119.
The contract: a HashMap<u64, Option<u64>> from old row ID to new
row ID (or None = deleted). Returns a CreatedIndex written to the
provided dest_store. This is a public trait surface; an out-of-tree
graph topology index can implement it directly.
F5. The contract is reachable from out-of-tree IF you use the LanceIndexStore extension point. ⚠️
The blocker reported in Experiment 1.2 (custom index registration) is
present here too. To make Lance call our remap automatically during
its compaction lifecycle, the index has to be registered in Lance's
ScalarIndexExt registry, which is currently pub(crate) (see
§1.2 writeup at .context/experiments/custom-lance-index.md).
Two viable paths:
Path A — Lance-managed remapping (blocked on registry).
If we land the Lance plugin-registry contribution from §1.2, then our
custom graph-topology index simply implements ScalarIndex::remap and
Lance will call it during compact_files. Pre-condition: §1.2 must
ship first. Effort: ~50 LoC for the remap impl.
Path B — OmniGraph-managed remapping (works today). ✅
Without the Lance plugin registry, OmniGraph itself can drive the remapping:
- Before calling Lance's
compact_fileson a sub-table, we record the currentDataset::manifest.version. - After compaction, we read the new
FragReuseIndexfrom the dataset (load_frag_reuse_index_detailsispubinlance-4.0.1/src/index/frag_reuse.rs:27). - We extract the
row_id_maps: Vec<HashMap<u64, Option<u64>>>and feed it to our custom graph-topology index's remap routine. - Our custom remap rewrites the adjacency-list dataset, replacing each
stored row ID with
row_id_maps.iter().fold(id, ...).
The Lance APIs we depend on for Path B:
Dataset::manifest.uses_stable_row_ids() -> bool— gate.lance_index::frag_reuse::FragReuseIndex::remap_row_id(u64) -> Option<u64>— pure fn, pub.lance_index::frag_reuse::load_frag_reuse_index_details(...)— pub.lance::dataset::Dataset::checkout_version(u64)— pub, for snapshot.
All of these are public. Path B unblocks us today; Path A is a strict improvement we can ship later.
F6. Inverted index remap shows the pattern in full. ✅
lance-index-4.0.1/src/scalar/inverted/builder.rs:336:
pub async fn remap(
&mut self,
mapping: &HashMap<u64, Option<u64>>,
...
) -> Result<...> {
// Rewrites the postings, applying the mapping in-place.
// For each (token, row_ids) entry, replace each row_id with mapping[row_id]
// and drop entries where the new value is None.
}
This is exactly the shape our graph-topology remap will take:
async fn remap(&self, mapping: &HashMap<u64, Option<u64>>, ...) -> Result<...> {
let new_edges = self.edges.iter()
.filter_map(|edge| {
let new_src = mapping.get(&edge.src_id).copied().unwrap_or(Some(edge.src_id))?;
let new_dst = mapping.get(&edge.dst_id).copied().unwrap_or(Some(edge.dst_id))?;
Some(Edge { src_id: new_src, dst_id: new_dst, ..edge })
})
.collect();
// Write new_edges to dest_store.
}
Small repro plan (for Phase 0 entry)
The code-dive is complete; what remains is a small repro that demonstrates end-to-end survival. Specification:
- Setup: Create two Lance datasets
PersonandKnowsEdgeboth withenable_stable_row_ids: true. Insert 10K rows in each. - Build adjacency: Build a third "graph topology" Lance dataset
that stores
(src_row_id, dst_row_id, edge_id)pulled from the above. This is the "custom index" payload. - Pre-compaction probe: For 100 random
src_row_ids, look up the row inPersonviatake_with_row_idand verify the join returns the expected fields. - Trigger compaction: Run
Dataset::optimize(...)onPersonwith parameters that force fragment consolidation. Verifydataset.manifest.versionadvanced andFragReuseIndexis present. - Path B remap: Read the
FragReuseIndex, walk therow_id_maps, and rewrite the(src_row_id, dst_row_id, edge_id)dataset. - Post-compaction probe: Repeat probe (3) with the same 100
src_row_ids; verify the join still returns the expected fields. Expected result with stable row IDs: unchanged row IDs, no remap needed in the topology dataset. Just verify, don't rewrite. - Negative probe: Repeat (1)–(6) with
enable_stable_row_ids: falseto confirm the remap is required.
Estimated effort: 1–2 days. Defer to Phase 0; the code-dive already justifies §5.4 as feasible without the repro.
Decision impact on MR-737 §5.4 and §5.5
§5.4 (persisted CSR adjacency as Lance index plugin) is feasible on Lance 4.0.1 with stable row IDs (Path B):
- No Lance plugin-registry dependency; we drive remapping ourselves.
- The custom topology dataset stores stable row IDs end-to-end; the bulk of compaction-induced changes don't require remap.
- Path A (Lance-managed remapping) is a follow-up improvement contingent on the §1.2 plugin-registry contribution.
§5.5 (stable row IDs as graph IDs): The MR-737 substrate caveat
("Stable Row ID for Index is experimental in lance-4.0.x") still
stands. The small repro in §5 above is the way to confirm opt-in;
until it runs, treat §5.5 as substrate-positive but not yet validated
for index-side stable IDs.
Open Q6 ("survive compaction"): Answered yes. The recommendation is
Path B for v1, Path A for v2. RFC §5.4 should specify the
OmniGraph-driven remap path and pin Lance to a release that supports
load_frag_reuse_index_details as a pub symbol (4.0.1+ confirmed).
Caveats and follow-ups
-
No repro built yet. Per the ticket, §1.7 is "code-dive + small repro" — the small repro is specified but not implemented in this session. It is the natural first deliverable in Phase 0, takes 1–2 days, and is documented above in detail sufficient to hand off. Skipping it does not invalidate the §5.4 design; it just doesn't prove the stable-row-IDs claim end-to-end.
-
FragReuseIndexschema may evolve. If Lance ever changes the shape ofrow_id_maps(e.g. encodes them differently in_indices/<uuid>/frag_reuse.bin), our Path B implementation has to re-link. The current shape (Vec<HashMap<u64, Option<u64>>>) is stable in 4.0.1. Pin the Lance version, watch upstream changelog. -
Stable row IDs cost ~12 bytes per row in
_row_id_sequences/. At 1B rows per dataset, this is ~12 GB. Worth measuring at our scale before assuming free. Lance docs claim "negligible overhead" but our scale may be in the long tail of "negligible". -
Path B has a write-amplification cost. Every time
compact_filesruns on a sub-table, our graph-topology dataset must be re-scanned and re-written. For a 10M-row topology, this is a 100MB rewrite — small but worth scheduling outside the hot path (background reconciler at the same cadence as compaction itself). -
Path A (Lance-managed) is materially better long-term. When the §1.2 plugin registry lands, switch. Until then, Path B is production-grade. See
validation-prototypes/custom-lance-index/for the registry blocker repro.
Phase 1 small repro (built and run 2026-05-12)
Built under MR-927 Phase 1 to produce the empirical attachment the RFC
needs. The crate lives at validation-prototypes/stable-rowid-index/.
Matrix: {BTree, Bitmap, LabelList} × {stable=true, stable=false}, each
case creates a dataset with small max_rows_per_file (so the seed lays
down ~6 fragments), creates the index, appends more data, runs
compact_files with target_rows_per_fragment: 10_000 (so compaction
actually consolidates), and probes the index with a with_row_id()
scan pre- and post-compaction.
Repro output (verbatim)
=== MR-927 Phase 1 matrix ===
idx stable manif1 manif2 pre/post.cnt pre/post.cnt fragments row_id key=500 row_id key=1234 id500 id1234 ok
BTree true true true 1->1 1->1 6->2 (+2,-6) 500->500 1234->1234 true true OK
BTree false false false 1->1 1->1 6->2 (+2,-6) 8589934592->25769804276 17179869418->30064771306 false false OK
Bitmap true true true 1->1 1->1 6->2 (+2,-6) 500->500 1234->1234 true true OK
Bitmap false false false 1->1 1->1 6->2 (+2,-6) 8589934592->25769804276 17179869418->30064771306 false false OK
LabelList true true true 1->1 1->1 6->2 (+2,-6) 500->500 1234->1234 true true OK
LabelList false false false 1->1 1->1 6->2 (+2,-6) 8589934592->25769804276 17179869418->30064771306 false false OK
=== On-disk index inspection (BTree, stable=true) ===
(BTree, stable=true) _indices/ tree:
7ee5ae1a-4c15-4762-9f4d-d1b0475d3de0/
page_data.lance (16371 bytes)
page_lookup.lance (985 bytes)
=== Side experiment: stage_overwrite flag preservation ===
create (enable_stable_row_ids: true) -> manifest.uses_stable_row_ids = true
staged Overwrite (WriteParams without enable_stable_row_ids: true) -> manifest.uses_stable_row_ids = true
direct Dataset::write Overwrite (same flag absent) -> manifest.uses_stable_row_ids = true
ALL CASES OK — all post-compaction probes returned 1 row.
F7. All three built-in scalar index types are stable-row-id-aware today. ✅
Every case in the matrix returns count = 1 for both the pre-compact
probe and the post-compact probe, on both the existing key (key=500,
present pre-append) and the newly-appended key (key=1234, present
post-append). Compaction does not break BTree, Bitmap, or LabelList
indices on a stable-row-id dataset. It also does not break them on a
non-stable-row-id dataset — Lance's index machinery transparently does
the right thing for both.
F8. Stable row IDs survive compaction; physical row addresses change. ✅
With enable_stable_row_ids: true:
key=500→row_id=500pre-compact →row_id=500post-compact (identical)key=1234→row_id=1234pre-compact →row_id=1234post-compact (identical)
With enable_stable_row_ids: false:
key=500→row_id=8589934592(=2 << 32 | 0, fragment 2 offset 0) pre-compact →row_id=25769804276(=6 << 32 | 500) post-compact (changed)key=1234→row_id=17179869418(fragment 4) pre-compact →row_id=30064771306(fragment 7) post-compact (changed)
This is exactly the contract advertised by the manifest flag. Stable
IDs are logical and tiny; non-stable IDs are physical addresses
(fragment_id << 32 | local_row) that move when fragments are rewritten.
F9. Compaction is real, not a no-op. ✅
fragments: 6 → 2 (+2, -6) for every case — six original small
fragments (from the small max_rows_per_file seeding) merged down into
two consolidated ones (the +2 are the consolidated outputs; -6 are the
originals). All four post-compact probes still resolve correctly across
both index types and both row-ID schemes.
F10. Side experiment: Operation::Overwrite preserves uses_stable_row_ids. ✅
This closes the open suspicion about crates/omnigraph/src/table_store.rs:956
(the stage_overwrite path that does NOT set
enable_stable_row_ids: true in its WriteParams).
Three Overwrite shapes all preserve the flag:
| Path | manifest flag after |
|---|---|
Initial create with enable_stable_row_ids: true |
true |
InsertBuilder::with_params({mode: Overwrite, …}) + CommitBuilder::execute (WriteParams flag absent) |
true |
Dataset::write(reader, uri, WriteParams { mode: Overwrite, …}) (flag absent) |
true |
The mechanism is at lance-4.0.1/src/dataset/write/commit.rs:286-290:
let use_stable_row_ids = if let Some(ds) = dest.dataset() {
ds.manifest.uses_stable_row_ids() // inherit from existing dataset
} else {
self.use_stable_row_ids.unwrap_or(false) // only when dest is a fresh URI
};
So CommitBuilder::execute(txn) reads the flag from the existing
dataset's manifest, ignoring both the builder's
use_stable_row_ids and the WriteParams flag, whenever the destination
is a Dataset (which is the case for every Overwrite-on-existing path
in OmniGraph). The stage_overwrite site at table_store.rs:956 is NOT
a latent bug.
F11. BTree on-disk layout. (informational)
_indices/<uuid>/ for a BTree index of 1000 unique UInt64 keys on a
stable-row-id dataset:
_indices/7ee5ae1a-…/
page_data.lance (16371 bytes)
page_lookup.lance (985 bytes)
Both are Lance file-format containers. The bytes are opaque without
loading them through lance-index. What matters for the RFC is the
behavior already documented above, not the byte-level encoding. The
disk layout is included here only as a pointer for the RFC's "shape of
the index" appendix.
RFC shape for MR-927 (recommended)
Given F7–F11, the right RFC shape is (a) docs PR: remove the "experimental" caveat from the Stable Row ID for Index docs page, because the feature works correctly today across all three built-in scalar index types on Lance 4.0.1.
What the RFC should argue:
- Built-in scalar indices (BTree, Bitmap, LabelList) all return logical row IDs that survive compaction on stable-row-id datasets.
CommitBuilder::executecorrectly inherits the flag from the existing manifest, so Overwrite operations preserve it.- The dataset-level
enable_stable_row_idsflag is sufficient — there is no per-index opt-in mechanism, and none is needed. - The repro in
validation-prototypes/stable-rowid-index/is the attached evidence.
What the RFC should NOT argue:
- That a new API is required. The plumbing is already correct.
- That a per-index opt-in flag is needed. The dataset flag is enough.
- That FragReuseIndex behavior needs to change. It is unused on stable-row-id paths.
The RFC should ask Lance maintainers to confirm any remaining
"experimental" concerns (e.g. specific format-version interactions,
or specific compaction edge cases like deletion-materialization
intersecting with materialize_deletions) before the docs change
lands. If there ARE specific edge cases that are still considered
experimental, the RFC should propose updated docs that scope the
"experimental" label to those edges instead of the whole feature.
Decision impact on MR-737 §5.5
Substrate caveat ("Stable Row ID for Index is documented as
experimental in lance-4.0.x") is empirically resolved to: the feature
works, the docs are conservative. MR-737 §5.5 should be downgraded
from "substrate risk" to "docs-staleness observation" and reference
this experiment writeup and MR-927 as the upstream remediation. The
caveat does NOT block Phase 0 entry.
production-ready and OSS-compatible.