Skip to content

feat(transaction): implement the index management actions - #8645

Open
wjones127 wants to merge 45 commits into
will/transaction-v2-actionsfrom
will/transaction-v2-index-actions
Open

wjones127 wants to merge 45 commits into
will/transaction-v2-actionsfrom
will/transaction-v2-index-actions

Conversation

@wjones127

@wjones127 wjones127 commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on the action vocabulary PR. Implements the three index-management actions the Transaction V2 draft left unwritten — AddIndexSegment, RemoveIndexSegment, and AdjustIndexCoverage — the lowering of the legacy CreateIndex operation onto them, and the conflict rule that lets two writers extend one index at once. No format change: the wire shapes these need land in #7954.

Supersedes #8630 and #8641, which are folded together here and whose heads were on a fork, so they could not be part of a GitHub stack.

The format has no first-class "index" separate from its segments — a logical index is the set of segments sharing a name — so the actions operate on segments. Creating an index and extending one are the same action; dropping an index is one removal per segment. Because a segment's fields, coverage, and base path are all Refs, one commit can now append data and index what it just appended, which previously took two versions.

Two writers can extend one index

Conflict detection previously treated a logical index as a single thing only one writer could touch: any two commits adding a segment to the same index collided. That was a faithful port of the legacy CreateIndex rule, which conflicts on index name alone.

But the query path unions the segments, so two writers indexing different fragments are both right and both should land. The name-level rule is replaced with a claim on the index — the same kind of pairwise footprint entry key assertions use. Two claims on one index conflict when they cover overlapping committed fragments, when either does not state what it covers (the system indices), or when they disagree about what the index is: its fields, its config, or its version. Fragments the operation mints itself are left out of the comparison, since they have no id yet and no concurrent writer can be covering one.

RemoveIndexSegment and AdjustIndexCoverage carry a name. The coverage adjustment needs it to say which index it is widening; both use it when applying, to reject an action whose segment turns out to belong to a different index than the one it was planned against.

Example

Appending a fragment and covering it with a new index segment, in one atomic commit — the index names the fragment by the token it was minted under, since the fragment has no id until the commit lands:

CompositeOperation::new(vec![UserStep::new("append and index", vec![
    Action::AddFragment(AddFragment { local: 0, physical_rows: 5, .. }),
    Action::AddDataFile(AddDataFile { fragment: Ref::Local(0), file, .. }),
    Action::AddIndexSegment(AddIndexSegment {
        name: "by_a".into(),
        covered_fragments: Some(vec![Ref::Committed(0), Ref::Local(0)]),
        ..
    }),
])])

Two writers each doing this against different fragments now both commit, and the index is the union of their segments.

Not included

AdjustIndexCoverage keeps the shape the draft gave it, including the note that coverage representation is still an open design area. It rejects a segment that records no coverage rather than treating "unknown" as an empty set to add to.

The remaining drafted actions (AddOverlays, RefreshRowVersionMetadata, UpdateCompactedSsTables, AssertUniqueKeys) are still unimplemented and still rejected on load.

Behavior differences from the legacy path

The CreateIndex lowering is verified by building the same manifest both ways and asserting the resulting index metadata is identical, but two edges the legacy path silently tolerates are rejected here: removing a segment the manifest does not have, and adding one whose uuid an existing segment already uses. Either means the operation was planned against a different set of segments than it is landing on.

@wjones127

Copy link
Copy Markdown
Contributor Author

Self-review pass (polish-pr), 2026-09-20

Decision

AddIndexSegment was the one index action whose decode did not check its name. RemoveIndexSegment and AdjustIndexCoverage both call non_empty; AddIndexSegment took message.name raw. It is the action that creates the name, and Footprint::build_index keys claims on it, so an empty-named segment created a nameless logical index that no removal could then name and that shared one pseudo-index — and inherited its conflicts — with every other empty-named segment. Fixed, with a third #[case::addition] on test_an_index_action_without_a_name_is_rejected, proven to fail without the change.

The covered-index fence hunk that this PR carried in assemble_manifest has moved down to #8644, where the action path first assembles a manifest. The commit message no longer claims the move, and this PR no longer touches manifest_build.rs.

Status

Settled and implemented. fmt clean, clippy --all --tests --benches -- -D warnings clean, scoped pre-commit run --files clean. lance-table transaction tests 246 pass; lance dataset_transactions + io::commit 172 pass.

Open questions

