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
…ction V2)
Draft the full action vocabulary for action-based transactions (Transaction
V2) directly in canonical `transaction.proto`, so it can drive the OSS-1529
squash/merge spike and the OSS-757 PMC vote.
A `UserOperation` (a new `Transaction.operation` oneof arm, field 116) is an
ordered list of `UserAction` steps, each expanding to granular `Action`
deltas. Actions record the *change* to the manifest (not a post-image), which
is what makes compound commits and branch merge fall out uniformly. Minted
identifiers (field/fragment/base ids) carry a `Local` token via a single
`Ref { committed | local }` so they relocate on merge/rebase; reference-stable
changes key off stable coordinates. Computed conflict footprints and large
derivable row-level deltas stay off the wire.
Library support is intentionally READ-side fail-closed only: a transaction
carrying a `UserOperation` is rejected on load with a clear "not supported"
error, and there is no write path, no `apply`, no translation, and no conflict
resolution yet. This keeps older writers safe (a concurrent V2 commit in the
conflict window aborts an in-flight commit rather than being silently skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tory Reorganize the action-based transaction (Transaction V2) wire draft into its own files under protos/transaction/ so the design reads clearly and the shared building blocks have a proper home: - protos/transaction/actions.proto: the V2 vocabulary (Ref, UserOperation, UserAction, Action + action messages), as top-level messages. The design rationale is summarized in an in-tree file header (deltas vs post-images, minting vs reference-stable, Ref/Local resolution, field-level schema, index segments, what stays off the wire) so it stands on its own. - protos/transaction/common.proto: UpdateMap/UpdateMapEntry and KeyExistenceFilter/ExactKeySetFilter/BloomFilter, promoted from nested Transaction messages to top-level so both the legacy operations and the V2 actions can reference them without a circular import. Wire-compatible: field numbers unchanged and none of these types are Any-packed, so the fully-qualified name change is invisible on the wire. - protos/transaction.proto moves to protos/transaction/transaction.proto and keeps only the Transaction envelope, legacy operations, and the user_operation oneof arm. Comments use block style for IDE folding. Rust references to the promoted types are repointed from pb::transaction::X to pb::X (mechanical, compiler-checked); the hand-written dataset::transaction domain types are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The delta/action direction solves the real atomic-commit and N×N conflict-growth problem and is stronger than composing legacy post-images. The current proposal is not yet a self-contained experimental wire contract: its governing lifecycle and forward-reader prerequisite are not in force, and core payloads cannot yet be interpreted losslessly or unambiguously. Land the governance dependency first, keep unaware readers able to open affected datasets, use the outer transaction envelope as the single source of identity/version, and preserve covering-index semantics.
| /* Action-based transaction (Transaction V2). See actions.proto. | ||
| * EXPERIMENTAL: currently rejected on load; no write path. | ||
| */ | ||
| CompositeOperation composite_operation = 116; |
There was a problem hiding this comment.
This new oneof arm is unknown to pre-v12 readers. When a composite transaction is inlined, prost drops the unknown arm and those releases propagate the resulting missing-operation error from Dataset::open. #9454 verifies the v1–v11 exposure, and the only matching fix is #7740 on main; no maintained-release backport PR exists. That fails #8304's prerequisite that feature writers not affect unaware readers. Before accepting this field, either make V2 transactions external-only until the compatibility floor moves (and specify/test that writer rule), or land the maintained-release backports first.
There was a problem hiding this comment.
Backports are unfortunately unlikely to be realistic. We have a lot of different packages that depend on Lance and bake it into their binaries, and many of them are size constrained on releases.
Realistically, I think the best solution we can do here is add a strong warning that the datasets written with this won't be backwards compatible for now. This is strictly an opt-in feature to use these. When we actually stabilize it, we'll make it a writer flag to use these. Maybe this bug means it will also be a reader flag.
There was a problem hiding this comment.
An opt-in warning does not satisfy the forward-compatibility boundary this PR relies on: the proposed experimental policy requires writers using the feature not to affect unaware readers, while an inlined V2 transaction still prevents pre-v12 Dataset::open. The current head therefore still needs either an external-only writer rule or a compatible reader floor before this field can be accepted.
There was a problem hiding this comment.
Added, in ea4b6f3f and 0d8addd2. Agreed the backports are not realistic, so the docs now treat this as durable rather than transitional.
Four places a writer will actually hit it:
- the
composite_operationoneof arm here, with the mechanism spelled out - the
actions.protofile header, short-form - a
!!! dangeradmonition in the transaction specification, stating that a table with one anywhere in its history has a minimum reader version of v12.0.0 - Rust: the
actionmodule's Stability section, a# Compatibilitysection onCommitBuilder::with_experimental_composite_operations, and theNotSupportedmessage a caller gets without opting in -- that error is the first and possibly only place they read about it
All of them say the same thing explicitly: the version cannot be opened, not merely read as history.
On the writer/reader flag at stabilization -- agreed, and #9454 does look like it makes the reader-flag case. Worth settling on the discussion when we get there rather than now.
There was a problem hiding this comment.
The warnings accurately document the break, but this finding is about the acceptance contract rather than discoverability. #8304 requires experimental writers not to affect unaware readers, while pre-v12 Dataset::open still fails. This therefore still needs an external-only writer rule or a compatible reader floor before merge.
There was a problem hiding this comment.
Well I think the only solution would be to create a reader feature flag. But that would also cause Dataset::open to fail, just with a nicer error message. Would adding that reader flag make things acceptable? One thing that makes me not like the idea of a reader flag is this is experimental and when we stabilize it we'll actually want the flag to mean "I understand the stable version of the protobuf". So on some level I'm fine with the experimental version just not being compatible. It's entirely opt-in: A user would have to manually construct a transaction with a CompositeOperation. So it's not unlike using an unstable file format version. I'm inclined to let it just fail as is while it's experimental, and add a reader flag upon stabilization.
There was a problem hiding this comment.
A reader flag that still makes Dataset::open fail would not meet #8304's forward-compatibility prerequisite. Opt-in changes writer exposure, but the resulting stable table remains unreadable to an unaware reader. If the governing policy is changed and approved to permit that consequence, the acceptance contract changes; under the current proposed policy, this finding remains.
| // Unique identifier for this operation (matches Transaction.uuid semantics). | ||
| UUID uuid = 1; | ||
| // The dataset version this operation was planned against. | ||
| uint64 read_version = 2; |
There was a problem hiding this comment.
CompositeOperation repeats both Transaction.uuid and Transaction.read_version, so the wire format permits two transaction identities and two source versions without saying which copy is authoritative or requiring equality. The outer values already drive transaction-file naming and rebase checkout; an action implementation that reads the inner values can therefore apply against a different source state. This also conflicts with the protobuf rule to store each fact once. Remove the inner copies and make the outer Transaction authoritative—the existing alternative in #7737 already uses that invariant—rather than asking every implementation to reconcile contradictory valid messages.
There was a problem hiding this comment.
These are intentional. The idea is that if multiple transactions were squashed into a single one, you would still be able to derive the original identity and read versions.
There was a problem hiding this comment.
The stated intent is not representable by this schema: squashing flattens each original CompositeOperation into a UserAction, but UserAction carries neither uuid nor read_version, and there is only one inner pair. The inner pair therefore cannot preserve multiple originals, while disagreeing inner and outer values remain valid but uninterpretable. Please encode per-original metadata at the repeated level or remove the duplicate pair.
There was a problem hiding this comment.
Oh, good point. I don't know why it's at this level. It should be at UserAction level then.
There was a problem hiding this comment.
Agreed—that places identity and read version at the repeated level that represents each original operation. The current head still keeps the singleton pair on CompositeOperation while UserAction lacks it, so this finding remains until the fields move or the duplicate pair is removed.
There was a problem hiding this comment.
Fixed in d1bc81a8: CompositeOperation no longer duplicates transaction identity or read version; the enclosing Transaction is now the sole source for both values.
| UUID uuid = 1; | ||
| string name = 2; | ||
| // Indexed field ids (Committed, or Local for same-op minted fields). | ||
| repeated Ref fields = 3; |
There was a problem hiding this comment.
AddIndexSegment cannot represent the current IndexMetadata.covering_fields declaration. fields contains keyed columns followed by carried columns, and covering_fields is the only state that splits them; losing it makes selection and maintenance treat payload columns as keys and can omit the covering-index reader feature flag. Add a non-duplicating representation (for example, the validated count of trailing covering fields) and round-trip a covering segment.
Reproducer
printf 'fields { committed: 1 } fields { committed: 2 } covering_fields { committed: 2 }\n' | \
protoc -I protos --encode=lance.table.AddIndexSegment protos/transaction/actions.protoThe observed head fails with Message type "lance.table.AddIndexSegment" has no field named "covering_fields", while IndexMetadata accepts the equivalent keyed/carried declaration.
There was a problem hiding this comment.
This is a good point. Will address.
There was a problem hiding this comment.
Thanks. The current head still cannot encode the covering-field split, so I am keeping this finding until the promised representation and round-trip evidence land.
There was a problem hiding this comment.
Addressed. AddIndexSegment now carries covering_fields (field 12) alongside fields, and it takes #9159's contract rather than main's: fields is the keyed columns only, covering_fields is independent of it, and the segment's dependency set is the union. The action has no historical encoding to stay compatible with, so there was no reason to be born with the legacy suffix convention.
The gap is that no release can store that contract yet — #9159 reserves FLAG_INDEPENDENT_COVERING_FIELDS with mark_supported(..., false), so no build writes or opens a manifest that sets it. Rather than block the action on that, apply lowers:
- Disjoint declaration (
fields = [1],covering_fields = [2]) → legacyIndexMetadata { fields: [1, 2], covering_fields: [2] }underFLAG_COVERED_INDEX_METADATA. Commits today, and the lowering drops out once the flag is implemented. - Overlapping declaration (
fields = [1],covering_fields = [1, 2]) → rejected withNotSupportednaming the flag. Flattening it would publish the weaker claim that the column is merely keyed, silently losing the fact that the segment serves its values.
The action's own shape does not change when the flag lands; only the lowering and the rejection come out.
The wire message and the format doc are in this PR (actions.proto, plus the covering paragraph in docs/src/format/table/transaction.md); the apply behaviour and its tests are in #8645.
There was a problem hiding this comment.
Fixed in be2dd47e: AddIndexSegment now carries independent covering_fields, the format text defines the keyed/carried union contract, and the protobuf round-trip preserves both lists.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
Author feedback clarified that original transaction identity and read version may be retained for squashed operations; that metadata must be carried per original operation, because the current single inner pair cannot represent multiple originals or resolve disagreement with the outer envelope. The remaining contract is unchanged: land #8304 first, keep experimental writers forward-compatible with unaware readers, and preserve covering-index field roles. A warning alone does not satisfy the explicit forward-compatibility prerequisite, and the promised covering-index representation is not yet in this head.
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>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The new warning now accurately states the pre-v12 open failure, but it documents rather than removes a compatibility break that the experimental policy forbids. This revision still needs the same self-contained wire contract: land #8304 first, keep unaware readers able to open affected datasets, encode identity and read version per original squashed operation instead of as one conflicting singleton pair, and preserve covering-index field roles. The delta/action design remains worthwhile once those constraints are met.
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>
Decision
StatusSettled 2026-09-21. Wire message and format doc on #7954; apply behaviour and tests on #8645 ( Options and criteriaCriteria, in order: (a) the action is a new encoding with no history, so it should carry the contract we want rather than the one
Rejected, with the reason and when it was tried
Open questions
|
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
1 fixed / 3 remain. The covering-field decision is now represented directly in AddIndexSegment and round-trips, so that blocker is closed. The proposal still needs an admissible experimental wire contract: land #8304 first, keep pre-v12 readers able to open V2-authored tables rather than only documenting the break, and encode identity and read version per original squashed operation instead of as a conflicting singleton duplicate. The delta/action design remains worthwhile once those constraints are met.
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>
Decision
StatusDone, in d1bc81a (proto) and 9abec50 on #8644 (the Rust conversions that existed only to populate them). Options and criteriaThe criterion was whether either field can ever carry information the envelope does not already have. The justification for them was provenance across a future transaction squash. That justification does not survive contact with the squash design in this same file: squashing collapses each original That was confirmed against the code rather than assumed. They were write-only — stamped from the envelope on the way out, dropped on the way in; the Rust struct never modelled them. No commit or conflict path consults a per-operation read version: Options weighed:
(2) over (1) because the feature that would justify the fields does not exist, and the current misplacement is itself evidence that they are hard to place correctly before it does. Adding an optional field later is cheap; Rejected, with the reason and when it was tried
Open questions
|
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
3 fixed / 1 remains. The duplicate identity/read-version fields are gone, joining the already-closed governance and covering-field findings. The remaining compatibility contract is unchanged: an inlined V2 transaction makes pre-v12 readers unable to open the table, while the merged experimental-feature policy requires writers using the feature not to affect unaware readers. Keep V2 transactions external-only until the compatibility floor moves (and specify/test that writer rule), or establish an equivalent path that leaves those readers able to open the table. The delta/action design remains worthwhile once that prerequisite is met.
|
I'm going to fix #9454 before marking this ready for review |
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/:actions.proto— the V2 vocabulary (Ref,CompositeOperation,UserAction,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-level 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(per-facet, so a concurrent cast and nullability change on the same field commute). 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-stepUserAction.descriptionis what keeps history readable.UserActionkeeps its name: it is the grouping a user recognizes.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.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