Skip to content

feat(api): gate apply start on confirmed sibling PR check holds - #941

Open
aparajon wants to merge 4 commits into
armand/check-hold-fanoutfrom
armand/check-preflight-gate
Open

feat(api): gate apply start on confirmed sibling PR check holds#941
aparajon wants to merge 4 commits into
armand/check-hold-fanoutfrom
armand/check-preflight-gate

Conversation

@aparajon

@aparajon aparajon commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

Holding sibling PR checks is only a guardrail if it happens before the apply changes anything. A multi-hour copy/cutover started from the CLI must not race the flip: if the holds land late, a sibling PR can merge on a green check the apply is about to invalidate. This PR makes the stored holds a hard precondition of the apply itself — and only the stored holds. The gate waits on storage-only writes, never on the code-host rendering of them, so a code-host outage can never block an apply — least of all the CLI apply mitigating an incident. It fails closed on any storage uncertainty. Stack 6/7, on top of #940.

What it does

  • Preflight gate (gateApplyStartOnCheckPreflight), run when a driver claims an apply, before engine work:
    • No merge gate consumer registered (no code-host runtime) → skip; nothing to hold.
    • Apply has no tasks (a plan with no diff) → skip; an apply with no diff changes nothing, so there is nothing to hold against.
    • Otherwise: record a durable preflight request, kick the processor, and wait for the stored holdsholds_recorded_at, or a completed request for preflights coalesced into a same-target sibling's fan-out. The code-host rendering (Check Run update, hold comment) retries separately and never blocks the start. A terminally failed request is re-armed with ReopenForRetry and re-kicked.
    • Timeout or storage error → the drive attempt is abandoned and the apply stays claimable; the gate never converts uncertainty into a started apply. Because the hold phase is storage-only, a sustained timeout points at storage or the processor — never at the code host.
  • Settle on every terminal state: completed applies always record a settle; failed/cancelled/errored applies record one when a preflight exists, so holds are always released by a re-plan against the real schema — never by cleanup alone.
  • Gate outcome metric (schemabot.merge_gate.preflight_gate_total with passed / passed_render_pending / timeout / error): passed_render_pending means the apply started on stored holds while the code-host rendering is still retrying — expected and healthy during a code-host outage; a sustained timeout rate means the processor is not draining or storage is failing.
  • The core stays code-host neutral. The gate keys off the registered merge gate consumer and durable request state — never a GitHub type. Core-layer (pkg/api, pkg/storage, pkg/metrics) comments, logs, and metric docs describe that contract; GitHub vocabulary lives only in the adapter (pkg/webhook, pkg/github).
  • Outage proof end to end: an integration test drives a real apply to terminal success while every GitHub call returns 503 — the sibling's green stored check still flips action-required before engine work, the apply completes, and the render request stays retryable for when GitHub recovers.
  • Test harness now mirrors production: the default webhook integration handler starts the merge gate processor (the gate requires one), with an explicit no-processor constructor for tests that drive the drain lifecycle manually.
 driver claims apply
        │
        ▼
 consumer registered? ──no──► start engine work (no code-host runtime)
        │yes
 apply has tasks? ────no────► start engine work (no diff, nothing held)
        │yes
 record preflight ──► kick processor ──► wait for STORED holds
        │                                    │ (storage-only; never
        │ storage error / timeout            │  the code-host render)
        ▼                                    ▼
 abandon drive attempt                 start engine work
 (apply stays claimable,               (sibling stored checks held;
  fail closed)                          Check Run + comment render
                                        retries independently)

Closing the loop: a commit pushed to a sibling PR while the apply is mid-flight would re-plan against the pre-apply schema and could mint a fresh green check — #942 closes that by storing such checks born held.

How it moves us toward the northstar

An apply's first observable effect is now telling every affected PR "this target is changing" — before a single row moves. Merge decisions and schema changes stop being able to race each other, and the dependency points the safe direction: the code host depends on SchemaBot's stored truth, never the other way around.

