Skip to content

feat(transaction): action-based transaction wire format (Transaction V2) - #7954

Open
wjones127 wants to merge 26 commits into
mainfrom
will/oss-1530-draft-action-based-transaction
Open

wjones127 wants to merge 26 commits into
mainfrom
will/oss-1530-draft-action-based-transaction

Conversation

@wjones127

@wjones127 wjones127 commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Defines the wire format for action-based transactions (Transaction V2): a CompositeOperation that replaces the single legacy Operation with an ordered list of granular, composable Action deltas. 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 .proto file.

Layout

The V2 vocabulary lives in its own files under protos/transaction/, and its messages in their own lance.transaction package, apart from the table messages in lance.table (which already has an unrelated AddDataFile, a fragment-tree mutation). In Rust, the generated code is lance_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 nested Transaction messages to top-level lance.transaction messages to avoid a circular import. Wire-compatible: field numbers unchanged and none are Any-packed.
  • transaction.proto — moved into the directory; keeps the Transaction envelope, legacy operations, and the new composite_operation oneof arm (field 116).

What's in the wire format

CompositeOperation { [UserStep] }
UserStep      { description, [Action] }        // human-readable step; survives squash
Action        { oneof of 19 actions }
Ref           { committed: uint64 | local: uint32 }   // one ref type for field/fragment/base ids

