Skip to content

feat(engine): WriteTxn - validate schema + open each data table once per write - #298

Merged
ragnorc merged 14 commits into
mainfrom
rfc-013-step-3b-writetxn-v2
Jun 23, 2026
Merged

feat(engine): WriteTxn - validate schema + open each data table once per write#298
ragnorc merged 14 commits into
mainfrom
rfc-013-step-3b-writetxn-v2

Conversation

@ragnorc

@ragnorc ragnorc commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

WriteTxn

A single mutate/load re-validated the schema contract ~4× and opened each touched data table 4× — redundant per-write I/O that scales with the write's internal structure, not its work. This threads a capture-once WriteTxn { branch, base } so a write validates the schema once and opens each data table at most once. Constant-factor/RTT win (3a already removed the depth-slope term).

Locked by two cost gates (write_cost.rs)

  • write_validates_schema_contract_once3 read_text / 2 exists (was 12/9)
  • keyed_insert_opens_table_at_most_oncedata_open_count = 1 (was 4)

How

  • Schema-once: capture txn once at entry (the single validation); the 4 sites collapse — entry check removed; open_for_mutation_on_branch + prepare_updates_for_commit source txn.base; commit_all uses fresh_snapshot_for_branch_unchecked (the OCC manifest re-read minus the schema re-validation, also dropping a spurious commit-graph exists probe — same Snapshot).
  • Open-collapse 4→1: Add OpenAPI documentation endpoint and schema #1 accumulation open eliminated (node path discarded it; read the pinned entry.table_version); [codex] Fix CLI ergonomics and stream export output #2 staging open kept (the one open); Add support for null literals in query parameters #3 drift-guard reads live HEAD via a cheap latest_version_id() probe off the already-open staged handle (not a fresh open); Support multi-statement mutations (insert + edge in one query) #4 index build reuses the commit_staged handle threaded through CommittedMutation instead of reopening.
  • Counter fix (review finding): open_countdata_open_count/internal_open_count classified by URI, so the gate measures data-table opens (not the __manifest/_graph_commits opens the publisher/commit-graph append make) and is actually reachable.
  • Positional returns named (OpenedForMutation, CommittedMutation) + a debug_assert pinning the open-skip contract (a None handle is legal only on the non-strict-txn path).

When txn is None (branch merge, schema apply, tests) every threaded path is byte-identical. Strict ops keep live-HEAD + ensure_expected_version; the fork path and the commit-time OCC re-read stay fresh (correctness machinery).

Deferred to step 5

Session-aware write base opens — the one remaining open stays a HEAD open; the warm-session benefit is an object-store (S3) phenomenon invisible on local FS, so it earns its own write_cost_s3.rs gate in step 5, where WriteTxn becomes the non-optional publish carrier (PublishPlan unification).

Verification

Full engine suite + failpoints (59, recovery-sidecar lifecycle) + cargo test --workspace --locked all green. No new concurrency test needed: #2 stays a HEAD open (no pinned/session base introduced), so the publisher CAS + #3 live-HEAD probe fences are unchanged.

Stacked on merged #297 (optimize-vs-write race).


Note

Medium Risk
Changes the mutate/load write hot path (schema validation timing, skipped opens, pinned base) while relying on preserved strict-op and OCC guards; edge cardinality now depends on an extra live-HEAD open on a narrow path, with regression coverage but remaining TOCTOU called out for step 4.

Overview
RFC-013 step 3b adds a capture-once WriteTxn { branch, base }: mutate and load call open_write_txn after recovery heal (and drop the duplicate entry ensure_schema_state_valid), then thread Option<&WriteTxn> through staging and publish. Resolve sites reuse txn.base instead of re-running resolved_branch_target / snapshot_for_branch (which re-validated the schema contract). None keeps branch merge and other callers byte-identical.

Open collapse (4→1 on the hot path): open_for_mutation_on_branch returns OpenedForMutation; for non-strict Insert/Merge under a txn on the target branch it skips the accumulation Dataset::open and only pins expected_version from the manifest entry. Staging still does one reopen; commit drift uses latest_version_id() on the staged handle instead of another open; CommittedMutation.committed_handles lets publish-time index build reuse post-commit_staged handles instead of reopen_for_mutation.

OCC stays fresh: commit-time manifest re-read uses fresh_snapshot_for_branch_unchecked (manifest-only, no second schema check / commit-graph probe) when a txn is present. Strict Update/Delete, fork paths, and publisher CAS behavior are unchanged.

Integrity: when the edge open is skipped, non-default @card validation uses edge_cardinality_read_handle (live HEAD), plus a regression test for stale-handle concurrent commits. IO probes add data_open_count / internal_open_count; write_cost.rs gates schema-once reads and data_open_count <= 1 on keyed insert.

Developer handoff docs for RFC-013 and schema-apply recovery flake are added/linked.

Reviewed by Cursor Bugbot for commit c9a2a4b. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This PR implements RFC-013 step 3b: a capture-once WriteTxn { branch, base } that validates the schema contract exactly once per mutate/load call and collapses ~4 data-table opens per write down to 1 (on the non-strict main-branch insert path). All previously-flagged issues (cardinality stale-handle regression, feature-branch schema re-validation) are addressed or explicitly deferred to step 4 with regression tests and handoff documentation.

  • Schema-once: open_write_txn captures a pinned base snapshot after the single ensure_schema_state_valid call; open_for_mutation_on_branch, prepare_updates_for_commit, and the OCC re-read in commit_all all consume it via fresh_snapshot_for_branch_unchecked (dropped commit-graph side-load) rather than re-resolving.
  • Open-collapse (4→1): collapse Add OpenAPI documentation endpoint and schema #1 skips the accumulation open for non-strict Insert/Merge on the txn's own branch; collapse Add support for null literals in query parameters #3 replaces a fresh open_dataset_head_for_write drift-guard call with latest_version_id() on the already-open staged handle; collapse Support multi-statement mutations (insert + edge in one query) #4 threads post-commit_staged handles via CommittedMutation.committed_handles to reuse them for the index build.
  • Cardinality fix + regression test: edge_cardinality_read_handle restores the live-HEAD read for non-default @card validation when collapse Add OpenAPI documentation endpoint and schema #1 skipped the accumulation open; cardinality_rejected_for_stale_handle_after_concurrent_edge_commit locks this deterministically.

Confidence Score: 5/5

The write pipeline change is well-guarded: strict ops (Update/Delete/Overwrite) are untouched by the open-collapse, the OCC re-read in commit_all still uses a fresh manifest snapshot, and the publisher CAS still fences against concurrent advances. The cardinality regression introduced by collapse #1 is correctly repaired and regression-tested.

The schema-once and open-collapse paths are gated conservatively — strict ops, the fork path, and no-txn callers all keep their prior behavior byte-identical. The two locked cost gates make regressions detectable in CI. The one flagged item is a defensive fallback arm documented as unreachable today and carries only forward-looking risk.

The defensive None arm in edge_cardinality_read_handle (exec/mutation.rs) warrants attention if future work makes txn.base not contain the edge table entry — it would open at a pinned manifest version rather than live HEAD, silently reintroducing the staleness the function was added to prevent.

Important Files Changed

