Skip to content

feat(observability): make a resumed or failed drive explain itself in the apply log - #1027

Merged
aparajon merged 6 commits into
mainfrom
armand/resumed-drive-observability
Aug 15, 2026
Merged

feat(observability): make a resumed or failed drive explain itself in the apply log#1027
aparajon merged 6 commits into
mainfrom
armand/resumed-drive-observability

Conversation

@aparajon

Copy link
Copy Markdown
Collaborator

Why this matters

The apply log stream is the only account of a schema change an operator actually reads — it is what the CLI renders and what a failed apply folds into its PR summary comment. Two of the moments most worth reading were absent from it.

An apply that changes hands mid-flight goes quiet. The engine's log lines were wired into the stream only by the drive that started the apply, so the first lease handover ended engine capture for good — and the drive that finishes the work, the one an operator is trying to read, contributes nothing.

An apply that fails says nothing about why. The failure paths moved the apply to its terminal state and left the reason in the server logs alone. Read from the CLI or the PR comment, the apply reached failed for no stated cause; the retry budget drained across attempts with only gaps between them to show for it.

before                                    after

drive 1  ├── engine lines ──> stream      ├── engine lines ──> stream
         └── (dies)                       └── (dies)
drive 2  ├── engine lines ──> ∅           ├── engine lines ──> stream
         └── fails         ──> ∅          ├── attempt 4 of 10  ──> stream
                                          └── failed: <cause>  ──> stream
recovery └── gives up      ──> ∅          └── gave up after 10 ──> stream

What it does

Wires engine log capture on both resume paths. A resumed drive now routes the engine's own lines into the apply's stream, so an apply reads the same whether one driver or four carried it. The wiring goes up before the engine accepts the resume and comes down with the drive that polls the work; a detached resume polls past the call it started in, so that goroutine unwires it instead.

Stops the resume asserting a cause it never checked. Every resume announced a heartbeat expiry. The claim arm that selected the apply — a stale heartbeat, a pending control request, a parked cutover — is decided by the claim query and is not carried into the drive, so the message now reports what this drive knows and leaves the cause to the claim logs.

Gives a failed apply a stated cause. Both failure paths record their transition: a permanent failure at error level, and a retryable one at warn naming the attempt it spent, so the recovery budget visibly drains instead of showing as silence between attempts. Expiry records why recovery stopped, closing the arc from first paused attempt to permanent failure. The record has one owner now, so call sites that logged their own copy no longer duplicate it in the fold.

🤖 Generated with Claude Code

Two gaps left an apply silent for the drive that actually ran it:

- The engine's log callback was wired only by the drive that started an
  apply, so the apply log stream went quiet the moment the apply changed
  hands. Both resume paths now wire it for as long as they drive engine
  work; a detached grouped resume unwires it from the goroutine that
  outlives the call.
- Every resume announced itself as a heartbeat expiry, a cause this path
  never checks — the claim arm that selected the apply is decided by the
  operator's claim query and is not carried here. It now reports what it
  knows and leaves the cause to the claim logs.
A failing apply wrote nothing to the apply log: the failure and retryable
helpers moved state and left the reason in the server logs alone, so the
CLI and the PR summary — which render that stream — showed an apply going
terminal with nothing stating why. Both helpers now record the transition
they make, and expiry records why operator recovery stopped retrying, so
the stream carries the whole arc from the first paused attempt to the
permanent failure.

The failure record now has a single owner, so the call sites that logged
their own copy no longer do.
Copilot AI lite review requested due to automatic review settings August 14, 2026 08:28

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 improves operator-facing observability by ensuring resumed drives continue streaming engine logs into the apply log, and by recording explicit failure/expiry causes in the apply log so the CLI and PR failure summaries are self-explanatory.

Changes:

  • Wire Spirit engine log capture for resumed drives (sequential resume + grouped/atomic resume, including detached polling).
  • Record permanent and retryable failure transitions as durable apply-log entries (and remove duplicate call-site logging).
  • Record operator retry-budget expiry as a durable apply-log entry, with unit/integration tests covering the new behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pkg/tern/local_resume_engine_logging_integration_test.go Integration test asserting resumed drives capture Spirit engine log lines into apply logs.
pkg/tern/local_control_resume.go Adds Spirit log wiring to resume paths; improves resume messaging and removes duplicate failure logging.
pkg/tern/local_apply_grouped.go Removes duplicated apply-log failure events and routes failures through the centralized failure helpers.
pkg/tern/local_apply_failure.go Adds durable apply-log entries for permanent and retryable failures.
pkg/tern/apply_failure_log_test.go Unit tests asserting failure helpers append the expected apply-log entries.
pkg/api/operator.go Appends an apply-log entry when operator recovery expires a retryable apply.
pkg/api/operator_test.go Unit test verifying expiry appends the expected apply-log entry.

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

Comment thread pkg/tern/local_control_resume.go
Comment thread pkg/tern/local_apply_failure.go Outdated
…s attempt count in budget

A detached grouped resume polls under a context that deliberately outlives
its caller, but its engine log wiring held the caller's context — so capture
ended the moment the caller returned and the rest of the schema change went
unrecorded. The wiring is now bound to the context the poll runs under.

The paused-attempt record counted drives against the recovery budget, two
quantities that diverge because a recovery claim advances the apply's attempt
counter. At the last attempt the record read past its own limit. It now
reports the attempt counter the budget is measured against.
@aparajon
aparajon marked this pull request as ready for review August 14, 2026 08:54
…tored cause

Consolidating the failure record onto one owner moved two call sites' framing
into the message passed to failApplyWithTasks, which is stored as the apply's
and every task's error message — not just written to the log. That prefixed a
cause an operator reads in status output and PR comments with wording meant
for the log line.

The stored cause is the engine's text again. The apply log entry carries its
own framing, and the recovery entry that precedes it already establishes that
the drive was a resumed one.
@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pull/1027, 339ed56.

Verdict: 7 findings — 0 blocking, 3 non-blocking, 4 suggestions; safe to land. CI is 31/31 GREEN on head 339ed56: the earlier Integration RED was a re-run-confirmed flake (TestFullWorkflow_Spirit_PlanApplyVerify stalled on a saturated runner) plus 10m job-budget exhaustion (green baselines already run 8m22s–9m20s; this PR added only ~0.8s to pkg/tern) — not a PR-introduced hang, though the suite's ~9m-of-10m budget headroom is a repo-level fragility worth a follow-up.

Non-blocking

  1. The default sequential MySQL path still writes no apply-level failure or pause entry. finalizeSequentialApply persists Failed/FailedRetryable via a bare storage Update (pkg/tern/local_apply_sequential.go:660) and the task-level transitions pass applyID=0, so a non-defer-cutover MySQL apply that fails retryably never shows "Apply paused for operator retry (N of 10…)" or "Apply failed: " — the exact silence the PR body's diagram claims fixed, on the most common path. Pre-existing gap (the new helpers in pkg/tern/local_apply_failure.go:54 are never called from the sequential file), so a follow-up routing finalizeSequentialApply through them would close it.

  2. Data race with a nil-function-call panic window on the engine log callback. spiritLogFilter.Handle double-dereferences the callback pointer with no lock (pkg/engine/spirit/logger.go:34, :55) while SetLogCallback writes under e.mu (pkg/engine/spirit/spirit.go:274); the PR's new resume-path unwires can nil the pointer between check and call while the migration goroutine is still logging, panicking with no recover. CI can't catch it — integration tests deliberately omit -race (Makefile:591); fix is a lock or atomic in pkg/engine/spirit as a self-contained follow-up (the unlocked reader pre-exists; this PR widens exposure rather than introducing it).

  3. The PR's headline detached-resume handover has no production caller and no real-engine test. Both production launchAtomicResume sites pass block=true (pkg/tern/local_control_resume.go:1570, :1635), and the only block=false callers are mock-engine unit tests where setupSpiritLogging is a no-op, so the rewire/unwire at :862/:866 never executes against a real engine anywhere. A refactor that reorders the goroutine defers or a future block=false caller could silently drop engine lines or clobber a newer drive's wiring with no test noticing — add a focused test or an explicit call-out.

General suggestions

  1. First retryable failure of a grouped drive logs "0 of 10 recovery attempts used". apply.Attempt increments only on a claim FROM failed_retryable (pkg/storage/internal/sqlstore/applies.go:1854), so the message at pkg/tern/local_apply_failure.go:100 reads "0 of 10" on the very first failure — technically accurate but odd to an operator expecting 1-based counting.

  2. Two tests hardcode the recovery budget as a literal. pkg/api/operator_test.go:272 ("10 of 10 attempts") and pkg/tern/apply_failure_log_test.go:87 would fail spuriously if storage.MaxRecoveryAttempts changes; the sibling assertion at :104 already shows the constant-driven pattern to copy.

  3. logApplyExpiration is the third near-identical best-effort apply-log helper in operator.go. pkg/api/operator.go:251, :1451, and :1583 share the same nil-store guard, timeout, and warn-on-error shape; a future contract change will predictably miss one — extract a shared append helper.

  4. Operator doc still points triage at server logs. docs/apply-lifecycle.md:224 sends operators to server logs (infra access required) when the failure cause, attempt budget, and expiry reason now land in the apply's own log stream — update the doc to point at the CLI/PR-comment surface first.

The one thing that could have broken, verified