Design principles (fully documented in actions.proto):

  • Deltas, not post-images — actions record the change (AddDataFile, TombstoneFieldData), so compound commits and branch merge fall out uniformly.
  • Minting vs. reference-stable — minted ids (field/fragment/base) carry a Local token via a single Ref and relocate on merge/rebase; reference-stable changes key off stable coordinates and keep a post-image with a derivable delta.
  • Field-level schema — AddField / DropField / identity-preserving AlterField (rename and nullability; logical_type is reserved and rejected at apply, so a cast is DropField + AddField + data). No wholesale SetSchema.
  • Index segments — AddIndexSegment / RemoveIndexSegment / AdjustIndexCoverage (a logical index is the segments sharing a name; per-segment config duplication is a pre-existing limitation this draft doesn't fix).
  • Assertions — AssertUniqueKeys carries merge-insert's non-derivable key-existence filter as an explicit precondition.
  • Off the wire — computed conflict footprints and large derivable row-level deltas (deletion affected-rows, update matched-offsets) are recomputed at conflict time, not serialized.
  • Additive-safe — message identities and core mutation fields are pinned; discoveries land as added optional fields.

data_change markers (Delta-style, for CDC skip of compaction) and index-coverage representation carry in-tree TODOs 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:

  • UserOperation becomes CompositeOperation, naming what distinguishes it from the other operations, and loses its description — the per-step UserStep.description is what keeps history readable. UserAction is renamed UserStep (field numbers unchanged), so the step a user recognizes is not confused with the Actions it holds.
  • The field actions reference fields by Ref, so an action can name a field the same operation is minting.
  • AddIndexSegment gains base, created_at, and dataset_version, and its covered_fragments becomes an optional wrapper rather than a bare repeated. 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_version is a correctness gate rather than provenance: an overlay committed at or before it counts as already folded into the index.
  • RemoveIndexSegment and AdjustIndexCoverage carry the name of the logical index the segment belongs to, which is what conflict detection compares.
  • The update_compacted_ss_tables oneof arm is named to match its UpdateCompactedSsTables message, which is what the action vocabulary keys the wire encoding off.
  • AddFragment carries a spilled row lineage sequence as a RowLineageColumn arm, exactly as DataFragment does since feat(format): define hidden row lineage columns #9253: the sequence is a hidden column of one of the data files the fragment's AddDataFile actions add.
  • Doc-only: SetDeletionFile.fragment is committed-only because the deletion file path embeds the fragment id; an explicit AddIndexSegment.dataset_version must equal the read version for now; ResetTable discards 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 AddField minted, so readers never see the column without its data:

CompositeOperation
└── UserStep "add column b"
    ├── AddField    { local: 0, parent: none, def: b: int32 }
    ├── AddDataFile { fragment: Committed(0), field_ids: [Local(0)], file: b-0.lance }
    └── AddDataFile { fragment: Committed(1), field_ids: [Local(0)], file: b-1.lance }

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

  • Append + delete, and append + index, need a reservation commit first. A deletion file's path embeds the fragment id (_deletions/{fragment_id}-{read_version}-{id}.{ext}), and apply does no IO, so SetDeletionFile takes a committed id. An index segment's files likewise record real fragment ids and row addresses. Commit ReserveFragmentIds first. Then write the fragments as Ref::Committed(reserved_id) and build the deletion file or segment against those ids.
  • Indexing a column added in the same commit is not possible with today's builders. AddIndexSegment.fields accepts Local(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:

  • One arm in TryFrom<pb::Transaction> returns Error::NotSupported for a CompositeOperation, with a comment forbidding lenient parsing (the try_collect contract aborts a concurrent V2 commit rather than silently skipping it).
  • Regression test test_composite_operation_rejected_on_load.

No Operation enum variant, no apply / translation / conflict resolution, no write path, no Cargo feature gate, no Python/Java changes.

Verification

lance-table + lance build; new test passes; cargo fmt --all and cargo clippy -p lance-table -p lance --tests -- -D warnings clean.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Ignore keyword(s) in the title.

⛔ Ignored keywords (2)
  • WIP
  • Draft

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: d6c3e873-ac4a-40f7-89f6-e462d4db9be4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch will/oss-1530-draft-action-based-transaction

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@github-actions github-actions Bot added A-format On-disk format: protos and format spec docs enhancement New feature or request labels Jul 23, 2026
@codecov

codecov Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.75510% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-table/src/format/key_existence.rs 44.44% 5 Missing ⚠️
rust/lance-table/src/transaction/proto.rs 97.50% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@wjones127
wjones127 force-pushed the will/oss-1530-draft-action-based-transaction branch from 19ac8e0 to f53d35a Compare July 28, 2026 22:41
@wjones127
wjones127 changed the base branch from main to refactor/transaction-module-split July 28, 2026 22:41
@wjones127
wjones127 force-pushed the will/oss-1530-draft-action-based-transaction branch from f53d35a to 5422956 Compare July 28, 2026 23:21
Base automatically changed from refactor/transaction-module-split to main August 19, 2026 18:38
@wjones127
wjones127 force-pushed the will/oss-1530-draft-action-based-transaction branch from 5422956 to 132c3b4 Compare August 19, 2026 20:30
@wjones127 wjones127 changed the title feat(transaction): draft action-based transaction wire format (Transaction V2) feat(transaction): action-based transaction wire format (Transaction V2) Aug 19, 2026
@wjones127
wjones127 force-pushed the will/oss-1530-draft-action-based-transaction branch from 4734fe4 to ddafb45 Compare September 8, 2026 19:51
@github-actions github-actions Bot added A-ci CI / build workflows format-change A change to the format spec, which requires a vote. Remove if minor (e.g. fixing typo). labels Sep 8, 2026
@wjones127

Copy link
Copy Markdown
Contributor Author

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

Decision

No 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.

Status

Open. The items below want a decision before the vote pins the tags.

Open questions

Wire schema:

  • CompositeOperation duplicates uuid and read_version, which Transaction already carries. The comment says uuid "matches Transaction.uuid semantics", which is the question rather than the answer: two copies that can disagree, with no stated rule for which wins. Drop them, or state that the outer fields are authoritative.
  • AssertUniqueKeys embeds KeyExistenceFilter and then documents filter.field_ids as permanently ignored and left empty. A wire field that must be empty and must not be read is a trap once the tag is pinned. Splitting a payload message holding just the oneof { ExactKeySetFilter, BloomFilter } would let both sides carry their own field list.
  • Ref is a generic top-level name in lance.table and a Rust keyword, so every generated call site is pb::r#ref::Kind. IdRef would also say what it refers to.
  • Ref.committed is uint64, but the id spaces it names are not all that. Fragment id is uint64, base id is uint32, field id is int32 — and a field id has negative sentinels (-1 unassigned, -2 tombstoned, the value TombstoneFieldData writes). Worth stating that the committed value is the non-negative id only, and what apply does with an out-of-range one.
  • The Ref-vs-committed-id line is drawn inconsistently across the fragment actions. SetDeletionFile.fragment is a bare uint64 with a good reason. RefreshRowVersionMetadata.fragment_ids and AdjustIndexCoverage.remove_fragments are bare and unexplained. RemoveFragment, TombstoneFieldData, AddOverlays and DropField take a Ref — and AddOverlays's own comment says it targets committed fragments.
  • ReserveFragmentIds.count is uint32 while ReserveRowIds.count is uint64, in two messages the file presents as counterparts.
  • RemoveIndexSegment.name is derivable from the segment uuid at the read version; the comment justifies it only as an apply-time cross-check, which is worth saying outright.
  • AddFragment restates six DataFragment fields rather than embedding it, unlike AddField.def and AddBase.base, with no stated reason.
  • The Action oneof tags no longer follow reading order (add_data_file = 2 after add_field = 3; reserve_row_ids = 20 before assert_unique_keys = 19).
  • AddIndexSegment's uuid / name / index_details / index_version and ConfigUpdate's three optional UpdateMap fields are the undocumented ones in otherwise well-documented messages; protos/AGENTS.md asks optional fields to state what absent means.

Tests and process:

  • decode_inline_transaction must keep returning None for a CompositeOperation, so that opening a dataset whose newest version was written by a newer Lance still works. This PR now depends on that fail-open half of the split and nothing pins it; the function is private and takes &[u8], so a unit test at the bottom of dataset.rs is a few lines.
  • test_only_persisted_protos_are_format_changes is blind to a stale glob. A protos/ glob matching nothing contributes nothing to the resolved set, so re-adding the now-deleted protos/transaction.proto to the config still passes — which is the failure mode this PR's config edit exists to repair. Asserting that every protos/-prefixed glob matches at least one file closes it.
  • Nothing exercises the pb conversions for the messages hoisted out of Transaction into lance.table (UpdateMap, KeyExistenceFilter, BloomFilter, ExactKeySetFilter). The move is wire-compatible on inspection — field numbers and embedded encodings unchanged, none carried in a google.protobuf.Any — but a round-trip test in key_existence.rs would prove it, and would also cover the zero-means-default branch.
  • protos/AGENTS.md asks a persisted-format proto change to land with the matching docs/src/format/ change. The only docs edit here is a link retarget, and CompositeOperation is not described in docs/src/format at all — worth either a short "Transaction V2 (draft)" subsection or a line in the PR description saying the spec text lands with the vote.

🤖 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 and others added 22 commits October 26, 2026 19:51
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>
lance-gatekeeper[bot]

This comment was marked as outdated.

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>
lance-gatekeeper[bot]

This comment was marked as outdated.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +619 to +632
!!! 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is this still a worry after we did #9482?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +637 to +638
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: this is a bad example given the issues with updating index on new data. Let's say instead:

Suggested change
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in fd58812.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +652 to +654
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded around multi-statement transactions in fd58812.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +657 to +658
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: this is a little convoluted. Please simplify the language. Maybe split these sentences

Suggested change
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in fd58812.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +681 to +686
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`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: this seems like a detail to leave to the protobuf file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what does this mean?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/src/format/table/transaction.md Outdated
Comment on lines +820 to +822
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: again, is this still true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, same as the warning above. Rewrote this bullet in fd58812 to point at Inline Transaction Sections.

Comment thread protos/transaction/actions.proto Outdated
Comment on lines +25 to +27
* 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: again, is this true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Reworded in fd58812, along with the matching comments in transaction.proto and the action.rs module doc (#8644).

…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>

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

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.

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

A-ci CI / build workflows A-format On-disk format: protos and format spec docs breaking-change enhancement New feature or request format-change A change to the format spec, which requires a vote. Remove if minor (e.g. fixing typo). K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants