Implement RFC-022 unified graph write protocol (#343)

* Implement unified graph write protocol

* Preserve recovery error wire compatibility
This commit is contained in:
Andrew Altshuler 2026-07-11 14:02:54 +03:00 committed by GitHub
parent 0c8d769501
commit f758ff0d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 13393 additions and 2050 deletions

View file

@ -4,11 +4,14 @@
> 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` write **directly to the target table**
and call `ManifestBatchPublisher::publish` once at the end with
`expected_table_versions` (the per-table manifest versions captured before
the first write). Cross-table OCC is enforced inside the publisher; the
publisher's row-level CAS on `__manifest` is the single fence.
`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.
## What this means in practice
@ -19,15 +22,93 @@ publisher's row-level CAS on `__manifest` is the single fence.
`__run__*` branch on an upgraded graph is swept off `__manifest` by the
v2→v3 internal-schema migration on first read-write open. (The inert
`_graph_runs.lance` bytes remain until a `delete_prefix` primitive lands.)
- Cancelled mutation futures leave **no graph-visible state** — the manifest
is never advanced. They can leave two kinds of unreferenced residue, both
self-healing: orphaned Lance fragments (reclaimed by `omnigraph cleanup`),
and — on the *first* write to a table on a branch, which forks it before the
publish — a manifest-unreferenced branch ref. The next write to that table
reclaims the stale fork and re-forks (`reclaim_orphaned_fork_and_refork`),
and `cleanup`'s per-table reconciler is the guaranteed backstop; see the
- 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 and `cleanup` treat any
matching pending sidecar as a hard stop. Quiesced full recovery accepts both
crash shapes — sidecar durable with no ref yet, or an exact untouched ref at
the inherited version — and removes the latter 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. `cleanup`
remains the backstop for genuinely unclaimed legacy/stale refs; see the
fork-reclaim note in [invariants.md](invariants.md).
## Mutation/load coarse OCC (RFC-022 first adapter)
Mutation and load use a closed prepare → effect → publish attempt:
1. run the branch-aware recovery barrier, then capture the target branch's native Lance
`BranchIdentifier`, exact `graph_head:<branch>` (including absence on a
fresh branch), accepted schema identity, and table snapshot;
2. 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;
3. acquire the schema gate, branch gate, then sorted table queues; re-check for
a relevant sidecar armed since step 1, then revalidate the token. Any
unresolved relevant intent returns typed `RecoveryRequired` before effects;
4. on a pre-effect mismatch, discard the complete attempt. Append/Insert/Merge
reprepare with a bounded retry; strict Update/Delete/Overwrite return typed
`ReadSetChanged`;
5. 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 authority fence (adapter bridge)
Branch merge still uses its writer-specific multi-commit table effects and
confirmation sidecar; it has not yet been converted to the RFC-022 exact-effect
adapter. It does, however, join the closed control boundary needed by this first
slice: after the strict recovery barrier it acquires the root-shared schema gate
and the sorted source/target branch gates, performs the final sidecar check,
loads one operation-local catalog from the accepted contract, captures both graph
heads plus the base/source/target snapshots, and holds those gates through table
effects and manifest publication. Planning stays outside table queues. Before
Phase A, merge acquires the conservative all-catalog table envelope for both
source and target, re-lists sidecars, and compares fresh source/target manifest
versions with the captured snapshots. A stale warm handle catalog or coordinator
snapshot is never accepted as that revalidation.
That fence prevents a same-process target delete/recreate from reusing the branch
name underneath a merge plan. The race test deliberately recreates a target with
the same name and numeric Lance version but a different `BranchIdentifier`, so
version-only checking cannot accidentally satisfy it. This is a process-local
bridge, not a cross-process conditional-ref primitive and not a substitute for
the later full branch-merge read-set/reprepare adapter. `sync_branch` joins the
same root schema gate before replacing a handle's coordinator, so it cannot
overwrite merge's temporary target coordinator or change a native control's
active-branch authority mid-operation.
### Branch-delete orphaning exception
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.
## Read-your-writes within a multi-statement mutation
A `.gq` query with multiple ops (e.g. `insert Person … insert Knows …`)
@ -47,12 +128,20 @@ shared by both `mutate_as` and the bulk loader:
`TableStore::scan_with_pending`, which scans committed via Lance
and applies the same SQL filter to the pending batches via DataFusion
`MemTable`. Same-query writes are visible to subsequent reads.
- At end-of-query, `MutationStaging::finalize` issues exactly one
`stage_*` + `commit_staged` per touched table (concatenating
accumulated batches; merge-mode dedupes by `id`, last-write-wins),
and the publisher publishes the manifest atomically across all
touched sub-tables. Cross-table conflicts surface as
`ManifestConflictDetails::ExpectedVersionMismatch`.
- 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_all` prepares exactly one staged
transaction per touched table and `commit_all` commits it (concatenating accumulated
batches; merge-mode dedupes by `id`, 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 in
@ -153,9 +242,9 @@ 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 still run
under the per-table write queues. Empty-table overwrite is represented as
a valid zero-fragment Lance `Overwrite` transaction, not as
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.
### Open-time recovery sweep
@ -170,8 +259,8 @@ test pins.
A second, narrower drift class — the **finalize → publisher window**
is closed across one open cycle by the open-time recovery sweep:
`MutationStaging::finalize` runs `stage_*` + `commit_staged` per touched
table sequentially, then the publisher commits the manifest. Lance has
`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
@ -179,30 +268,37 @@ 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::finalize`, `schema_apply::apply_schema_with_lock`,
`MutationStaging::commit_all`, `schema_apply::apply_schema_with_lock`,
`branch_merge_on_current_target`, `ensure_indices_for_branch`,
`optimize_all_tables`):
1. **Phase A**: writer writes a sidecar JSON to
`__recovery/{ulid}.json` BEFORE its first HEAD-advancing commit
`__recovery/{ulid}.json` BEFORE its first independently durable physical
effect (including a first-touch Lance branch ref) or HEAD-advancing commit
(`commit_staged`, or `compact_files` for `optimize_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-local `stage_*`; the sidecar already carries
its pre-minted transaction identity.
2. **Phase B**: writer's per-table `commit_staged` loop runs.
- **Phase-B confirmation (`BranchMerge` only)**: a `BranchMerge` writer
- **Phase-B confirmation:** a `BranchMerge` writer
advances each table's HEAD by *several* commits (append → upsert →
delete), so a bare "HEAD moved" is ambiguous — it could be a complete
publish or one crashed mid-sequence. After the whole per-table loop
finishes, the writer re-writes the sidecar stamping each pin's
`confirmed_version` with the exact achieved version, then proceeds to
Phase C. This is the commit point of the recovery WAL: a crash *after*
confirmation rolls forward to those versions; a crash *during* Phase B
(sidecar still unconfirmed) rolls back. Other writers don't confirm —
their drift is derived state (index coverage, compaction) that a partial
roll-forward never corrupts.
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 exact `SubTableUpdate` plus 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. Remaining legacy
writers don't confirm — their drift is derived state (index coverage,
compaction) that a partial roll-forward never corrupts.
3. **Phase C**: publisher commits the manifest.
4. **Phase D**: writer deletes the sidecar.
@ -226,6 +322,18 @@ recovery sweep in `crates/omnigraph/src/db/manifest/recovery.rs`:
`BranchMerge` sidecar, a moved HEAD with no `confirmed_version` classifies
as `IncompletePhaseB` (a partial multi-commit publish) and forces roll-back;
with a `confirmed_version`, roll-forward targets exactly that version.
Schema-v3 Mutation/Load additionally requires `EffectsConfirmed`, 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.
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 (crash before fork), or removes
an exact unchanged unpublished ref after proving 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 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.
@ -243,14 +351,14 @@ recovery sweep in `crates/omnigraph/src/db/manifest/recovery.rs`:
each iteration. The audit row's `to_version` records 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 audit row is
recorded — the graph commit lineage (the `graph_commit` rows in `__manifest`
since RFC-013 Phase 7) carries a commit tagged
`actor_id = "omnigraph:recovery"`, and a sibling
`_graph_commit_recoveries.lance` row carries `recovery_kind`,
`recovery_for_actor` (the original sidecar's actor), `operation_id`,
per-table outcomes. Operators run `omnigraph commit list --filter
actor=omnigraph:recovery` to find recoveries.
- After a successful roll-forward or roll-back, an internal
`_graph_commit_recoveries.lance` row records `recovery_kind`,
`recovery_for_actor` (the original sidecar's actor), `operation_id`, and
exact per-table outcomes. A v3 roll-forward publishes the interrupted
writer's fixed lineage intent, including its original actor; rollback and
legacy recovery commits use `actor_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
@ -264,13 +372,24 @@ contention exceeding `PUBLISHER_RETRY_BUDGET = 5` retries.
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
the same per-`(table_key, table_branch)` write queues every sidecar
writer holds from before `write_sidecar` until after `delete_sidecar`
so it serializes against a live writer instead of rolling its
schema → branch → sorted-table gates that overlap the writer's guarded
sidecar lifetime. RFC-022 mutation/load writers hold the complete order. Branch
merge now holds schema plus source/target branch authority for its whole attempt
and then the all-catalog source/target table envelope; other legacy adapters
serialize through their existing table or schema gate until their own adapter
slices land. 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
queues → coordinator, matching every writer's commit→publish path.
schema → branch → sorted tables → coordinator, matching the writer effect path.
Enrolled mutation/load attempts and branch merge perform one additional
`list_dir` after acquiring their authority gates; that final check closes the
pre-gate recovery TOCTOU without moving mutation/load validation or staged-file
construction under the gate.
Pinned by the four
`tests/failpoints.rs::*_after_finalize_publisher_failure_heals_without_reopen`
tests (load, mutation, schema apply, branch merge). The maintenance
@ -280,8 +399,11 @@ 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: restore is
unsafe under concurrency because Lance's `check_restore_txn` accepts
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`).
@ -289,17 +411,24 @@ 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 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 runs inside the load / schema-apply flows after their
entry heal, and its strict preconditions still fail loudly on drift
when invoked directly.
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.
The publisher-CAS contract is unchanged: a *concurrent writer* that
advances any of our touched tables between snapshot capture and
publisher commit produces exactly one winner. The residual above is
about *our* abandoned commits in the failure path, not about
concurrency races.
For enrolled mutation/load, the publisher rechecks the attempt's exact native
branch identity and `graph_head` as well as the touched-table versions. 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 returns `ReadSetChanged`; after any effect, any later
error returns `RecoveryRequired` and leaves the fixed v3 intent durable. Legacy
writers still arbitrate only their explicit touched-table expectations until
their adapters are enrolled.
**Sidecar I/O failure semantics** (all sidecar I/O goes through the
backend-generic `StorageAdapter`; the contracts below are pinned by the
@ -341,19 +470,25 @@ error, not a silent `false`.
## Conflict shape
Concurrent writers to the same `(table, branch)` produce exactly one
success and one failure. The losing writer's error is
`OmniError::Manifest` with kind `Conflict` and details
`ManifestConflictDetails::ExpectedVersionMismatch { table_key, expected,
actual }`. The HTTP server maps this to **409 Conflict** with body
`{"error": "...", "code": "conflict", "manifest_conflict": { "table_key":
"...", "expected": N, "actual": M }}` — see [docs/user/server.md](../user/operations/server.md).
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`.
## Audit
## Commit actor history
`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`). Audit history is queried via `omnigraph commit list`.
`_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.
## Storage versioning (no in-place migration)