Repository navigation
Conversation
|
Important Review skippedIgnore keyword(s) in the title. ⛔ Ignored keywords (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: QUIET Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
19ac8e0 to
f53d35a
Compare
f53d35a to
5422956
Compare
5422956 to
132c3b4
Compare
4734fe4 to
ddafb45
Compare
Self-review pass (polish-pr), 2026-09-20DecisionNo code changes. The diff is mechanical apart from the new wire schema and one fail-closed decode arm; the self-review found nothing here that should be auto-applied, because every finding either pins a proto field number or reshapes a message, and the field numbers ripple through all three PRs above this one. StatusOpen. The items below want a decision before the vote pins the tags. Open questionsWire schema:
Tests and process:
🤖 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 |
Refinements to the Transaction V2 wire draft (protos/transaction/actions.proto), no behavior change: - AddFragment / SetDeletionFile: resolve a contradiction. AddFragment's doc implied a freshly-minted (Local) fragment could take a deletion file via SetDeletionFile, but SetDeletionFile.fragment is a committed-only uint64. Clarify that a new fragment has no deletion vector and deletions arrive in a later operation once the id is committed, and document why SetDeletionFile takes no Ref. - data_change: document the marker on every carrier (previously only on AddFragment), cross-referencing the canonical definition. Spell out its non-obvious meaning on AddIndexSegment / RemoveIndexSegment, where it refers to the indexed data rather than table rows. - AssertUniqueKeys: note that key_fields is authoritative and the embedded filter.field_ids (an artifact of the shared KeyExistenceFilter type) is ignored. - AdjustIndexCoverage: rename bare add / remove fields to add_fragments / remove_fragments for self-documentation and consistency with AddIndexSegment.covered_fragments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the drafted protos to the shape the implementation settled on, so the whole format lands in one PR and one vote: - `UserOperation` becomes `CompositeOperation`, which names what distinguishes it from the other operations -- a composite of granular actions committed atomically -- and loses its `description`, since the per-step `UserAction.description` is what keeps history readable. `UserAction` keeps its name: it is the grouping a user recognizes. - The field actions reference fields by `Ref`, so an action can name a field the same operation is minting. - `AddIndexSegment`, `RemoveIndexSegment`, `AdjustIndexCoverage` and `UpdateCompactedSsTables` join the action set. - The index actions carry the name of the logical index the segment belongs to, which is what conflict detection compares. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ReserveFragmentIds` documents that "fragments written against a reserved range reference those ids as Committed", but `AddFragment` carried only a `local` token, so nothing could name a reserved id and the reservation was unreachable from the action vocabulary. Widen `AddFragment.local` into `Ref id`, which spans both forms. A writer that has to know a fragment's id before it commits -- because it bakes row addresses into an index it writes in the same commit -- takes the id from a reservation and names it as `Committed`. Field 1 changes type rather than being deprecated in place. The actions schema is used exclusively by the draft Transaction V2 format, which is read-side fail-closed with no write path, so there are no encoded messages to stay compatible with.
The counterpart of `ReserveFragmentIds` for the row id space. Reserving a fragment id fixes the high half of a row address, which is enough for a dataset without stable row ids; with them, an index records row ids instead, and those come off their own counter with no way to reserve from it. Drafted only here -- the apply side lands with the action implementation.
The multi-line comment style landed with its own pre-commit hook (#8991) while this draft was out for review, and these files predate it. No wording changes; the hook did the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`protos/transaction.proto` became `protos/transaction/`, and two globs in the area labeler still name the old path. One of them drives the format-spec vote gate, so a glob that matches nothing would let a change to these protos land without the PMC vote the gate exists to require. Both now match the directory, which covers the split files and any later sibling. The spec doc's link to the same path is repointed at the directory for the same reason: it names three files now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_only_persisted_protos_are_format_changes` compared the raw config entries against a flat `protos/*.proto` glob, so moving the transaction protos into `protos/transaction/` broke it two ways at once: the gated entry became a glob rather than a path, and the files it covers stopped being visible to the non-recursive listing. Resolve both sides to concrete files before comparing. The test now states what it always meant -- every persisted proto is gated, and nothing else is -- and keeps holding as protos move between directories. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The operation's uuid was a string while every other uuid in the action protos -- and in `table.proto` generally -- is the `UUID` message. A string leaves the encoding unstated and costs 36 bytes where 16 will do. Addresses review feedback on the draft wire format. Transaction V2 is a pre-vote draft with no compatibility contract, so the field changes type in place rather than being deprecated alongside a replacement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file-level comment ran to 810 words, which is more than a reader needs before the first message. Cut to under 300 by dropping the section headings, the points that only restated the message comments below them (relocation by watermark, index-segment duplication), and the STATUS/ADDITIVE-SAFETY prose, keeping the five reasons the wire shape is not obvious from the messages themselves. Also drops the TODO over `message Action` asking whether `data_change` belongs per-action or per-UserAction: per-action is the answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transaction V2 was labelled a DRAFT, which is not a status the project recognizes. The governance vote on experimental specification features passed on 2026-08-27 (discussion #8305), so use that status instead: EXPERIMENTAL, with the two commitments a reader needs stated where the label appears -- breaking changes without a separate vote, and removal if the stabilization vote does not pass. The process also requires the feature to be marked in the documentation, and `CompositeOperation` was not described in docs/src/format at all. Adds a section to the transaction specification covering the two-level CompositeOperation/UserAction shape, why actions are deltas with local id placeholders, and what the feature asks of a reader that does not implement it: reject a transaction it cannot decode rather than skip it, and tolerate an undecodable inline transaction when opening a table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review raised that the compatibility cost was understated, and the backports that would remove it are unlikely: several downstream packages bake Lance into size-constrained binaries, so older release lines will not pick up #7740. That makes this durable rather than transitional, so say so where a writer will see it. Committing a CompositeOperation does not merely make the transaction unreadable as history -- it makes that table version unopenable by Lance before v12.0.0, because those releases decode the manifest's inline transaction while opening the table and propagate the failure (#9454). Room in the actions.proto header, which is budgeted at under 300 words, comes from dropping the point about index actions targeting a segment; AddIndexSegment's own comment already says it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the message could not represent IndexMetadata's covering_fields at all, so a covering index was not expressible as an action. Adds the field where the message is defined. Targets the contract in #9159 rather than the one on main: `fields` is the keyed columns only, `covering_fields` is an independent declaration of the carried ones, and the dependency set is the union. A new action has no historical encoding to stay compatible with, so it can take the contract the format is moving to and avoid a field-number change later. #9159 leaves FLAG_INDEPENDENT_COVERING_FIELDS unimplemented, so a manifest cannot yet express a column that is both keyed and carried. The overlapping form is therefore rejected at apply, and a disjoint one lowers to the legacy subset representation; both come out when the flag is implemented, with no change to this message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CompositeOperation carried uuid and read_version, both copies of fields the enclosing Transaction already has. Nothing read them back: the to-wire path stamped them from the envelope and the read side dropped them. The stated purpose -- letting a squashed operation keep its provenance -- is one the fields cannot serve where they sat. Squashing collapses each original CompositeOperation into one UserAction, so the message holding the identity is exactly the one squashing destroys. If per-step provenance is wanted, it belongs on UserAction, and it can be added as an optional field when squashing exists to need it. Renumber actions to 1 rather than reserving 1 and 2: this message has not shipped, and the file states that its field numbers are not a stable contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DataFragment no longer has `external_*` row lineage arms: a spilled sequence is now a hidden column of one of the fragment's data files, marked by a `RowLineageColumn` arm (#9253). AddFragment carries its sequences exactly as DataFragment does, so its arms follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sion comments SetDeletionFile takes a committed id because the deletion file path embeds it. AlterField.logical_type is rejected at apply; a cast is drop + add + data. An explicit AddIndexSegment.dataset_version must equal the read version for now. ResetTable discards the actions before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A step holds actions; calling it an action too made the two levels hard to tell apart. Field numbers are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename CompositeOperation.actions to steps and the Rust module pb_transaction to pb_actions, drop AlterField.logical_type, and clarify proto comments on Ref, AddField.parent, AddOverlays, DropField and UserStep squashing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field references become FieldRef { sint32 committed; uint32 local } so
AddDataFile can bind the reserved lineage ids; fragment and base refs stay
unsigned. AddDataFile and SetDeletionFile take an optional base Ref, and
the embedded file.fields / base_id must be left unset for apply to stamp.
Also documents: data_change as a per-action guarantee, reservations as
the one exception to in-order application, ResetTable never rewinding
counters, what RefreshRowVersionMetadata writes, and that
AssertUniqueKeys.filter.field_ids must be empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without read assertions, footprint conflicts give snapshot isolation, so UPDATE a=b and UPDATE b=a from one snapshot both commit. A writer whose values depend on a read declares it with AssertCellsUnchanged, whose RowSelection mirrors the legacy cell preconditions proposed in #9121. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Committed fragment, field, and base ids must exist at the read version. AdjustIndexCoverage carries the segment's fields so its footprint can require their data. DropIndex removes every segment of an index as they stand at apply. DropField rejects a field with live children. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eaning Main's #9705 gave DataReplacement.data_change = false effects beyond change data capture: index coverage is kept, overlays are left alone, and no row's last-updated-at version moves. A V2 re-encode is TombstoneFieldData followed by AddDataFile, so give a false tombstone the same meaning, and state what makes the flag valid: every tombstoned field is re-bound by an AddDataFile that is also false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add AppendFragReuseTransition (23) and TrimFragReuse (24). The history is not an index on the V2 wire: the four index actions reject `__lance_frag_reuse`, and the new actions never name its entry, uuid, or index_version. A transition is merged onto the current history at every attempt; apply derives its digests and validates it without IO. Specify that whole-fragment removal does not conflict with an index build's data requirement, and allow tagged histories on stable-row-id tables as long as no row-id-domain index coexists with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| !!! danger "Writing Transaction V2 breaks compatibility with readers before v12.0.0" | ||
|
|
||
| A table version committed as a `CompositeOperation` **cannot be opened** by | ||
| Lance before v12.0.0 — not merely read as history. Those releases decode the | ||
| manifest's inline transaction section while opening the table, and an | ||
| operation they do not recognize decodes to no operation at all, which fails | ||
| the open. The fix | ||
| ([#7740](https://github.com/lance-format/lance/pull/7740)) shipped in | ||
| v12.0.0 and is not expected to be backported to earlier release lines; see | ||
| [#9454](https://github.com/lance-format/lance/issues/9454). | ||
|
|
||
| Treat this as durable, not transitional. Writing a `CompositeOperation` is | ||
| opt-in for exactly this reason, and a table that has one anywhere in its | ||
| history raises the minimum reader version for that table to v12.0.0. |
There was a problem hiding this comment.
question: is this still a worry after we did #9482?
There was a problem hiding this comment.
No. Since #9482 (v14.0.0) the inline transaction is in manifest field 23, which older readers ignore, so any reader can open and scan the table. Only a reader before v14.0.0 that decodes the transaction itself (history, conflict checks) fails, and in a conflict check that aborts the older writer's commit, which is safe. Replaced the danger box with a short note in fd58812.
| single named operation covers, such as appending a fragment and updating an | ||
| index together, and makes the transaction a true diff of the manifest rather |
There was a problem hiding this comment.
nitpick: this is a bad example given the issues with updating index on new data. Let's say instead:
| single named operation covers, such as appending a fragment and updating an | |
| index together, and makes the transaction a true diff of the manifest rather | |
| single named operation covers, such as appending a fragment and updating table config together, and makes the transaction a true diff of the manifest rather |
| manifest. The two levels exist so the transaction history stays readable when | ||
| several operations are squashed into one: the descriptions survive, while the | ||
| action lists are flattened on apply. |
There was a problem hiding this comment.
suggestion: instead of pushing the squash idea, maybe we should say they are for multi-statement transactions, which is a more canonical database idea and really goes along with the "combining multiple operations into the same transaction".
There was a problem hiding this comment.
Reworded around multi-statement transactions in fd58812.
| identifier (a field, fragment, or base id) names it with a local placeholder | ||
| that resolves against the target's counters at apply time. Both properties are |
There was a problem hiding this comment.
nitpick: this is a little convoluted. Please simplify the language. Maybe split these sentences
| identifier (a field, fragment, or base id) names it with a local placeholder | |
| that resolves against the target's counters at apply time. Both properties are | |
| identifier (a field, fragment, or base id) names it with a local placeholder. When building the manifest before commit, the local placeholders are translated into concrete identifiers. Both properties—true deltas and local placeholders—are |
| Field references are `FieldRef`s rather than the `Ref` that fragments and | ||
| bases use, because a field id is signed: an `AddDataFile` binds a data file's | ||
| columns with the same ids its `DataFile.fields` would carry, including the | ||
| reserved negative ids of the hidden row lineage columns. A data file inside an | ||
| action leaves `fields` and `base_id` unset, and apply stamps them from the | ||
| action's `field_ids` and `base`. |
There was a problem hiding this comment.
nitpick: this seems like a detail to leave to the protobuf file.
There was a problem hiding this comment.
Removed in fd58812. actions.proto already covers FieldRef's signedness and how apply stamps fields/base_id.
| - no source is consumed by any other transition, in the history or in the same | ||
| operation; | ||
| - no index on the table stores stable row ids rather than row addresses; | ||
| - a stable partition has between 1 and 65,536 destinations. |
There was a problem hiding this comment.
question: what does this mean?
There was a problem hiding this comment.
The fragment reuse history translates row addresses: a reader maps an index hit at an old fragment's address to its new address after compaction. On a table with stable row ids an index can store stable row ids instead; those don't change on compaction, so translating them would be wrong. So apply refuses to record a transition while such an index exists (and adding one while a tagged history exists is refused at publish). Reworded the bullet to say this in fd58812.
| data. In the Rust implementation this has been true only since v12.0.0, which | ||
| is what makes writing Transaction V2 a compatibility break against earlier | ||
| releases rather than a graceful degradation. |
There was a problem hiding this comment.
question: again, is this still true?
There was a problem hiding this comment.
No, same as the warning above. Rewrote this bullet in fd58812 to point at Inline Transaction Sections.
| * COMPATIBILITY WARNING. A table version carrying one of these cannot be | ||
| * opened at all by Lance before v12.0.0, and the fix is not backported | ||
| * (issue #9454). Writing V2 is opt-in for that reason. |
There was a problem hiding this comment.
question: again, is this true?
…prose Since #9482 the inline transaction is in a manifest field older readers ignore, so a CompositeOperation no longer prevents opening a table; only readers before v14.0.0 decoding the transaction itself fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
The compatibility text now matches the prerequisite already supplied by #9482 in the base: writers use manifest field 23, which older readers ignore, so an unsupported transaction need not prevent opening and scanning the data. Operations that require interpreting that transaction, including conflict checking, can still fail safely. The four original findings remain closed.
Delta actions and latest-history fragment-reuse append/trim remain reasonable foundations; this revision changes neither their wire encoding nor runtime behavior. Acceptance is still limited to the experimental, format-only scope: there is no V2 writer or executor, and V2 transaction loads fail closed. Replay and trim races, mapping-payload validation, stable-row-ID index admission, reference bounds, drop ordering, and coverage preconditions remain implementation-verification gaps. Paired re-encodes must preserve raw base values, overlays, index coverage, and row versions; missing translation coverage must fall back to scanning.
Snapshot isolation still requires read-derived writers to emit AssertCellsUnchanged. The other adoption costs remain an earlier ID-reservation commit for append-plus-index or append-then-delete, and wide-append metadata overhead. The selected empty-fields representation removes placeholder overhead but retains per-field references. These are not merge prerequisites for this no-writer PR.
Defines the wire format for action-based transactions (Transaction V2): a
CompositeOperationthat replaces the single legacyOperationwith an ordered list of granular, composableActiondeltas. This is the format change only — read-side fail-closed, no write path. Every protobuf change the implementation stack needs lands here, so the format is a single vote; the PRs stacked on top of it change no.protofile.Layout
The V2 vocabulary lives in its own files under
protos/transaction/, and its messages in their ownlance.transactionpackage, apart from the table messages inlance.table(which already has an unrelatedAddDataFile, a fragment-tree mutation). In Rust, the generated code islance_table::format::pb_transaction.actions.proto— the V2 vocabulary (Ref,CompositeOperation,UserStep,Action+ the action messages), with the design rationale summarized in an in-tree file header so it stands on its own.common.proto— shared building blocks (UpdateMap,KeyExistenceFilter, …) referenced by both legacy operations and V2 actions, promoted from nestedTransactionmessages to top-levellance.transactionmessages to avoid a circular import. Wire-compatible: field numbers unchanged and none areAny-packed.transaction.proto— moved into the directory; keeps theTransactionenvelope, legacy operations, and the newcomposite_operationoneof arm (field 116).What's in the wire format
Design principles (fully documented in
actions.proto):AddDataFile,TombstoneFieldData), so compound commits and branch merge fall out uniformly.Localtoken via a singleRefand relocate on merge/rebase; reference-stable changes key off stable coordinates and keep a post-image with a derivable delta.AddField/DropField/ identity-preservingAlterField(rename and nullability;logical_typeis reserved and rejected at apply, so a cast isDropField+AddField+ data). No wholesaleSetSchema.AddIndexSegment/RemoveIndexSegment/AdjustIndexCoverage(a logical index is the segments sharing aname; per-segment config duplication is a pre-existing limitation this draft doesn't fix).AssertUniqueKeyscarries merge-insert's non-derivable key-existence filter as an explicit precondition.optionalfields.data_changemarkers (Delta-style, for CDC skip of compaction) and index-coverage representation carry in-treeTODOs to finalize.Settled by the implementation
These started out in the PRs stacked above and were pulled down here so the format is voted on once:
UserOperationbecomesCompositeOperation, naming what distinguishes it from the other operations, and loses itsdescription— the per-stepUserStep.descriptionis what keeps history readable.UserActionis renamedUserStep(field numbers unchanged), so the step a user recognizes is not confused with theActions it holds.Ref, so an action can name a field the same operation is minting.AddIndexSegmentgainsbase,created_at, anddataset_version, and itscovered_fragmentsbecomes an optional wrapper rather than a barerepeated. A bare repeated field cannot distinguish "no coverage recorded" — what the MemWAL and fragment-reuse indices carry, and what the query path treats as "serve this segment" — from "covers no fragment", which it treats as "skip it".dataset_versionis a correctness gate rather than provenance: an overlay committed at or before it counts as already folded into the index.RemoveIndexSegmentandAdjustIndexCoveragecarry thenameof the logical index the segment belongs to, which is what conflict detection compares.update_compacted_ss_tablesoneof arm is named to match itsUpdateCompactedSsTablesmessage, which is what the action vocabulary keys the wire encoding off.AddFragmentcarries a spilled row lineage sequence as aRowLineageColumnarm, exactly asDataFragmentdoes since feat(format): define hidden row lineage columns #9253: the sequence is a hidden column of one of the data files the fragment'sAddDataFileactions add.SetDeletionFile.fragmentis committed-only because the deletion file path embeds the fragment id; an explicitAddIndexSegment.dataset_versionmust equal the read version for now;ResetTablediscards actions before it in the same operation.Worked examples
Add a column and its data in one commit
A new field has no id until the commit lands. The data files name it by the token its
AddFieldminted, so readers never see the column without its data:At apply,
Local(0)resolves to the next field id, and both files are stamped with it. Replaying the set onto a newer version re-resolves the token against that version's counter.Caveats
_deletions/{fragment_id}-{read_version}-{id}.{ext}), and apply does no IO, soSetDeletionFiletakes a committed id. An index segment's files likewise record real fragment ids and row addresses. CommitReserveFragmentIdsfirst. Then write the fragments asRef::Committed(reserved_id)and build the deletion file or segment against those ids.AddIndexSegment.fieldsacceptsLocal(0), but every index builder reads the column through the committed schema. Commit the column first, then build and add the segment.Library changes (minimal, fail-closed)
Read-side rejection only:
TryFrom<pb::Transaction>returnsError::NotSupportedfor aCompositeOperation, with a comment forbidding lenient parsing (thetry_collectcontract aborts a concurrent V2 commit rather than silently skipping it).test_composite_operation_rejected_on_load.No
Operationenum variant, noapply/ translation / conflict resolution, no write path, no Cargo feature gate, no Python/Java changes.Verification
lance-table+lancebuild; new test passes;cargo fmt --allandcargo clippy -p lance-table -p lance --tests -- -D warningsclean.🤖 Generated with Claude Code