Skip to content

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

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

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

Conversation

@wjones127

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![UserAction::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 14 commits September 23, 2026 09:02
The index actions edit the index list as they are reached, so the list has
to be part of the state actions are applied against rather than an argument
the manifest assembly receives separately. ResetTable clears it where it
stands instead of setting a flag the assembly reads back.
The format has no first-class index apart from its segments, so one action
covers both creating an index and extending one: a logical index is the set
of segments sharing a name. Its fields, coverage, and base path are Refs, so
a segment can index what the same operation just wrote.

Three format changes fall out of implementing it:

- `covered_fragments` becomes an optional wrapper message. A bare `repeated`
  cannot tell "no coverage recorded" -- what the system indices carry, and
  what the query path treats as "serve this segment" -- from "covers no
  fragment", which it treats as "skip it".
- Added `base`, without which a segment imported from another dataset cannot
  be expressed.
- Added `created_at` and `dataset_version`, both describing the build rather
  than where it lands. `dataset_version` in particular is a correctness gate
  (an overlay committed at or before it counts as folded into the index) and
  a merged segment reflects only as much as its oldest input, so it is
  genuinely below the read version and cannot be derived. It defaults to the
  read version and may not exceed it.
Dropping a logical index is one of these per segment carrying its name,
since the format knows only segments. Removing a segment the dataset does
not have is rejected rather than treated as a no-op: it means the operation
was planned against a different set of segments.

Segments are named by uuid, which the writer picks, so the footprint
coordinate is the segment itself -- a concurrent writer extending the same
logical index adds a segment of its own and does not collide.
Moves fragments in and out of a segment's coverage without rewriting the
segment, which is what lets an append and the coverage extension over what
it appended commit as one operation.

A segment recording no coverage is rejected rather than treated as an empty
set to add to: "unknown coverage" is what the query path serves everything
for, so turning it into a concrete set would silently narrow the segment.
Covers the three index actions through the real commit path: one commit
that appends a fragment and adds a segment covering it by local token, one
that swaps a segment out, and one that moves coverage around.
…itions

`AddIndexSegment`'s DeepSizeOf skipped `index_details` on the grounds that
it is opaque. It is only a type url and a byte string, so both are now
measured.

`AdjustIndexCoverage` did not say when adding a fragment to a segment's
coverage is legitimate. It is one case -- a rewrite moved rows the segment
already covered into a new fragment, which the segment reaches through the
fragment-reuse remapping. Adding a fragment of new rows is a writer error
that nothing here can detect, so it is called out.
Conflict detection treated a logical index as a single coordinate, so two
writers adding segments to the same index always collided. That was a port of
the legacy `CreateIndex` rule, which conflicts on index name alone. Since the
query path unions the segments of an index, two segments over disjoint
fragments are both valid and should both commit.

Replaces `Coordinate::IndexName` with an index claim, a pairwise footprint entry
rather than a coordinate. Two claims on one index conflict when they describe
overlapping committed fragments, when either does not state its reach, or when
they disagree about what the index is (fields, details, version). Fragments
minted in the same operation are left out of the comparison: they have no id a
concurrent writer could be covering.

`RemoveIndexSegment` and `AdjustIndexCoverage` use the `name` the wire format
carries. The coverage adjustment needs it to make its claim; both use it at
apply to reject an action whose segment belongs to a different index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An index can carry a column's values without being keyed on it. The action
vocabulary had no way to say so, so an action set that built such an index
would have presented every field as indexed, and the manifest the action
path assembles would have published it without the fence that stops an
older library from answering a query from a carried column.

`AddIndexSegment` gains a `covering_fields` list of the same references as
`fields`. The two are independent -- `fields` means the keyed columns only
and the segment's dependency set is the union -- which is the contract in
#9159 and the one the action should be born with: unlike `IndexMetadata`,
it has no historical encoding to stay compatible with.

A manifest cannot express that contract yet, because
FLAG_INDEPENDENT_COVERING_FIELDS is reserved but unimplemented. So apply
lowers a disjoint declaration to the legacy form -- keys followed by the
carried columns, `covering_fields` naming that trailing subset -- and
rejects an overlapping one rather than publishing the weaker claim that
the column is merely keyed. Both come out when a release implements the
flag; the action's own shape does not change.

The split also joins the index identity two concurrent writers compare:
disagreeing about which columns are merely carried is disagreeing about
what the index answers for, not just about what it holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`load_indices` became the usable-index view upstream: an index this build has
no reader for is filtered out of it. These segments carry no `index_details`
on purpose -- they exist to be read back out of the manifest's index section,
not opened -- so asking that view for them returned an empty list.

`load_all_indices` is the accessor for the question these tests ask, which is
what the transaction committed rather than what a reader could use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ApplyState::read_version` returned the current manifest's version, which
is only the version the transaction read when the commit is uncontended.
After a lost race the set is replayed against whatever won, so an
`AddIndexSegment` that left `dataset_version` unset was stamped with a
version its writer never saw -- claiming the segment covers rows that
landed after it was built. The same substitution loosened the bound on an
explicit `dataset_version`, letting a segment claim a version committed
between the read and the retry.

Source it from the transaction instead, which is what "the version this
operation reads" meant all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The format spec's third index-invalidation case -- a fragment has had one
of the index's columns updated in place -- "cannot be detected just by
examining metadata", so a reader trusts `fragment_bitmap` and has no
recourse. Keeping it honest is a write-path obligation.

`TombstoneFieldData::apply` discharges it by rebinding the field, which
prunes the fragment from every index the manifest carries. That only
reaches indices already there: a segment built before the rewrite but
committed after it carries no rebind, and its footprint records an index
claim that is never compared against the `FieldData` coordinates the
rewrite writes. So it commits, and publishes coverage of a fragment whose
indexed column it no longer describes.

The control test pins the order that already works; the second fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An index build writes almost nothing -- it describes data it does not change
-- so nothing it depended on was ever compared against a concurrent writer,
and a segment built before an in-place column rewrite committed cleanly
after it, publishing coverage of a fragment whose indexed column it no
longer described. A reader cannot catch that: the format is explicit that an
in-place rewrite "cannot be detected just by examining metadata", so
`fragment_bitmap` is trusted as found.

`AddIndexSegment` now fills the `requires` set with the field data under its
coverage -- keyed and carried columns alike, since a stale carried value is
served just as readily as a stale key. The set itself, and the asymmetric
check that consumes it, arrive earlier in the stack with the other case they
serve: data written for a committed field requiring that field's definition.

The asymmetry is what makes this the right shape here. A rewrite arriving
*after* a segment is already correct, because applying it rebinds the field
and prunes the stale fragment out of the segment's coverage; only a segment
arriving after the rewrite is rejected.

Deletions and overlays are deliberately not requirements: both leave a trace
a reader acts on -- a deletion file, and an overlay's `committed_version`
against the segment's `dataset_version` -- so both may still run alongside a
build.

Rejecting is the conservative half of what legacy does here. It matches
`Operation::DataReplacement`, which also rejects; legacy's `Update` path
instead prunes the arriving index's coverage and wastes less work. An action
set is never rewritten to move to a newer version, so that option is not
available yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An index segment required the data of the fragments it covers, and
nothing else. Two things it also depends on were not stated.

Its fields' definitions. The segment was built over values in the type
the schema named at the time. A cast landing first rewrote that
definition, and the segment landed anyway, describing old-typed values
under a new type -- with nothing left to prune it, since the cast's
rebind ran before the segment existed. The segment now requires the
definition of every keyed and carried field, mirroring what
add_field_data already does for data files. A cast landing second still
passes: it rebinds the field everywhere and prunes the segment's coverage
as it applies.

Its fragments' existence. A requirement on a coordinate was only checked
against coordinates the committed set wrote, never against the regions it
removed, so a compaction landing first let a segment covering the removed
fragment land afterwards, describing rows the dataset no longer has.
`conflicts_with` now also tests this side's requirements against the
committed set's removed fragments and replaced maps, through the same
`removes` predicate the write check uses. AdjustIndexCoverage, which
cannot name fields and so cannot require data, at least requires the
fragments it widens onto; the residual gap -- a concurrent rewrite of an
indexed column in one of those fragments -- is documented on the action
rather than left implicit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…elper

The index action modules each spelled out `Footprint::from(&CompositeOperation
::new(...))` for the second side of a pair; use the `footprint` fixture in
test_support instead so a pair reads as two action lists and an answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@wjones127
wjones127 force-pushed the will/transaction-v2-index-actions branch from 77c2d88 to 1f5eb17 Compare September 23, 2026 16:17

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant