4. Split the post-failure pause off cooldownAfter (surge.failurePause)
- Status: Accepted
- Date: 2026-07-12
- Issue: #216
Context
surge.cooldownAfter was read by two per-NodePool start gates with opposite requirements (spec §5.2 step 2):
- Gate A — post-success settle. Written on the success path (
last-rotation-at), it waits for the drained node's Pods to re-land. It wants to be small: every second is dead time inside a maintenance window, and it feeds the layer-2 throughput forecastC = m·ceil(D / (t_rot_est + cooldownAfter)). - Gate B — post-failure inter-attempt pause. Written on the rollback path (
last-failure-at), it bounds candidate cycling under a systematic failure cause (zonal capacity shortage, instance-type unavailability,limitsheadroom) so a NodePool runs at most one attempt perreadyTimeout + cooldownAfter(§4.4). It wants to be large.
The two anchors and the two blocked-gate reason codes were already distinct; only the value was shared. An operator therefore could not express "settle 1m after a success, but pause 30m after a failure". Lowering cooldownAfter to reclaim window throughput silently weakened the failure pause; raising it to harden the failure pause silently deflated C and spuriously fired RotationSpansNextWindow. This is the same "one symbol, two callers" defect resolved for t_rot (ADR-0002), buffer (#215) and the t_rot_est terms (ADR-0003).
Gate A is also weaker than it looks. For PDB-covered workloads the settle is already enforced demand-driven: with maxUnavailable: 1 the Eviction API blocks the next node's eviction until the drained node's replacement is Ready. The fixed timer is redundant there and unsound for PDB-less workloads (no fixed duration is defensible without knowing startup time). So gate A deserves an allowed 0, with PDBs documented as the primary settle mechanism — not a must be positive rejection.
Decision
Give each gate its own value.
cooldownAfterbecomes the post-success settle only (gate A). It keeps feeding the layer-2 forecast. It may be0(validated non-negative, not positive) — PDBs are the primary settle. Its10mdefault is unchanged.surge.failurePauseis a new field (gate B), read only againstlast-failure-at, feeding no forecast. Unset, it resolves tomax(10m, cooldownAfter). It must be positive — a0pause would disable the §4.4 cost bound. There is deliberately no schema default: the fallback depends oncooldownAfterand admission cannot computemax()across fields, so it resolves in the controller'sresolve().- Layer 2 continues to use
cooldownAfteralone.CandRotationSpansNextWindowthen model only successful rotations, which is what their derivations already claim. - The blocked-gate reason code
cooldownAfterFailureis renamedfailurePauseto match.
Why the default is max(10m, cooldownAfter)
Direction-of-error. A too-short failure pause lets a systematic cause burn a readyTimeout-worth of failed-surge billing on every candidate it cycles through — the exact harm §4.4 exists to prevent. A too-long pause only slows recovery from a transient failure. So the prior must err long. max(10m, cooldownAfter) never shortens any existing install's failure pause on upgrade: installs with cooldownAfter ≥ 10m are unchanged, and installs that lowered cooldownAfter < 10m for throughput have their failure pause lengthened back to 10m — the corrective direction, and exactly the case the shared value could not express. It is a field rather than a fixed prior for the same reason #212/#220 chose fields: a cluster whose systematic causes clear slowly can raise it.
Flat, not escalating (v1)
The issue floats an escalating failurePause mirroring retryBackoff's doubling. Deferred: retryBackoff already escalates at claim scope, and a pool-scope escalation needs a new durable pool-level consecutive-failure counter (only per-claim retry-count exists today). A flat pause already bounds cycling to one attempt per readyTimeout + failurePause; escalation is a separable follow-up.
What must not change
- Both gates stay per NodePool; distinct pools rotate concurrently.
retryBackoffkeeps its meaning — the per-NodeClaim re-selection backoff, escalating, capped at 8×. It does not subsume gate B (different scope: same-claim vs. pool).drain_bound(tGP + buffer) is untouched — a deadline, not a pause.- Deleting
cooldownAfteris not on the table: it would take gate A's throughput term and the only unconditional pause on the capacity-absorb path (surge_wait ≈ 0) along with gate B.
Consequences
Positive
- Operators can express the two pauses independently ("settle
1mafter success, pause30mafter failure"). CandRotationSpansNextWindowstop being corrupted by the post-failure pause's value — they model only successful rotations.cooldownAfter: 0lets PDB-serialized workloads reclaim the window throughput the fixed settle timer consumed.
Negative / trade-offs
- Upgrading changes failure-pause behavior only for installs that set
cooldownAfter < 10m: their pause lengthens to10m(the safe direction). Every other install is fully additive — the failure pause, the settle, andCare all unchanged. - The blocked-gate reason code
cooldownAfterFailurebecomesfailurePause— an observable label change on the "no rotation candidate" diagnostic (pre-1.0,v1alpha1).
Scope
- Splits the field only.
cooldownAfterkeeps gate A and its layer-2 term; escalation is deferred.drainEstimate/provisioningEstimate(ADR-0002/0003) are unaffected.