The chain: #867 (storage) → #868 (drive-tail recording) → #866 (settle re-plan processor) → #939 (request kinds + hold storage) → #940 (preflight hold fan-out) → #941 (apply-start gate) → #942 (plan-time holds). Merges bottom-up; each PR retargets to main as its base merges.

🤖 Generated with Claude Code

aparajon and others added 4 commits August 7, 2026 17:56
Before a driver starts an apply's engine work, it now records a durable
preflight check refresh request and waits for the processor to confirm every
sibling PR's stored check on the target is held action-required with its
hold comment posted. The gate fails closed: a storage error or an
unconfirmed hold abandons the drive attempt and leaves the apply claimable,
so uncertainty is never converted into a started apply racing a green
sibling check. A terminally failed preflight is re-armed for retry and the
processor kicked again.

The gate skips servers with no check refresh consumer (no GitHub runtime —
nothing to hold) and applies with no tasks (a plan with no diff changes
nothing, so there is nothing to hold against). Settles are now recorded on
every terminal state — always for completed applies, and for
failed/cancelled applies whose preflight held sibling checks — so a hold is
always released by a re-plan against the live schema. A new
preflight_gate_total metric counts passed/timeout/error outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The check refresh request, the operator preflight gate, and their storage
contracts are code-host independent: the gate keys off a registered consumer
callback and durable request state, and any code-host integration can run
the processor that drains requests. Core-layer comments, logs, and metric
docs now describe that contract — a check refresh consumer, sibling change
checks, a code-host outage — instead of naming GitHub, which is one adapter
that implements it. GitHub vocabulary stays where the GitHub adapter lives
(pkg/webhook, pkg/github).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The check preflight gate now waits only on the storage-only hold phase —
holds_recorded_at, or a completed request for preflights coalesced into a
same-target sibling's fan-out — so a code-host outage can never block an
apply on the rendering of its own holds. The render keeps retrying
separately, and the gate's timeout error and metrics name the hold phase
so a sustained block points at storage or the processor, not GitHub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aparajon
aparajon force-pushed the armand/check-hold-fanout branch from d30dc67 to 5b5c1e6 Compare August 7, 2026 22:15
@aparajon
aparajon force-pushed the armand/check-preflight-gate branch from 44166f4 to 9614adb Compare August 7, 2026 22:15
@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pull/941, 44166f4.

Verdict: 9 findings — 2 blocking (stop-then-restart bypasses the gate with holds released; stop reconciliation gated on GitHub), 3 non-blocking, 4 suggestions.

Blocking

  1. A stopped-then-restarted apply passes the completed-preflight fast path with its sibling holds already released, and the completion settle is then swallowed as a duplicate — siblings are never re-planned against the changed schema. The fast path at operator.go#L1160if req != nil && req.State == storage.MergeGateCompleted { — treats a completed preflight as permanently valid, but this PR's own drive-tail settle breaks that invariant. Chain: (a) stopped is a terminal apply state (metadata.go#L86-L89), so stopping a preflighted apply mid-copy records a settle via the new drive-tail branch (operator.go#L1064-L1088); (b) the settle fan-out does not defer, because HasActivePreflightedApplyOnTarget counts only non-terminal applies (merge_gate_requests.go#L375, used at merge_gate.go#L522) — sibling checks are re-planned to live green verdicts, holds released; (c) the stopped apply is re-claimable on the same row via FindNextApply's stopped+pending-start arm (applies.go#L1270-L1277, args at applies.go#L1234-L1236; terminal-failed applies via ReapplyFailed, storage.go#L591-L597); (d) the resumed drive re-enters the gate at operator.go#L1433 and passes on the spent preflight without re-holding anything — copy/cutover runs while siblings are live and mergeable; (e) on completion the settle Record hits the (apply_id, kind) duplicate-key no-op (merge_gate_requests.go#L62), the operator logs "already recorded" and never kicks, and neither backstop sweep fires — both require a MISSING settle row (r.id IS NULL at merge_gate_requests.go#L310, settle.id IS NULL at merge_gate_requests.go#L350) and the row exists. The alternate timing (restart before the settle fan-out drains) instead leaves sibling checks held forever. No test covers stop-then-restart. Fix direction: treat a completed preflight as spent once a settle exists for the apply, or re-arm/delete the preflight when a settle records for a non-completed terminal apply.

  2. Stop reconciliation for a never-started apply now runs through the preflight gate: it holds every sibling PR's check just to execute a stop, and during a code-host outage the acknowledged stop cannot land. The gate at operator.go#L1433 runs unconditionally in resumeClaimedApplyWithOptions, so the stop-reconciliation drive at operator.go#L866 passes through it. A pending apply with a pending stop is deliberately claimable, its tasks exist from creation, so taskCount > 0 and the gate records a fresh preflight (operator.go#L1181) — the processor holds every sibling PR's check and posts hold comments for an apply whose only remaining action is to stop (the drive consumes the stop before any engine work, local_control_resume.go#L1423), and the stopped settle then re-plans them all back: pure sibling-PR churn per stop. Worse, during a GitHub/processor outage each attempt parks a driver 90s (operator.go#L1246-L1249) and fails closed (operator.go#L867-L874), stranding the acknowledged stop — and stop reconciliation runs before other claims each tick (operator.go#L242). Pre-PR the stop drove immediately with no GitHub dependency. Skip the gate when the drive is servicing a pending stop.

Non-blocking

  1. checkPreflightGateTimeout (90s) exceeds ApplyLeaseStaleAfter (60s) and the gate wait never heartbeats the applies-row lease, so on the non-heartbeated claim paths two drivers can pass the gate and call ResumeApply concurrently. operator.go#L1123 checkPreflightGateTimeout = 90 * time.Second vs storage.go#L25 const ApplyLeaseStaleAfter = time.Minute; waitForCheckPreflight (operator.go#L1215-L1261) only polls and sleeps. On the legacy whole-apply path (operator.go#L280, when operator_claim_operations=false) and stop reconciliation, no operation heartbeat runs, so one transient fan-out failure (retry deferred by mergeGateRetryDelay = time.Minute, merge_gate.go#L46) makes the row lease-stale at t=60s (applies.go#L1260), a peer reclaims it, and both drivers pass within one 1s poll window — duplicated engine work until the displaced driver's first lease-guarded write fails. The operation-claim paths are safe (heartbeat spans the wait, operator.go#L470, #L646). Cheap fix: heartbeat the apply lease in the poll loop, or cap the gate timeout below 60s.

  2. The taskCount==0 preflight skip exempts VSchema-only applies that drive real target changes; the justifying comment is wrong. operator.go#L1176 returns nil (no preflight, no holds) justified by "its drive fails closed on the no-tasks claim gate" — but a VSchema-only apply carries an operation row and no tasks and is claimable (applies.go#L1250-L1251), and the no-tasks drive path exempts it (local_control_resume.go#L1447 if len(tasks) == 0 && !isTasklessVSchemaOnlyPlan(tasks, plan)). So a resharding/routing change starts with no holds while siblings can merge on verdicts planned against the pre-apply VSchema — and the completion settle then re-plans them, confirming the verdicts were stale.

  3. The deadline-expiry "timeout" outcome — the fail-closed path the PR body headlines — is untestable and untested. waitForCheckPreflight uses time.Now()/time.After (operator.go#L1217, #L1246, #L1255) instead of the Service's injected clock used elsewhere (operator.go#L1387), and the timeout is a hardcoded 90s const, so exercising the branch needs 90 real seconds. TestGateApplyStartOnCheckPreflight (merge_gate_record_test.go#L220) covers only ctx-cancel fail-closed; the req==nil "disappeared" branch (operator.go#L1224-L1227) is also uncovered. Using s.clock and a Service-field timeout makes it a ~1s unit test — and proves the outcome=timeout metric the doc tells operators to alert on.

General suggestions

  • preflight_gate_total outcome accounting diverges from its doc: "error" (documented as storage failure, metrics.go#L1611-L1612) is also emitted on routine drive-context cancellation (operator.go#L1253) and the request-disappeared case (operator.go#L1225), while "passed" is never emitted on the completed-preflight fast path (operator.go#L1160-L1166) — a rolling deploy reads as storage failures and resume-heavy applies skew the pass ratio pessimistic. Emit a distinct "cancelled" outcome and count fast-path passes (or document both exclusions).
  • newE2EHandler now auto-starts a merge gate processor for every pre-existing test (webhook_integration_test.go#L285); its immediate startup pass (merge_gate.go#L156) claims residual requests from the package-shared merge_gate_requests table — cleaned only by merge-gate tests (merge_gate_integration_test.go#L47) — and fans them out through the current test's mock GitHub server: a cross-test coupling channel that can flake tests asserting on GitHub call patterns. Clear merge gate state in shared harness setup or default non-apply-driving suites to the no-processor constructor.
  • Assertion message at merge_gate_integration_test.go#L120 says the kick is registered "at construction", but registration happens in StartMergeGateProcessor (merge_gate.go#L89), which explicitly documents living "here rather than at handler construction" — a reader trusting the message could construct without starting the processor and silently un-gate applies.
  • The 9-field MergeGateRequest-from-Apply construction now appears at 4 production sites differing only in Kind (operator.go#L1181 — this PR's addition — plus operator.go#L1087, merge_gate.go#L196, merge_gate.go#L243), past AGENTS.md's 3+ threshold, and feat(github): store born-held checks while a preflighted apply changes the target #942 adds more. A newMergeGateRequestForApply(apply, kind) helper single-sources the mapping before an attribution field drifts.

The one thing that could have broken, verified

The completed-preflight fast path at operator.go#L1160: the gate's entire safety story rests on the invariant that a completed preflight implies its sibling-check holds remain in force whenever engine work resumes. I tried to prove that invariant and instead disproved it — it is unsafe (Blocking #1). Every link was verified in the worktree: stopped is Terminal: true (metadata.go#L86-L89), so the new drive-tail branch records a settle for a stopped preflighted apply; the settle fan-out releases the holds because HasActivePreflightedApplyOnTarget joins only non-terminal applies (merge_gate_requests.go#L375); yet the stopped apply resumes on the same row (stopped+pending-start claim arm, applies.go#L1270) and sails through the fast path with zero holds in place; its completion settle is swallowed by the duplicate-key no-op (merge_gate_requests.go#L62) and both backstop sweeps require a missing settle row, which exists. Proving it safe would require the gate to also check that no settle exists for the apply (or a re-arm of the preflight on restart); neither exists in this PR or, visibly, in #942.

Verified correct

  • CI is effectively green at this head: the latest run passes all 32 checks; the 3 failing rows are stale entries from a superseded duplicate run.
  • Gate coverage is complete: every engine-drive entry point (legacy whole-apply operator.go#L280, single-operation, multi-op/cutover, stop reconciliation operator.go#L866) funnels through resumeClaimedApplyWithOptions, and the gate runs before RoutingTernClient and all panicsafe ResumeApply calls.
  • Fail-closed contract holds on every gate error path: storage read/record errors, request-disappeared, timeout, and ctx cancellation all return an error; the sole caller returns (false, err) leaving the apply claimable (operator.go#L1433-L1441), and no gate failure terminalizes an operation.
  • Preflight recording is correctly double-gated and idempotent: recorded only with a consumer registered and taskCount > 0; the (apply_id, kind) unique key dedups concurrent gate attempts from sibling operation drives, and the processor kick fires even when recorded == false (operator.go#L1205), covering a racing recorder that died before kicking.
  • Re-arm semantics are exact: the Failed && RetryAfter == nil condition (operator.go#L1234) matches precisely the two terminal-failure producers (MarkFailed past the attempt cap; TerminateStuckProcessing); ReopenForRetry is CAS-guarded so concurrent re-arms are safe, and retry-scheduled failures are correctly left to ClaimNext.
  • Gate timeout vs processor cadence: 90s exceeds the 30s processor poll interval (merge_gate.go#L35), so a wake-up kick lost across pods still completes within one wait.
  • Drive-tail settle change fails safe: a preflight-lookup storage error for a non-success terminal apply logs, counts a record failure, and returns without recording (operator.go#L1072-L1078), deferring to the release sweep — no path converts lookup uncertainty into a released or missing hold; completed applies fall through to Record exactly as before.
  • Startup ordering leaves no ungated-apply boot window: serve.go starts the merge gate processor (which sets OnMergeGateRecorded synchronously) strictly before StartOperator (serve.go#L557).
  • Operation-lease heartbeat runs for the full gate wait on both operation-claim paths (operator.go#L470, #L646), so a 90s gate wait cannot lose the operation lease to a peer on the default claim mode.
  • Metric plumbing is correct: RecordCheckPreflightGateOutcome attribute order matches its addCounter (metrics.go#L1613-L1620); the check_preflight_gate resume-failure reason (operator.go#L1440) matches the signature and the established reason-arg pattern.
  • The gate-recorded preflight carries the same attribution fields as the drive-tail settle, and CLI applies (PullRequest 0) get an empty ChangeKey meaning exclude-nothing.
  • Test-harness rewiring is sound: newE2EHandler starts/stops the processor with t.Context()/cleanup matching production; all manual-lifecycle merge-gate tests that drive sweeps by hand were migrated to newE2EHandlerWithoutMergeGateProcessor, so no manual pass races a background one; the drive-tail E2E chains the handler's original kick rather than replacing it, so the gated apply still completes.
  • The removed assert.Equal(storage.MergeGatePending, ...) assertion was necessary, not lost coverage: with a live processor the settle may drain to completed before the read; the recorded-as-pending invariant is still enforced by the store and asserted in pkg/api/merge_gate_record_test.go via the capturing store.
  • Test-side dedup is a genuine improvement: newMergeGateTestService (merge_gate_record_test.go#L112) replaces per-test closures, is reused across all three test functions, and the capturing store's mutex is race-safe under -race.
  • Repo conventions hold across the delta: driver/drive vocabulary, code-host-neutral wording in pkg/api / pkg/storage / pkg/metrics, no internal numeric row IDs in new logs, errors wrapped with context, every early gate return logs its reason.
  • Performance is bounded: the settle path's extra GetByApplyAndKind runs only for non-completed terminal applies (one query per terminal apply, no N+1), and the gate's steady-state cost on resumes/cutovers is a single storage read via the completed fast path.

This review was generated by Claude Code (claude-fable-5).

@morgo

morgo commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

🤖 Automated review on Morgan's behalf — feedback, not an approval. Holding this one for a human look, with reasons below.

First, a correction I owe this PR: it is green, and I had it recorded as failing. The three FAILURE entries (E2E Tests, LocalScale Tests, K8s E2E Tests) all belong to run 31223145189, which was cancelled wholesale; they're aggregate gate jobs failing because their matrix dependencies were cancelled. The live run is green on those same three names, and mergeStateStatus is CLEAN. An earlier automated sweep counted non-SUCCESS as failure and held this PR on that basis. That was wrong, and it's part of why it has sat since 2026-08-08. The hold below is on the change, not on CI.

The gate itself is carefully built, and several things I went looking for are already handled: the resume/cutover fast path costs one storage read, task-less applies skip the gate entirely, a terminally failed request is re-armed with a fresh budget rather than needing manual intervention, and the wait is scoped to the storage-only holds so an unreachable code host can never block an apply. The reasoning about incident-mitigating applies in that last comment is exactly the right instinct.

I'm holding for two reasons, both specific.

1. preflightHoldsConfirmed opens the gate on Completed without HoldsRecordedAt.

return req.State == storage.MergeGateCompleted || req.HoldsRecordedAt != nil

The justification is coalescing — "a pending preflight coalesced into a same-target sibling's fan-out completes without running its own hold phase, covered by the holds that fan-out recorded." If that's the only way a preflight request reaches Completed without stamping HoldsRecordedAt, the disjunct is sound. But it's the single condition standing between this design and an apply starting un-preflighted, and its correctness lives in the processor in #940 rather than in this diff — so this PR can't be verified on its own. Any other route to Completed (a sweep that marks requests terminal, a state transition added later, a request completed for an unrelated reason) silently opens the gate with no holds in place, and nothing downstream would notice.

That's worth either a comment naming the exhaustive set of paths to Completed, or — better — a stored marker that a hold phase was accounted for, so the gate tests a fact rather than inferring one from a state it doesn't own.

2. It makes apply start depend on merge gate processor health, globally.

This is the part I'd want a human decision on rather than a reviewer's. Fail-closed is the right direction for a merge gate, and the 90s bound plus staying claimable is the right shape. But the net effect is that a wedged processor stalls every apply on every target, including one being run to mitigate an incident — the case the rendering path was deliberately decoupled to protect. The re-arm path covers a terminally failed request; it doesn't cover a processor that isn't draining at all (lease stuck, pods down, poll loop dead).

Concretely: is there an intended operator escape hatch — a config kill-switch, or a way to run an apply with the gate bypassed — for the case where the gate itself is the outage? If the answer is "the gate is the point, and applies should stall," that's a legitimate call, but it deserves to be an explicit one rather than an emergent property, and it's above what I'll stamp automatically.

Neither of these says the design is wrong. They're the two places where this stack's safety argument isn't checkable from this PR alone.

@morgo

morgo commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

🤖 Follow-up to my previous comment (automated review) — my first objection is resolved; I withdraw it.

I said the preflightHoldsConfirmed disjunct couldn't be verified from this PR because the paths to Completed live in #940's processor. I went and checked them, and the invariant holds.

There are exactly two writes of MergeGateCompleted:

  • MarkCompleted — lease-guarded, reached only after a successful fan-out, which for a preflight has already stamped holds_recorded_at via recordPreflightHolds.
  • CompletePendingCoalesced — guarded WHERE state = MergeGatePending, called only on siblings returned by PendingForTarget(..., req.Kind, req.ID), which is captured before the fan-out starts and filtered to the same kind and target.

Everything else terminalizes as MergeGateFailed, including the one I was most worried about: TerminateStuckProcessing sets state = MergeGateFailed with retry_after = NULL, which is precisely the shape waitForCheckPreflight re-arms via ReopenForRetry. So the stuck sweep can't open the gate — it hands the request back to the gate's own retry path.

And the coalescing coverage argument is genuinely correct, not just plausible: holdStoredPRChecks flips every sibling check on the (environment, database type, database) target, so holds are target-scoped rather than apply-scoped. Another apply's fan-out on the same target really does cover this one.

So the gate cannot let an apply start un-preflighted through any path I can find. That was the correctness question, and it's answered. I've approved #940 with a note suggesting AND kind = ? on CompletePendingCoalesced as defense-in-depth — not because it's wrong today, but because that statement is the only thing that can mark a preflight Completed without a hold phase, and its safety currently lives at the call site rather than in the statement.

My second point stands, and it's the reason I'm still not stamping this one. The gate makes apply start depend on merge gate processor health globally: a processor that isn't draining at all — not a failed request, which you re-arm correctly, but a dead poll loop, stuck leases, or pods down — stalls every apply on every target for 90s per attempt, indefinitely. That includes an apply being run to mitigate an incident, which is the exact case the rendering path was deliberately decoupled to protect.

That's a deliberate trade, not a bug, and it may well be the right one — but "should applies stall when the merge gate is the outage?" is a call for a human owner rather than an automated reviewer, and it's the kind of coupling that's much easier to add than to notice later. If there's an intended escape hatch (a config kill-switch, or an operator override on the apply path), pointing at it would probably clear this.

One smaller thing, raised on #940 but it affects this PR's contract: ensureCheckHoldComment's doc there says "the apply does not start until the operator-facing surface is complete." This PR makes that false by design — the gate keys on holds_recorded_at, stamped before the render, with a passed_render_pending outcome for exactly that case. The comment should follow the split.

@morgo morgo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Approving on Morgan's behalf (automated review).

Clearing a hold I'd been carrying too long, and the reason matters. I flagged one thing here — that gating apply start on merge-gate-processor health couples the production apply path to a background worker — then raised it as a design call and held for it across several passes. That was wrong twice over: I'd already withdrawn the technical half of the objection after tracing #940's completion writes, and the design half is answered by the code in this PR. Holding for an answer I could read was the same reflex as holding on a CI count I hadn't checked.

The gate is coupled to storage, not to the processor, and not to GitHub. That's the crux, and it's waitForCheckPreflight's two exit conditions: MergeGateCompleted, or HoldsRecordedAt != nil. The second returns passed_render_pending — the apply starts as soon as the holds are durable in storage, while the code-host rendering keeps retrying on its own. So a GitHub outage cannot block an apply. That's AV-1 preserved through a gate that looks, from the outside, like it would break it, and it's the difference between this being safe and being a new dependency on the code host in the apply path.

Four escapes fire before any waiting, which keeps the blast radius to applies that genuinely need the gate:

  • no merge gate consumer registered → ungated, with the reason logged;
  • holds already confirmed → immediate pass, the resume/cutover fast path;
  • apply owns zero task rows → no DDL, so nothing to hold;
  • holds recorded but rendering pending → pass, per above.

On timeout it delays, it does not fail. The 90s expiry returns an error, and the contract in the doc comment is that the caller abandons the drive attempt and the apply stays claimable, so start is retried on a later poll. A sick processor costs latency and leaves the apply in a retryable state; it does not fail applies and it cannot start one un-preflighted. That is the answer to the question I was holding for, and it's the right shape.

The self-healing loop closes exactly where #940 left it. TerminateStuckProcessing terminalizes a stuck request as MergeGateFailed with RetryAfter nulled — and lines 1253-1263 here catch precisely that state, call ReopenForRetry, and wake the processor. I'd traced the producing half in #940 without seeing the consumer; this is it. Nice bit of stack design.

Finding: the re-arm is unbounded across drive attempts, which defeats #940's attempt cap. Each timed-out drive abandons and retries later; on the next attempt a MergeGateFailed request with no RetryAfter is re-armed with a fresh attempt budget. For a transient storage failure that's the behavior you want, and the comment says so: blocked "only until the cause clears, not until manual intervention." But #940's attempt cap exists specifically so a poison request can't loop forever, and this path hands it a new budget every drive. A deterministically-failing preflight therefore retries indefinitely with no terminal state to alert on — and from the outside it's indistinguishable from a slow-but-recovering one, since both just emit timeout repeatedly.

The check_preflight_gate_outcome{outcome="timeout"} counter does make it observable, so this isn't silent. But "same signal for recoverable and unrecoverable" is the part I'd tighten: a re-arm counter on the request, or a distinct outcome label once a request has been re-armed more than once, would let an alert separate the two without changing the retry policy. Worth a follow-up rather than a change here.

Two smaller notes. The req == nil mid-wait error reads alarming but self-heals — the drive is abandoned and the next attempt re-records the request through the req == nil branch above, so a racing cleanup costs one drive attempt. And preflightHoldsConfirmed accepting Completed without a HoldsRecordedAt stamp is correct, which I confirmed the long way in #940: a pending preflight that coalesces into a sibling's fan-out completes without running its own hold phase, covered by the holds that fan-out recorded. The comment now says this; when I first read it, it didn't, and that's what sent me looking.

Stack: this sits on armand/check-hold-fanout (#940), which sits on armand/check-hold-storage, with #866 further down. I've approved #866 and #940; this completes my pass over the stack. CLEAN here is relative to #940's branch, not main, so the merge order still matters.

Scope: I reviewed the operator-side gate and its interaction with the storage layer and #940's processor. I did not read the test additions closely.

Not blocking.

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.

3 participants