The riskiest mechanism is the detached grouped resume's log-capture handover on the spirit engine's single global callback slot (pkg/tern/local_control_resume.go:855-868) — the one thing that could both corrupt observability and, per the initial CI cancellation, hang tests. The hang theory was refuted: setupSpiritLogging is registration-only (SetLogCallback sets a mutex-guarded slot and spawns no goroutine, pkg/engine/spirit/spirit.go:274), the detached poll goroutine pre-exists the PR, and re-running the same head passed Integration in 9m7s, pinning the RED on a flake plus 10-minute budget exhaustion. Lifecycle ordering is sound: every error return before pollDetached is set still unwires via the caller's deferred func, and the goroutine's LIFO defers unwire the callback before resumeCtx is cancelled (:864-866). Reachability tracing showed the detached arm is production-dead today (finding 3). The one real defect surfaced probing locking on both sides of the slot: the unlocked reader in pkg/engine/spirit/logger.go:34 (finding 2).

Verified correct

  • Removed-behavior audit: every deleted logApplyEvent site is re-established via the consolidated owners, now recording the TRUE previous state instead of hardcoded Pending (pkg/tern/local_apply_grouped.go:123).
  • Settled-apply guard: both helpers re-read and return before update+log when a concurrent driver already terminalized, and append only after a successful Update (pkg/tern/local_apply_failure.go:34).
  • Attempt count can never exceed 10 of 10 — only a failed_retryable claim increments it (pkg/storage/internal/sqlstore/applies.go:1854).
  • Detached handover ordering: pollDetached set before the rewire and launch; goroutine defers unwire the callback before cancelling resumeCtx (pkg/tern/local_control_resume.go:857).
  • Sequential and blocking-resume wiring exactly mirror the first-drive pattern with one registration per drive (pkg/tern/local_control_resume.go:297).
  • logApplyExpiration is bounded and best-effort per the existing operator-helper conventions, so a storage hiccup cannot stall the expiry loop (pkg/api/operator.go:251).
  • No stale consumers of removed/reworded log strings — repo-wide greps by three independent finders; the one asserting test was updated in-diff (pkg/tern/local_client_integration_test.go:2923).
  • Build/tests/CI: go build ./... and go vet (incl. -tags=integration) clean; new unit tests pass locally under -race; the new integration test drives the real ResumeApply seam (+~0.8s in CI); 31/31 checks GREEN on head 339ed56 after the flake re-run.

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

aparajon and others added 2 commits August 15, 2026 09:34
The sequential path is the one most applies take, and it wrote the
failure only to the server logs — so the apply's own history showed it
reach a terminal state with nothing stating why, on exactly the path an
operator is most likely to be reading. Route it through the same log
owners as every other path, and report the retry budget as attempts
remaining so the first pause reads as a countdown rather than "0 of 10".

Also folds the operator's three near-identical best-effort apply-log
appends into one helper, so a future contract change cannot miss one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rests on

A resume that hands its polling to a goroutine outliving the caller rewires the
engine's apply-log callback on that goroutine's own context. The reason is now
covered: a callback still holding the caller's cancelled context records
nothing, so the engine lines for the rest of the schema change would be lost.

The apply log store fixture stops ignoring its context, which is what makes the
difference observable — the real store treats a cancelled context as a failed
write, not a silently dropped one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aparajon

Copy link
Copy Markdown
Collaborator Author

🤖 All seven addressed. Two went to their own PR or commit rather than inline:

  • 2 — fixed in fix(observability): make the engine's log routing correct and race-free #1028 as the self-contained follow-up you suggested. The callback and the debug toggle both move into atomic slots, and the filter loads the callback once per record and calls through that copy, closing the check-then-call window. Atomics rather than e.mu: the filter runs on the Spirit runner's logging path, where taking the engine lock invites reentrancy against the engine methods already holding it. There is a concurrency test that reports the race when the atomic is swapped back for a mutex-written plain field.
  • 3601a6bc3 pins the claim the detached rewire rests on: a callback bound to the caller's cancelled context records nothing, one bound to a surviving context still writes. That needed the apply-log store fixture to stop ignoring its context. The residual you named stands — the block=false arm still has no production caller, and no test drives it against a real engine, because setupSpiritLogging type-asserts the concrete *spirit.Engine and the callback slot cannot be read back.
  • 1finalizeSequentialApply now routes through both helpers after a successful Update, so the default MySQL path records the same failure and pause lines.
  • 4 — message now derives from apply.Attempt as remaining-of-budget.
  • 5 — both literals replaced with storage.MaxRecoveryAttempts.
  • 6 — the three helpers now share one appendApplyLog.
  • 7 — the doc points at schemabot logs <apply> and the PR summary comment first, server logs second.

On the CI note in your verdict: the 10-minute Integration budget bit again on #1019 (cancelled at 10m11s, every prior step green). Tracked as a follow-up to split the integration package into its own job rather than raise the cap.

Reviewed and addressed by Claude Code (claude-opus-5).

@aparajon
aparajon merged commit 62c77a6 into main Aug 15, 2026
31 checks passed
@aparajon
aparajon deleted the armand/resumed-drive-observability branch August 15, 2026 02:08
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