Filename Overview
crates/omnigraph/src/db/omnigraph.rs Introduces WriteTxn struct and open_write_txn/fresh_snapshot_for_branch_unchecked; wires txn through open_for_mutation_on_branch and commit_updates_on_branch_with_expected. Logic is sound.
crates/omnigraph/src/db/omnigraph/table_ops.rs Core collapse logic: adds OpenedForMutation struct, implements collapse #1 (non-strict txn early-return for main/active branch), and collapse #4 (post-commit handle reuse in prepare_updates_for_commit). Guards and edge cases (fork path, strict ops) are correctly kept out of the collapse paths.
crates/omnigraph/src/exec/mutation.rs Threads WriteTxn through execute_named_mutation and all op sub-fns; adds edge_cardinality_read_handle to restore live-HEAD cardinality scan when collapse #1 skipped the accumulation open; gates cardinality validation on !is_default() (safe, matching the inner early-return in validate_edge_cardinality_with_pending). Strict op callers use .expect() for always-Some handles.
crates/omnigraph/src/exec/staging.rs Refactors commit_all return type to CommittedMutation; replaces the collapse #3 fresh open_dataset_head_for_write drift-guard open with latest_version_id() on the already-open staged handle; carries committed_handles for collapse #4 index-build reuse. OCC correctness (fresh manifest re-read, strict CAS check) is preserved.
crates/omnigraph/src/loader/mod.rs Replaces db.snapshot_for_branch(branch) with open_write_txn + txn.base; threads txn through per-table opens and commit_all. Overwrite path uses MutationOpKind::SchemaRewrite (strict), so collapse #1 never fires for it — correct. Branch already exists before open_write_txn is called.
crates/omnigraph/src/instrumentation.rs Adds data_open_count/internal_open_count to QueryIoProbes; record_open classifies by URI last segment; INTERNAL_TABLE_DIRS covers the four system tables. open_dataset_tracked and open_table_dataset both call record_open.
crates/omnigraph/tests/write_cost.rs Adds two locked cost gates: write_validates_schema_contract_once (3 read_text / 2 exists) and keyed_insert_opens_table_at_most_once (data_open_count <= 1). Gates are tight and independently verifiable.
crates/omnigraph/tests/validators.rs Adds cardinality_rejected_for_stale_handle_after_concurrent_edge_commit — deterministic regression guard for the #298 cardinality fix: handle B commits a second edge for a @card(0..1) endpoint that handle A already saturated; validates that the live-HEAD reopen rejects the insert.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant M as mutate()/load()
    participant WT as open_write_txn
    participant OFM as open_for_mutation_on_branch
    participant S as MutationStaging
    participant CA as commit_all
    participant P as ManifestPublisher

    C->>M: mutate(branch, query, params)
    M->>M: heal_pending_recovery_sidecars()
    M->>WT: open_write_txn(branch)
    WT->>WT: resolved_branch_target → ensure_schema_state_valid (ONCE)
    WT-->>M: "WriteTxn { branch, base }"

    loop per table (Insert/Merge on txn branch)
        M->>OFM: "open_for_mutation_on_branch(branch, table, Insert, Some(&txn))"
        OFM->>OFM: ensure_schema_apply_not_locked
        OFM->>OFM: use txn.base entry (no re-validate)
        Note over OFM: Collapse #1: non-strict + txn → handle=None, expected_version=base.version
        OFM-->>M: "OpenedForMutation { handle: None, expected_version }"
        M->>S: ensure_path(table, full_path, table_branch, expected_version)
    end

    M->>S: stage_merge_insert / stage_append per table
    M->>CA: "commit_all(db, branch, ..., Some(&txn))"
    CA->>CA: acquire per-(table,branch) write queues
    CA->>CA: fresh_snapshot_for_branch_unchecked (OCC re-read, no schema check)
    CA->>CA: "Collapse #3: latest_version_id() drift check (no new Dataset::open)"
    CA->>CA: commit_staged per table → committed_handles map
    CA-->>M: "CommittedMutation { updates, expected_versions, committed_handles }"

    M->>P: "commit_updates_on_branch_with_expected(branch, updates, ..., Some(&txn), committed_handles)"
    P->>P: "prepare_updates_for_commit: Collapse #4 reuses committed_handles for index build"
    P->>P: fresh_snapshot_for_branch_unchecked (OCC re-read)
    P->>P: ManifestBatchPublisher::publish (CAS on expected_versions)
    P-->>C: commit version
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant M as mutate()/load()
    participant WT as open_write_txn
    participant OFM as open_for_mutation_on_branch
    participant S as MutationStaging
    participant CA as commit_all
    participant P as ManifestPublisher

    C->>M: mutate(branch, query, params)
    M->>M: heal_pending_recovery_sidecars()
    M->>WT: open_write_txn(branch)
    WT->>WT: resolved_branch_target → ensure_schema_state_valid (ONCE)
    WT-->>M: "WriteTxn { branch, base }"

    loop per table (Insert/Merge on txn branch)
        M->>OFM: "open_for_mutation_on_branch(branch, table, Insert, Some(&txn))"
        OFM->>OFM: ensure_schema_apply_not_locked
        OFM->>OFM: use txn.base entry (no re-validate)
        Note over OFM: Collapse #1: non-strict + txn → handle=None, expected_version=base.version
        OFM-->>M: "OpenedForMutation { handle: None, expected_version }"
        M->>S: ensure_path(table, full_path, table_branch, expected_version)
    end

    M->>S: stage_merge_insert / stage_append per table
    M->>CA: "commit_all(db, branch, ..., Some(&txn))"
    CA->>CA: acquire per-(table,branch) write queues
    CA->>CA: fresh_snapshot_for_branch_unchecked (OCC re-read, no schema check)
    CA->>CA: "Collapse #3: latest_version_id() drift check (no new Dataset::open)"
    CA->>CA: commit_staged per table → committed_handles map
    CA-->>M: "CommittedMutation { updates, expected_versions, committed_handles }"

    M->>P: "commit_updates_on_branch_with_expected(branch, updates, ..., Some(&txn), committed_handles)"
    P->>P: "prepare_updates_for_commit: Collapse #4 reuses committed_handles for index build"
    P->>P: fresh_snapshot_for_branch_unchecked (OCC re-read)
    P->>P: ManifestBatchPublisher::publish (CAS on expected_versions)
    P-->>C: commit version
Loading

Comments Outside Diff (1)

  1. crates/omnigraph/src/exec/mutation.rs, line 820-843 (link)

    P2 Schema re-validated on feature-branch path despite captured txn

    The fork-queue-guard check calls self.snapshot_for_branch(Some(active)).await?, which calls resolved_branch_targetensure_schema_state_valid() — a full schema re-validation. This fires for every feature-branch mutate call (when requested.as_deref() is Some), so feature-branch inserts validate the schema at least twice despite the captured txn. The two cost gates (write_validates_schema_contract_once and keyed_insert_opens_table_at_most_once) only instrument main-branch inserts where requested.as_deref() is None, leaving this path uncovered.

    The guard's only use of this snapshot is reading table_branch to detect whether a fork is needed — a field that can't change between open_write_txn capture and this point without holding the write-queue guard that also serializes our path. Using &txn.base directly here would eliminate the redundant validation.

    Fix in Claude Code

  2. crates/omnigraph/src/db/omnigraph.rs, line 115-126 (link)

    P2 _unchecked variant is pub(crate) without the caller precondition enforced

    The doc comment states "Callers with no WriteTxn MUST use the checked variant," but the function is pub(crate), so any code in the engine crate can call it without a WriteTxn in scope. The invariant is upheld by convention only. Consider a ZST witness token, a private submodule, or a stronger name like _without_schema_check to make the safety consequence visible at every call site.

    Fix in Claude Code

Reviews (4): Last reviewed commit: "docs(engine): tighten 3b claims to match..." | Re-trigger Greptile

