Skip to content

Make live recovery refresh atomic, cheap, and off the write path - #488

Closed
ragnorc wants to merge 2 commits into
rfc-031-typed-storage-failuresfrom
rfc-031-live-full-refresh
Closed

Make live recovery refresh atomic, cheap, and off the write path#488
ragnorc wants to merge 2 commits into
rfc-031-typed-storage-failuresfrom
rfc-031-live-full-refresh

Conversation

@ragnorc

@ragnorc ragnorc commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #491. Makes Omnigraph::refresh() the served-graph recovery primitive — running the same Full sweep as read-write open, through one shared helper — so a long-running server heals a rollback-eligible sidecar without a restart.

The first commit introduces that. The second commit closes three problems it landed with:

It could leave the handle torn. refresh published the recovered coordinator, released the schema gate, and only then reloaded the schema view. A failure in that reload — or a schema apply interleaving in the gap — left the handle carrying a new coordinator beside a stale catalog.

Restructured into prepare-then-publish: recovery, the branch re-open, and the prospective schema view are all built against local state; then one publish takes the coordinator write lock, drops derived caches, and swaps coordinator and view together with no fallible work after the first replacement. This requires holding the schema gate across recovery and view preparation, so the reload splits into an acquiring wrapper plus a gate-held builder (the write queue is not reentrant). That builder takes the snapshot as a parameter and never reads self.coordinator — which keeps the publish path clear of the non-reentrant RwLock deadlock already pinned by composite_flow_schema_apply_then_branch_ops_no_deadlock_in_refresh.

It cost two or three full manifest scans. The shared helper always re-read the coordinator, because read-write open builds its coordinator before awaiting the schema gate. refresh builds its coordinator after acquiring the gate, so that re-read was a second full __manifest open and scan for no new information — on a path the supervisor calls, over a journal whose scan cost grows with commit depth. Freshness is now explicit at the call site, and the trailing re-read is skipped when the sweep changed nothing (using the signal process_sidecar already returns).

It silently upgraded ordinary writes to destructive recovery. mutate and load call refresh() to reprepare when a pre-effect read set changes. Neither file was touched when refresh gained Full-recovery semantics, so both quietly began running rollback-capable recovery on the contended write path — up to 32 times in the loader's reprepare loop. They now use refresh_coordinator_only. An unresolved rollback-eligible sidecar is already surfaced to these callers by the write-entry roll-forward barrier as RecoveryRequired.

Additionally: a handle opened read-only no longer performs recovery in refresh(). A caller that asked to skip the open-time sweep did not consent to restores, sidecar deletion, or schema-staging promotion later, and its credentials may not permit them — the cluster crate opens read-only handles purely to probe graphs.

The safety boundary is unchanged: handles in one process share the root gates; foreign writer processes remain unsupported.

Verification

  • refresh_without_sidecars_uses_one_manifest_open — steady-state refresh is exactly one manifest open and one scan, at commit-history depth.
  • failed_refresh_publishes_nothing_and_still_converges — a failure in preparation leaves the schema projection untouched and the handle converges on retry. (Scope is stated in the test: the coordinator half is not black-box observable because reads self-refresh.)
  • read_only_refresh_never_runs_recovery — a read-only handle neither consumes nor rewrites a sidecar; a read-write handle still does.
  • write_reprepare_paths_do_not_run_full_recovery — source guard, non-vacuous (asserts the reprepare site is still seen). Source-level because the regression was invisible in the diff that caused it.
  • Live rollback-eligible recovery on the same handle and a peer handle; concurrent-writer serialization; Mutation, Load, SchemaApply, BranchMerge, EnsureIndices, Optimize recovery; schema reload/cache invalidation; recovery re-entry; the deadlock pin.
  • cargo test --workspace --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints — 75 suites, zero failures.
  • Default and failpoint-superset workspace Clippy with warnings denied; cargo fmt --all --check; scripts/check-agents-md.sh.

No storage-format migration.

Greptile Summary

The PR makes explicit refresh on writable live handles run the same process-gated Full recovery sweep as read-write open while preserving a coordinator-only path for read-only handles and contended write retries.

  • Stores each handle’s opening mode and prevents read-only refresh from performing recovery writes.
  • Prepares recovery and schema state locally before atomically adopting the refreshed coordinator and schema view.
  • Keeps mutation and load retry loops on the lightweight coordinator-only refresh path.
  • Extends recovery, concurrency, read-only, and steady-state cost coverage.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported read-only recovery write path is now blocked and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/omnigraph/src/db/omnigraph.rs Adds retained opening mode, read-only-safe refresh routing, and prepare-then-publish Full recovery for writable live handles; the previously reported read-only write path is closed.
crates/omnigraph/src/db/manifest/recovery.rs Clarifies Full versus roll-forward-only recovery ownership and avoids a redundant coordinator refresh when no durable recovery action occurred.
crates/omnigraph/src/exec/mutation.rs Keeps authority-conflict repreparation on coordinator-only refresh rather than invoking destructive Full recovery.
crates/omnigraph/src/loader/mod.rs Keeps load repreparation on the lightweight coordinator-only refresh path.
crates/omnigraph/tests/failpoints.rs Expands live-handle Full recovery coverage, including serialization, re-entry, rollback, schema adoption, and the read-only no-recovery contract.
crates/omnigraph/tests/forbidden_apis.rs Updates source-level protocol guards for the new recovery helper and coordinator-only retry paths.
crates/omnigraph/tests/warm_read_cost.rs Updates warm-read cost expectations to account for refresh cache invalidation.
docs/dev/testing.md Documents the expanded live recovery and cost-test coverage.
docs/dev/writes.md Documents Full live recovery, process-local serialization boundaries, and the read-only refresh exception.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Handle as Omnigraph handle
    participant Gate as Root schema gate
    participant Recovery as Full recovery
    participant Live as Live coordinator/schema view
    Caller->>Handle: refresh()
    alt Read-only handle
        Handle->>Live: refresh coordinator and invalidate caches
        Live-->>Caller: refreshed read view
    else Read-write handle
        Handle->>Gate: acquire process-global schema gate
        Handle->>Recovery: open temporary coordinator
        Recovery->>Recovery: reconcile schema staging and sidecars
        Recovery-->>Handle: recovered coordinator
        Handle->>Handle: prepare coherent schema view
        Handle->>Live: invalidate caches and atomically adopt state
        Handle->>Gate: release gate
        Live-->>Caller: recovered live handle
    end
Loading

Reviews (2): Last reviewed commit: "fix(recovery): make live refresh atomic ..." | Re-trigger Greptile

Context used:

@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 using default effort 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 9c09e5e. Configure here.

Comment thread crates/omnigraph/src/db/omnigraph.rs
Comment thread crates/omnigraph/src/db/omnigraph.rs

@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: 9c09e5e281

ℹ️ 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 thread crates/omnigraph/src/db/omnigraph.rs Outdated
Comment on lines 1610 to 1611
drop(schema_guard);
self.reload_schema_if_source_changed().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.

P2 Badge Keep the schema gate through catalog reload

Do not release schema_guard before reloading the schema view. If another handle completes apply_schema after this drop but before reload_schema_if_source_changed reacquires the gate, this handle combines the newly accepted schema with the coordinator captured before that migration; an added-table migration therefore makes refresh() fail with a schema/manifest conflict, while compatible metadata-only changes can install a catalog from a different accepted view than the adopted coordinator. Reload under the already-held, non-reentrant gate (for example through a helper that assumes the gate is held), then release it after both coordinator and schema view are published.

AGENTS.md reference: AGENTS.md:L165-L167

Useful? React with 👍 / 👎.

The live Full-recovery refresh landed with three problems this commit closes.

**It could leave the handle torn.** `refresh` published the recovered
coordinator, released the schema gate, and only then reloaded the schema view.
A failure in that reload — or a schema apply interleaving in the gap — left the
handle carrying a new coordinator beside a stale catalog: data on one contract,
catalog on another.

Restructure into prepare-then-publish. Recovery, the branch re-open, and the
prospective schema view are all built against local state; then one publish
takes the coordinator write lock, drops derived caches, and swaps coordinator
and view together with no fallible work after the first replacement. A failure
anywhere is a clean no-op. This needs the schema gate held across recovery
*and* view preparation, so the reload splits into an acquiring wrapper and a
gate-held builder — the write queue is not reentrant. That builder takes the
snapshot as a parameter and never reads `self.coordinator`, which is what keeps
the publish path clear of the non-reentrant `RwLock` deadlock already pinned by
`composite_flow_schema_apply_then_branch_ops_no_deadlock_in_refresh`.