The one worth a decision before this merges:

  • IndexIdentity compares Ref::Local tokens across transactions. fields and covering_fields are Vec<Ref> compared with PartialEq, while the coverage side of the same claim deliberately strips local refs (committed_fragments, with a comment saying why). So two writers that each mint a field and index it under one name both present fields: [Local(0)], compare equal, pass the identity gate and are treated as building the same index. Ordering is a second unstated assumption — [Committed(1), Committed(2)] vs [Committed(2), Committed(1)] reads as a disagreement. Either normalize local tokens out the way coverage does (and treat a local field as unverifiable, i.e. conflict), or state why comparing them here is safe. The same shape appears in feat(transaction): implement the remaining drafted actions #8646's KeyAssertion.key_fields, which is also compared raw across footprints; whichever way this goes should go the same way there.

Smaller ones:

  • AddIndexSegment::files collapses "no files recorded" and "recorded, empty" via (!self.files.is_empty()).then(...), one line away from FragmentCoverage, which exists purely to keep exactly that distinction for coverage.
  • created_at.timestamp_millis() as u64 on encode has no counterpart to the fallible from_timestamp_millis on decode; a pre-1970 timestamp wraps to a nonsense wire value that only Rust round-trips.
  • AddIndexSegment.base and ApplyState::resolve_base have no apply-level test — every fixture sets base: None, so the AddBase + Ref::Local composition that motivates the ref is untested.
  • Covering fields are tested only on the rejection path; nothing asserts a valid covering_fields list resolves and lands in IndexMetadata. The test_two_writers_extending_one_index matrix also has no disagreeing-covering_fields case, so that field could be dropped from IndexIdentity with the suite still green.
  • ResetTable now clears state.indices in place during apply rather than at assembly, so an AddIndexSegment sequenced after it survives where it used to be wiped. No test pins the new behaviour.

🤖 Generated with Claude Code

@wjones127

Copy link
Copy Markdown
Contributor Author

Legacy conflict characterization run against this PR (burn-down item 14)

Decision

#9220's characterization suite passes unchanged on this PR: 42 passed, 0 failed, 6 ignored, ~2s.

Status

Verified 2026-09-20. Nothing in the stack changes legacy conflict behaviour.

Options and criteria

The suite's branch (will/legacy-conflict-characterization, base 8cdffd30e) sits 56 commits ahead of this stack's base (93ba1d20c), so the suite does not compile on the stack as-is — FileFragment::write_overlay, which scenarios.rs uses, does not exist at the older base. That is base skew, not a behavioural difference.

Method: for each PR, a throwaway branch off the PR head, merge 8cdffd30e (clean, no conflicts), cherry-pick the suite's five commits, cargo test -p lance --lib conflict_matrix. Throwaway branches deleted afterwards; nothing here is pushed.

Results, all four layers:

PR Result
#7954 42 passed, 0 failed, 6 ignored
#8644 42 passed, 0 failed, 6 ignored
#8645 42 passed, 0 failed, 6 ignored
#8646 42 passed, 0 failed, 6 ignored

#8644 is the layer that matters most here — it is the one that touches conflict_resolver.rs, adding the fail-closed CompositeOperation arm to every check_*_txn and spelling out check_add_bases_txn's wildcard. The suite covers exactly that file and is unaffected.

Open questions

  • The suite's 6 ignored cases are ignored on its own branch too; this run does not change them.
  • The stack will need a rebase onto current upstream/main before merge regardless; the clean merge above is evidence that rebase is not expected to be eventful.

🤖 Generated with Claude Code

@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch from c56f4ca to 1589925 Compare September 21, 2026 17:12
@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch from 1589925 to e376707 Compare September 21, 2026 17:43
@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch 2 times, most recently from 6ecaa35 to b90947c Compare September 21, 2026 20:11
@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch 2 times, most recently from 1090ca2 to d8b20e5 Compare September 21, 2026 22:13
@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch 4 times, most recently from b071969 to 77c2d88 Compare September 22, 2026 23:46
@wjones127

Copy link
Copy Markdown
Contributor Author

Restacked onto the new #8644 head and added 77c2d8885: an index segment now requires the definitions of its keyed and covering fields, this side's requirements are also checked against the committed set's removed fragments and replaced maps, and AdjustIndexCoverage requires the fragments it widens onto. Decision record and the residual gaps are in #8644 (comment).

wjones127 and others added 29 commits October 28, 2026 23:58
…e read version

- After applying, segments under each touched index name must agree on
  resolved fields, covering fields and type, and cover disjoint fragments.
