Skip to content

Supervise configured graph availability, with typed failures - #489

Open
ragnorc wants to merge 4 commits into
rfc-031-live-full-refreshfrom
rfc-031-graph-supervision
Open

Supervise configured graph availability, with typed failures#489
ragnorc wants to merge 4 commits into
rfc-031-live-full-refreshfrom
rfc-031-graph-supervision

Conversation

@ragnorc

@ragnorc ragnorc commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #488. The registry retains every configured graph — including ones that fail to open — and a per-graph supervisor drives open and recovery. The first commit introduces that; the rest close what it landed with.

The retry classifier could never return "retryable." FailureClass::Io/Timeout came only from OmniError::Io, which the engine has no construction site for. Every Lance and object-store failure arrived as an opaque string and landed in Unknown, which is not retried — so a transient S3 error pinned a graph permanently and full_jitter_backoff plus the whole Opening{next_retry} arm were unreachable code. The existing unit test passed only because it hand-built OmniError::Io values nothing emits. Now consumes the classification #491 derives at the substrate boundary, and the arm that sniffed error text for "internal schema v" is gone — a wire-visible field must not depend on internal prose.

One failed refresh could permanently kill reads. The non-retryable branch dropped the live handle and stored Unavailable, a terminal state with no way back — and because of the above, every recovery failure took it. Recovery is a write concern: a failed refresh() leaves the engine's live view intact, Dataset::restore appends rather than removes, reads pin an exact version, cleanup refuses while a sidecar is pending, and byte-destructive paths are guarded to objects absent from the live manifest. Serving is now a one-way door: a permanent failure is Blocked { retry_at: None } — writes blocked, reads intact. Unavailable is reachable only from Opening.

A newer recovery request could be clobbered. Attempts read state, refresh without the entry lock, then store a result derived from that read; the success path re-checked only that the graph was still Serving, which every write state satisfies. Entries now carry a generation, bumped per trigger and captured after the permit, and an attempt publishes only if it still holds.

Backoff never escalated. Coalescing reset attempts to zero, so sustained write traffic held the supervisor at its shortest delay indefinitely — each retry a full recovery sweep whose cost grows with commit depth. Coalescing now preserves the attempt count and any scheduled deadline.

Attempts were unbounded in time and awaited inline, holding one of four permits. Four graphs stalled on an unresponsive store starved every other graph, and the loop saw neither shutdown nor new notifications while one ran. Wrapping the attempt in a timeout would be wrong — a recovery mutates durable state under an armed sidecar, and dropping that future mid-restore is exactly what the tracked-write executor exists to prevent. So the 240s deadline bounds the wait, not the work: attempts are spawned into a TaskTracker the set owns, run to a terminal result, and publish under their own generation check. The loop starts no second attempt for a graph while one is in flight.