ragnorc added 9 commits June 22, 2026 14:54
Add the RFC-013 write-path handoff doc, and correct §4.1's WriteTxn sketch from the
4-subagent validation against current code:
- HandleCache → handle-threading (forward the commit-return handle; a version-keyed
  cache misses because HEAD walks N→N+1→N+2 across staging + index-build commits).
- "re-resolution unrepresentable" softened to "pinned base for the pre-commit phase +
  named fresh re-reads at the commit/fork boundary" — three reads (commit-time OCC, the
  live-HEAD drift probe, fork authority) are irreducible correctness machinery.
- WriteParams DOES carry a session field; the real constraint is "stage off an open
  Dataset," so attach the Session by opening read-style then staging off it.
…robe

Two write-path cost gates, RED today, GREEN after the WriteTxn lands:
- write_validates_schema_contract_once: a write must validate the schema contract
  once (3 read_text + 2 exists). Today re-validates at every resolve point —
  measured 12 read_text / 9 exists (~4 validations) via CountingStorageAdapter
  (zero production change; the write twin of the read-path schema-once test).
- keyed_insert_opens_table_at_most_once: a keyed single-table write must open its
  table <=1x. Today measured 10 opens.

Adds an exact open-CALL probe: open_count + record_open() on QueryIoProbes (mirroring
probe_count/record_probe), called at both open chokepoints; surfaced as
IoCounts.open_count. forbidden_apis guarantees every write open routes through them.
The capture-once write transaction (RFC-013 step 3b): WriteTxn{branch, base:
Snapshot, session} + Omnigraph::open_write_txn, which validates the schema contract
once and pins the base snapshot + the shared per-graph Session.

Landed as reviewed scaffolding (gated #[allow(dead_code)]); the next pass threads
Option<&WriteTxn> through open_for_mutation_on_branch / staging on the non-strict
bound-branch path — opening the base once from the pinned entry with the warm session
(a session-aware pinned opener returning a SnapshotHandle) and skipping the per-table
schema re-validation — to turn the two RED cost gates green. Strict ops / fork / the
commit-time OCC re-read keep their fresh reads.
…p 3b)

The keyed_insert_opens_table_at_most_once gate asserted open_count <= 1, but
open_count was a single unclassified counter: record_open() fires in both
open chokepoints, and open_dataset_tracked also opens the internal/system
tables (__manifest via layout.rs, _graph_commits/_graph_commit_actors via
commit_graph.rs). So the count conflated data-table opens with the publisher
CAS + commit-graph append opens — making the gate measure the wrong quantity
and unreachable by threading alone (the manifest publish keeps it >1 regardless).

Scope it by table class, mirroring the read-side counters (which already split
by URI prefix via separate wrappers): record_open(uri) classifies the open's
last path segment and feeds data_open_count vs internal_open_count. IoCounts
exposes both; the gate now asserts data_open_count <= 1.

Re-baselined: a single keyed insert is data_open_count=4 / internal_open_count=6
(sum 10, the old conflated value). The RED target for the WriteTxn threading is
now the real data-table-open count (4 -> 1), with internal opens correctly out
of scope. Pure test-harness/instrumentation; no production behavior change
(classification runs only inside the probe closure, skipped when no probes are
installed).

Also marks #297 (optimize-vs-write race) as landed in the step-3b handoff —
this branch is already stacked on origin/main after it merged.
…ep 3b)

A single mutate/load re-validated the schema contract ~4 times: at the entry
(ensure_schema_state_valid), per-table in open_for_mutation_on_branch
(resolved_branch_target), at the commit-time OCC re-read (fresh_snapshot_for_branch),
and in the publisher's index-build snapshot (snapshot_for_branch). Each validation
is 3 read_text + 2 exists on the storage adapter — O(touched resolve-points) of
redundant contract I/O on every write.

Thread the already-landed WriteTxn carrier through the write path: capture
`txn = open_write_txn(branch)` once at the mutate/load entry (the single validation),
then source the per-table entry and the commit/publish snapshots from `txn.base`
instead of re-resolving. When `txn` is None (branch merge, schema apply, tests) every
function is byte-identical to before.

- mutate_with_current_actor / load_jsonl_reader capture txn once (replacing the
  entry-point ensure_schema_state_valid) and thread Some(&txn) through
  execute_*/open_table_for_mutation, commit_all, and
  commit_updates_on_branch_with_expected.
- open_for_mutation_on_branch sources (snapshot, branch) from txn.base/txn.branch
  when present — skipping resolved_branch_target's re-validation. The OPEN itself is
  unchanged (still HEAD via open_dataset_head_for_write), and strict ops keep
  ensure_expected_version. Schema-once applies to strict and non-strict alike; the
  data-open collapse is a separate change.
- commit_all uses fresh_snapshot_for_branch_unchecked (the OCC manifest re-read minus
  the schema re-validation) when txn is present; the drift guard is unchanged.
- prepare_updates_for_commit uses txn.base for the publisher index-build snapshot.

fresh_snapshot_for_branch{,_unchecked} now read the manifest directly via
ManifestCoordinator instead of resolve_target. The OCC re-read consumes only the
Snapshot (per-table location + version), which ManifestCoordinator::open().snapshot()
produces identically — but resolve_target additionally opened the commit graph (a
spurious _graph_commits.lance exists probe the OCC read never consults). Dropping that
load is a pure read-cost reduction for every fresh-snapshot caller (commit_all's None
arm, optimize, repair, fork reclaim); the returned Snapshot is unchanged and the read
is a fresher cold manifest re-read, so the OCC freshness guarantee is preserved.

Greens write_validates_schema_contract_once (3 read_text / 2 exists, was 12/9).
keyed_insert_opens_table_at_most_once stays red (data_open_count=4) — the open
collapse lands next. Full engine suite green otherwise.
A single keyed-node mutate opened its data table 4 times: accumulation (to read
.version()), staging (the real write base), the commit-time drift guard (to read
live HEAD), and the publisher's index build (reopen at the just-committed version).
Collapse three of the four — using the WriteTxn carrier threaded for schema-once —
so a write opens each touched data table at most once.

- #1 accumulation: open_for_mutation_on_branch now returns
  (Option<SnapshotHandle>, expected_version, full_path, table_branch). On the txn's
  own branch, a non-strict (Insert/Merge) op needs no open — the only thing the
  caller reads is .version() (the CAS fence), which is exactly the pinned base
  version (entry.table_version). So skip open_dataset_head_for_write and source the
  version from txn.base. The node insert path already discarded that handle; the
  edge path resolves a pinned read only when non-default cardinality needs it.
  STRICT ops and any write that must fork still open live HEAD + ensure_expected_version.
- #3 commit drift guard: commit_all reads live HEAD via
  entry.dataset.dataset().latest_version_id() — a cheap manifest-pointer probe off
  the already-open staging handle (the same primitive ManifestCoordinator::
  probe_latest_version uses) instead of a fresh open_dataset_head_for_write. The
  head<current / head>current drift classification is byte-identical.
- #4 index build: commit_all now returns the per-table post-commit_staged
  SnapshotHandle map; commit_updates_on_branch_with_expected threads it into
  prepare_updates_for_commit, which builds indices on the threaded handle instead of
  reopening at the same just-committed version. Absent a handle (other writers,
  inline/delete tables) the reopen path is byte-identical.

When txn is None (branch merge, schema apply, tests) every function opens and checks
exactly as before. Greens keyed_insert_opens_table_at_most_once (data_open_count 4->1).
Schema-once gate stays 3/2. Full engine suite + failpoints (recovery sidecar lifecycle)
green.
…ep 3b)

The open collapse left two positional returns that are easy to mis-thread and
carry an unwritten contract: open_for_mutation_on_branch's
(Option<SnapshotHandle>, u64, String, Option<String>) and commit_all's 5-tuple
(updates, expected_versions, sidecar_handle, guards, committed_handles). Replace
both with named structs so each field reads at the call site and the Option's
contract is documented, not folklore.

- OpenedForMutation { handle, expected_version, full_path, table_branch } with a
  require_handle(ctx) helper for the callers that must have a handle (strict ops,
  the fork path, every no-txn caller — branch merge, the seed test). The handle is
  None only on the non-strict-txn open-skip path (collapse #1); require_handle
  panics with a named context if that contract is ever broken.
- CommittedMutation { updates, expected_versions, sidecar_handle, guards,
  committed_handles } for commit_all; consumers destructure into the same local
  bindings they already used, so the publish/sidecar/guard-hold logic is unchanged.
- A debug_assert in open_table_for_mutation pins the skip contract: a missing handle
  is legal only on the non-strict txn path, so a future strict arm returning None
  trips in debug builds instead of handing None to a require_handle consumer.

Pure refactor — no behavior change. Both cost gates stay green (schema 3/2,
data_open_count=1), full engine suite + lib (162) green.
…013 step 3b)

The open collapse greens data_open_count<=1 by SKIPPING the accumulation open,
PROBING live HEAD with latest_version_id, and REUSING the commit_staged handle —
none of which consume a session. The captured WriteTxn.session was therefore dead
(`#[allow(dead_code)]`): unearned surface a reviewer rightly flags.

Remove it. The carrier is now {branch, base} — exactly what schema-once + the open
collapse use. Step 5 (PublishPlan unification) makes WriteTxn the non-optional
publish carrier and is the right home for session-aware base opens, where the
warm-session benefit on the single remaining open — an object-store (S3) phenomenon,
invisible on local FS — can be earned by its own cost gate rather than carried dead
through this PR.

No behavior change; both cost gates stay green (schema 3/2, data_open_count=1).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 47707d1. Configure here.

Comment thread crates/omnigraph/src/exec/mutation.rs
Comment thread crates/omnigraph/src/exec/mutation.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47707d166b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1168 to +1171
let committed_ds = match handle {
Some(h) => h,
None => self.edge_cardinality_read_handle(txn, &table_key).await?,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Revalidate edge cardinality against live head

When an edge type has non-default cardinality and another writer commits an edge after open_write_txn captures txn.base but before this insert validates cardinality, the None branch opens the pinned base snapshot instead of the live table. Because edge inserts remain non-strict and commit_all refreshes the expected version to allow rebasing, this write can then commit on top of the concurrent edge without ever counting it, allowing @card max bounds to be violated; keep the cardinality scan on a live head (or revalidate under the write queue) for this path.

Useful? React with 👍 / 👎.

ragnorc added 5 commits June 23, 2026 17:18
Three findings this cycle share one root — the write base is a stale, un-probed,
un-classified pin (the read path probes; the write path returns the warm
coordinator snapshot):

- #298 edge-@card stale-read regression (cursor High / codex P1, VALID): collapse #1
  made the cardinality scan read txn.base instead of live HEAD, so a concurrent edge
  is uncounted and a max can be exceeded. Fix on #298: restore the live-HEAD read +
  deterministic test + correct the single-writer doc comment.
- The structural liability underneath: no unified write-validation read-set —
  endpoint/cardinality/uniqueness each pick freshness ad hoc (warm/pinned/live),
  the same cardinality check forks mutation-vs-loader, none re-validated at commit.
- The served-strict-write stale-view false-fail (validated on prod + a #[ignore]
  repro): a strict update/delete false-fails ExpectedVersionMismatch after an external
  optimize advance — the write-side mirror of #297/§6.6. The naive blanket probe is
  proven wrong (breaks the cross-process lost-update OCC contract).

All three converge on Design A (step 5): open_txn's warm probe makes the base fresh,
the op-class-aware precondition (derive maintenance vs logical from Lance per-version
transaction metadata — no parallel marker) fast-forwards maintenance and fails logical,
and §7.1's read-set-in-CAS unifies + re-validates the validation read-set. §8 records
the #298 follow-up, the widened §7.1 scope, and the step-5 two-test acceptance contract.
#298)

Regression guard for the cursor-High/codex-P1 finding on #298: 3b's collapse #1
made the non-strict edge-insert cardinality scan read the pinned txn.base instead
of live HEAD (edge_cardinality_read_handle), so a concurrent edge committed after
txn capture is uncounted and a @card max is silently exceeded (invariant 9).

Deterministic two-handle test (no failpoint): handle A commits WorksAt(Alice->Acme)
to the @card(0..1) max; stale handle B (never read since) inserts a second WorksAt
for Alice. B's coordinator is stale by construction (the write path doesn't probe),
so B scans txn.base (Alice has 0) and wrongly commits the 2nd edge. RED: the insert
that must be rejected currently succeeds (panics at unwrap_err). Goes green when the
scan reads live HEAD.
…298)

3b's collapse #1 skips the non-strict edge accumulation open, so edge_cardinality_
read_handle reopened the edge table at the pinned txn.base for the @card scan. Since
cardinality is validated once (never rechecked at commit), a concurrent edge committed
after txn capture was uncounted and a @card max could be silently exceeded (invariant
9) — the cursor-High/codex-P1 regression on #298. Pre-3b the scan read live HEAD (the
mutation's own open_dataset_head_for_write handle).

Restore the live-HEAD read: take the table LOCATION from the pinned entry (stable
across versions) and open the dataset at its current HEAD via open_dataset_head_for_
write. Gate-safe — the data_open_count / merge-insert-only gates are node inserts; the
edge cardinality path (non-default @card only) is untouched by them, and the extra
live-HEAD open is exactly the pre-3b shape. Also drops the dead None-fallback's schema
re-validation (greptile P2, auto-resolved). The residual validate->commit TOCTOU is the
pre-existing §7.1 gap (RFC-013 step 4), recorded in handoff §1d/§8.

