docs(reborn): design — write-behind lease durability + side-effect gate - #5249
serrrfirat wants to merge 2 commits into
Conversation
Council-synthesized design (Opus 4.8 + GPT 5.5 XHigh, fusion-design-council) for removing the deployed-Postgres lease-expiry / "scheduler_heartbeat_failed" churn that #5232 reduced but did not eliminate. Core: split durable writes into a liveness plane (heartbeats → per-replica write-behind cache + coalescing drain, safe to lose on crash) and a correctness plane (claim/complete/fail/block/idempotency/tool-side-effect commits → always synchronously durable). The load-bearing safety mechanism is a side-effect gate: before each side-effecting call, synchronously check the DURABLE lease runway and stop the run rather than dispatch without it, plus a run-level self-stop at 45s of durable-lag — both pure-local checks that hold during a Postgres partition, so a stale owner cannot keep producing side effects a reclaiming replica then repeats. Includes quantified recovery bounds (no false reclaim while skew<15s; dead-run strand <= TTL+poll+skew), a fault/latency-injection test plan driven through the scheduler, and a dark-mode, feature-flagged, reversible rollout safe on both Postgres and libSQL. Status: accepted by both council slots, no blockers. Design doc only — no code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds an ADR-style plan for write-behind lease durability, covering the problem statement, timing constraints, buffered heartbeat writes, dispatch-time side-effect gating, dual-backend expectations, validation and rollout steps, and an agreement ledger. ChangesWrite-behind lease durability plan
Estimated code review effort🎯 1 (Trivial) | ⏱️ ~2 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a design document for 'Write-Behind Lease Durability + Side-Effect Gate' to address runner heartbeat latency issues on Postgres/Supabase. The design splits writes into SyncCritical and AsyncLossTolerantCoalesced planes, introduces a LeaseWriteBehind cache with a background drain task, and implements a side-effect gate to prevent double execution during network partitions. The review feedback highlights several critical areas for refinement: ensuring the drain task's CAS skip condition also checks for fence changes, using safe/checked arithmetic for the freshness gate subtraction to prevent panics, running the voluntary self-stop check on a frequent cadence to avoid sampling delays, and correcting a mathematical inconsistency in the safety buffer calculation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| `LeaseWriteBehind` — per-replica `Arc` singleton: | ||
| - `dirty: std::sync::Mutex<HashMap<RunId, LeaseIntent>>`, `LeaseIntent{ expires_at, fence, last_durable_flush_succeeded_at, last_flushed_expiry, enqueued_at, consecutive_flush_failures }`. | ||
| - **Hot path `renew(run, fence, now)`** (30s heartbeat tick): `want = now + T_lease`; lock std mutex (NO `.await`, NO I/O); `if fence >= e.fence { e.fence = fence; e.expires_at = e.expires_at.max(want) }`. Non-blocking; cannot fail from Postgres latency. **The heartbeat tick no longer does a durable write → the single-heartbeat insta-fail is removed** (G1, G2). | ||
| - **Drain task** (one supervised `tokio::spawn` per replica): every `T_flush`, snapshot+coalesce (one row per run; **skip CAS if `expires_at == last_flushed_expiry`**), write via the existing `put_with_cas` sidecar with bounded concurrency; success → record `last_durable_flush_succeeded_at` + `last_flushed_expiry`; transient failure → keep dirty + bump `consecutive_flush_failures`; **CAS/fence conflict → mark reclaimed → cancel the local executor**. Durable write rate falls to ≤ 1 / `T_hb` per run (write *less*, not just *async*). |
There was a problem hiding this comment.
When skipping the CAS write in the drain task, ensure the skip condition checks both expires_at == last_flushed_expiry and that the fence (or any other lease metadata) has not changed. If a fence update or other critical metadata changes but expires_at remains identical (e.g., due to rapid successive renewals or clock resolution limits), skipping the CAS write would prevent the new fence from being persisted, potentially causing subsequent correctness writes to fail CAS validation against the stale database fence.
| ### 4.3 The side-effect gate (load-bearing correctness mechanism) | ||
| Reactive fencing alone is insufficient: between flush attempts a partitioned owner can keep dispatching real side effects until another replica reclaims → double execution. Bound exposure with two purely-local (no Postgres round-trip) checks: | ||
|
|
||
| 1. **Per-dispatch freshness gate (primary).** Synchronously, **immediately before each side-effecting tool/model call**: `remaining = last_flushed_expiry − now`; require `remaining > expected_op_duration + cancel_grace + S`. Else attempt **one synchronous flush**; if still no runway → **abort/pause the run** (`lease_degraded`). Evaluated **on the dispatch path itself**, off the 30s/5s cadence. **Uses the DURABLE `last_flushed_expiry`, never the in-memory `expires_at`** — a `renew` that never flushed grants NO runway. |
There was a problem hiding this comment.
When implementing the per-dispatch freshness gate, ensure that the subtraction last_flushed_expiry - now is performed safely to prevent panics or underflows. If last_flushed_expiry is less than now (which can easily happen during database lag or network partitions), a naive subtraction using unsigned duration types or std::time::Instant will panic. Use saturating subtraction or checked arithmetic (e.g., checked_sub or saturating_duration_since in Rust) to safely default remaining to zero or a negative duration, triggering the synchronous flush or abort path gracefully.
| Reactive fencing alone is insufficient: between flush attempts a partitioned owner can keep dispatching real side effects until another replica reclaims → double execution. Bound exposure with two purely-local (no Postgres round-trip) checks: | ||
|
|
||
| 1. **Per-dispatch freshness gate (primary).** Synchronously, **immediately before each side-effecting tool/model call**: `remaining = last_flushed_expiry − now`; require `remaining > expected_op_duration + cancel_grace + S`. Else attempt **one synchronous flush**; if still no runway → **abort/pause the run** (`lease_degraded`). Evaluated **on the dispatch path itself**, off the 30s/5s cadence. **Uses the DURABLE `last_flushed_expiry`, never the in-memory `expires_at`** — a `renew` that never flushed grants NO runway. | ||
| 2. **Run-level voluntary self-stop (backstop).** If `now − last_durable_flush_succeeded_at > D_stop (45s)`, cancel the run. `D_stop=45 < 60` (heartbeat→durable-expiry margin) leaves a `15s − S` buffer to stop **before** any other replica could legitimately reclaim. |
There was a problem hiding this comment.
To ensure the run-level voluntary self-stop is enforced promptly at D_stop (45s), the check should be evaluated on a frequent cadence (such as the 5s drain task or the executor's main loop) rather than only on the 30s heartbeat tick. If evaluated only during the 30s heartbeat, a flush failure occurring shortly after a heartbeat could experience up to a 30s sampling delay before the self-stop is triggered, potentially pushing the actual stop time past the safe D_stop window.
| A monotonic `fence` (generation) per run persisted on the lease sidecar AND validated on **every** correctness write — `complete`, `fail`, `block`, **tool-result/side-effect commit**, and recovery `reclaim` — not only the heartbeat CAS. A reclaim bumps/tombstones the fence; any stale-fence write fails CAS. **Idempotency records (SyncCritical) are the third line.** | ||
|
|
||
| ### 4.5 Bounds (explicit, named variables) | ||
| - **No false reclaim of a live run:** owner self-stops at `D_stop=45s`; durable lease valid `≥ 60s` past the last flushed heartbeat; `45 + S < 60` ⇒ owner stops before reclaim is possible (requires `S < 15s`). |
There was a problem hiding this comment.
There appears to be a minor mathematical inconsistency in the safety buffer calculation: If T_lease = 90s and the owner self-stops at D_stop = 45s of durable lag (measured from the last successful flush), the owner stops at t = 45s relative to that flush. Since the lease expires at t = 90s on the database clock, the other replica can reclaim at t = 90s - S. Therefore, the actual safety buffer before a legitimate reclaim is (90 - S) - 45 = 45s - S, rather than 15s - S. The 15s - S buffer would only apply if the lease duration was 60s or if D_stop was 75s.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/plans/2026-06-25-write-behind-lease-durability.md`:
- Around line 3-4: The plan status is too strong because the fence-vs-runner_id
durability decision is still unresolved in the document. Update the status block
in the accepted section to either reflect that the contract is not yet closed or
explicitly name the committed fallback/choice, and make sure the sections
referenced by the write-behind lease design are consistent with that decision so
the “Accepted”/“No unresolved blockers” wording is accurate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 453723cf-bff4-4970-9976-122501a610bd
📒 Files selected for processing (1)
docs/plans/2026-06-25-write-behind-lease-durability.md
| **Status:** Accepted (council signoff — anthropic-slot Opus 4.8: ACCEPT; openai-slot GPT 5.5 XHigh: ACCEPT_WITH_NONBLOCKING_NOTES). No unresolved blockers. | ||
| **Origin:** fusion-design-council (Opus 4.8 + GPT 5.5 XHigh), 1 draft round + 1 cross-review round + signoff. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Status overstates closure.
§6 still leaves the fence-vs-runner_id durability choice open (“MUST be explicit”, “Commit to one before enable”), so Status: Accepted / No unresolved blockers is misleading. Please either name the committed fallback here or downgrade the status until that enablement-critical contract is fixed.
Also applies to: 67-68, 114-115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/plans/2026-06-25-write-behind-lease-durability.md` around lines 3 - 4,
The plan status is too strong because the fence-vs-runner_id durability decision
is still unresolved in the document. Update the status block in the accepted
section to either reflect that the contract is not yet closed or explicitly name
the committed fallback/choice, and make sure the sections referenced by the
write-behind lease design are consistent with that decision so the
“Accepted”/“No unresolved blockers” wording is accurate.
|
Strong design — the durability-plane split (write-behind heartbeats vs synchronously-durable ownership/side-effect writes) is exactly right. A few things that may be worth folding in, from tracing the Postgres heartbeat path while debugging the "heartbeat could not be recorded" cascade: 1. The connection pool itself is likely the dominant bottleneck, and the doc does not address it. 2. Consider generalizing the coalescing drain beyond heartbeats to the append-only event log. 3. Relationship to #5234 (remove per-record lock convoys via shared 4. Drain/critical-write round-trips. Where the drain flushes a batch or commits a set of correctness writes, tokio-postgres pipelining (or a single transaction) would cut the cross-region round-trip count further. None of these block the design — just additive coverage so the implementation PR closes the cascade on all three layers (in-process convoy → #5234, pool starvation → pool size/timeout, sync heartbeat hot path → this PR). |
|
@henrypark133 |
Fold in PR review (henrypark133) + a durable-write hot-path audit. Adds §12 framing the deployed lease-expiry cascade as three independent layers: - Layer A: in-process lock convoy → #5234 (open); write-behind composes on the post-#5234 CAS path. - Layer B: pool starvation — DEFAULT_POSTGRES_POOL_MAX_SIZE=2 shared across all Postgres FS I/O. Notes the existing 30s checkout guard (closes the infinite hang) but flags pool-too-small + checkout(30s)>apply(15s); cheap mitigations (raise pool, reserve a critical connection, align checkout<apply) prior to and complementary with write-behind. - Layer C: the synchronous hot-path write map (events / governor / thread-append / lease / memory) with the write-behind-vs-batch-coalesce distinction. Events are the top batch-coalesce target (highest churn, O(1) INSERT no CAS); must stay DURABLE (source of truth), per-step flush to preserve live SSE. Memory stays synchronous (FTS-only, no embedding write; agent-initiated, low churn). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — this is exactly the systems-level framing the doc was missing. I verified each point against current 1. Pool — confirmed and amplified, with one correction. 2. Generalize coalescing to the event log — strongly agreed; an independent audit reached the same conclusion. The event-append plane is the highest-churn durable path per turn (~2M+2C+ appends), and each is a clean O(1) 3. #5234 — noted. Added as Layer A; doc now states the write-behind cache must compose on the post-#5234 4. Pipelining/transactions — added to Layer C for both the drain flush and any batched SyncCritical commit set, to cut cross-region round-trips. Net sequencing in the doc: (B) pool size + reserved connection [cheap, now] → land #5234 (A) → lease write-behind (this PR, C) → batch-coalesce events (C, top) → governor shard+coalesce → thread-append coalesce. Memory stays synchronous (verified FTS-only, no embedding write, agent-initiated). Pushed as |
|
🚅 Deployed to the ironclaw-pr-5249 environment in ironclaw-ci-preview
|
What
A design doc (no code) for eliminating the deployed-Postgres lease-expiry /
scheduler_heartbeat_failedchurn that #5232 reduced but did not remove. Adds docs/plans/2026-06-25-write-behind-lease-durability.md.Why
#5232moved runner heartbeats to a per-run durable sidecar, removing per-user contention — but the heartbeat is still a durable per-run CAS write through Postgres, so it's bound by per-write latency / pool pressure / cross-region RTT, and the scheduler still insta-fails a run on a single failed heartbeat. On the deployed Supabase instance this shows up as "constantly getting lease expired"; local libSQL (in-memory) never reproduces it.The design
Split durable writes into two planes:
renew(), so the single-heartbeat insta-fail disappears.The load-bearing safety mechanism is a side-effect gate: write-behind alone is not safe under a Postgres partition (a stale owner keeps executing tools while flushes fail, then another replica reclaims and re-runs → double side-effects). So, immediately before each side-effecting call, synchronously check the durable lease runway and stop the run rather than dispatch without it — plus a run-level self-stop at 45s of durable-lag. Both are pure-local checks needing no Postgres round-trip, so they hold during a partition.
Includes quantified recovery bounds (no false reclaim while clock-skew < 15s; dead-run strand ≤ TTL+poll+skew), a fault/latency-injection test plan driven through the scheduler, and a dark-mode, feature-flagged, reversible rollout safe on both Postgres and libSQL.
Provenance
Synthesized via a two-model design council (Opus 4.8 + GPT 5.5 XHigh): independent drafts → adversarial cross-review (which caught the partition/double-side-effect gap) → fusion → signoff. Final state: both slots accepted, no blockers. Agreement ledger is in §10 of the doc.
Status / next steps
Design only — accepted, not yet implemented. Two gates before any enable (both reviewers insisted): (1) decide explicitly whether the sidecar persists a
fenceor commits torunner_id-only CAS; (2) measure real Railway↔Supabase clock skew and confirm< 15s. Implementation would be a follow-up PR (starter: lease write-behind + side-effect gate; then the system-wideDurabilityClasslayer).🤖 Generated with Claude Code