Also: typed GraphState with one conversion to the wire form (replacing a &'static str re-parsed by two matches that each silently reported "unavailable" for anything unrecognised); public 503s carry graph id and class only, never backend text, since that response is built in middleware ahead of any per-graph policy check; Retry-After only when a retry is actually scheduled; a graph-local invalid URI becomes an unavailable entry instead of aborting the process; one process-global attempt-permit pool; and SupervisorSet::idle() is removed so a live write executor can never be paired with a set that has no consumer for its wake-ups.

Verification

  • Classification mapping across all four storage kinds, including that retryable stays retryable.
  • a_trigger_arriving_mid_attempt_is_not_clobbered — drives the race through the attempt seam.
  • coalescing_preserves_the_backoff_ladder; a_permanent_recovery_failure_keeps_reads_available.
  • Public 503 asserts graph id + class and nothing else.
  • cargo test --workspace --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints — 75 suites, zero failures.
  • Workspace Clippy with warnings denied; cargo fmt --all --check.

Known, deliberate

mark_recovering still has no production caller at this commit — the caller is the tracked-write executor in #490. Reviewers checking this PR in isolation will correctly observe that the recovery half of the state machine has no producer yet; that is a property of the stack split, not a defect.

Cluster-quarantined graphs (recovery sidecars) are still filtered before settings are built, so they 404 rather than 503. Converting them to configured entries is follow-up work.

No storage-format migration.

Greptile Summary

The PR retains all configured graphs in the registry and supervises opening and recovery independently while exposing bounded availability metadata.

  • Adds atomic graph runtime states, retry classification, jittered backoff, and process-wide attempt limiting.
  • Returns structured unavailable-graph responses and configured graph status through the HTTP API.
  • Allows default boot with no healthy graphs while preserving strict all-graph startup.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported backend-diagnostic disclosure has been removed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/omnigraph-server/src/lib.rs Integrates configured-registry startup and emits unavailable responses using bounded state and failure-class values rather than backend diagnostics.
crates/omnigraph-server/src/registry.rs Introduces immutable configured entries with atomically readable serving, recovery, and unavailable runtime states.
crates/omnigraph-server/src/supervisor.rs Implements bounded per-graph opening and recovery attempts with classification, coalescing, and retry backoff.
crates/omnigraph-server/src/handlers.rs Lists configured graph availability and distinguishes unavailable configured graphs from unknown IDs.
crates/omnigraph-api-types/src/lib.rs Adds shared graph-state and structured unavailable-response DTOs.
openapi.json Updates the published API schema for graph status and unavailable responses.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Auth
    participant Registry
    participant Supervisor
    participant Engine
    Client->>Auth: "Request /graphs/{id}/..."
    Auth->>Registry: Resolve configured graph
    alt Graph is serving
        Registry-->>Client: Dispatch with live handle
    else Graph is opening or unavailable
        Registry-->>Client: 503 graph_unavailable
    end
    Supervisor->>Engine: Open or refresh
    alt Attempt succeeds
        Engine-->>Supervisor: Healthy handle
        Supervisor->>Registry: Mark ready
    else Retryable failure
        Engine-->>Supervisor: Classified failure
        Supervisor->>Registry: Publish retry metadata
        Supervisor->>Supervisor: Full-jitter backoff
    else Permanent failure
        Engine-->>Supervisor: Non-retryable failure
        Supervisor->>Registry: Mark unavailable/degraded
    end
Loading

Reviews (3): Last reviewed commit: "fix(server): give every AppState a live ..." | Re-trigger Greptile

Context used:

Comment thread crates/omnigraph-server/src/lib.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: d53d883da3

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

OmniError::Compiler(error) => {
GraphFailure::new(FailureClass::InvalidConfiguration, error.to_string())
}
error => GraphFailure::new(FailureClass::Unknown, error.to_string()),

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 Preserve retryability for Lance-backed open failures

When an S3/object-store outage makes DatasetBuilder::load fail during the initial graph open, the engine maps that failure to OmniError::Lance (crates/omnigraph/src/instrumentation.rs:551-554), but this catch-all classifies it as Unknown, which FailureClass::retryable rejects. The entry therefore becomes permanently unavailable, so the new supervisor never retries after storage recovers and operators must restart the process; preserve enough typed Lance/object-store error information to recognize transient failures.

Useful? React with 👍 / 👎.

/// Block new writes and coalesce a Full-recovery wake-up for a serving
/// graph. The prior blocking operation remains the public diagnostic until
/// recovery succeeds; later requests are never treated as replays of it.
pub async fn mark_recovering(

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 Connect RecoveryRequired results to the supervisor

When a served write leaves a durable sidecar and returns OmniError::RecoveryRequired, the handlers only convert it with ApiError::from_omni; a repository-wide search for mark_recovering finds no production caller of this method. Consequently the entry remains ready with write_ready=true, no notification reaches attempt_recovery, and the advertised background recovery path never runs; transition the graph at the request/error boundary and gate subsequent writes while recovery converges.

AGENTS.md reference: AGENTS.md:L166-L167

Useful? React with 👍 / 👎.

@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 d53d883. Configure here.

Comment thread crates/omnigraph-server/src/lib.rs
@ragnorc
ragnorc force-pushed the rfc-031-live-full-refresh branch from 9c09e5e to 3c06689 Compare August 12, 2026 20:55
Four defects, all in the same layer: the supervisor could not tell a retryable
failure from a permanent one, and could not tell its own conclusion from a
newer request.

**The retry classifier could never return "retryable."** `FailureClass::Io` and
`Timeout` came only from `OmniError::Io`, which the engine has no construction
site for. Every Lance and object-store failure arrived as an opaque string and
landed in `Unknown`, which is not retried — so a transient S3 error pinned a
graph permanently, and `full_jitter_backoff` plus the whole `Opening{next_retry}`
arm were unreachable code. The unit test passed only because it hand-built
`OmniError::Io` values nothing emits. Consume the classification the engine now
derives at the substrate boundary, and delete the arm that sniffed error text
for "internal schema v" — a wire-visible field must not depend on internal
prose.

**One failed refresh could permanently kill reads.** The non-retryable branch
dropped the live handle and stored `Unavailable`, a terminal state with no way
back — and because of the above, *every* recovery failure took it. Recovery is
a write concern: a failed `refresh()` leaves the engine's live view exactly as
it was, `Dataset::restore` appends a version rather than removing one, reads pin
an exact version, `cleanup` refuses while a sidecar is pending, and the
byte-destructive paths are guarded to objects absent from the live manifest. So
`Serving` is now a one-way door with respect to recovery: a permanent failure is
`Blocked { retry_at: None }` — writes blocked, reads intact. `Unavailable`
remains reachable only from `Opening`.

**A newer recovery request could be clobbered.** Attempts read state, refresh
without the entry lock, then store a result derived from that read; the success
path re-checked only that the graph was still `Serving`, which every write state
satisfies. A `mark_recovering` landing mid-attempt was silently marked `Ready`.
Entries now carry a generation, bumped on each trigger and captured after the
permit is acquired, and an attempt only publishes if it still holds.

**Backoff never escalated.** Coalescing reset `attempts` to zero, so sustained
write traffic against a broken graph held the supervisor at its shortest delay
indefinitely — each retry a full recovery sweep whose cost grows with commit
depth. Coalescing now preserves both the attempt count and any scheduled
deadline; a new trigger invalidates an in-flight attempt without pulling the
schedule earlier.

Also in this pass:

- `GraphAvailability.state` is a typed enum with one conversion to the wire
  form, replacing a `&'static str` re-parsed by two independent matches that
  each silently reported "unavailable" for anything unrecognised.
- Public 503s carry the graph id and a class, never backend text. That response
  is built in middleware, before any per-graph policy check, so it reaches
  principals a Cedar policy would deny — and storage errors carry bucket names
  and filesystem paths. The class summary stays on the policy-gated
  `GET /graphs`; the full diagnostic stays in structured logs.
- `Retry-After` is emitted only when a retry is actually scheduled.
- A graph-local invalid URI becomes an unavailable configured entry instead of
  aborting the process. Aborting made a typo *more* fatal than a graph that
  cannot open at all. Invalid ids and duplicate URIs stay fatal, now with the
  real reason recorded: the registry is keyed by `GraphId`, so an unparseable id
  has no key, and a duplicate URI is genuinely ambiguous ownership.
- One process-global attempt-permit pool. Two `Semaphore::new` calls meant the
  documented four-attempt bound was per-set, and the test asserting a
  process-wide bound was asserting a property the code did not have.
Neither `Omnigraph::open` nor `refresh()` was bounded in time, and both were
awaited while holding one of only four attempt permits. Four graphs stalled on
an unresponsive object store starved open and recovery for every other
configured graph, with no diagnostic — they simply sat in `Opening` or
`Recovering` forever. The supervisor loop also awaited each attempt inline, so
while one ran it observed neither shutdown nor new notifications, and
`SupervisorSet::shutdown` blocked on it.

The obvious fix — wrap the attempt in `tokio::time::timeout` — is wrong here. A
recovery attempt mutates durable state under an armed sidecar: it may restore
table A, then table B, then publish, then delete the sidecar. Dropping that
future at an arbitrary await leaves a partially compensated multi-table state.
That is precisely the failure mode the tracked-write executor exists to prevent
for HTTP writes; introducing it for recovery would be worse, not better.

So the deadline bounds the *wait*, not the work. Each attempt is spawned into a
`TaskTracker` the supervisor set owns. After 240 seconds the loop stops waiting,
logs, and moves on; the task keeps running to a terminal result and publishes
under its own generation check. The loop retains the handle and starts no second
attempt for that graph until the first finishes, so two refreshes never contend
on the engine's gates for the same graph. Shutdown takes the same path:
the loop can exit while an attempt it owns is still settling, and
`drain_attempts` waits for them under a caller-supplied bound.

`tokio-util` moves to the workspace dependency table, where the other shared
async crates already live.
@ragnorc
ragnorc force-pushed the rfc-031-graph-supervision branch from d53d883 to 953b208 Compare August 12, 2026 23:32
`AppState::open*` and `new_multi` paired a live write executor with
`SupervisorSet::idle()` — a set with no per-graph tasks. Those constructors are
public and used by embedders and the whole server test suite. A single
`RecoveryRequired` result, or a panic in a tracked write, marks the graph
recovering; with no supervisor there is no consumer for that wake-up, so the
graph stays write-blocked with `write_ready: false` until the process restarts.

The state was reachable only because the type system allowed it. Removing
`idle()` makes the combination unconstructible: every `AppState` supervises its
own registry. A set over an empty or already-serving registry costs one parked
task per graph, which is what the loop does at rest anyway.
@ragnorc ragnorc changed the title feat(server): supervise configured graph availability Supervise configured graph availability, with typed failures Aug 12, 2026
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