Turns cardinality_rejected_for_stale_handle_after_concurrent_edge_commit green;
validators / write_cost / writes / consistency / end_to_end / branching all green.
Review caught several comments/docs overclaiming what the code does (the session
drop + the #298 cardinality fix left stale/too-strong wording). No logic change.

- open_write_txn doc: drop the stale "shared per-graph Session" (WriteTxn no longer
  carries one); scope "once" to the table-touch hot path and note edge/load RI
  validation still re-resolves (→ step 4 §7.1) + the session-aware open is step 5.
- edge cardinality call-site comment: it said the scan uses a "pinned txn.base" — it
  now opens LIVE HEAD (#298); corrected.
- write_cost.rs: "opens the base once (with the shared Session)" → session-aware base
  open is deferred to step 5.
- data_open_count completeness (instrumentation.rs + write_cost.rs): forbidden_apis
  only keeps engine code OUTSIDE the storage layer on the chokepoints; table_store.rs
  is allow-listed and holds direct Dataset::opens for branch-management ops (not the
  keyed-write hot path the gate measures). Narrowed the claim accordingly.
- handoff §4: "schema once / open once" is the node hot path (the two gates); edge
  endpoint + loader RI/cardinality still re-validate and read warm — #298 un-regresses
  cardinality only, it does NOT close write-validation freshness (that's step 4 §1d/§7.1).

build clean; write_cost / validators / forbidden_apis green.
@ragnorc ragnorc changed the title feat(engine): validate schema + open each data table once per write (RFC-013 step 3b) feat(engine): validate schema + open each data table once per write Jun 23, 2026
@ragnorc ragnorc changed the title feat(engine): validate schema + open each data table once per write feat(engine): WriteTxn - validate schema + open each data table once per write Jun 23, 2026
@ragnorc
ragnorc merged commit 7d3a52d into main Jun 23, 2026
8 checks passed
ragnorc added a commit that referenced this pull request Jun 23, 2026
… → step 5

The implementation (#298, merged) deliberately diverged: it shipped a minimal
WriteTxn{branch,base} (schema-once + open-collapse) and deferred this spec's
full §4.1 design — open-at-pinned-version, shared Session on writes, the
write-local handle cache, and the strict-op conflict-timing move — to step 5.
Add a banner so the spec doesn't misrepresent reality (§2.1–§2.4 + the §5
differential test are step-5 design, not #298; what shipped is handoff §4). The
three greptile review comments on this doc are moot for #298 and banked as
step-5 constraints in handoff §5.1.
@ragnorc ragnorc mentioned this pull request Jun 25, 2026
ragnorc added a commit that referenced this pull request Jun 25, 2026
Patch release over v0.7.1: write-path latency reductions (#288 direct table
opener, #298 schema-once + open-each-table-once) and three correctness fixes on
the maintenance and recovery paths (#297 optimize survives a cross-process write
race, #291 optimize compacts the internal metadata tables + non-destructive
auto_cleanup strip, #296 recovery converges under a concurrent manifest advance).
No breaking changes, no on-disk format change, no migration.

Version coherence: all 7 crate manifests + path-dep constraints, Cargo.lock,
openapi.json, and the AGENTS.md surveyed version bumped 0.7.1 -> 0.7.2. Build
green under --locked; OpenAPI drift check green.
ragnorc added a commit that referenced this pull request Jun 25, 2026
… migration, schema-version floor (#299)

* docs(rfc-013): bank the #295 spec-review comments as step-5 constraints (§5.1)

3b shipped a minimal WriteTxn{branch,base} and deferred the full §4.1 opener
unification (pinned-base opener, shared Session, write-local handle cache,
strict-op conflict-timing move) to step 5. The greptile comments on the #295
spec were moot for #298 (none of those constructs were built) but are
load-bearing for step 5: (1) the handle cache must be Send+Sync (Mutex, not
RefCell); (2) the strict-op timing move needs an explicit retry contract — txn
discarded after any commit, retry re-opens a fresh base — which is the SAME
contract as the stale-view false-fail (§1d.2); (3) the opener-equivalence test
must advance HEAD externally then assert pinned-base, not the trivial HEAD==base.

* feat(engine): fold graph lineage into the __manifest publish CAS (RFC-013 Phase 7)

Graph lineage no longer lives in a second write to _graph_commits.lance. Each
commit's graph_commit + graph_head:<branch> rows now ride the SAME __manifest
merge-insert as the table-version rows (one atomic version), and CommitGraph reads
its cache from the manifest projection (read_graph_lineage). _graph_commits.lance is
no longer written commit rows (it remains only as a Lance branch-ref carrier).

Mechanism: a LineageIntent { graph_commit_id (ULID, minted once), branch, actor,
merged_parent, created_at } threads through ManifestBatchPublisher::publish. Inside
the publisher retry loop the parent is resolved per attempt from the just-loaded
branch-scoped manifest (the should_replace_head winner over the visible graph_commit
rows — branch-correct by Lance branch isolation; the graph_head row is written for
forward-compat + the §7.1 contention point but is not the parent source, so a
freshly-forked branch resolves the right fork-point parent). A CAS-conflict retry
re-reads the advanced head → correct new parent; the commit_id is stable across
retries.

Closes two known gaps BY CONSTRUCTION (one write, no second step to fail/ race):
- manifest→commit-graph atomicity (no crash window between manifest + lineage),
- commit-graph parent under concurrency (no refresh→append TOCTOU; the per-write
  commit_graph.refresh() is gone).

Recovery, branch-merge, and genesis route their lineage through the same CAS
(merge: one commit_merge_with_actor; recovery: publish_recovery_commit folds the
recovery commit, actor=omnigraph:recovery; genesis rides the init __manifest write).
The dead _graph_commits write helpers (append_commit/_merge/_actor) are
#[allow(dead_code)] (the actor sidecar table is still enumerated by optimize).

Verified (sequential): build clean; the new lineage_projection gate (manifest-only —
_graph_commits/_actors have 0 rows; full lineage reconstructs via the projection);
branching/merge_truth_table (exhaustive, branch-aware)/composite_flow/point_in_time/
changes/consistency/recovery; failpoints (59, incl. recovery lifecycle + the
now-closed atomicity gap); full --workspace. Cost tests REVERT to their pre-fold
values (writes +1, write_cost ceiling 80) — the proof of true single-CAS (no extra
write). invariants.md marks both gaps CLOSED.

PENDING (next stages, this PR): the §7.1 concurrent graph_head one-winner gate (stage
5 — two concurrent same-branch commits, exactly one wins); the stamp bump v4 +
migrate_v3_to_v4 backfill + read-only refuse for EXISTING graphs (stage 4); full
doc-sync of storage.md/architecture.md/writes.md.

* feat(engine): migrate existing v3 graphs to manifest lineage (RFC-013 Phase 7 stage 4)

The Phase-7 fold made CommitGraph read lineage from the __manifest projection, so a
pre-Phase-7 (internal-schema v3) graph — lineage in _graph_commits.lance, none in
__manifest — would read an empty commit DAG. Stage 4 makes existing graphs upgrade
seamlessly and not break reads.

- Stamp 3 -> 4 + migrate_v3_to_v4: bumps INTERNAL_MANIFEST_SCHEMA_VERSION and adds the
  3 => migrate_v3_to_v4 arm. The migration reads this branch's _graph_commits/_actors,
  emits one graph_commit row per commit + exactly one graph_head:<branch> for the head
  (should_replace_head winner, deterministic id-sort — no hash-map-order in migration
  output), merge-inserts into __manifest, then set_stamp(4) LAST. Idempotency guard
  first (read_graph_lineage non-empty -> just stamp); crash before set_stamp re-enters
  at v3 and the guard completes it. Does NOT touch the unenforced-PK metadata. Runs per
  branch: migrate_on_open backfills main; load_publish_state backfills each branch on
  its first write (root_uri/branch threaded through migrate_internal_schema).
- v3-read fallback: CommitGraph version-gates the lineage source — stamp < 4 reads the
  (re-activated) _graph_commits.lance; >= 4 uses the manifest projection. So a READ-ONLY
  open of an un-migrated graph reads correct history with no write. Correctness catch:
  the legacy _graph_commit_actors.lance was never branched, so the fallback reads it
  FLAT (no branch checkout) while checking out the branch only on the commits dataset.
- Read-only stamp-refuse: a ReadOnly open of a FUTURE-stamped graph now refuses with the
  same upgrade error (future-proofing the next format bump; the write path already
  refused via migrate_internal_schema).
- Docs: storage/architecture/writes/invariants/constants updated to manifest-stored
  lineage; release note docs/releases/v0.8.0.md (format v4, old writers clean-break,
  data preserved, upgrade writers first).

6 new tests (v3 backfill, idempotent, v3 read-only fallback, future-stamp refuse in both
modes, crash-before-stamp completes, legacy branch+flat-actor read). Full engine suite +
failpoints (59) + cargo test --workspace --locked green; check-agents-md passes.

* test(engine): graph_head concurrency gate — disjoint same-branch writers form a linear commit DAG (RFC-013 Phase 7)

Two (or N) writers committing disjoint tables on one branch still share the
mutable `graph_head:<branch>` manifest row, so the only row-level CAS
contention is that row. The contract — exactly one writer wins each CAS round;
the loser retries inside the publisher, re-resolves its parent off the
freshly-advanced head, and re-commits, so every writer lands and the
graph_commit DAG stays a single LINEAR chain (no fork) — had no acceptance
test. This adds it.

- concurrent_disjoint_writes_share_head_and_form_linear_chain: two disjoint
  writers + distinct LineageIntent, tokio::join!; both commit; the on-disk DAG
  is genesis -> c -> c' (asserted linear: exactly one genesis, no two commits
  share a parent, the head is the unique non-parent).
- n_concurrent_disjoint_writers_converge_to_one_linear_chain: N=8 disjoint
  writers each with an app-level retry loop (the publisher's internal budget
  can be exhausted under contention); all converge to one linear chain of 8.
- concurrent_disjoint_writes_form_linear_chain_on_s3: the same race on a real
  object store (true conditional-put CAS), bucket-gated.

Cites both tests from the §7.1 contention note in invariants.md.
Test-only; no production change.

* perf(engine): fold the lineage parent scan into the publish path's single __manifest scan (RFC-013 P2)

Each lineage publish scanned `__manifest` twice: `load_publish_state` read
table state via one scan, then `resolve_lineage_rows` did a second full
`read_graph_lineage` scan only to find the parent commit. Fold the
`graph_commit` extraction into the existing scan.

- `read_manifest_scan` gains a `collect_lineage` flag. The publish path
  (`read_publish_scan`) collects the `graph_commit` rows in the same pass; the
  table-state hot path leaves them in the forward-compat skip arm, so it never
  pays the O(commits) lineage JSON decode (it also skips reading the
  `object_id` column entirely). One shared `decode_graph_commit_row` serves
  both the folded path and the standalone `read_graph_lineage`, so the two
  cannot drift.
- `resolve_lineage_rows` is now sync and takes the already-parsed rows; the
  per-attempt re-read is preserved because `load_publish_state` runs once per
  CAS attempt, so a retry still re-parents off the advanced head.
- `load_publish_state` returns a named `LoadedPublishState` instead of a
  four-tuple; the thin `read_registered_table_locations` /
  `read_tombstone_versions` accessors fold away. `read_manifest_entries` becomes
  `#[cfg(test)]`: the fold removes its last production caller, leaving only the
  test-only namespace module (`db/manifest.rs`: `#[cfg(test)] mod namespace`),
  so gating it keeps it from becoming dead code in non-test builds.

Measured at depth ~5: per-write `__manifest` reads drop 44 -> 26 (total reads
54 -> 36). write_cost.rs gains a `manifest_reads <= 34` sub-ceiling that trips
if a publish-path scan is re-added, and its calibration comment is corrected.

* test(engine): red — transient legacy-open failure silently completes the v3→v4 migration

A pre-Phase-7 (internal schema v3) graph keeps its graph lineage in
`_graph_commits.lance`; the v3→v4 internal-schema migration backfills it into
`__manifest` and stamps v4. `read_legacy_commit_cache` currently maps EVERY
`Dataset::open` error to "no legacy data" (`Err(_) => empty`), so a transient or
corrupt open during the one-time migration backfills nothing and still stamps
v4 — orphaning the real lineage permanently (the migration runs once; the v3
fallback is then disabled).

Add a `migration.v3_to_v4.legacy_open` failpoint that injects a non-not-found
Lance error at the legacy open, and a fault-injection regression test in the
`failpoints` binary. Against the current swallow the migration completes anyway,
so the test fails on its "migration must abort" assertion — the predicted
symptom. The fix follows in the next commit.

Test support reachable from the `failpoints` integration binary (it compiles the
crate without `cfg(test)`): the v3-fixture helpers and a stamp/row-count reader
are gated `cfg(any(test, feature = "failpoints"))`, still excluded from release
builds. Failpoint tests stay in the integration binary because the fail registry
is process-global.

* fix(engine): propagate non-not-found legacy-open errors in the v3→v4 migration

`read_legacy_commit_cache` mapped EVERY `Dataset::open` error to an empty cache
(`Err(_) => empty`) on both the legacy commits dataset and its actor sidecar. The
v3→v4 internal-schema migration reads this once before stamping internal-schema
v4; a transient or corrupt open therefore backfilled nothing and stamped v4
anyway, orphaning the graph's real lineage permanently (the migration runs once,
and the stamp-gated v3 fallback is disabled at v4). This is the "no silent
failures" deny-list violation, and realistic on object storage.

Both opens now match the not-found variants — Lance maps an object-store NotFound
to `DatasetNotFound` — as the benign "no legacy data" / "no authors" signal, and
propagate anything else as a loud error. The two arms share the variant contract
but carry different rationale (commits-absent is the legitimate empty signal;
actor-sidecar-absent is benign, but a corrupt actor open silently wiping
authorship before stamping v4 is the same loss hole), commented at each site.

Pinned by the `lance_surface_guards.rs::dataset_open_missing_returns_not_found_variant`
guard (turns red if a Lance bump changes the absence variant) and greens the
fault-injection regression test from the previous commit.

* test(engine): cover the per-branch v3→v4 migration against a real Lance branch

`seed_legacy_v3_lineage` writes every commit (including the "feature"-tagged one)
to MAIN's `_graph_commits.lance` with `manifest_branch` as a mere field, so the
production per-branch migration path — `read_legacy_commit_cache` checking out a
real Lance branch, and a branch-scoped `__manifest` — was never exercised.

Add `seed_legacy_v3_lineage_with_branch`, which forks a real `feature` Lance
branch on BOTH `_graph_commits.lance` and `__manifest` (the branch inherits
main's stripped v3 state), and a test that migrates the BRANCH and asserts the
branch's lineage lands in the BRANCH's `__manifest` (genesis + A + branch commit,
`graph_head:feature` → branch commit, parents + actors intact) with main's
`__manifest` untouched.

This empirically resolves the open question behind the merge robustness work: the
fast-path `read_graph_lineage(dataset)` has no `manifest_branch` filter, but
`__manifest` is Lance-branched per graph-branch, so a branch reads only its own
lineage — the test confirms migrating one branch does not leak into another. No
branch filter is needed.

* refactor(engine): type the lineage-backfill merge conflict via the publisher classifier

`state::merge_lineage_rows` (the v3→v4 lineage backfill's standalone `__manifest`
merge-insert) stringified its `execute_reader` error, discarding the Lance
variant. Route it through the publisher's `map_lance_publish_error` (now
`pub(crate)`) so a concurrent first-open's row-level CAS loss surfaces as the
SAME typed `OmniError::Manifest{ details: RowLevelCasContention }` the publisher's
own retry consumes — one vocabulary, no raw-Lance matching in the migration.

Deliberately NOT unified with `optimize::is_retryable_lance_conflict`: that
classifier also matches `CommitConflict`/`RetryableCommitConflict` from the
compaction commit path, which a row-level merge-insert never emits. Cross-linked
with a comment at both sites.

Behavior-preserving: the only path that changes is the error TYPE on a CAS loss
(previously an opaque `Lance` string, now a typed conflict); no success/failure
outcome changes. The bounded re-open retry that consumes the new type lands next.

* test(engine): red — concurrent v3→v4 migrations error instead of converging

`migrate_v2_to_v3` is concurrent-runner idempotent by design; v3→v4 regressed it.
`merge_lineage_rows` uses `conflict_retries(0)` and `migrate_v3_to_v4` has no
app-level retry, so when two processes open the same legacy graph at once the
backfill's row-level CAS loser errors the whole open instead of converging.

The test opens two `__manifest` handles at the same pre-migration (v3,
empty-lineage) HEAD and runs both `migrate_internal_schema` calls under
`tokio::join!`, forcing the `graph_head:main` CAS to fire every run. Against the
current code the loser fails with `RowLevelCasContention` ("Attempted 0
retries.") — the predicted symptom — so the "both must converge" assertion
panics. The bounded re-open retry that makes both converge lands next.

* fix(engine): make the v3→v4 lineage backfill converge under concurrent runners

`migrate_v2_to_v3` is concurrent-runner idempotent; v3→v4 was not. Two processes
(or open-for-write handles) opening the same legacy graph at once both reach the
backfill merge, and `merge_lineage_rows`'s `conflict_retries(0)` made the
row-level CAS loser error the whole open instead of converging.

Two contention points, both now handled all-or-nothing:

1. The backfill merge on `graph_head:<branch>`. Wrap (fast-path re-read → read
   legacy → merge) in a bounded re-open retry loop: a `RowLevelCasContention` loss
   re-opens the manifest past the winner's (atomic) commit and re-loops; the
   fast-path re-read then sees the winner's lineage and stamps. On budget
   exhaustion it returns a `RowLevelCasContention`-typed error so the publisher's
   OUTER retry loop completes it. The retry decision reuses the publisher's
   `is_retryable_publish_conflict` so the two stay in lockstep.

2. The terminal stamp bump. Making the merge loser converge newly lets BOTH
   runners reach `set_stamp(4)` — an `UpdateConfig` commit on the same key — so the
   loser gets `lance::Error::IncompatibleTransaction` (NOT a row-level CAS, so the
   merge loop doesn't catch it). This surfaced only under the concurrent
   full-suite run, not the isolated test. Both write the SAME value, so the
   conflict is benign: `commit_v4_stamp_idempotently` re-opens and, if the stamp
   already reached the target, succeeds; else re-applies (bounded).

Greens the race test from the previous commit (3x isolated, 5x full-suite, no
flake). The new `IncompatibleTransaction` match is pinned by
`lance_surface_guards.rs::lance_error_incompatible_transaction_variant_exists`.

* fix(engine): refuse a future internal-schema stamp on the branch read path

`load_commit_cache_for_branch` dispatched on the branch's internal-schema stamp —
`< CURRENT` to the v3 legacy fallback, `>= CURRENT` to the manifest projection —
but never refused a `> CURRENT` branch stamp, so a newer-binary shape would be
misread by the projection rather than rejected.

Add `refuse_if_stamp_too_new(stamp)` (re-exported `pub(crate)` from `migrations`)
right after the branch stamp is read, mirroring the main read path's
`refuse_if_internal_schema_too_new`. This is defense-in-depth, not a live hole:
migrations run main-first (main migrates on open; each branch on its first write),
so main's stamp is always >= every branch's and the main path refuses first. The
guard closes the gap if that ordering invariant is ever weakened.

Tested by force-stamping a real branch past CURRENT and asserting the branch read
refuses with the upgrade error (the test misreads via the projection — returns Ok
— without the guard, confirmed by removing it).

* docs(rfc-013): record the v3→v4 migration robustness fixes

invariants.md Known Gaps: the `migrate_v3_to_v4` entry now states the migration is
loud on non-not-found legacy-open errors and concurrent-runner idempotent (bounded
re-open retry on the merge CAS + idempotent stamp bump), and that the branch read
path refuses a `> CURRENT` stamp.

lance.md: note the two new surface guards the migration depends on
(`dataset_open_missing_returns_not_found_variant`,
`lance_error_incompatible_transaction_variant_exists`).

testing.md: note the migration fault-injection test in the failpoints row.

* refactor: remove dead code and silence warnings across engine + cluster

Dead-code sweep follow-up to the RFC-013 stack. No behavior change.

- engine: delete the orphaned `validate_edge_cardinality` — the load path uses
  `validate_edge_cardinality_with_pending_loader` for every mode (including
  Overwrite, which it treats as the replacement table image), so the old
  standalone validator had no caller — and correct its sibling's now-stale doc
  reference. Gate `TableStore::append_batch` `#[cfg(test)]`: it is the inline-
  commit residual kept only for recovery test setup, with no non-test caller.
- cluster: drop unused imports in `lib.rs`, delete the unused
  `ClusterStore::payload_display`, and raise `LiveGraphObservation` /
  `GraphObservationJson` / `PolicyTarget` to `pub(crate)` to match the functions
  that return them.

Both lib crates now build warning-free.

* fix(engine): match Lance's typed DatasetAlreadyExists, not the message string

The internal create-or-open idempotency fallbacks in `db/commit_graph.rs` and
`db/recovery_audit.rs` classified the "already exists" race by
`err.to_string().contains("Dataset already exists")` — a Lance display string,
not an API contract. A wording change upstream would silently break the fallback
(a re-create would error instead of opening the existing table). Match the typed
`lance::Error::DatasetAlreadyExists { .. }` variant instead — the same discipline
as the v3→v4 migration's not-found classifier — pinned by the new
`lance_surface_guards.rs::lance_error_dataset_already_exists_variant_exists`
guard so a Lance rename turns red instead of silently regressing.

* refactor(engine): consolidate now_micros into one crate::db helper

Four `fn now_micros() -> Result<i64>` copies (commit_graph, recovery_audit,
graph_coordinator, manifest/graph) had already drifted: three mapped the
clock error to `OmniError::manifest("...UNIX_EPOCH...")` while recovery_audit
used `OmniError::manifest_internal("...unix epoch...")`. Replace all four with
one `pub(crate) fn now_micros()` in `db/mod.rs` (the majority `manifest`
variant), and repoint the eight call sites at `crate::db::now_micros()`. No
test asserts on the failure message, so unifying the variant is behavior-safe;
the timestamp-mapping contract can no longer fork across the rows it stamps.

* refactor(engine): drop the dead snapshot param from roll_back_sidecar

`roll_back_sidecar` took `snapshot: &Snapshot` only to discard it with
`let _ = snapshot;` — rollbacks now always publish (the restored HEAD plus a
recovery-commit lineage row), so the snapshot is never read to decide whether
to skip a publish. Remove the parameter, the two call-site arguments, and the
suppressor. A signature must not advertise inputs it does not consume. The
`Snapshot` import stays — `process_sidecar`, `roll_forward_all`, and
`record_audit_recovery_rollforward` still take it.

* test(engine): red — open_at_branch wedges a branch on a missing commit-graph ref

A v4 graph keeps its graph lineage in `__manifest` (RFC-013 Phase 7); the
`_graph_commits.lance` branch ref is a derived artifact. An interrupted
fork-reclaim or a `cleanup` race can drop that derived ref while the manifest
lineage stays intact. Per invariants 7 + 15 a missing derived ref must not fail
a logical read of the lineage.

This wedge builds a real v4 `feature` branch (its `graph_head:feature` row in
`__manifest`), force-deletes ONLY the `_graph_commits.lance` `feature` ref, then
asserts the branch reads (`open_at_branch` / list-commits / `merge_base`)
succeed from `__manifest` while a write that needs the derived ref
(`create_branch`) fails loudly with the typed actionable error.

Red against current code: `open_at_branch`'s hard `checkout_branch(branch)?` on
the missing ref errors `OmniError::Lance` (Lance "Not found:
_graph_commits.lance/tree/feature/_versions"), wedging the logical read.

* fix(engine): read manifest lineage independent of the derived _graph_commits ref

`CommitGraph::open_at_branch` did a hard `checkout_branch(branch)?` on the
`_graph_commits.lance` branch ref before reading lineage — so a missing derived
ref (an interrupted fork-reclaim, or a `cleanup` race) wedged the branch's
commit-list / merge-base / snapshot resolution even though the lineage is
readable from the authoritative `__manifest` (RFC-013 Phase 7). That is a
derived/physical artifact failing a logical read — invariants 7 and 15.

Make the held commits handle `Option<Dataset>` (mirroring `actor_dataset`).
`open_at_branch` and `refresh` check out the derived ref best-effort: a typed
not-found (`RefNotFound`/`NotFound`) yields a `None` handle while the read
re-syncs from `__manifest`; any other open error still propagates. The manifest
existence gate is unchanged — `load_commit_cache_for_branch` keeps its hard `?`,
so a truly absent branch still fails loudly at the manifest. `create_branch`
(the only writer that forks a ref) and the folded-in version lookup return a
loud, actionable error on `None`, deferring repair to `cleanup`'s existing
orphan reconciler rather than inlining a write on a read-side refresh. Reads
(`head_commit`/`load_commits`/`get_commit`/`merge_base`) never touch the handle.

Greens the wedge regression from the preceding commit.

* fix(engine): v3→v4 retry loops return retryable contention on exhaustion

`commit_v4_stamp_idempotently`'s retry loop used `0..=STAMP_RETRY_BUDGET`
(6 iterations) with an `attempt < STAMP_RETRY_BUDGET` guard, so the LAST
iteration's `IncompatibleTransaction` fell through to
`Err(e) => OmniError::Lance(...)` — stringified, non-retryable — instead of the
intended `RowLevelCasContention`, and the post-loop contention return was dead
code. The publisher's outer retry only re-runs `is_retryable_publish_conflict`,
so under sustained concurrent v3→v4 migration the one-time stamp bump could fail
instead of converging, defeating the idempotency the migration is supposed to add.

Fix the loop to `0..BUDGET` with an UNGUARDED `IncompatibleTransaction` arm: the
retryable variant is always handled inside the loop (re-open + same-value check +
retry), so it can never reach the stringifying catch-all, and the post-loop is the
SINGLE reachable exhaustion path — the typed `RowLevelCasContention`. The `Err(e)`
arm now catches only genuine non-contention errors. Apply the same range alignment
to the sibling merge loop in `migrate_v3_to_v4` (behaviorally correct today — its
`Err(err)` returns the already-typed contention — but it carried the identical
off-by-one structure the stamp loop was copied from; aligning both stops the next
copy from re-introducing it).

Test-first. The exhaustion path is otherwise near-unreachable — a real concurrent
winner stamps the same value, so the re-read returns Ok on the first retry — so a
new `migration.v4_stamp.force_incompatible` failpoint forces every stamp attempt to
lose, driving exhaustion deterministically. Against the pre-fix loop the new
`v4_stamp_exhaustion_returns_retryable_contention` test goes red with
`Lance("Incompatible transaction: injected failpoint triggered…")`; with the fix it
asserts the typed `RowLevelCasContention`. Found by automated review on #299.

* feat(engine): minimum-supported internal-schema floor + retirement tripwire

The internal-schema migration chain (`migrate_internal_schema`) had a too-new
ceiling but no floor, so every old `migrate_vN_…` arm and the v3 legacy readers
it needs stay forever — the pile grows by one migration + readers + tests every
schema version. Add `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` (1 today, a pure
no-op: `read_stamp` floors an absent stamp at 1 and no real graph carries 0) as
the oldest stamp this binary opens; raising it is how the chain sheds old code.

Collapse the one-sided `refuse_if_stamp_too_new` into `refuse_if_stamp_unsupported`
checking both bounds, so the floor lands at all three stamp-enforcement sites —
the write-path migrate dispatcher, the read-only open guard, and the branch
lineage-read path (`commit_graph.rs`) — via one compiler-enforced rename. A
hand-wired floor twin would have had to touch each site, and the branch-read path
is easy to miss; one combined guard cannot half-enforce. Rename the read-only
wrapper `refuse_if_internal_schema_unsupported` to match.

A compile-time tripwire (`const _: () = assert!(LOWEST_REGISTERED_MIGRATION_SOURCE
== MIN_SUPPORTED…)`) fails the build if a future floor bump forgets to delete the
now-dead migration arm (or vice versa) — stronger than a runtime test, impossible
to skip, and it doubles as the use that keeps the mirror const live.

Tests: a sub-floor graph is refused in both open modes (twin of
`future_stamp_is_refused_in_both_open_modes`); the guard accepts exactly
[MIN, CURRENT]. No behavior change for any real graph. The retirement runbook
lives on the `MIN_SUPPORTED` doc-comment + invariants.md.

* fix(engine): compose migration contention with publisher retry; precise recovery-converge audit commit

Three review-surfaced fixes on the RFC-013 Phase 7 path.

Publisher retry vs migration contention: `publish()` propagated a
`load_publish_state` error fatally via `?`, so a `RowLevelCasContention` surfaced
by the v3->v4 migration's exhausted merge/stamp budgets aborted the publish
instead of being retried — only `merge_rows` conflicts hit the retry. This
contradicted the migration's own design, which returns that typed error
EXPECTING the publisher to re-run the load (by which point a concurrent winner
has usually finished the migration, so the next scan is a no-op). Route a
retryable load error through the same retry path as a retryable `merge_rows`
conflict. Regression test (failpoints): a one-shot retryable contention injected
into `load_publish_state` now commits via the retry; red without the fix (the
write fails with the injected contention).

Recovery-converge audit commit id: `converge_or_defer_roll_forward` recorded the
branch HEAD as the audit row's `graph_commit_id`, but a concurrent user write can
advance `graph_head` past the recovery commit between the winner's publish and
this read — attributing the audit to a later, wrong commit. Use the latest
`RECOVERY_ACTOR`-authored commit (what `publish_recovery_commit` mints), which is
the recovery commit by construction. The audit's actor was already correct (it
comes from `sidecar.actor_id`, not the commit).

Dead param: drop the unused `snapshot` from `record_audit_recovery_rollforward`
(removing the `let _ = snapshot;` suppressor). `storage` stays — it is used to
delete the sidecar.
ragnorc added a commit that referenced this pull request Jun 25, 2026
Folds in v0.7.2 (release #301) + RFC-013 Phase 7 (graph lineage in __manifest,
internal schema v3→v4 migration #299; WriteTxn #298; recovery convergence #296)
under the MCP branch.

Conflict resolutions (2 files):
- crates/omnigraph-server/Cargo.toml: take main's 0.7.2 path-dep constraints;
  keep our omnigraph-mcp dep (bumped to 0.7.2).
- docs/releases/v0.8.0.md (add/add): both branches drafted v0.8.0 notes for the
  same next minor — combined them. v0.8.0 now documents BOTH the MCP surface
  (ours) and main's __manifest lineage fold + the breaking internal-schema-v4
  upgrade-order requirement (kept prominent under Upgrade notes). Corrected our
  'no breaking changes / on-disk format unchanged' line, which the v4 migration
  makes false.

Coherence: omnigraph-mcp [package] + Cargo.lock bumped 0.7.1→0.7.2; openapi.json
auto-merged to info.version 0.7.2 (no API-surface drift from the incoming
engine-internal commits). Verification deferred to CI (no local rebuild).
@aaltshuler
aaltshuler deleted the rfc-013-step-3b-writetxn-v2 branch July 2, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant