2. Drain estimate vs. force-kill deadline (split t_rot into a deadline bound and a throughput forecast)
- Status: Accepted
- Date: 2026-07-10
- Issue: #212
Note (2026-07-11, #215). The
Buffer = 15mfigures quoted below predate #215, which right-sizedbufferto4·shortRequeue = 2m. With the new constant the Rejected alternative's layer-2 denominator isreadyTimeout 15m + buffer 2m + cooldownAfter 10m + tGP = 27m + tGP(not40m + tGP), so reachingC = 2on a90mwindow needstGP < 63m(not< 50m); and on the §3.2 worked scheduleCgoes1 → 7andK·Cgoes2 → 14(not1 → 5/2 → 10). The decision is unaffected: it turns on the "It is unsafe" ground below —tGPcensors its own observations and must never be fitted to them — not on the exact throughput margin.Note (2026-07-11, #220). The
t_rot_est = readyTimeout + drainEstimate + bufferformula below is superseded by ADR-0003, which dropsbufferand replacesreadyTimeoutwith aprovisioningEstimateterm for the same reason this ADR replacedtGPwithdrainEstimate:readyTimeoutis the surge-abandon deadline, not the expected provisioning duration. After #220t_rot_est = provisioningEstimate + drainEstimate, so on the §3.2 worked scheduleCgoes1 → 10andK·C2 → 20(the Consequences1 → 5/2 → 10figures — already re-stated1 → 7/2 → 14by the #215 note above — are likewise superseded). The deadline/forecast split, the containment boundary, and the "never fit a deadline to observation" argument are unaffected;drainEstimateitself is unchanged.
Context
The §3.2 derivation collapsed the whole rotation cost into a single quantity, t_rot = readyTimeout + terminationGracePeriod (tGP) + buffer, and handed it to two callers with opposite requirements.
The deadline side — leadTime, the age threshold A, the guaranteed chances G, the §3.3 surge-less forceful-fallback deadline race, and the §5.2 drain_stuck bound — needs an upper bound: the latest instant a node can still complete before Karpenter force-completes its drain. For that, tGP is exactly right, because tGP is the deadline Karpenter force-kills a PDB-respecting drain at.
The throughput side — the per-window capacity C = m·ceil(D / (t_rot + cooldownAfter)) and the RotationSpansNextWindow carry-over predicate — needs an expected service time: how long a healthy rotation actually takes, so it can forecast how many fit in a window. Here tGP is wrong by orders of magnitude. On stock EKS Auto Mode tGP = 24h, so on the §3.2 worked schedule (E = 14d, {Wed, Sat} 02:00–06:00, K = 2) the denominator is dominated by a 24-hour term that no real drain approaches, and C collapses to 1 (K·C = 2).
#211 removed the C < 1 early return and switched C to ceil, which restored two throughput findings — ThroughputBelowArrival and ThroughputBurstShortfall — that had been suppressed. Those findings are only as trustworthy as C, and a C computed from the force-kill deadline is not: it under-counts capacity by budgeting every rotation at a full tGP, so on stock Auto Mode the restored findings fire spuriously and RotationSpansNextWindow trips on every daily-or-more-frequent schedule.
Decision
Split the single derived quantity in two.
t_rot = readyTimeout + tGP + bufferstays the deadline bound, unchanged. It continues to feedleadTime,A,G, the §3.3 forceful-fallback deadline race, and the §5.2drain_stuckbound.t_rot_est = readyTimeout + drainEstimate + bufferis a new throughput forecast, read by layer 2 alone:C = m·ceil(D / (t_rot_est + cooldownAfter))and the carry-over predicate.
surge.drainEstimate is a new optional RotationPolicy field. When it is unset the forecast falls back to min(tGP, 10m), silently. That default is non-additive — the observable output (which layer-2 warnings appear) changes on upgrade with no configuration change and no change in rotation behavior — which is why this warrants an ADR under the issue's Process section rather than shipping as a plain refactor. An explicit estimate above tGP is unreachable (Karpenter force-completes the drain at tGP), so it emits a DrainEstimateAboveTGP Warn and is clamped to tGP. When tGP is unset there is no Karpenter deadline to clamp against, so an explicit estimate is used as-is.
t_rot_est is a forecast denominator, not a runtime gate. What actually blocks the next rotation start at runtime is cooldownAfter, the freeze marker, and the active-rotation anchor.
The containment boundary
drainEstimate influences no rotation timing, no candidate selection, no start gate, no drain bound, and no Fatal finding. Everything that can gate a NodePool out of starting rotations, or that can make a node race its own Forceful Expiration, is on the deadline side and keeps using tGP. The only thing a wrong drainEstimate can do is make a forecast-side Warn too loud or too quiet — a layer-2 throughput warning, or the DrainEstimateAboveTGP warning that Derive itself emits about the forecast input.
This is pinned by TestDeriveDrainEstimateContainment in internal/schedule/schedule_test.go. Holding every other input fixed and sweeping drainEstimate across nil, 1m, 10m, 1h, and 25h (clamped), the test asserts that TRot, A, G, and the entire layer-1 finding set are invariant, while only C, TRotEst, DrainEstimate, and the forecast-side findings (the layer-2 warnings plus DrainEstimateAboveTGP) may move. It deliberately forces a non-empty layer-1 set (RetryBackoffShort) first, so the invariance is not vacuous.
Rejected alternative — calibrate tGP from observed drains
The obvious cheaper move is to skip the new field and instead recommend operators lower tGP toward observed drain durations, so that the single t_rot serves layer 2 acceptably. It was rejected on two independent grounds.
It does not work. With a 90m maintenance window the layer-2 denominator is readyTimeout 15m + Buffer 15m + cooldownAfter 10m + tGP = 40m + tGP. Reaching even C = 2 therefore requires tGP < 50m — below the 1h that §3.2's own calibration note recommends as a floor. drainEstimate reaches C = 2 on that same window with tGP = 24h left untouched.
It is unsafe. tGP is not a measurement of how long drains take; it is a declaration of how long a PDB-respecting drain will be waited for before pods are force-killed. Fitting it to observation discards exactly the tail it exists to cover — an incident, a stuck finalizer, a PDB that changed. And unlike drainEstimate, tGP censors its own observations: lowering it right-truncates every subsequent drain, which biases the observed distribution downward and invites a further reduction — a ratchet. drainEstimate never influences the running rotation, so the drains it is estimated from stay unbiased. That asymmetry is precisely why a future EWMA over observed drains (issue #212's "Option 2") is safe for drainEstimate and would not be for tGP. The controller may report that an estimate disagrees with observation; it must never tell an operator to lower a deadline.
The one-shot rebuttal — "under today's tGP = 24h nothing is ever force-killed, so the observations are uncensored and a single recommendation is mathematically sound" — is valid arithmetic and still wrong to ship. The observed sample contains only ordinary drains; the tail tGP exists to cover is, by definition, exactly what has not yet been observed. Recommending a tGP fitted to that sample would strip the backstop of its reason to exist.
Consequences
Positive
- Stock EKS Auto Mode stops over-warning: on the §3.2 worked schedule
Cgoes1 → 5andK·Cgoes2 → 10, withAandGunchanged. - The two findings #211 restored —
ThroughputBelowArrivalandThroughputBurstShortfall— become trustworthy, because they now compare against a capacity forecast from an expected service time rather than from a force-kill deadline. RotationSpansNextWindowstops firing on every daily-or-more-frequent schedule.
Negative / trade-offs
- Upgrading changes which layer-2 warnings appear, with no change in rotation behavior, candidate selection, or any deadline. Operators who tuned alerting to the old (louder) warnings will see them quiet down.
- A
drainEstimateset too low silences a carry-over warning that a genuinely slow drain would have justified.10mis a prior, not a measurement. The signal for the real event — a drain actually running long — remainsnoderotation_drain_stuck, which fires attGP + bufferand is unaffected bydrainEstimate. - After this change
tGPno longer affectsC,RotationSpansNextWindow, orThroughputBurstShortfall. In auto mode it still reachesThroughputBelowArrivalindirectly, because that check comparesC·AagainstN·PandA = E − (K·P + t_rot)still carriestGP. Under an explicitageThresholdoverride even that path closes, andtGPinfluences layer 2 not at all.