Conversation
c6a7e75 to
361a1b4
Compare
361a1b4 to
1b1bf31
Compare
1b1bf31 to
c56f4ca
Compare
Self-review pass (polish-pr), 2026-09-20Decision
The covered-index fence hunk that this PR carried in StatusSettled and implemented. fmt clean, Open questionsThe one worth a decision before this merges:
Smaller ones:
🤖 Generated with Claude Code |
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. StatusVerified 2026-09-20. Nothing in the stack changes legacy conflict behaviour. Options and criteriaThe suite's branch ( Method: for each PR, a throwaway branch off the PR head, merge Results, all four layers:
#8644 is the layer that matters most here — it is the one that touches Open questions
🤖 Generated with Claude Code |
c56f4ca to
1589925
Compare
1589925 to
e376707
Compare
6ecaa35 to
b90947c
Compare
1090ca2 to
d8b20e5
Compare
b071969 to
77c2d88
Compare
|
Restacked onto the new #8644 head and added |
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>
77c2d88 to
1f5eb17
Compare
Stacked on the action vocabulary PR. Implements the three index-management actions the Transaction V2 draft left unwritten —
AddIndexSegment,RemoveIndexSegment, andAdjustIndexCoverage— the lowering of the legacyCreateIndexoperation 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
CreateIndexrule, 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.
RemoveIndexSegmentandAdjustIndexCoveragecarry aname. 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:
Two writers each doing this against different fragments now both commit, and the index is the union of their segments.
Not included
AdjustIndexCoveragekeeps 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
CreateIndexlowering 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.