Skip to content

fix(operator): pace the automatic retries of an interrupted apply, and skip them when the target's verdict is final - #1041

Open
aparajon wants to merge 10 commits into
mainfrom
armand/retry-budget-backoff
Open

fix(operator): pace the automatic retries of an interrupted apply, and skip them when the target's verdict is final#1041
aparajon wants to merge 10 commits into
mainfrom
armand/retry-budget-backoff

Conversation

@aparajon

@aparajon aparajon commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

A transient failure moves an apply to failed_retryable and a recovery driver redispatches it, up to a fixed budget of attempts. Nothing separated one attempt from the next, so the budget was spent fastest by the failures that reproduce fastest: a refused lock or a connection error could burn all ten attempts in well under a minute — on exactly the failures a little time was most likely to clear. The apply reached permanent failed before the condition it was waiting on had any chance to lift, and the operator sees a dead apply rather than one still trying.

What it does

Each admitted attempt arms a wait for the next one on a new applies.retry_after column, and the failed_retryable claim clause honours it. The wait steps up and then holds flat, so the first couple of retries stay immediate for a blip while a fully spent budget spreads over roughly ten minutes of wall clock instead of a minute.

 running --(recoverable failure)--> failed_retryable
    ^                                     |
    |     recovery driver reclaims,       |  wait out the backoff
    +---- attempt counter +1, resume <----+
          from checkpoints

The wait is armed when an attempt is admitted, not when it fails, so it is measured from the start of that attempt: an attempt that ran longer than its own backoff has already spaced itself out and retries the instant it fails. Only the automatic-retry clause is gated — an operator start runs now, and a driver that dies mid-attempt is picked up by a peer as soon as its lease goes stale. Neither waits out a retry nobody asked for.

The deadline is computed by the database rather than bound as a Go timestamp, so the wait never depends on the app and database clocks agreeing, and it is stored at the precision the policy is written in on both dialects — a coarser column would shift the wait, in the direction that holds back a retry carrying no wait at all.

The PR comment and the CLI progress box name the attempt and the clock time the next one is due, so a waiting apply reads apart from a stalled one. The time is absolute rather than a countdown: the comment is not re-rendered while an apply waits, and a countdown would freeze at whatever it read when posted. It is reported only while the apply is actually waiting — a claim leaves the armed deadline behind on the row it resumes, and a resumed apply must not advertise a retry that is not coming.

**`orders`**: 🟧🟧🟧⬜… 🔄 Retrying · attempt 3/10 · next 14:32 UTC

Two consistency fixes around that surface: an interrupted apply now draws in the halted color on every surface (the CLI drew it in the healthy-wait yellow while the PR comment drew it orange), and the CLI prints the failure cause for an interrupted apply as well as a permanently failed one — naming the next attempt without saying what it is retrying past leaves the operator nothing to judge. The interrupted progress view gets a preview fixture, so both are visible in TEMPLATES.md.

Failures that are not worth pacing

Pacing only helps a failure that time can clear. The other kind reproduces on every attempt, and spacing those out is strictly worse: the same verdict arrives ten times over ten minutes while the apply holds the database's active-apply slot, and the operator watches a countdown to a foregone conclusion.

Those failures now skip failed_retryable entirely. The engine classifies what the target told it: a duplicate value under a new unique index, existing rows that do not fit a narrowed column, a column the table does not have, DDL the target refuses outright. A unique index over non-unique data is the case worth naming — the row copy drops the duplicates rather than refusing them, so the rejection never arrives as a target error at all; it surfaces as a checksum that can never be made to agree, after a full copy. Retrying that spends the budget on ten full table copies.

Membership in the permanent set is deliberately narrow, and the asymmetry decides the edge cases: a rejection left out keeps the retries and costs the operator only the wait they were already spending, while one wrongly added ends an apply that would have recovered on its own. Anything the target might answer differently once a lock, a lagging replica, disk, or a peer transaction has moved on — lock wait timeouts, deadlocks, read-only, connection loss — keeps its retries.

How it moves us toward the northstar