**It cost two or three full manifest scans.** The shared helper always re-read
the coordinator, because read-write *open* builds its coordinator before
awaiting the schema gate and a live handle may have published meanwhile.
`refresh` builds its coordinator after acquiring the gate, so that re-read was a
second full `__manifest` open and scan for no new information — on a path the
supervisor calls, over a journal whose scan cost grows with commit depth. Make
the freshness explicit at the call site, and skip the trailing re-read when the
sweep changed nothing (the common deferred-sidecar outcome), using the signal
`process_sidecar` already returns. A steady-state refresh is now one open, one
scan, pinned by a cost budget.

**It silently upgraded ordinary writes to destructive recovery.** `mutate` and
`load` call `refresh()` to reprepare when a pre-effect read set changes. Neither
file was touched when `refresh` gained Full-recovery semantics, so both quietly
began running rollback-capable recovery on the contended write path — up to 32
times in the loader's reprepare loop. A reprepare needs current authority and
nothing else: it now uses `refresh_coordinator_only`. An unresolved
rollback-eligible sidecar is already surfaced to these callers by the
write-entry roll-forward barrier as `RecoveryRequired`, which the server
resolves by scheduling an explicit refresh. A source guard pins it, because the
regression was invisible in the diff that caused it.

Also: a handle opened read-only no longer performs recovery in `refresh()`. A
caller that asked to skip the open-time sweep did not consent to restores,
sidecar deletion, or schema-staging promotion later, and its credentials may not
permit them — the cluster crate opens read-only handles purely to probe graphs.
@ragnorc
ragnorc force-pushed the rfc-031-live-full-refresh branch from 9c09e5e to 3c06689 Compare August 12, 2026 20:55
@ragnorc
ragnorc changed the base branch from main to rfc-031-typed-storage-failures August 12, 2026 20:55
@ragnorc ragnorc changed the title fix(recovery): allow full refresh on live handles Make live recovery refresh atomic, cheap, and off the write path Aug 12, 2026
@aaltshuler

Copy link
Copy Markdown
Collaborator

Maintainer review: changes requested

The prepare-then-publish direction is sound, but two correctness blockers remain:

  1. Orphan-sidecar discard can leave the live coordinator stale. discard_orphaned_branch_sidecar publishes a lineage-only main-manifest commit, then bypasses the changed-durable-state path, so the final coordinator refresh is skipped. Return a typed sweep outcome or explicitly propagate manifest_changed. Add a regression proving the same handle immediately observes the recovery commit, manifest version, graph head, and commit graph.
  2. Read-only refresh leaves schema state stale. It refreshes only the coordinator and neither prepares/swaps schema_view nor performs the read-only schema-coherence proof. Add a non-mutating prepare/publish path under the schema gate. Test peer SchemaApply followed by read-only refresh, and prove catalog/schema_source become current with zero writes.

Please rebase after #491 is corrected and run canonical workspace/failpoint tests, Clippy, format, focused recovery tests, and RustFS coverage. The production delta is separable from #491, so restacking directly on main is also viable if this needs to land independently.

@aaltshuler

Copy link
Copy Markdown
Collaborator

Closing this implementation PR in favor of RFC-034: #496

The requirement remains—recover one unavailable graph without restarting unrelated graphs—but the replacement design uses a safer boundary:

  • durable recovery is separate from non-mutating view refresh;
  • recovery capabilities are enforced at the engine boundary;
  • automatic recovery is forward-only;
  • Restore/delete/undo requires explicit sole-writer and replica-quiescence authority;
  • recovery builds a complete fresh service generation and activates it atomically;
  • requests use closeable admission and generation-owned caches;
  • partial progress, cancellation, readiness, and shutdown are typed and testable.

This closes the in-place-refresh implementation direction, not the problem. Useful failure schedules and test ideas from #488 are carried into RFC-034's acceptance matrix; the #488 commits should not be merged as-is.

@aaltshuler aaltshuler closed this Aug 13, 2026
@aaltshuler

Copy link
Copy Markdown
Collaborator

The replacement design is now split into three reviewable RFCs: durable recovery authority in #496, served-operation ownership and shutdown in #498, and atomic runtime activation/supervision in #499. This keeps the recovery protocol independent from server lifecycle and availability machinery.

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.

2 participants