3. Provisioning estimate vs. surge-abandon deadline (drop buffer and readyTimeout from t_rot_est)
- Status: Accepted
- Date: 2026-07-11
- Issue: #220
Context
ADR-0002 (#212) split the single rotation cost into a deadline bound t_rot = readyTimeout + tGP + buffer and a throughput forecast t_rot_est = readyTimeout + drainEstimate + buffer, so layer 2 stops budgeting the drain phase at the force-kill deadline tGP. It fixed the drain term and left the other two untouched.
Those other two terms carry the same defect ADR-0002 removed from the drain term, one step down. t_rot_est claims to be an expected service time — how long a healthy rotation actually takes — yet:
readyTimeoutis not how long provisioning takes; it is the deadline the surge attempt is abandoned at (the node failed to reach Ready and the attempt rolls back). A provision that ever succeeds did so withinreadyTimeout, typically far below it — real EKS Auto Mode reaches Ready in 1–3m against a15mdefault.bufferis not service time at all; it is fixed slack covering the controller's own detection lag (#215). Slack has no place in an expected duration.
So on stock Auto Mode t_rot_est = readyTimeout 15m + drainEstimate 10m + buffer 2m = 27m, of which only the 10m drain term is an actual expected phase duration and the ~15m provisioning phase is over-stated by a timeout. The denominator is inflated by roughly 17m over the genuine ~15m of expected service time, which nearly halves C. This is exactly the over-conservatism ADR-0002 corrected for the drain phase, now visible on the provisioning phase.
The crux: the residual conservatism is not neutral
C appears in ThroughputBelowArrival (C·A < N·P), ThroughputBurstShortfall (N > K·C) and, via the shared denominator, RotationSpansNextWindow (denom > gap). A smaller C makes all three fire more readily — annoying, but safe. Correcting the inflation reverses that: every minute removed from the denominator raises C, and a C that over-states capacity makes all three findings go silent in exactly the situations they exist to catch. That is a worse failure than the one being fixed. Whatever prior replaces readyTimeout must therefore err high, and an operator whose cluster provisions slower than the prior assumes must be able to raise it.
Decision
Drop buffer and readyTimeout from the forecast and model provisioning with its own expected-duration term, shaped exactly like drainEstimate.
t_rot_est = provisioningEstimate + drainEstimatet_rot = readyTimeout + tGP + bufferstays the deadline bound, unchanged.bufferand thereadyTimeout/tGPdeadline terms remain here, where slack and deadlines belong; they feedleadTime,A,G, the §3.3 forceful-fallback deadline race and the §5.2drain_stuckbound.surge.provisioningEstimateis a new optionalRotationPolicyfield. Unset, the forecast falls back tomin(readyTimeout, 5m), silently. The default is bounded above by the timeout it estimates, so it can never claim a provision takes longer than the attempt is allowed to run. An explicit value abovereadyTimeoutis unreachable (the surge is abandoned atreadyTimeout), so it emits aProvisioningEstimateAboveReadyTimeoutWarnand is clamped.readyTimeoutis always resolved to a positive value upstream, so — unlikedrainEstimate'stGP— there is no unset-deadline fork.
ProvisioningEstimateDefault = 5m errs high against the observed 1–3m while staying well under the 15m readyTimeout default, per the direction-of-error argument above.
Why a field rather than a fixed prior
The same reasoning ADR-0002 used for drainEstimate: a cluster whose provisioning is genuinely slow (large images, cold AMIs, constrained capacity) must be able to raise the estimate rather than discover a silent finding. The "two overlapping margin knobs" objection #215 raised against a tunable buffer does not apply here: this term estimates a duration, it does not add slack.
The containment boundary
provisioningEstimate influences no rotation timing, no candidate selection, no start gate, no drain bound, and no Fatal finding — identical to drainEstimate's boundary in ADR-0002. Everything that can gate a NodePool out of rotating, or make a node race its own Forceful Expiration, is on the deadline side and keeps using readyTimeout and tGP. The only thing a wrong provisioningEstimate can do is make a forecast-side Warn too loud or too quiet.
This is pinned by TestDeriveProvisioningEstimateContainment in internal/schedule/schedule_test.go, mirroring TestDeriveDrainEstimateContainment: holding every other input fixed and sweeping provisioningEstimate across nil, 1m, 5m, 15m, and 20m (clamped), it asserts TRot, A, G, and the entire layer-1 finding set are invariant, while only C, TRotEst, ProvisioningEstimate, and the forecast-side findings may move. It forces a non-empty layer-1 set (RetryBackoffShort) first, so the invariance is not vacuous.
Consequences
Positive
- The forecast now sums only genuine expected phase durations — provisioning and drain — with no deadline term and no slack. On the §3.2 worked schedule
t_rot_estgoes27m → 15m,Cgoes7 → 10andK·Cgoes14 → 20, withAandGunchanged. - The over-conservatism ADR-0002 fixed for the drain phase is now also removed from the provisioning phase, so
Cno longer under-counts by areadyTimeout-sized margin.
Negative / trade-offs
- Upgrading changes which layer-2 warnings appear, with no change in rotation behavior, candidate selection, or any deadline — the same non-additive class as ADR-0002. Operators who tuned alerting to the old (louder) warnings will see them quiet further.
- A
provisioningEstimateset too low silences a carry-over or throughput warning that a genuinely slow provision would have justified.5mis a prior, not a measurement. The signal for the real event — a provision actually failing to reach Ready — is the surge timing out atreadyTimeoutand rolling back, which is unaffected byprovisioningEstimate.
Scope
- This supersedes only the
t_rot_estformula from ADR-0002. The deadline/forecast split, the containment boundary, and the "never fit a deadline to observation" argument all stand.drainEstimateis unchanged.