RFC-029: Write-protocol lifetime ownership and recovery barriers#378
RFC-029: Write-protocol lifetime ownership and recovery barriers#378ragnorc wants to merge 1 commit into
Conversation
…n barriers Proposes three coordinated, independently landable changes closing the class behind the interrupted-mutation availability incident: engine-owned write-protocol lifetime (cancellation shielding, staged server-first), full recovery escalation at the write-entry heal under exclusive gates (restart barrier -> gate barrier), and server boot supervision that makes quarantine an observable, converging state. The RFC-022 commit protocol, Armed/EffectsConfirmed classification, fail-closed ambiguity, and the single-writer-process boundary are explicitly unchanged. Every motivating claim is validated against current code and Lance upstream source at the pinned rev, including decoding the incident's _versions/18446744073709551611.manifest as an ordinary V2-named commit of table version 4 and locating Lance's verbatim orphaned-transaction- file warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqR7rPVfAT7UMiy85pG9b5
| (`run_mutate`, `run_ingest`, schema apply, branch create/delete/merge, | ||
| maintenance): | ||
|
|
||
| ```rust | ||
| let engine = Arc::clone(&handle.engine); | ||
| let task = tokio::spawn(async move { |
There was a problem hiding this comment.
Detached Work Releases Admission
The example captures only the engine call, while the current handler owns its admission guard outside this spawned future. When the client disconnects, the request drops that guard even though the write continues, so repeated disconnects can exceed the actor's concurrency limit and contradict the admission contract stated below.
Context Used: AGENTS.md (source)
|
|
||
| ### 5.1 Design | ||
|
|
||
| The engine keeps deciding once and failing closed; the server gains the | ||
| supervision loop it currently lacks. Division of labor: | ||
|
|
||
| - **Registry state.** A graph whose open fails enters | ||
| `Quarantined { since, attempts, last_error, retry_at }` in the registry | ||
| instead of vanishing from the handle set. (The registry's documented `Gone` | ||
| direction already anticipates non-serving states.) | ||
| - **Re-open loop.** One supervision task per quarantined graph retries the | ||
| full `open_single_graph` with capped exponential backoff plus jitter | ||
| (proposed: 5 s initial, ×2, 10 min cap; final values are an unresolved | ||
| question below). The retry unit is deliberately the *whole open*: recovery | ||
| is idempotent by its replay-bounded plans and pinned by the | ||
| convergence-idempotent roll-forward regression, so re-driving it is always | ||
| safe, and no per-I/O retry policy is smeared through the recovery module. |
There was a problem hiding this comment.
All-Quarantined Boot Cannot Recover
When every configured graph fails its initial open, the current startup path aborts before the server and supervision loop can run. A single-graph deployment with the transient S3 failure described by this RFC therefore still exits instead of entering quarantined and retrying; default subset mode must explicitly permit zero serving graphs when quarantined entries exist.
Context Used: AGENTS.md (source)
| The engine keeps deciding once and failing closed; the server gains the | ||
| supervision loop it currently lacks. Division of labor: | ||
|
|
||
| - **Registry state.** A graph whose open fails enters | ||
| `Quarantined { since, attempts, last_error, retry_at }` in the registry | ||
| instead of vanishing from the handle set. (The registry's documented `Gone` | ||
| direction already anticipates non-serving states.) |
There was a problem hiding this comment.
Retry Tasks Lack Generation Fencing
The proposed per-graph retry loop has no cancellation or generation rule for a graph removed or reconfigured while it is quarantined. If runtime registry replacement is added as specified, an older delayed retry can keep running or publish a handle from stale startup configuration after a newer registry state exists, causing an obsolete graph or URI to become serving again.
Context Used: AGENTS.md (source)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb55888a10
ℹ️ 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".
| let task = tokio::spawn(async move { | ||
| engine.mutate_as(&branch, &query, &name, ¶ms, actor_id.as_deref()).await | ||
| }); |
There was a problem hiding this comment.
Move admission guard into the spawned task
In the current run_mutate path the per-actor permit is acquired before this call (handlers.rs:686-689). If Stage 1 follows this snippet, the spawned task owns only the engine and inputs; _admission stays in the handler and is dropped when the client disconnects, releasing the slot while the write continues. That violates this RFC's admission invariant and lets disconnecting clients bypass per-actor concurrency limits, so the spawned future needs to own the guard or the whole admitted operation must be spawned.
Useful? React with 👍 / 👎.
| 1. **PR-1 (W1 Stage 1):** server-boundary shield + the two W1 tests + stale | ||
| comment fix. Smallest change, kills the incident's trigger. |
There was a problem hiding this comment.
Move engine-cancellation test to Stage 2
PR-1 is scoped to the HTTP server-boundary shield, but requiring both W1 tests here also includes the failpoint test above that drops the engine caller future directly. That case remains cancellable until PR-4's engine-level Arc/inner-handle restructure, so PR-1 cannot turn its required red test green. Split the plan so PR-1 only tests a dropped HTTP client and reserve the direct future-drop test for Stage 2.
Useful? React with 👍 / 👎.
What & why
This RFC proposes three coordinated changes to close structural defects in write-protocol cancellation safety and recovery residual handling:
W1 — Engine-owned write-protocol lifetime: Shield armed write protocols from caller cancellation by spawning protocol execution as a detached task. Caller disconnection means "stopped waiting for the result", never "abandoned the protocol".
W2 — Full recovery at the write-entry barrier: Escalate rollback-class recovery residuals to
RecoveryMode::Fullunder exclusive admission at the write-entry heal, instead of parking them for process restart. ResolvesRecoveryRequiredas a transient condition.W3 — Boot supervision: Replace silent graph exclusion on open failure with an observable
quarantinedregistry state and a capped-backoff re-open loop, making recovery from transient substrate errors automatic.Together these establish the invariant: Caller fate is not a protocol participant. An armed graph-write protocol completes or compensates under engine ownership regardless of caller cancellation, and resolving any recovery residual requires only exclusive gate acquisition — never process restart.
Backing issue / RFC
docs/rfcs/0029-write-lifetime-and-recovery-barriers.mdMotivation
A validated production incident showed the chain of failures: client timeout interrupted a multi-statement delete mid-protocol, leaving an
Armedsidecar; the live-handle heal is roll-forward-only and parks rollback-class residuals; the next write hit a stale manifest version; boot recovery hit a transient S3 error and silently quarantined the graph until redeploy. Every link was validated against current code (Lance 9.0.0-rc.1 at pinned rev).The RFC documents this incident in detail (§1) and validates the mechanism at each step. The long-run liability: cancel-safety is maintained by audit today, and every write-path change must re-reason about cancellation at every await. W1 replaces that with a structural property.
Design summary
W1 (Stage 1 — server boundary): Apply the existing spawn-and-clone idiom from
server_exportto every_aswriter handler. The HTTP response contract is unchanged; on client disconnect, the spawned task runs the protocol to terminal state (success or error) while the request future drops.W1 (Stage 2 — engine level): Move the shield into the engine via
Arc<Self>receivers or anArc<OmnigraphInner>split (shape TBD in review), so the property holds for embedded SDK callers.W2: When the write-entry heal encounters a rollback-class sidecar, escalate it to
RecoveryMode::Fullunder exclusive admission + existing schema → branch → table gates, instead of parking it. The heal re-reads the sidecar under those gates (existing behavior), dispatches withFullmode (new), and the triggering write proceeds from a fresh capture.W3: A graph whose open fails enters
Quarantined { since, attempts, last_error, retry_at }in the registry. One supervision task per quarantined graph retries the fullopen_single_graphwith capped exponential backoff (proposed: 5 s initial, ×2, 10 min cap).GET /graphsgains a per-graphstatusfield (serving|quarantined) with quarantine detail.Safety argument
https://claude.ai/code/session_01EqR7rPVfAT7UMiy85pG9b5
Note
Low Risk
Documentation-only RFC addition; proposed behavioral changes are described for future PRs, not shipped here.
Overview
Adds draft RFC-029 (
docs/rfcs/0029-write-lifetime-and-recovery-barriers.md) — design-only; no runtime changes in this PR.The RFC responds to a validated production chain (client timeout →
Armedsidecar → writes wedged until restart → boot quarantine on transient S3). It keeps RFC-022’s commit protocol and proposes three landable workstreams:W1 — Cancellation shielding: Spawn write-protocol work so disconnect means “stopped waiting,” not “aborted mid-arm.” Stage 1:
tokio::spawnat server_ashandlers (same pattern asserver_export). Stage 2: engine-level'staticexecution for SDK callers.W2 — Write-entry full recovery: Escalate rollback-class sidecars from roll-forward-only live heal to
RecoveryMode::Fullunder exclusive admission plus existing gates, soRecoveryRequiredclears on the next write instead of requiring restart.W3 — Boot supervision: Registry
quarantinedstate, capped-backoff re-open loop, and additiveGET /graphsstatus(serving|quarantined).Proposes invariant “caller fate is not a protocol participant,” documents contract/behavior ledger (503 lifetime, admission on disconnect, quarantine visibility), test-first evidence plan, and a four-PR rollout order.
Reviewed by Cursor Bugbot for commit bb55888. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR defines RFC-029 for cancellation-safe writes and automatic recovery. The main changes are:
Confidence Score: 4/5
The RFC's admission ownership and quarantine lifecycle need fixes before merging.
docs/rfcs/0029-write-lifetime-and-recovery-barriers.md
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD C[HTTP or SDK caller] --> A[Admission] A --> T[Engine-owned write task] T --> P[Arm recovery sidecar] P --> E[Apply table effects] E --> M[Publish manifest] M --> D[Delete sidecar] C -. disconnect .-> X[Caller stops waiting] X -. task and admission continue .-> T R[Next write] --> H[Exclusive write-entry heal] H --> F[Full recovery] F --> T B[Server boot] --> O[Open graph] O -->|success| S[Serving] O -->|failure| Q[Quarantined] Q -->|backoff retry| O Q --> G[GET /graphs status]%%{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"}}}%% flowchart TD C[HTTP or SDK caller] --> A[Admission] A --> T[Engine-owned write task] T --> P[Arm recovery sidecar] P --> E[Apply table effects] E --> M[Publish manifest] M --> D[Delete sidecar] C -. disconnect .-> X[Caller stops waiting] X -. task and admission continue .-> T R[Next write] --> H[Exclusive write-entry heal] H --> F[Full recovery] F --> T B[Server boot] --> O[Open graph] O -->|success| S[Serving] O -->|failure| Q[Quarantined] Q -->|backoff retry| O Q --> G[GET /graphs status]Reviews (1): Last reviewed commit: "Add RFC-029: write-protocol lifetime own..." | Re-trigger Greptile
Context used: