feat(api): record durable merge gate requests at apply drive tails - #868
feat(api): record durable merge gate requests at apply drive tails#868aparajon wants to merge 4 commits into
Conversation
02dbe05 to
6ba60ea
Compare
4a57268 to
5bce323
Compare
When a drive settles an apply to terminal success, the target's live schema has changed and other open PRs' stored check state against that target is stale. The operator drive tails now record a durable check refresh request (idempotent per apply) as part of the terminal transition, before pending control requests are completed — the same ordering the terminal summary uses. Recording never fails the drive: a storage error is logged and counted, and the processor's backstop sweep re-records it. Rollbacks need no special casing — a rollback is an ordinary apply row settling to Completed, so the same tails cover it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heck refresh The refresh processor polls the durable request table, so a request recorded right after a tick waits a full poll interval before any sibling PR check moves. OnCheckRefreshRecorded lets the webhook handler register a wake-up that the drive tail invokes after a successful record; the durable row stays the source of truth, so a missed call (processor on another pod, callback unset) only costs poll latency, never the refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…time A gRPC/CLI-only server has no PR check state to refresh and no processor to drain refresh requests, so a drive tail recording there would leave rows pending forever. The webhook handler's registration of the recorded-notifier now doubles as the consumer signal: drive tails record (and wake the consumer) only when it is set, and skip with a debug log otherwise. The check is a nil test, so the drive tail pays nothing for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…requests The drive tail records the originating change as a provider-scoped change_key string via ChangeKeyForPullRequest, replacing the pull_request integer, and component prose follows the merge gate rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6ba60ea to
56faf88
Compare
|
🤖 Review findings - created by Kiran's code review agent - for pull/868, 56faf88. Verdict: 6 findings — 3 non-blocking (legacy claim-path gap, untested gate branches, future-fact docs), 3 suggestions. Non-blocking
General suggestions
The one thing that could have broken, verifiedThe riskiest mechanism is drive-tail coverage completeness combined with exactly-once recording: every path that newly settles an apply to terminal success must pass one of the three inserted
Verified correct
This review was generated by Claude Code (claude-fable-5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approving on Morgan's behalf (automated review).
Additive (+397/-0), CI green across the full suite, and the design decisions I probed hold up:
- The consumer gate is the right shape. Using the webhook handler's
OnMergeGateRecordedregistration as the "is there a GitHub runtime" signal means a gRPC/CLI-only server never records rows that nothing would ever drain, and it costs a nil check on the drive tail. Reusing an existing callback as the capability probe rather than adding a parallel config flag is the less error-prone option — the two can't drift out of sync. - Placement before control-request cleanup at all three tails, with the comment explaining that a cleanup error must not suppress the record. That's the same ordering
publishTerminalSummaryIfWonalready uses, so it's consistent rather than ad hoc. - Recording never fails the drive — reload error, nil apply, and
Recorderror all log and return, withRecordMergeGateRecordFailurecounted. A guardrail that can fail the thing it guards is worse than the gap it closes; this gets that right. - The wake-up is explicitly loss-tolerant and the durable row stays the source of truth. Worth noting the
!recordedbranch returns beforeOnMergeGateRecorded(), so a pod that records and dies before waking leaves the request to poll latency on any later re-drive — which is exactly what the doc says it's willing to pay, so it's disclosed rather than accidental. - Reloading the apply because "the derived-state write operates on a copy and does not mutate the caller's row" is a real trap avoided, not boilerplate.
One finding: a partially-succeeded apply mutates the target schema and records nothing.
if !state.IsState(apply.State, state.Apply.Completed) {
// Only terminal success mutates the target schema; every other outcome
// (still running, stopped, cancelled, failed, reverted) leaves sibling
// plan checks accurate.
return
}The gate is right; the stated justification is what doesn't hold. Checking the derivation in pkg/state/apply.go:
if counts[Apply.Failed] > 0 {
return Apply.Failed
}
...
if counts[Apply.Completed] == total {
return Apply.Completed
}Failed needs one failed task. Completed needs all tasks complete. So a ten-table apply where nine tables cut over and the tenth fails settles to Failed — and nine tables' live schemas have changed on the target. Same structure for Cancelled and Stopped (single-task-wins, checked before the all-complete rule), and an operator stopping a multi-table apply midway is not an exotic scenario.
The consequence lands squarely on this stack's premise: every sibling PR planning against those nine tables holds check state computed against a schema that no longer exists, and no durable breadcrumb is left. It's precisely the failure the PR opens by describing, reached through the partial path instead of the terminal-success one.
I'd be cautious about simply widening the state test — recording on Failed would fan out re-plans for applies that genuinely changed nothing, which has its own cost. The honest fix is to key on whether any task completed rather than on the apply's rolled-up state, since task completion is what actually mutates the target. If the intended scope really is terminal success only, the comment is the thing to fix: it currently asserts something about the other states that the derivation contradicts, and that's the kind of comment a later reader builds on.
Related, and I couldn't settle it from this diff alone: the backstop sweep is described as re-recording "anything missed here." Whether it re-records partial applies depends on whether it selects on completed applies or on completed tasks — worth confirming, because if it selects on apply state it inherits this same gap rather than covering it.
Nit, non-blocking: ChangeKeyForPullRequest renders a bare PR number with no repository qualifier. Repository is a sibling field on the row so the composite identity is fine — just flagging that the key is only meaningful paired with it, in case it ever travels alone.
Not blocking.
Records a durable merge gate request at the moment an apply settles to terminal success, so the schema change that just landed on a target is captured before anything else can lose it. Builds on #867, which added the table and store; #866 adds the processor that drains what this records.
The recording runs at all three operator drive tails — recovery, multi-operation drive, and pending-stop recovery — placed ahead of pending control request completion, the same ordering the terminal summary already uses. Only terminal success records. A rollback is an ordinary apply row settling to completed, so it is covered without a special case.
Gated on a consumer existing. A server with no code-host runtime has no PR check state to refresh and no processor to drain requests, so recording there would leave rows pending forever. The webhook handler's registration of
OnMergeGateRecordeddoubles as the consumer signal — a nil check, so a drive tail with no consumer pays nothing for the gate.The wake-up is a hint, never the record. After a successful record the drive tail invokes the same callback so a co-located processor drains immediately instead of waiting out its poll interval. The durable row stays the source of truth: a lost wake-up costs poll latency, never the re-plan.
Recording never fails the drive. A storage error is logged with the apply's triage attributes and counted, and the request is left for the processor's backstop sweep over completed applies with no request row. A sustained sweep-sourced record rate is the operator signal that drive tails are failing to record.
Invariants
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
mainas its base merges.🤖 Drafted by Armand's AI agent (Claude Fable 5)