Skip to content

Pause and preserve checkpoint on deployment timeout, add manual resume#428

Merged
ppXD merged 1 commit into
mainfrom
fix/checkpoint-preserving-timeout
Jun 11, 2026
Merged

Pause and preserve checkpoint on deployment timeout, add manual resume#428
ppXD merged 1 commit into
mainfrom
fix/checkpoint-preserving-timeout

Conversation

@ppXD

@ppXD ppXD commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • On wall-clock timeout, a deployment now pauses (Paused) with its checkpoint preserved instead of transitioning to Failed and deleting the checkpoint. The already-completed batches are no longer thrown away.
  • Adds a manual resume trigger — POST /api/tasks/{id}/resumeResumeServerTaskCommandServerTaskControlService.ResumeTaskAsync — that re-dispatches the paused deployment. It reuses the existing, proven resume machinery: StartExecutingAsync already transitions Paused → Executing (IsResumed), and the state-agnostic ResumeCheckpointPhase (Order 50) restores progress, so resume skips completed batches.
  • New default is the safe behaviour. The historical fail-fast path (Failed + checkpoint deleted) stays available behind SQUID_DEPLOYMENT_TIMEOUT_RESUMABLE=false for operators who alert on the terminal Failed state.

Design notes

  • Paused is the only resumable state (Paused → Executing is the lone valid resume transition; Failed/TimedOut are terminal). So a resumable timeout must land in Paused — hence OnTimedOutAsync transitions Executing → Paused, keeps the checkpoint (it is the resume point), and writes no DeploymentCompletion record (a paused deployment hasn't completed).
  • ResumeTaskAsync is the strict, operator-facing sibling of the opportunistic TryAutoResumeAsync (which fires after an interruption response). Both now share one EnqueueResumeAsync. Unlike the auto path, ResumeTaskAsync surfaces every precondition failure as a typed exception — ServerTaskNotFoundException, ServerTaskStateTransitionException (task not paused), ServerTaskAwaitingInterruptionException (resume a guided-failure pause by submitting the interruption, not here) — so the API returns an actionable error rather than a silent 200.
  • Non-breaking surface: the new completion-handler method has a single implementer (updated); the endpoint/command/exception are purely additive. The one intentional behaviour change (timeout terminal-state Failed → Paused) is the requested safe-default flip and is reversible via the env var.

Test plan

  • Unit — DeploymentCompletionHandlerTests: OnTimedOutAsync transitions Executing → Paused, does not delete the checkpoint, does not record a completion, does not trigger auto-deploys.
  • Unit — DeploymentPipelineRunnerTimeoutTests: timeout routes to OnTimedOutAsync by default and OnFailureAsync when fail-fast; DeploymentTimedOutEvent emitted in both modes; cancel still wins over timeout; SQUID_DEPLOYMENT_TIMEOUT_RESUMABLE const pinned + 15-case parse matrix + env→property wiring.
  • Unit — ServerTaskControlServiceResumeTests: paused+no-interruption enqueues + persists new job id; not-found / not-paused (7 states) / pending-interruption all throw and never enqueue; TryAutoResumeAsync regression after the shared-enqueue refactor.
  • Integration (real Postgres) — IntegrationTimeoutResume: OnTimedOutAsync → task Paused + checkpoint row preserved (with progress); fail-fast contrast → Failed + checkpoint deleted.
  • E2E — KubernetesResumeCheckpointE2ETests.ResumeAfterTimeoutPause_CompletesDeploymentFromCheckpoint: plant checkpoint at batch 0 + set Paused (post-timeout state) → ProcessAsync runs only batches 1+2, ends Success, checkpoint cleaned up. (Runs on the CI Kind-cluster E2E gate.)
  • Full DeploymentExecution + Deployments + Handlers unit sweep: 4355 passed, 0 failed.

Follow-up (out of scope)

  • A timeout currently records the DeploymentFailed audit-event category (pre-existing mapping; EventCategory has no TimedOut/Paused value). With the resumable default this is mildly imprecise — a dedicated category + the operator-facing "Resume" button belong with the frontend Events work.

A deployment that exceeds the wall-clock timeout previously transitioned
to Failed and had its checkpoint deleted, discarding every completed
batch and forcing a restart from scratch. It now pauses (Paused) with
its checkpoint intact and can be resumed from the last completed batch
via POST tasks/{id}/resume, reusing the existing checkpoint-resume
machinery (StartExecutingAsync Paused->Executing + ResumeCheckpointPhase).

The historical fail-fast behaviour (Failed + checkpoint deleted) stays
available behind SQUID_DEPLOYMENT_TIMEOUT_RESUMABLE=false for operators
who alert on the terminal Failed state rather than the resumable Paused
state.
@ppXD ppXD added this to the 1.8.18 milestone Jun 10, 2026
@ppXD
ppXD merged commit 52131c5 into main Jun 11, 2026
15 checks passed
@ppXD
ppXD deleted the fix/checkpoint-preserving-timeout branch June 11, 2026 00:51
ppXD added a commit that referenced this pull request Jun 11, 2026
* Pause (resumable) on a transient infra failure instead of failing

A Halibut RPC drop that outlives the library's own retries, or an agent
that goes unreachable mid-script, used to fail the deployment terminally
AND — via the strategy's finally that cleared the in-flight pointer on
any exit — destroy the reattach pointer, so a resume re-dispatched a
duplicate of the still-running script.

Classify a transient infrastructure failure (HalibutClientException /
AgentUnreachableException, or an AggregateException of only those) as a
PAUSE: the task transitions to Paused with its checkpoint AND in-flight
pointer preserved, and a resume re-attaches to the still-running script.
Reuses the #428 pause/resume machinery; env-gated by
SQUID_DEPLOYMENT_TRANSIENT_RESUMABLE (default true) so operators can opt
back into fail-fast.

- HalibutMachineExecutionStrategy: clear the in-flight pointer ONLY on a
  definitive observation (return), preserve it on a throw.
- TargetCatchClassifier: a transient infra failure leaves the target
  in-flight (not terminal) and does NOT fail-fast healthy peers — they
  finish, then the deployment pauses. Owns the shared IsTransientInfraFailure.
- DeploymentPipelineRunner: a transient failure routes to OnTransientPauseAsync
  (Paused + checkpoint preserved), not OnFailureAsync; does not rethrow.
- DeploymentCompletionHandler: OnTransientPauseAsync (mirrors OnTimedOutAsync).

A genuine (non-transient) exception, and a transient drop racing a real
cancel/timeout, still fail terminally.

Tests:
- Unit: TargetCatchClassifier transient cases + IsTransientInfraFailure
  matrix; DeploymentPipelineRunner transient->pause vs fail-fast vs genuine
  failure, aggregate-of-transients, env-var pin + parse matrix
- Integration: HalibutResumeReattach observe-throws-transient preserves the
  in-flight pointer for resume

* Harden transient-pause classification (adversarial review fixes)

Adversarial review of the transient-pause change found five reachable
paths that defeated the resumable-pause guarantee. Fixes:

- Centralise the transient definition in TransientFailureClassifier
  (Squid.Core.Halibut.Resilience) so the pipeline runner, per-target
  classifier, per-action catch, and the re-attach probe all agree.
- NARROW the predicate: a permanent Halibut protocol/invocation failure
  (ServiceInvocation / NoMatchingServiceOrMethod / MethodNotFound /
  ServiceNotFound / AmbiguousMethodMatch) is NOT transient — classifying
  it so would pause-loop forever. Base/transport HalibutClientException,
  AgentUnreachableException, and CircuitOpenException ARE transient.
- Non-required step: a transient now propagates (rethrow before the
  per-target/terminal failure recording) regardless of step.IsRequired —
  previously it was swallowed into FailureEncountered -> OnFailureAsync,
  which deleted the checkpoint AND the preserved in-flight pointer,
  orphaning the still-running script.
- Re-attach probe: a TRANSIENT probe failure on resume now preserves the
  pointer and propagates (was: type-blind catch cleared it and dispatched
  fresh -> duplicate-execution risk when the agent recovers).
- Runner transient catch is guarded by !timeoutCts && !registryCts && !ct
  so a user-cancel / wall-clock timeout racing a transient RPC drop is not
  reclassified as a pause (cancel/timeout win).
- CircuitOpenException is treated as transient (its own contract) so a
  tripped breaker on resume pauses + preserves the pointer instead of
  forcing a terminal Failed.

Tests:
- Unit: TransientFailureClassifier matrix (permanent subtypes excluded,
  CircuitOpen/AgentUnreachable/base included, aggregate all/mixed/empty);
  runner transient-during-user-cancel does NOT pause
- Integration: reattach-probe transient failure preserves the pointer and
  does not dispatch fresh

* Make transient-pause unconditional (drop env-var toggle) + real-phase tests

Remove the SQUID_DEPLOYMENT_TRANSIENT_RESUMABLE escape hatch added
earlier in this PR: pausing-resumable on a transient infra blip is the
only correct behaviour (failing fast would discard already-completed
progress and risk a duplicate run — no legitimate use case), so it needs
no opt-out. The env var never shipped, so removing it is non-breaking.
The wall-clock-timeout toggle (SQUID_DEPLOYMENT_TIMEOUT_RESUMABLE, shipped
in 1.8.18) is unaffected.

The runner's transient catch keeps its cancel/timeout guard
(!timeoutCts && !registryCts && !ct) so cancel/timeout still win.

Tests:
- Drop the env-var pin / parse-matrix / fail-fast tests (no longer apply)
- Add real-ExecuteStepsPhase propagation tests
  (DeploymentExecutionLoggingTests.TransientStrategyFailure_PropagatesOutOfPhase_RegardlessOfRequired):
  a throwing strategy on a step proves a transient PROPAGATES out of the
  phase for BOTH required AND non-required steps (closing the review's
  non-required-swallow gap end-to-end), and FailureEncountered stays false;
  the existing StepFailed_DoesNotLogCompleted pins the contrast that a
  non-required GENUINE failure is still swallowed

* Exclude CircuitOpenException from transient (keep fail-fast-on-open-breaker)

The K8s E2E HalibutCircuitBreakerE2ETests.BreakerForcedOpen_* asserts an
open breaker fails the deployment fast (Failed) — a deliberate
production-safety invariant. Classifying CircuitOpenException as transient
(from the review's HIGH #5 suggestion) flipped that to Paused and broke it.

Excluding it is the more correct design, by the same principle as the
permanent-Halibut-subtype exclusion: the per-machine breaker only opens
after 3+ consecutive failures — a SUSTAINED agent problem, not a one-off
blip — and is raised BEFORE any script is dispatched (fail-fast), so there
is no in-flight script to re-attach to. Pausing on it would loop on a dead
agent. The narrow resume-with-open-breaker orphan #5 described is better
addressed by probe-before-breaker (a separate focused change), not by this
blunt reclassification.

The other four review HIGH fixes (non-required propagation, narrowed
predicate, reattach-probe preserve, cancel/timeout guard) are unchanged.
Test flipped: CircuitOpen_IsNotTransient.
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.

1 participant