Unattended operation means the automatic paths have to be worth trusting. A retry budget that is really a budget of reproductions gives up on the transient failure it exists for, and turns a self-healing apply into an operator escalation. Pacing the attempts makes the budget a span of recovery time, spending it only where time is what is missing, and naming the next attempt in the PR comment lets an author tell "waiting" from "wedged" without asking anyone.

Before deploying

applies.retry_after is new in both dialect schema files. On MySQL the startup diff adds it automatically. The PostgreSQL bootstrapper verifies that expected columns exist but does not alter existing tables, so a database already bootstrapped by an earlier version needs the column added before this one starts:

ALTER TABLE applies ADD COLUMN retry_after timestamp DEFAULT NULL;

A deployment taking this change and #1039 together needs both PRs' columns in place first.

Opened by Claude (Opus 5).

Copilot AI lite review requested due to automatic review settings August 15, 2026 08:32
A failure that reproduces instantly — a refused lock, a connection error
— spent the whole recovery budget in well under a minute, because nothing
separated one automatic attempt from the next. The faster a failure
failed, the less real recovery time its budget bought, on exactly the
failures a little time was most likely to clear.

Each admitted attempt now arms a wait for the next one on the apply's new
retry_after column, and the failed_retryable claim clause honours it. The
wait is measured from the start of an attempt, so an attempt that ran
longer than its own backoff retries as soon as it fails. The first
couple of retries stay immediate and the wait then steps up and holds
flat, spreading a fully spent budget over roughly ten minutes. Operator
starts and stale-lease recovery are unchanged: neither waits out a retry
nobody asked for.

The retry line in the PR comment and the CLI progress box now name the
attempt and the clock time the next one is due, so a waiting apply reads
apart from a stalled one.

  **`orders`**: 🟧🟧🟧⬜… 🔄 Retrying · attempt 3/10 · next 14:32 UTC
@aparajon
aparajon force-pushed the armand/retry-budget-backoff branch from cdaa73b to f75d8dc Compare August 15, 2026 08:34
@aparajon aparajon changed the title fix(github): pace the automatic retries of an interrupted apply fix(operator): pace the automatic retries of an interrupted apply Aug 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces paced automatic retries for failed_retryable applies by adding a durable applies.retry_after gate that the claim logic honors, preventing fast-reproducing transient failures from burning the full retry budget immediately. It also surfaces the current attempt and the next eligible retry time in the PR comment preview/templates and the CLI progress view.

Changes:

  • Add retry_after storage/schema support plus a capped backoff policy (storage.RetryBackoff) and enforce it in retryable-claim SQL predicates.
  • Render “Retrying · attempt N/10 · next HH:MM UTC” in PR comments and expose attempt/retry timing in the progress API/CLI.
  • Add unit/integration coverage for the backoff policy and claim gating, plus lifecycle documentation updates.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
TEMPLATES.md Updates the example interrupted/retrying table row output to include attempt + next eligible time.
pkg/webhook/templates/preview.go Updates preview data to include attempt and computed RetryAfter for deterministic template rendering.
pkg/webhook/templates/apply.go Adds RetryAfter to template data and renders the “next HH:MM UTC” segment when applicable.
pkg/webhook/templates/apply_test.go Updates retryable rendering assertions and adds coverage for the “next retry” segment behavior.
pkg/webhook/multi_apply.go Plumbs stored RetryAfter into multi-deployment comment data.
pkg/webhook/apply.go Plumbs stored RetryAfter into single-deployment comment data.
pkg/storage/types.go Introduces capped backoff constants and RetryBackoff, plus Apply.RetryAfter field.
pkg/storage/types_test.go Adds tests validating the backoff step/cap and overall retry pacing window.
pkg/storage/internal/sqlstore/apply_operations.go Gates retryable operation claims on retry_after and arms backoff on redispatch.
pkg/storage/internal/sqlstore/applies.go Adds retry_after column scanning, claim gating predicate, and arms backoff on retryable claims.
pkg/storage/internal/sqlstore/applies_test.go Adds integration tests proving retry backoff gating and exceptions (operator start, stale lease recovery).
pkg/schema/postgres/applies.sql Adds retry_after column to Postgres applies table.
pkg/schema/mysql/applies.sql Adds retry_after column to MySQL applies table.
pkg/cmd/internal/templates/progress.go Adds a “Retry” detail row in CLI progress output with attempt and next eligible time.
pkg/cmd/internal/templates/progress_parse.go Adds Attempt and RetryAfter fields to parsed progress data.
pkg/apitypes/apitypes.go Extends ProgressResponse to include attempt and retry_after.
pkg/api/progress_handlers.go Overlays attempt/retry timing from stored apply state onto progress responses.
pkg/api/ensure_schema_postgres_test.go Updates schema column expectations for the new Postgres retry_after column.
docs/apply-lifecycle.md Documents paced retries, what’s gated vs not gated, and how it’s surfaced to operators/authors.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/cmd/internal/templates/progress.go Outdated
Comment thread pkg/api/progress_handlers.go
Comment thread pkg/schema/mysql/applies.sql Outdated
Three follow-ups on the automatic-retry pacing.

Store the deadline at the precision the policy is written in. The
deadline expression and the predicate that reads it back are both
microsecond-precision, but the MySQL column rounded to whole seconds, so
a sub-second step in the policy would shift the wait by up to a second —
in the direction that holds back a retry carrying no wait at all — and
the shift would depend on which dialect the deployment runs. The column
now matches Postgres, and the comparison is made at the same precision as
the value.

Report the deadline only while the apply is waiting on it. A claim leaves
the armed deadline behind on the row it resumes, so a running or
completed apply carried a time naming a retry that was not coming. The
spent attempt count still reports in every state: on a permanently failed
apply it is the record of how many recoveries were tried.

Render the CLI retry row against the package's nowFunc rather than
time.Now, so a preview with a pinned clock renders deterministically like
every other time-dependent row in that view.
@aparajon
aparajon marked this pull request as ready for review August 15, 2026 08:48
The retry row had no preview fixture, so the one view that tells an
operator an apply is waiting rather than wedged could not be seen without
reproducing an interruption. Adds a `retrying` progress preview: budget
spent, next attempt due, mid-copy table, and the cause. Its next-attempt
time is derived from the real backoff policy, so the preview cannot drift
from what an operator will see.

Two things the fixture surfaced.

Absolute clock times rendered in the zone of whoever ran the generator,
which would have made TEMPLATES.md churn between machines. The zone is
now a package variable that preview mode pins to UTC, matching how the
package already pins its clock.

The failure cause was printed under the box for a permanently failed
apply but not an interrupted one, so a retrying apply named its next
attempt with no hint of what it was retrying past. Both states ask the
operator the same question, and only the message answers it.
…face

The PR comment drew a retrying table's bar orange while the CLI drew it
yellow, so the same apply read differently depending on where an operator
looked. Yellow in this vocabulary is a healthy wait — waiting for
cutover, revert window open — and an apply that keeps failing while it
spends a finite budget toward permanent failure is not that. Orange is
the halted family: work stopped partway, not progressing until something
resumes it, which is exactly an apply between automatic retries.

Both surfaces now use it, and the CLI's state label and status colors
follow their own bar, as they already do for every other state.
@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pull/1041, dc2269c.

Verdict: 8 findings — 4 non-blocking, 4 general suggestions, nothing blocking; CI is green with 34/34 checks passing at head dc2269c (Build, Unit, Integration x2, all E2E suites incl. K8s/Vitess/gRPC multi-deployment, LocalScale x3, all lint jobs, DCO).