- AddIndexSegment rejects an explicit dataset_version other than the read
  version: nothing checks for rewrites between an older build and the read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transaction layer accepts a segment keyed on a field the same commit
mints, but no index builder can build over a column that is not committed
yet, so the segment carries coverage only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the index actions

AlterField has no type facet any more, so the build-vs-cast test becomes
build-vs-drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ils payload to commute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ootprint

Matches the apply-time segment check and the legacy resolver's
same_logical_index, so a details payload or index version difference no
longer makes two disjoint builds of one index conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rejected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unrecorded coverage stays reserved for the MemWAL and fragment-reuse
system indices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keep its coverage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t assembly

A segment added after a rewrite in the same operation was built over the
new data, so the rewrite no longer strips that fragment from it. Segments
present when the rewrite runs are pruned as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nown bases rejected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…claim helper

State one reason dataset_version stays on the wire, document the
remaining public fields, rename Footprint::build_index to
claim_index_segment, and name the stale-segment test for what it asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers widening onto a sibling's fragment, two segments in one
operation, validation of only the indices an operation adds to or
adjusts, a reset followed by a rebuilt index, unrecordable fragment ids,
unrepresentable created_at values, and data_change for the index
actions. A created_at above i64::MAX is expected to be rejected; today it
wraps to a negative timestamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of wrapping it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…survives a stale retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… out of one index

Ported from the gatekeeper's reproducer. Both a concurrent and a
sequential composite commit of a Dot segment beside an L2 segment must be
rejected; today both commit and an L2 query returns the wrong neighbor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Segments under one name are unioned by the query path, so the final
segment-set check and the footprint's index identity now compare a
per-type query contract. For vector indices that is the metric, the
presence of an HNSW layer and the quantization kind, read from
VectorIndexDetails; independently trained models still mix. Details that
cannot be decoded, or predate the compression field, must match byte for
byte. Other index types have no contract yet.

lance-table cannot use the generated VectorIndexDetails, which lives in
lance-index, so it decodes a mirror of the fields it reads; an e2e test
encodes with the generated type to keep the two in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the FieldRef split: AddIndexSegment's keyed and carried field
lists, and the index identity the footprint compares, take FieldRef and
resolve to schema fields only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also moves the index-action tests onto committed ids that exist at the
read version, which apply now requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A widening's footprint claimed only the segment and the fragments'
existence, so a concurrent rewrite of an indexed column in a newly
covered fragment went undetected. The action now carries the segment's
keyed and carried fields, apply checks them against the segment, and
the footprint requires their data in each committed fragment it adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DropIndex removes every segment of an index as they stand at apply, and
claims the index with unstated coverage so it conflicts with any
concurrent segment under the same name. Dropping an index segment by
segment let a concurrently added segment resurrect it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A re-encode with data_change false moves a field's values without changing
any, so an index segment built over them still describes them. Claim the
moved data as a new Mode::Moves, which collides with writes the way a write
does but not with a segment's requirement, matching main's #9705: a
concurrent index build no longer conflicts with a DataReplacement whose
data_change is false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd builds to survive removal

The fragment reuse history is not an index on the action path, so the
four index actions must reject `__lance_frag_reuse`. And removal isn't
staleness: neither a compaction nor a whole-fragment deletion of a
covered fragment conflicts with an index build, while an in-place write
still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t builds survive removal

The four index actions reject `__lance_frag_reuse`: the fragment reuse
history is not an index on the action path, so an index action naming it
could only splice in a snapshot.

An index segment's requirement on the data it covers is now
Mode::RequiresUnlessRemoved. It collides with an in-place write, as
Requires does, and commutes with a re-encode, but a removal of the whole
fragment no longer breaks it: removal is visible in the metadata, so the
segment either translates through the reuse history or loses that
coverage. Neither a compaction nor a whole-fragment deletion conflicts
with an index build any more. Data written into a committed fragment
still collides with its removal, and AdjustIndexCoverage still requires
each fragment it adds to exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

❌ Gate recommendation: request changes.

The restack leaves the nested-field build-freshness finding unchanged. Keep the composable actions and make those dependencies ancestry-aware, or conservatively conflict data-changing rewrites of covered fragments; final segment-set consistency cannot establish freshness.

The declared-field checks narrow the author-accepted coverage-widening risk, but widening over committed fragments can still race nested-field rewrites and yield incorrect indexed answers. Widening onto fragments minted in the same operation avoids that race.

Please mark this PR with the breaking-change label.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-changes Latest Gatekeeper recommendation requests changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant