See template drift, and optionally roll cells to fix it - #10
Merged
Conversation
Closes #2. Two layers, because the first turned out to be missing entirely. **Drift was invisible.** Each cell was stamped with the hash of the template it was created from, and nothing ever read it back — so changing spec.cell left the pool silently unaware. The hash is now observed (OuterState.TemplateHash), surfaced per cell as status.cells[].templateHash, and compared in a new Updated condition. That alone is worth having: knowing you have drifted is useful even if you replace cells by hand. **spec.updatePolicy.type: RollingUpdate** then acts on it, opt-in. Replacement is deletion plus recreation — there is no in-place update of a VM's image — so every gate here is protecting running work: one cell at a time, only cells the capacity provider reports IDLE, not during a resize (a rollout must not race a scaling decision for the index it is about to free), not while the workload cluster is unreachable (then "is this cell busy?" has no answer), lowest index first so the order is predictable. The existing drain gate re-checks allocations again before the object goes. On a pool whose stale cells are all busy it makes no progress, indefinitely, and says so via UpdateBlocked. It will not evict anything to make room — this operator waits for a GPU to be released rather than taking it away, which is why it holds no pods/eviction right at all. A cell with no recorded hash counts as CURRENT, not stale: it predates the field, and treating unknown as out-of-date would replace an entire healthy pool the first time someone switched the policy on. **And a latent bug this exposed, which was never about rolling updates.** Cell names are reused — index 0 is always <pool>-0 — and status rows are keyed by name, so a replacement inherited its predecessor's Draining phase and was deleted on the pass that created it: create, destroy, create, destroy, with no timeout that could ever break the loop. The harness caught it as "the stale cell was never replaced"; the trace showed a new guest UID every pass. Rows describing a different guest UID no longer lend their phase. The failure counter still carries, because the replacement backoff is counted per index rather than per incarnation. Any replacement path could hit this — a failed cell, or a scale-down and scale-up on the same index — and it was masked only because nothing had previously refilled an index whose row still said Draining. Also: idleness was read from the capacity provider only when automatic scale-down was enabled, so a rolling update saw zero idle cells and silently never acted. Both destructive paths need it, so both ask for it now. Signed-off-by: William Rizzo <william.rizzo@gmail.com>
This was referenced Aug 9, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2.
Two layers, because the first turned out to be missing entirely.
Drift was invisible
Each cell was stamped with the hash of the template it was created from — and nothing ever read it back. Changing
spec.cellleft the pool silently unaware; the design doc's claim that a template change is "observable (Updated=False,TemplateChanged)" was not true, because no such condition was ever computed.The hash is now observed, surfaced per cell as
status.cells[].templateHash, and compared in a newUpdatedcondition. That is worth having on its own: knowing you have drifted is useful even if you replace cells by hand.spec.updatePolicy.type: RollingUpdateOpt-in, because a template edit is not consent to destroy running work. Replacement is deletion plus recreation — there is no in-place update of a VM's image — so every gate is protecting something:
The existing drain gate re-checks allocations again immediately before the object goes.
It can stall indefinitely, and that is the point. On a pool whose stale cells are all busy,
Updatedstays False withUpdateBlockedand a message naming the cause. It will not evict anything to make room — this operator waits for a GPU to be released rather than taking it away, which is why it holds nopods/evictionright at all.A cell with no recorded hash counts as current, not stale: it predates the field, and treating unknown as out-of-date would replace an entire healthy pool the first time someone enabled the policy.
A latent bug this exposed, which was never about rolling updates
Cell names are reused — index 0 is always
<pool>-0— and status rows are keyed by name. So a replacement inherited its predecessor'sDrainingphase and was deleted on the pass that created it: create, destroy, create, destroy, with no timeout that could ever break the loop.The harness caught it as "the stale cell was never replaced"; tracing showed a new guest UID every single pass. Rows describing a different guest UID no longer lend their phase. The failure counter still carries, because the replacement backoff is counted per index rather than per incarnation.
Any replacement path could hit this — a failed cell, or a scale-down followed by a scale-up on the same index — and it was masked only because nothing had previously refilled an index whose row still said
Draining. It has its own regression test that does not involve templates at all.Also fixed: cell idleness was read from the capacity provider only when automatic scale-down was enabled, so a rolling update always saw zero idle cells and silently never acted.
Docs
New
docs/updates.mdcovers seeing drift, the manual cordon/drain/delete sequence, and the rolling policy including its stall behaviour and the capacity gap during a replacement (no surge: a cell holds a physical GPU).docs/limitations.mdloses the entry, the runbook gains the new reasons, and the index links it.make test,make lintandmake verifypass.🤖 Generated with Claude Code