Non-blocking

  1. The final failed attempt renders "attempt 11/10 · next HH:MM UTC" — a retry that can never come. The FailedRetryable arm renders retry.attempt+1 unconditionally at pkg/webhook/templates/apply.go:833 (same overrun in the CLI at progress.go:97), while the claim arm caps at a.attempt < MaxRecoveryAttempts and expiry terminalizes without a comment re-render — so when attempt 10 fails inside its 90s backoff, the frozen comment promises an 11th attempt forever. Gate the +1 and the next-time segment on retry.attempt < storage.MaxRecoveryAttempts on both surfaces.

  2. Already-bootstrapped Postgres deployments crash-loop until retry_after is added by hand. The new column at pkg/schema/postgres/applies.sql:21 is verified by presence-only at startup (ensure_schema_postgres.go:182), so an existing Postgres deployment fails EnsureSchema on rollout; MySQL auto-adds via the Spirit diff. This is documented repo procedure (AGENTS.md mandates the pre-deploy ALTER), but the PR body carries no deploy note — add one, coordinated with sibling feat(github): surface engine throttle pauses and checkpoint resumes #1039's tasks columns.

  3. The multi-deployment redispatch backoff has zero test coverage. The new gate at apply_operations.go:988 and the arming at :1150 appear in no operations test, while the single-apply path got two thorough integration tests. A regression here ships silently, reintroducing unpaced budget burn (or an unarmed wait) for exactly the multi-deployment applies this PR fixes — mirror the two ClaimApplyByID tests.

  4. The FailedRetryable halted-color fix is untested. The halted-orange assertion loop at progress_states_test.go:621 covers only Stopped/Cancelled/Reverted, and TEMPLATES.md strips ANSI, so reverting progress.go:448 back to yellow keeps everything green. Add FailedRetryable to that loop.

General suggestions

  1. Preview fixtures arm the backoff one index off the real policy. An Attempt=3 row really carries RetryBackoff(3) = 60s from attempt start, but preview_progress.go:889 uses previewTime.Add(RetryBackoff(4)) = now+90s (same at preview.go:1431), despite the comment claiming the preview "cannot drift" from the policy. Cosmetic only — no runtime path uses these values.

  2. Stale help text for comment_apply_retrying. pkg/cmd/commands/preview.go:280 still says "attempt counter" while the fixture now also renders the next-attempt time; the parallel comment at preview.go:148 was updated in this PR but this listing was missed.

  3. Per-deployment detail can name a next-attempt time that will not be honored (PLAUSIBLE). multi_apply.go:171 gates the retry line on op.State but renders the parent's retry_after, so a failed_retryable deployment of a parent-active multi-op apply shows a deadline the claim arms won't act on (redispatch requires the parent itself failed_retryable). Display-only, on a mostly-dormant fan-out path.

  4. The concurrency comment at apply_operations.go:1101 is now half-stale. Once one failed_retryable operation is redispatched, the parent's freshly armed retry_after blocks a failed sibling's redispatch for up to 90s, so "two operators can claim different failed_retryable operations concurrently" only holds for claims racing before the first commit — arguably intended apply-level pacing, but undocumented; update the comment.

The one thing that could have broken, verified

The riskiest mechanism is the new retryBackoffElapsed predicate ANDed into the two FOR UPDATE SKIP LOCKED claim clauses — it could have locked crash recovery or operator commands out of claiming, or gated nothing at all. Every arm of both WHERE clauses was enumerated against every caller: only the automatic failed_retryable arms gained the predicate (applies.go:1477, apply_operations.go:988), the stale-active arm is untouched and failed_retryable is excluded from claimableApplyStates (applies.go:109), and start is refused for failed_retryable at the API layer (control_handlers.go:1634). The injected fragments emit only literal intervals, so the Postgres ?$n rebind cannot desynchronize the surrounding 11-placeholder UPDATE (applies.go:1882). The PR's own tests prove both bypasses with a deliberately armed 1-hour deadline (applies_test.go:2463). Residual risk: the composed claim SQL never runs against Postgres in tests, and the multi-op arming path is untested (finding 3).

Verified correct

  • Backoff gates exactly the two automatic failed_retryable arms; operator-start and stale-lease bypasses proven by applies_test.go:2463.
  • NULL/pre-migration rows claim immediately — inclusive predicate (applies.go:76) and RetryBackoff(1)=0.
  • Arm-on-admission is atomic in one CASE-gated UPDATE with 11 placeholders matching 11 args (applies.go:1882).
  • Schedule matches docs — step 30s, cap 90s, ~10m30s over the budget — pinned by types_test.go:359.
  • Deadline is DB-clock at microsecond precision on both dialects (schema/mysql/applies.sql:21, schema/postgres/applies.sql:21) — no Go timestamp bound.
  • A resumed apply's leftover deadline is suppressed on all three surfaces, each with exact-value tests (progress_handlers.go:491).
  • Column plumbing complete on both dialects, and applyStore.Update omits retry_after, so stale in-memory values can never clobber the claim-owned column.
  • TEMPLATES.md is byte-identical to a regeneration from the PR-head binary; composition with sibling feat(github): surface engine throttle pauses and checkpoint resumes #1039 is clean in either merge order; 34/34 CI checks pass at dc2269c (Build, Unit, Integration x2, all E2E incl. K8s/Vitess/gRPC multi-deployment, LocalScale x3, lint, DCO).

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

