History: the Run state machine and
__run__<id>staging branches were removed in MR-771 (shipped v0.4.0). Writes now go directly to the target table; this document specifies that direct-publish path.
mutate_as and load prepare against one immutable branch-authority token,
write directly to the target table, and call
ManifestBatchPublisher::publish once at the end. The token is
(Lance branch identifier, exact optional graph_head, accepted schema identity);
the exact graph_head check protects validation dependencies on tables the
write does not touch. Publisher row-level CAS on __manifest is the visibility
fence. Process-local branch/table queues reduce retries but are not distributed
authority.
- No
RunRecord, no_graph_runs.lance, no_graph_run_actors.lance. - No
omnigraph run *CLI subcommands and no/runs/*HTTP endpoints. - No
__run__<id>staging branches;__run__*is no longer a reserved name. The branch-name guard was removed in MR-770, and any stale__run__*branch on an upgraded graph is swept off__manifestby the v2→v3 internal-schema migration on first read-write open. (The inert_graph_runs.lancebytes remain until adelete_prefixprimitive lands.) - Cancelled mutation futures leave no graph-visible state unless the
manifest publish already completed. Before that point they can leave
reclaimable uncommitted Lance files, or sidecar-covered committed table
effects that the next quiesced recovery rolls forward/compensates. A
first-touch named-branch write can also leave a target table ref, but it is
never created without ownership: the schema-v3 sidecar is durable first and
names that
(table_path, target ref). Reclaim andcleanuptreat any matching pending sidecar as a hard stop. Quiesced full recovery accepts both logical crash shapes — sidecar durable with no ref yet, or an exact untouched ref at the inherited version. Because Lance creates a branch dataset before writing its authoritativeBranchContents, the first shape may still contain a clone-only tree; recovery force-reclaims that absent-ref tree idempotently. It removes an exact untouched ref before deleting the empty intent. If several pending intents claim one ref, a no-effect intent discards only itself while any competitor remains; the last no-effect survivor cleans an untouched ref, or the effect-owning survivor recovers normally.cleanupremains the backstop for genuinely unclaimed legacy/stale refs; see the fork-reclaim note in invariants.md.
Mutation and load use a closed prepare → effect → publish attempt:
- run the branch-aware recovery barrier, then capture the target branch's native Lance
BranchIdentifier, exactgraph_head:<branch>(including absence on a fresh branch), accepted schema identity, and table snapshot; - run the complete validator and prepare every effect outside the effect gate. Existing-table transactions may stage reclaimable files here. A first-touch named-branch table retains its batch/predicate and pre-mints the transaction identity instead: Lance branch-local files cannot be staged until its target ref exists;
- acquire the schema gate, branch gate, then sorted table queues; re-check for
a relevant sidecar armed since step 1, then revalidate the token and require
every existing physical target's live Lance HEAD to equal its manifest pin.
Any unresolved relevant intent returns typed
RecoveryRequired; uncovered HEAD drift points toomnigraph repair. Both fail before this attempt arms recovery; - on a pre-effect mismatch, discard the complete attempt. Append/Insert/Merge
reprepare with a bounded retry; strict Update/Delete/Overwrite return typed
ReadSetChanged; - arm a schema-v3 recovery sidecar. For each deferred first-touch table, create its target ref, stage branch-local files on that ref, and bind the staged transaction to the pre-minted UUID. Then commit every planned transaction with zero transparent conflict retries, confirm exact transaction UUIDs and table updates, and publish the pre-minted lineage intent under the same token.
The publisher checks the exact head and native branch identity on every CAS
attempt. It never reparents a validation-sensitive intent after contention. A
mismatch after any physical effect returns RecoveryRequired and leaves the
sidecar intact; it is not an ordinary retry loop.
This adapter preserves the documented single-writer-process support boundary. The native branch identifier detects delete/recreate ABA but is not a Lance conditional-ref fence, and destructive recovery remains unsafe beside a live foreign process.
Branch merge retains its writer-specific row classifier and multi-commit table algorithms, but its authority, recovery, and visibility boundary now use the RFC-022 adapter contract:
- capture source and target as coherent
WriteTxnsnapshots. The target token is(BranchIdentifier, exact optional graph_head, accepted schema identity); the effective lineage head is captured separately because a fresh named branch can inherit a parent while its owngraph_head:<branch>row is absent; - compute the merge base from those captured commit ids and classify against the immutable base/source/target snapshots outside table gates;
- acquire the conservative all-catalog source/target table envelope, re-list
recovery intent, revalidate the complete target token, and revalidate the
source incarnation. Before arming, every existing target ref that will receive
a physical effect must also have live Lance HEAD equal to its captured target
manifest pin; the verified handle is carried into the effect instead of being
reopened. First-touch refs remain absent until after the sidecar. A target
change returns typed
ReadSetChangedbefore effects. A later source-head advance is allowed: the contract is "merge the captured source commit," never "substitute whatever source is latest"; - pre-mint the merge lineage and each table's ordered Lance data-transaction
chain, then arm a schema-v4 BranchMerge sidecar before the first HEAD advance
or first-touch table ref. Logical data steps commit with those exact
(read_version, uuid)identities and zero transparent conflict retries. Its physical-effect set can be smaller than its intended manifest delta: pointer-only table updates are still recorded so recovery publishes the complete logical merge; - after every multi-commit table effect completes, confirm exact final table
versions, every logical
SubTableUpdate, and every first-touch targetBranchIdentifier; then publish once withExactGraphHeadand the captured table expectations.
Publisher retries cannot re-parent the prepared merge onto a newer target. Any
failure after the v4 sidecar is durable returns RecoveryRequired. Full recovery
rolls confirmed effects forward only while the captured target authority still
matches; otherwise it compensates the owned effects while preserving the target
winner, or fails closed when foreign/interleaved table state makes compensation
unverifiable. An Armed first-touch ref with no data HEAD movement is reclaimed
without manufacturing rollback lineage. Armed recovery accepts only a
contiguous prefix of the pre-minted data chain. Rebuildable CreateIndex
transactions may follow only the complete chain and are rollback-discardable
derived state; any other, unreadable, or non-contiguous transaction fails
closed. A compensating Lance Restore is also recognized by its exact target so
a crash after restore but before the manifest publish resumes without restoring
again.
The handle-local coordinator swap and merge_exclusive mutex remain an
implementation detail until target-context extraction lands; neither is treated
as persistent authority. Native ref create/delete still lack conditional CAS, so
first-touch destructive recovery retains the documented single-writer-process
boundary. sync_branch continues to join the schema gate and cannot replace the
temporary coordinator during a merge.
Branch deletion runs the healer first and then holds schema, the target branch,
and every accepted-catalog table gate through the native ref removal. An
unresolved sidecar scoped to that target does not permanently block deletion:
once those gates prove its in-process owner is no longer live, removing the
manifest branch makes its physical effects unreachable. The next write/open
records the orphan-discard recovery audit and deletes the sidecar. A
SchemaApply sidecar remains graph-global and blocks deletion. This exception
is specific to removing the authority that made the intent reachable; create,
merge, mutation, and load still reject relevant unresolved ownership.
Graph branch create/delete do not use the graph-visible table-effect sidecar or
emit graph lineage. Their sole logical authority is Lance BranchContents for
the __manifest dataset, and Lance mutates that authority in two physical
phases:
- create shallow-clones
tree/{branch}before writingBranchContents; - delete removes
BranchContentsbefore reclaiming that tree.
Under the schema/branch/table control gates, create validates the name before the clone and rejects a live graph name that is a physical path ancestor or descendant of another live name. It then force-reclaims any absent-ref same-name tree and performs at most two native attempts. An ambiguous result is accepted only when fresh metadata has the captured parent branch/version/incarnation plus exactly one new identifier element and the target opens. Foreign or broken authoritative refs are never deleted. Delete captures the exact target identifier; after an ambiguous error, an absent ref is logical success, the same identifier preserves the original error, and a different identifier is a typed delete/recreate conflict. Derived tree cleanup is retried best-effort.
There is deliberately no branch-control sidecar: within the supported single-writer-process topology, an absent ref makes a same-name tree unreachable garbage; the path-prefix-disjoint namespace is what makes Lance's recursive force cleanup exact. Same-name create is therefore the targeted reconciler. First-touch data-table forks remain sidecar-owned because they are physical effects of a graph-visible mutation/load. Lance does not expose conditional ref create/delete, so this classifier is not advertised as a cross-process branch-control fence.
Legacy prefix-overlap recovery is the one first-touch case that does not prove an entire nested tree unreachable. If a Full sweep finds an ancestor first-touch target with a live path-child, it keeps the sidecar. Open may complete for leaf-first deletion only when the sidecar owns no physical table effect. A mixed attempt that owns an effect plus an untouched fork must roll back as one recovery outcome, so open fails closed while the child blocks fork cleanup. After an existing handle or an offline Lance-level branch tool removes the child, a later Full sweep reclaims the untouched fork, compensates the owned effect, and retires the intent.
A .gq query with multiple ops (e.g. insert Person … insert Knows …)
must observe earlier ops' writes when validating later ops (referential
integrity, edge cardinality). After MR-794 step 2+ this is implemented
via an in-memory MutationStaging accumulator in
crates/omnigraph/src/exec/staging.rs,
shared by both mutate_as and the bulk loader:
- On the first touch of each table, the pre-write manifest version is
captured into
expected_versions[table_key](the publisher's CAS fence at end-of-query). - Each insert/update op pushes a
RecordBatchinto the per-table pending accumulator. Lance HEAD does not advance during op execution. - Read sites (validation, predicate matching for
update) consumeTableStore::scan_with_pending, which scans committed via Lance and applies the same SQL filter to the pending batches via DataFusionMemTable. Same-query writes are visible to subsequent reads. - Blob-bearing updates use the materializing variant: Lance reads only matched committed blob payloads as binary, the engine normalizes them back to the logical blob schema, and the full rows join the same pending-shadow union. Rewriting matched blob bytes costs I/O proportional to those bytes, but makes correctness independent of whether a physical index selects a different Lance merge plan.
- At end-of-query,
MutationStaging::stage_allprepares exactly one staged transaction per touched table andcommit_allcommits it (concatenating accumulated batches; merge-mode dedupes byid, last-write-wins), and the publisher publishes the manifest atomically across all touched sub-tables. Existing tables stage before gate acquisition; a first-touch named-branch table stages after sidecar + fork under the gates so its uncommitted files live in the correct Lance branch tree. Cross-table conflicts surface as typed read-set or manifest conflicts. - Deletes stage too (MR-A). Lance 7.0's
DeleteBuilder::execute_uncommitted(#6658) makes delete a two-phase op, so deletes no longer inline-commit. Each delete records a predicate inMutationStaging.delete_predicates; at end-of-querystage_allcombines a table's predicates into onestage_delete(a deletion-vector transaction, no HEAD advance) committed through the samecommit_stagedpath as writes. A predicate matching zero rows stages nothing — no inline residual, and the zero-row drift class is closed by construction. The parse-time D₂ rule (below) still prevents inserts/updates from coexisting with deletes in one query.
This upholds the manifest-atomic mutation and read-your-writes invariants tracked in docs/dev/invariants.md.
A single mutation query is either insert/update-only or delete-only. Mixed → rejected at parse time with a clear error directing the user to split the query. This is a deliberate boundary, not a temporary limitation. Inserts/updates accumulate as pending batches and deletes as predicates, and both stage correctly; keeping a single query to one kind means read-your-writes within that query stays unambiguous (a read never reconciles pending inserts against same-query delete predicates) and each touched table commits at most one version. Compose mixed operations by issuing separate atomic mutations (writes, then deletes), or a branch + merge for one atomic commit. Allowing mixing would instead require an in-query delete view, pending pruning, and per-table two-commit ordering in the hot mutation path — complexity this boundary deliberately avoids.
MR-793 hoists the staged-write pattern into a TableStorage trait
surface with sealed-trait enforcement and opaque SnapshotHandle /
StagedHandle types — see crates/omnigraph/src/storage_layer.rs.
The trait is the canonical surface for new engine code; existing call
sites route graph-visible effects through it. Capability and statistics
surfaces for planner decisions remain separate roadmap work.
Three writers have been migrated onto staged primitives:
ensure_indices(db/omnigraph/table_ops.rs::ensure_indices_for_branch) — a typedstage_create_indicesrequest combines every missing BTree, Inverted/FTS, and full-table vector artifact for one table into one LanceOperation::CreateIndex, followed by onecommit_staged_exact. Which index a@index/@keyproperty gets is dispatched by type vianode_prop_index_kind(enum + orderable scalar → BTree, free-text String → Inverted/FTS, Vector → vector). This build is existence-gated (it creates a missing index over current fragments); folding fragments appended afterward into an existing index isoptimize'soptimize_indicespass — an inline-commit residual, not a staged write (Lance exposes no uncommitted index-optimize), covered by the optimize recovery sidecar (see maintenance.md).branch_merge::publish_rewritten_merge_table(exec/merge.rs) — merge_insert now usesstage_merge_insert+commit_staged; its deletes usestage_delete+commit_staged(MR-A).schema_applyrewritten_tables (db/omnigraph/schema_apply.rs) — rewrites usestage_overwrite+commit_staged, including empty-table rewrites via a zero-fragment LanceOperation::Overwrite.
A defense-in-depth integration test (tests/forbidden_apis.rs) walks
engine source and fails if non-allow-listed code calls Lance's
inline-commit APIs directly. The trait surface itself is the primary
enforcement (sealed + only-callable-via-trait once call sites land);
the grep test catches type-system bypass attempts.
The "finalize → publisher residual" described below applies equally to
the migrated writers — Lance has no multi-dataset atomic commit primitive, so
the per-table commit_staged → manifest-publish gap is the same drift class.
The shipped sidecar recovery protocol closes that gap.
MR-793's acceptance criterion §1 ("TableStore (or successor) public API has
no method that performs a manifest commit as a side effect of writing") holds
by construction. db.storage() (&dyn TableStorage) exposes only staged
primitives + reads. Lance 7.0's DeleteBuilder::execute_uncommitted
(#6658) moved delete to
stage_delete; beta.21's full-table index execute_uncommitted shape moved the
last vector build into stage_create_indices. InlineCommitResidual,
storage_inline_residual(), and inline create_vector_index are removed.
Generic multi-segment exact index publication remains covered by Lance
#6666, but it is not used by
the current one-segment full-table shape.
The parse-time D₂ rule remains a deliberate boundary (constructive XOR
destructive per query), not residual scaffolding. The
tests/forbidden_apis.rs guard catches direct lance::* inline-commit misuse
outside the storage layer and also pins the retired residual symbols absent.
The bulk loader's Append, Merge, and Overwrite modes all use the
staged-write path described above. LoadMode::Overwrite accumulates
replacement batches in memory, validates node/edge constraints, referential
integrity, and edge cardinality before any Lance HEAD movement, stages
each touched table with Lance Operation::Overwrite, then runs
commit_staged under the normal SidecarKind::Load recovery sidecar
before publishing __manifest. OMNIGRAPH_LOAD_CONCURRENCY applies to the
fragment-writing stage only; the commit and manifest publish run while holding
the root-shared schema → branch → sorted-table gates. Empty-table overwrite is
represented as a valid zero-fragment Lance Overwrite transaction, not as
truncate-then-append.
The staged-write rewire eliminates one drift class by construction at
the writer layer: an op that fails before pushing to the in-memory
accumulator (validation errors, missing endpoints, parse-time D₂
rejection) leaves Lance HEAD untouched on every staged table. This is
the case the partial_failure_leaves_target_queryable_and_unblocks_next_mutation
test pins.
A second, narrower drift class — the finalize → publisher window — is closed across one open cycle by the open-time recovery sweep:
MutationStaging::stage_all prepares the table transactions and commit_all
runs their independent HEAD advances before the publisher commits the manifest. Lance has
no multi-dataset atomic commit, so the per-table commit_staged calls
are independent operations: if commit_staged on table N+1 fails after
commit_staged on tables 1..N succeeded, or if the publisher's CAS
pre-check rejects after every commit_staged succeeded, tables 1..N
are left at Lance HEAD = manifest_pinned + 1.
Recovery protocol (lifecycle of every staged-write writer —
MutationStaging::commit_all, schema_apply::apply_schema_with_lock,
branch_merge_on_current_target, ensure_indices_for_branch,
optimize_all_tables):
Before Phase A, under the writer's final schema → branch → table gates, existing
physical targets must still match their manifest pins. Ahead drift is never folded
or claimed by manufacturing a new sidecar; it is attributed to an existing recovery
intent or refused with explicit omnigraph repair guidance. First-touch targets use
the separate sidecar-before-ref protocol. SchemaApply also verifies that AddType and
RenameType target dataset paths are absent, so recovery cannot register an orphan or
foreign dataset as if this apply created it.
- Phase A: writer writes a sidecar JSON to
__recovery/{ulid}.jsonBEFORE its first independently durable physical effect (including a first-touch Lance branch ref) or HEAD-advancing commit (commit_staged, orcompact_filesforoptimize_all_tables, which advances the Lance HEAD via a reserve-fragments + rewrite commit rather than a staged write). The sidecar names every(table_key, table_path, expected_version, post_commit_pin)it intends to commit + the writer kind + actor_id. For a first-touch named-branch Mutation/Load table, Phase A is followed by target-ref creation and branch-localstage_*; the schema-v3 sidecar already carries its pre-minted transaction identity. Branch merge uses schema v4: it distinguishes multi-commit HEAD effects from ref-only forks, records each multi-commit effect's ordered exact transaction chain, and records the complete intended manifest delta, including pointer-only slots. SchemaApply uses schema v7: it captures the main native branch identity, exact optional graph head, accepted schema identity, fixed original lineage + initiating actor, and fixed rollback id. Every existing-table overwrite and AddType / RenameType first-touch create has one pre-minted Lance transaction identity; the latter is a strict read-version-zero create. The sidecar also carries the complete registration/update/tombstone delta, including metadata-only applies whose table-effect set is empty. Readers retain the old schema-v5 target-hash- Phase-C-confirmation bridge semantics for files written by older binaries; v5 table effects remain loose and are never reinterpreted as v7. EnsureIndices uses schema v8: it captures native branch + graph-head + schema authority, fixed original and rollback lineage, one pre-minted mixed CreateIndex transaction per touched table, and the complete table-pointer delta. A first-touch effect also records its inherited source version and later binds the exact created ref identity. Readers retain schema-v6 EnsureIndices files as a narrow compatibility bridge under their original loose classification and fixed-rollback semantics; v6 is never reinterpreted as v8 ownership.
- Phase B: writer's per-table
commit_stagedloop runs.- Phase-B confirmation: a schema-v4
BranchMergewriter advances each table's HEAD by several exact commits (append → upsert → delete). Recovery proves a contiguous prefix of the pre-armed transaction chain rather than inferring ownership from numeric HEAD movement. After the whole per-table loop finishes, the writer atomically confirms each exact achieved version, the complete logical manifest delta, and first-touch ref identities, then proceeds to Phase C. Schema-v3 Mutation/Load sidecars also confirm: each table must match the staged Lance transaction's(read_version, uuid), and the sidecar records the exactSubTableUpdateplus original lineage intent. This is the commit point of the recovery WAL: a crash after confirmation rolls forward only when the captured branch token still matches; a crash during Phase B (sidecar still unconfirmed) rolls back. Schema-v7 SchemaApply follows the same boundary with writer-specific effects: exactOverwritefor an existing table and exact version-oneCreatefor a new target path, all committed with zero transparent conflict retries. After every achieved identity/version and all schema staging files are durable, it confirms the complete registration/update/tombstone delta and movesArmed → EffectsConfirmed. Schema-v8 EnsureIndices likewise requires one exact achieved transaction atread_version + 1per table, binds every first-touch ref identity, confirms the complete table-pointer delta, and only then movesArmed → EffectsConfirmed. Optimize remains the legacy writer without this confirmation boundary.
- Phase-B confirmation: a schema-v4
- Phase C: publisher commits the manifest.
- Phase D: writer deletes the sidecar.
Phase letter convention. Throughout the recovery code, log messages, failpoint names (e.g.
branch_merge.post_phase_b_pre_manifest_commit), and the per-writer integration tests, "Phase A/B/C/D" refers exclusively to the four-step lifecycle above. The per-table staged-write contract (stage_*thencommit_staged, two steps) is referred to by those API verbs — never by phase letters — so a reader ofrecovery.rs,failpoints.rs, or this document only encounters phase letters in the per-writer context.
A failure between Phase A and Phase D leaves the sidecar on disk. The
next Omnigraph::open (gated on OpenMode::ReadWrite) runs the
recovery sweep in crates/omnigraph/src/db/manifest/recovery.rs:
- For each sidecar in
__recovery/, compare every named table's Lance HEAD to the manifest pin. Classify per the all-or-nothing decision tree (RolledPastExpected / NoMovement / UnexpectedAtP1 / UnexpectedMultistep / IncompletePhaseB / InvariantViolation). For a legacyBranchMergesidecar, a moved HEAD with noconfirmed_versionclassifies asIncompletePhaseB(a partial multi-commit publish) and forces roll-back; with aconfirmed_version, roll-forward targets exactly that version. Schema-v4 BranchMerge recovery additionally requires the captured target token, fixed original/rollback lineage ids, the exact ordered data transaction chains, exact confirmed physical effects, first-touch ref identities, and the complete confirmed manifest delta. A changed target token is rollback-only and can never re-parent the merge onto the winner. Recovery refuses a foreign or non-contiguous transaction instead of restoring through it, and recognizes an already-landed exact compensation restore on restart. Schema-v3 Mutation/Load additionally requiresEffectsConfirmed, the exact Lance transaction identity at the confirmed version, the original immutable manifest delta, and a matching captured authority token. A changed token is rollback-only; an unknown/foreign effect is refused rather than adopted. Schema-v7 SchemaApply applies the same exact-ownership rule to each existing overwrite and first-touch create, and additionally binds accepted + target schema identities, fixed original/rollback lineage, the initiating actor, and its complete registration/update/tombstone delta.Armedis rollback-only;EffectsConfirmedcan roll forward only when every achieved transaction and output matches and the captured main authority is still live. A disjoint authority winner is preserved while recovery compensates only owned effects. If foreign movement buries an owned same-table effect, recovery fails closed with the sidecar intact instead of restoring through or adopting the winner. Schema-v8 EnsureIndices applies the exact rule to each one-transaction mixed index batch: the observed transaction UUID/read version and achievedexpected + 1version must match the plan,EffectsConfirmedmust carry the complete fixed table-pointer delta, and a first-touch named branch must retain the confirmed Lance ref identity. Changed authority is rollback-only; a foreign or buried index commit is never adopted or restored through. Schema-v6 files continue through the old loose classifier rather than being upgraded in place. First-touch rollback deletes only the exact owned version-one dataset and only while no manifest registration or competing recovery claim owns the path. A foreign winner at an unregistered first-touch path is left untouched and is never adopted; recovery can still roll back this attempt's other owned effects. An Armed first-touch intent with no owned transaction is deferred by live roll-forward-only healing because another handle may still own it. Quiesced full recovery tolerates an absent target ref (either crash before clone or a clone-only tree with noBranchContents) and force-reclaims that absent-ref target idempotently. If an authoritative ref exists, recovery removes it only when it is exactly unchanged and no other pending sidecar claims it. With competing claims, the current no-effect sidecar discards itself without touching the ref; the final survivor owns cleanup/recovery. During partial rollback, no-effect refs are removed before the rollback outcome is published so a retry cannot strand them. If a legacy live path-child blocks that cleanup, rollback returns an error and read-write open fails closed; only a sidecar proven to own no table effect may defer cleanup while returning an open handle. - If any table is
InvariantViolation(Lance HEAD < manifest pinned — should be impossible), abort with a loud error and leave the sidecar on disk for operator review. - Otherwise, if every table is
RolledPastExpected, roll forward: a singleManifestBatchPublisher::publishcall extends every pin atomically. For schema-v7 SchemaApply, the exact fixed manifest outcome lands first; only then does the writer or recovery promote the matching staged source/IR/state contract. A crash after one or two renames is completed by proving that fixed commit + delta visible and validating every remaining/live file against the same target identity. Schema-v5 bridge files retain their older confirmation-and-live-target recovery rule. - Read-only open performs this check without repairing anything: it may serve an
unpublished v7 attempt against the old manifest/schema pair, but it returns
RecoveryRequiredwhen the fixed original manifest outcome is visible and the target schema identity is not yet fully live. A read-write open completes it. - On a live handle, query, export, graph-index, and blob-read capture takes the process-local schema gate just long enough to bind one manifest snapshot to one immutable catalog rebuilt from the accepted contract. It never trusts the handle's warm ArcSwap after another handle may have applied a schema. Execution releases the gate and continues on that captured pair, so a long query does not block the whole schema apply. The gate does not serialize a long-lived reader in another process; that topology still requires the distributed schema-publication fence described in the known gaps.
- Otherwise roll back: per-table
Dataset::restoreto the manifest-pinned table version, then a singleManifestBatchPublisher::publishof the restored HEAD — symmetric with roll-forward, somanifest == HEADafter recovery (no residual drift). This convergence is what lets a failed-then-retried schema apply succeed instead of failing one version higher each iteration. The audit row'sto_versionrecords the logical rolled-back-to version (manifest_pinned); the manifest is published at the restore commit (manifest_pinned + 1, same content). - After a successful roll-forward or roll-back, an internal
_graph_commit_recoveries.lancerow recordsrecovery_kind,recovery_for_actor(the original sidecar's actor),operation_id, and exact per-table outcomes. Schema-v3 Mutation/Load, schema-v4 BranchMerge, schema-v7 SchemaApply, and schema-v8 EnsureIndices roll-forward publish the interrupted writer's fixed lineage intent, including its original actor. Schema-v7/v8 rollback reuse their pre-minted recovery commit ids and durable audit plans, with the recovery actor. Schema-v6 EnsureIndices files retain that compatibility behavior under their original loose classification. Other rollback and legacy recovery commits useactor_id = "omnigraph:recovery". Ordinary commit history is therefore not a complete recovery enumeration, and the CLI currently has no public query for the recovery-audit table. - Sidecar deleted as the final step.
Triggers for the residual: transient Lance write errors during finalize
(object-store retry budget exhaustion, disk full); persistent publisher
contention exceeding PUBLISHER_RETRY_BUDGET = 5 retries.
Long-running servers: the write entry points (load_as,
mutate_as, apply_schema_as, branch_merge_as) and
Omnigraph::refresh run roll-forward-only recovery in-process
(recovery::heal_pending_sidecars_roll_forward) — the common
Phase B → Phase C residual closes on the next write, without a
restart and without an explicit refresh. The heal lists __recovery/
(one list_dir; empty in the steady state) and, per sidecar, acquires
schema → branch → sorted-table gates that overlap the writer's guarded
sidecar lifetime. RFC-022 mutation/load writers hold the complete order. Branch
merge holds schema plus source/target branch authority for its whole attempt and
then the all-catalog source/target table envelope. SchemaApply holds schema → main
branch → every live table; EnsureIndices holds schema → target branch → every table
in its durable work plan. SchemaApply's schema-v7 payload is its full exact adapter:
it holds fixed authority/lineage and exact table identities from arm through one
ExactGraphHead publish, including an empty effect set for metadata-only changes.
Its owned first-touch cleanup and partial-table rollback happen only during Full
recovery; the in-process healer remains roll-forward-only. Schema-v5 remains a
backward-compatible bridge reader, not the current writer. EnsureIndices' current
schema-v8 payload holds fixed authority/lineage, one exact mixed-index transaction
per table, the complete manifest delta, and exact first-touch ownership from arm
through one ExactGraphHead publish. Armed is rollback-only;
EffectsConfirmed rolls forward only while the captured authority still matches.
Schema-v6 files remain backward-compatible bridge inputs with their original loose
classification and fixed rollback plan. Optimize retains legacy schema-v2 effect
provenance, but now has one graph-wide visibility envelope. Its entry recovery probe is
a fast path; it then acquires schema → main branch → every accepted-catalog table gate,
loads one operation-local accepted catalog, relists recovery, and plans productive work
from one fresh snapshot. All productive tables share one multi-pin Optimize sidecar;
their compact/reindex/index-create effects remain bounded-parallel, but no task publishes
or deletes recovery independently. After every effect settles, one maintenance-class
monotonic batch CAS publishes every still-needed pointer and one lineage commit. A
pointer already at or beyond Optimize's achieved version is converged and omitted rather
than forcing strict graph-head OCC. Any post-arm error returns RecoveryRequired and
leaves the shared intent for all-or-nothing v2 recovery. Main remains held through final
physical-only __manifest compaction so a new main recovery intent cannot arm before raw
manifest movement finishes. The remaining legacy boundary is provenance: v2 loose
classification has no exact transaction/authority/fixed-lineage proof and stays within
the documented single-writer-process recovery model until the exact Optimize adapter.
The manager is shared by every
Omnigraph handle for one canonical local root identity (relative, absolute,
and symlink aliases converge; object-store/custom schemes stay opaque), so this
also serializes a refresh or separately-opened handle against a live writer instead of rolling its
in-flight sidecar forward from under it (a sidecar whose queues can be
acquired belongs to a writer that finished or died; an existence
re-check after the wait skips the finished case). Lock order is
schema → branch → sorted tables → coordinator, matching the writer effect path.
Mutation/load, branch merge, SchemaApply, and EnsureIndices perform one additional
list_dir after acquiring their authority gates; that final check closes the
pre-gate recovery TOCTOU without moving validation or reclaimable staged-file
construction under the gate. Optimize's separate final list_dir runs under the
main branch gate because even table-disjoint main intents share graph-head authority.
Pinned by the four
tests/failpoints.rs::*_after_finalize_publisher_failure_heals_without_reopen
tests (load, mutation, schema apply, branch merge). The maintenance
entries need the heal for more than liveness: without it, a schema
apply re-plans rewrites from the manifest pin and orphans the drifted
Phase-B commit (dropping its rows), and a branch merge publishes the
drift as an unattributed side effect — both while the stale sidecar
lingers to misclassify later.
Sidecars that would require a Dataset::restore (mixed / unexpected
state) are deferred to the next OpenMode::ReadWrite open. Full open-time
recovery uses the same root-scoped ordered gates and post-wait existence check,
so it cannot Restore/delete under a live writer owned by another handle in the
same process. Restore remains unsafe across processes because Lance's
check_restore_txn accepts
the restore against in-flight Append/Update/Delete commits and
silently orphans them (pinned by
tests/staged_writes.rs::lance_restore_loses_to_concurrent_append_via_orphaning).
When such a deferred sidecar blocks a write, the commit-time drift
guard says so explicitly ("a pending recovery sidecar requires
rollback — reopen the graph read-write") instead of pointing at
omnigraph repair, which refuses while a sidecar is pending.
cleanup refuses pending sidecars at entry as well, before orphan reconciliation
or version GC: v3/v4 ownership and compensation recovery may need the retained
Lance transaction/version history, so garbage collection cannot outrun the
recovery barrier.
Continuous in-process recovery for the rollback path is the goal of a
future background reconciler. ensure_indices does not heal at entry itself;
it is an explicit maintenance/reconciliation call, separate from mutation,
load, and schema apply, and its strict preconditions fail loudly on drift.
For enrolled mutation/load, branch merge, SchemaApply, and EnsureIndices, the publisher rechecks the
attempt's exact native branch identity and graph_head as well as table
expectations. A
concurrent graph commit anywhere on the target branch therefore invalidates the
prepared authority instead of silently reparenting it. Before effects, an
insert-only mutation or Append/Merge load fully reprepares with a bounded retry; strict
Update/Delete/Overwrite and branch merge return ReadSetChanged; after any
effect, any later error returns RecoveryRequired and leaves the fixed v3/v4/v7/v8
intent durable. SchemaApply does not transparently reprepare after arming: a lost
authority token is resolved by exact recovery, preserving a disjoint winner or
failing closed on a buried same-table effect. EnsureIndices follows the same rule
without transparent post-arm reprepare. Optimize alone remains on its
writer-specific legacy arbitration until its adapter is enrolled.
Sidecar I/O failure semantics (all sidecar I/O goes through the
backend-generic StorageAdapter; the contracts below are pinned by the
storage-fault failpoints recovery.sidecar_{write,delete,list} /
recovery.record_audit and their tests in tests/failpoints.rs and
tests/recovery.rs):
- Phase A put fails (S3 PutObject / fs write): the writer aborts before its first HEAD-advancing commit — no sidecar, no drift, nothing to recover; a transient fault never wedges later writes.
- Phase D delete fails (S3 DeleteObject): swallowed with a warning —
the write already published, so failing the caller would report an
error for a durable write. The stale sidecar is consumed by the next
write's entry heal (or the next open) via the stale-sidecar
audit-recovery path, recorded as
RolledForward. __recovery/list fails (S3 ListObjectsV2): loud at every consumer — the write-entry heal fails the write, the open-time sweep fails the open. Silently skipping recovery would be consumer tolerance of drift.- Corrupt / unparseable sidecar: refused loudly by heal and read-write
open; the file stays on disk for operator inspection. Read-only open keeps
its historical tolerance when no schema staging exists, but returns
RecoveryRequiredwhen any schema-staging artifact is present because the malformed intent may be the only proof of a committed-but-unpromoted SchemaApply. - Legacy schema-v5 SchemaApply sidecar: read-only open admits only the
provably completed residue (
schema_apply_manifest_published = trueand the target schema identity already live). Marker-false or target-mismatched v5 intents still returnRecoveryRequired; they need the mutable legacy recovery path. - Audit append fails after a roll-forward publish: that recovery
attempt errors and keeps the sidecar; re-entry sees the
already-published manifest, records exactly one
RolledForwardaudit row, and deletes the sidecar (the retry tolerance documented onrecord_audit).
Backend notes (the adapter is one implementation over object_store
for every backend): local writes stage through name#<digits> temp
files that the backend filters from listings and refuses to address —
crash residue of that shape is invisible to the sweep, harmless, and
reclaimed by delete_prefix/manual cleanup. Storage errors are
backend-wrapped text without a typed NotFound discriminant — callers
that need missing-vs-error (the cluster store) probe exists() first.
exists() itself is object-store semantics everywhere: only objects
(or non-empty prefixes) exist, and a permission failure is a loud
error, not a silent false.
For mutation/load, a changed authority detected before effects is
ManifestConflictDetails::ReadSetChanged { member, expected, actual }.
Retryable Insert/Merge/Append attempts handle this internally by fully
repreparing; strict writes surface 409 Conflict with structured
read_set_conflict details. A changed authority discovered after a physical
effect, or any unresolved overlapping intent found at the synchronous recovery
barrier, is OmniError::RecoveryRequired { operation_id, … }, mapped to 503
Service Unavailable with structured recovery_required; retry only after the
sidecar has been resolved. Legacy, not-yet-enrolled writers may still surface
ExpectedVersionMismatch and manifest_conflict.
actor_id lands in the graph commit lineage — the graph_commit rows in
__manifest, written in the publish CAS (RFC-013 Phase 7; previously
_graph_commits.lance). Ordinary commit/actor history is queried via
omnigraph commit list. Crash-recovery actions additionally live in the internal
_graph_commit_recoveries.lance table described above; that exact recovery log
does not yet have a public CLI query.
db/manifest/migrations.rs is the single place the on-disk __manifest shape is
reconciled with what the binary expects. Storage is strict-single-version (the
strand model): this binary reads exactly ONE internal-schema version
(MIN_SUPPORTED == CURRENT == 4), so there is no in-place migration.
- Graph creation stamps
omnigraph:internal_schema_versionat CURRENT, so a fresh graph always opens. Omnigraph::open(both read-write and read-only) reads main's stamp before the coordinator reads any branch state and callsrefuse_if_stamp_unsupported: a stamp below CURRENT is refused with a rebuild-via-export/import message; a stamp above CURRENT is refused with "upgrade omnigraph". The publisher re-checks the stamp on its write path against the branch it targets, with no object-store writes, so the check is safe under a read-only open.- The stamp +
refuse_if_stamp_unsupportedfloor is the only seam a future in-place migration would re-introduce (re-add a dispatcher and lowerMIN_SUPPORTED). Until a concrete graph demands it, that machinery is deliberately absent — see versioning.md (the compatibility policy) and the upgrade guide (the rebuild recipe).
The stamp history (v1 PK-less, v2 unenforced-PK, v3 __run__* sweep, v4 lineage
in __manifest with the commit-graph tables retired) is recorded on the
INTERNAL_MANIFEST_SCHEMA_VERSION doc-comment; only v4 is served. An earlier-stamped
graph is rebuilt via export/import, not migrated in place.
The pre-MR-794 design had a known limitation: a multi-statement .gq
mutation where op-N inline-committed a Lance fragment and op-N+1 then
failed left the touched table at Lance HEAD = manifest_version + 1,
blocking the next mutation with ExpectedVersionMismatch.
MR-794 (step 1 + step 2+) closed this for inserts/updates by
construction at the writer layer: insert and update batches accumulate
in memory; no Lance HEAD advance happens during op execution; one
stage_* + commit_staged per touched table runs at end-of-query, and
only after every op succeeded. A failed op leaves Lance HEAD untouched
on the staged tables, so the next mutation proceeds normally with no
drift to reconcile.
The cancellation case (future drop mid-mutation) inherits the same guarantee — the in-memory accumulator evaporates with the dropped task and no Lance write was ever issued.
Delete-touching mutations now inherit the same guarantee (MR-A). Deletes
accumulate as predicates and stage via stage_delete at end-of-query, so a
delete cascade that fails mid-way advances no Lance HEAD — the same
"untouched on failure" property as inserts/updates. The old narrow inline
window (and the retry/cleanup workaround it required) is gone. The
parse-time D₂ rule keeps inserts/updates from coexisting with deletes in one
query as a deliberate boundary (see the D₂ section above), so a mutation is
always purely constructive or purely destructive.