The claim path admits an automatic retry only while attempt is under
MaxRecoveryAttempts, but both operator-facing surfaces announced
attempt+1 unconditionally. An apply whose last attempt failed inside its
backoff therefore read "attempt 11/10 · next HH:MM UTC" — naming a
retry no driver will ever claim, on a comment that is not re-rendered
again before expiry terminalizes it.

AnnouncedRetryAttempt puts the ceiling next to the budget constant it
belongs to, so the PR comment and the CLI progress view agree: a spent
budget names the last attempt made and promises no time.
The operation claim path gates on the parent apply's retry_after and
arms the next wait on it, but only the single-apply path had tests. A
regression there would silently restore unpaced budget burn for exactly
the fan-out applies the pacing is meant to protect.

Both directions are now pinned: a failed_retryable operation stays
unclaimable until the parent's wait elapses, and an admitted redispatch
arms the parent's next wait while consuming one unit of the shared
budget. The concurrency note alongside says how far the concurrent-claim
window actually extends now that the armed wait gates siblings too.
A deployment row of a multi-deployment apply rendered its own operation
state against the parent's armed deadline. An operation is re-leased for
redispatch only while the parent apply is itself failed_retryable, so a
failed deployment of a still-running rollout named a time nothing was
going to act on.

The preview fixtures were arming a wait one policy step ahead of the
attempt they depict; they now use the wait the real policy arms for that
attempt, and the retrying preview's listing says it shows the
next-attempt time.
@aparajon

Copy link
Copy Markdown
Collaborator Author

🤖 Thanks — all eight addressed across c8de8e9, 1dace5b, and b64ea57.

Findings 1, 3, 4, 5, 6, 7 and 8 are fixed as described. On 1, the overrun is now capped in storage.AnnouncedRetryAttempt so the ceiling lives next to the budget constant and both surfaces read it from one place; I mutation-tested the new assertions and they do reproduce attempt 11/10 · next 14:32 UTC without the fix. On 3, both directions are pinned — the parent's armed wait gating an operation claim, and a redispatch arming it while consuming a unit of the shared budget — and both fail if the gate or the arming is neutered. On 7 the cause was that the deployment row gated on op.State while carrying the parent's retry_after, so the fix is at the mapping rather than the renderer.

Finding 2 is now a "Before deploying" section in the PR body, including the note that a deployment taking this and #1039 together needs both columns first.

— Claude Code (Opus 5)

… retrying it

A schema change the target rejects for a reason that belongs to the statement
or to the data already in the table reproduces on every attempt. Reporting it
as retryable spent the whole recovery budget re-running it, and held the
database's active-apply slot for the entire wait.

The engine now classifies its failures: duplicate values under a new unique
index, rows that do not fit a narrowed column, a column the table does not
have, DDL the target refuses outright, and the checksum failure that is how a
unique index over non-unique data fails, are reported as permanent. Everything
the target might answer differently once a lock, a lagging replica, or a busy
target has moved on keeps its retries.
@aparajon aparajon changed the title fix(operator): pace the automatic retries of an interrupted apply fix(operator): pace the automatic retries of an interrupted apply, and skip them when the target's verdict is final Aug 16, 2026
aparajon and others added 2 commits August 17, 2026 01:52
The comment claimed each attempt paid for another full table copy. Spirit
resumes past a completed copy from the persisted copier watermark, so the
copy is not redone. What is redone is the verification: a checksum that
found differences is not allowed to persist a watermark, so every attempt
restarts the checksum phase from the beginning. Still a whole-table read
per attempt, and still entirely wasted on a rejection that reproduces, but
name the cost accurately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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