fix(operator): merge node-state deltas instead of restamping a stale snapshot - #413
Conversation
…snapshot The heavy pass, JobReconciler and PodReconciler are three controllers with three workqueues, all writing nodewright.nvidia.com/nodeState_<name> — one annotation whose value is a single JSON document covering every package. Before the Job and Pod watches became their own controllers they rode the heavy pass's queue at MaxConcurrentReconciles 1 and could not interleave; splitting them removed that guarantee without replacing it. The heavy pass builds its result from a snapshot taken at cluster-state build time and then patches the whole value unconditionally. A completion recorded by JobReconcile in between is reverted to in_progress, and nothing re-records it because the Job is already marked state-recorded: the stage recovers only by being torn down and re-run. Locking the write would not have fixed it. The value is computed long before the write, so a lock just serializes a stale value into place. Instead derive the pass's delta by diffing its starting snapshot against its result, and apply only that on top of whatever the annotation holds at write time, under an optimistic lock with retry. Entries the pass never touched keep whatever another writer put there. The patch target is rebuilt from the freshly read node rather than the pass's own object, deliberately: that object predates any concurrent write, so diffing against it would emit deletions for keys another writer added since the snapshot — another NodeWright's nodeState_* among them. SkyhookReconciler gains the uncached reader NewJobReconciler already takes. Without it RetryOnConflict re-reads through the informer, rebuilds the same resourceVersion precondition that just lost, and burns every attempt — which is how the earlier optimistic-locking attempt produced a conflict storm. The regression test was verified to fail against the old overwrite behaviour before being kept. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe reconciler now uses an uncached Kubernetes reader for conflict retries. Node persistence computes heavy-pass deltas and applies them to freshly read nodes instead of overwriting whole snapshots. Optimistic-lock conflicts trigger retries, while concurrent node-state entries and selected metadata changes are preserved. Reconciler construction and tests now pass the additional reader. New tests cover delta merging, stale snapshots, metadata replay, no-op behavior, and concurrent completion persistence. Estimated code review effort: 4 (Complex) | ~45 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@operator/internal/controller/node_state_merge_test.go`:
- Around line 210-286: Extend the saveNodeChanges tests with coverage for both
missing paths: use interceptor.NewClient to make the first Patch return
apierrors.NewConflict, then verify the retry reads via the uncached reader and
the merged state is eventually persisted; also add an untracked-node test
calling saveNodeChanges with original == nil and assert the node is actually
updated, exposing and fixing the empty-diff handling in saveNodeChanges.
In `@operator/internal/controller/skyhook_controller.go`:
- Around line 1352-1359: Wrap every propagated error returned by saveNodeChanges
and readNodeForSave with contextual fmt.Errorf messages using the %w verb,
including errors from parseNodeState and the patch/save operations. Preserve the
underlying errors so retry.RetryOnConflict can continue detecting conflicts
through apierrors.IsConflict, and eliminate all bare err returns in these
functions.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 2b4a015b-c73a-4973-bebc-4d0f761f7173
📒 Files selected for processing (8)
docs/designs/2026-07-10-package-execution-as-jobs.mdoperator/cmd/manager/main.gooperator/internal/controller/node_state_merge_test.gooperator/internal/controller/skyhook_controller.gooperator/internal/controller/skyhook_controller_test.gooperator/internal/controller/suite_test.gooperator/internal/controller/swap_test.gooperator/internal/controller/workload_migration_test.go
Coverage Report for CI Build 31653882834Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 78.974%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
…e's blast radius Review of #413 found that rebuilding the patch target from the freshly read node — the step that makes the node-state merge work — quietly turned two other fields into whole-value restamps, and left the wrapper answering from a cache the merge had invalidated. spec.taints and spec.unschedulable were assigned from the pass outright on the grounds that this controller owns them. It does not: the cluster autoscaler, node-problem-detector and a human with kubectl all write taints, and hasSkyhookCordon exists precisely because two Skyhooks can hold a cordon on one node. With the fresh read as the patch base, an untouched field became an explicit write — taints are listType=atomic so the list is replaced, and Unschedulable is omitempty so false diffs as an explicit null. A pass that never touched either could delete an autoscaler taint or uncordon a node another Skyhook was draining. Both now replay only what the pass changed. skyhookNode caches a parsed copy of node state and State() serves it whenever it is non-nil, so replacing the embedded Node with the merged one left IsComplete, NextStage and UpdateCondition reading the pre-merge map. That published a NotReady/Incomplete condition for a node that had just completed and dropped its MarkComplete event. The wrapper gains InvalidateStateCache, matching the nodeState = nil precedent Reset and CleanupSCRMetadata already use, and the merge calls it. The node condition patch was still based on the pass's build-time snapshot while being applied to the post-merge object. That re-sent whatever the kubelet had changed in .status and carried the snapshot's resourceVersion into the patch body, where the apiserver reads it as a precondition — so any concurrent write turned it into a 409, and that error gates both the Skyhook's UpdateCondition and its status patch. It is now based on the post-merge object, so the diff is exactly the conditions UpdateCondition touches. The untracked-node branch returned StrategicMergeFrom against a copy of the object itself, which is an empty patch: a broken invariant would have silently dropped the write. BuildState tracks and adds a node in the same step so it is unreachable; it now says so. Also drops the duplicated node reader in favour of the existing readNodeForPatch, which is a free function for exactly this reason. Both new spec-field regressions and the cache-coherence assertion were verified to fail against the unfixed code before being kept. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
|
Ran an adversarial review against this. It found a blocker I introduced, plus three should-fixes — all valid, all addressed in ff2ca87. Blocker: the fresh-read patch base silently made
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@operator/internal/controller/skyhook_controller.go`:
- Around line 1470-1477: The taint merge loop around taintID must replay
pass-owned edits after concurrent deletion: when a modified taint identity is
absent from fresh, append it if it was not in original or its value differs from
the original taint. Preserve skipping identities still present in fresh, and add
a regression test covering an original-to-modified value change followed by
deletion from fresh.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 3c109772-43f2-4cdc-9279-90ca07a739f6
⛔ Files ignored due to path filters (2)
operator/api/nodewright/v1alpha1/zz_generated.deepcopy.gois excluded by!**/zz_generated*.gooperator/api/v1alpha1/zz_generated.deepcopy.gois excluded by!**/zz_generated*.go
📒 Files selected for processing (11)
operator/config/crd/bases/nodewright.nvidia.com_deploymentpolicies.yamloperator/config/crd/bases/nodewright.nvidia.com_nodewrights.yamloperator/config/crd/bases/skyhook.nvidia.com_deploymentpolicies.yamloperator/config/crd/bases/skyhook.nvidia.com_skyhooks.yamloperator/config/rbac/role.yamloperator/config/webhook/manifests.yamloperator/internal/controller/node_state_merge_test.gooperator/internal/controller/skyhook_controller.gooperator/internal/wrapper/mock/SkyhookNode.gooperator/internal/wrapper/mock/SkyhookNodeOnly.gooperator/internal/wrapper/node.go
💤 Files with no reviewable changes (6)
- operator/config/crd/bases/skyhook.nvidia.com_deploymentpolicies.yaml
- operator/config/crd/bases/skyhook.nvidia.com_skyhooks.yaml
- operator/config/crd/bases/nodewright.nvidia.com_deploymentpolicies.yaml
- operator/config/crd/bases/nodewright.nvidia.com_nodewrights.yaml
- operator/config/rbac/role.yaml
- operator/config/webhook/manifests.yaml
These were the last untested part of the node-state merge, called out on the PR: nothing in the suite forced a conflict, so neither RetryOnConflict nor the uncached branch of readNodeForPatch ever ran. The optimistic lock is only worth anything if a conflict is retried AND the retry re-derives against a fresh read, and neither half was being checked. The first test forces a conflict on the first patch via an interceptor client (the pattern job_controller_test.go already uses) and supplies the uncached reader with a node the cached client does not have: a completion only the apiserver knows about. It then asserts the patch was retried, that exactly one uncached read happened (attempt 0 stays cached), and that the retry merged its delta onto what the uncached read returned rather than onto the stale cache. That last assertion is what makes the uncached branch load-bearing rather than an optimisation nobody would notice losing. The second covers the node being deleted between attempts, which returns nil rather than an error: a node that went away mid-pass has no state to resurrect. Both were verified against sabotaged code before being kept. Disabling the uncached branch fails both; replacing RetryOnConflict with a single call fails both. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
|
Closed the coverage gap I flagged, in 115d1a4. The retry test forces a conflict on the first patch with an interceptor client (the pattern
That third assertion is the one that matters. Without it the uncached branch is an optimisation nobody would notice losing; with it, reading from the stale cache on retry produces a wrong final state and fails. The second test covers the node being deleted between attempts — Verified load-bearing, same method as the earlier fixes — sabotage, confirm failure, restore:
That was the last thing I had listed as untested on this PR. |
CodeRabbit review on #413. Three findings; one was already closed by the adversarial-review pass, two were not. Wrap the propagated errors in saveNodeChanges. Five bare `return err` values from a function that reads, merges and patches, which the coding guidelines forbid outright. The patch error is wrapped with %w deliberately: RetryOnConflict decides via apierrors.IsConflict, which unwraps, so the retry keeps working — and the conflict-retry spec added in 115d1a4 fails if that ever stops being true, which is how this was checked rather than assumed. Replay a pass-owned taint edit that a concurrent delete removed. If original and modified hold the same taint identity with different values and the fresh read no longer has it, applyTaintChanges dropped the pass's edit: the second loop only appended identities absent from original. It now appends when the pass added the taint OR changed the value of one it inherited, while an identity the pass left exactly as it found it still yields to the concurrent deletion. This one is latent rather than live: Taint() early-returns when the key is already present and RemoveTaint() only removes, so the operator has no path that edits a taint's value in place and the scenario is unreachable today. Pinned with specs on both sides of the rule so it stays closed if such a path ever appears. Also adds the untracked-node spec CodeRabbit asked for, inverted to match what that branch now does. Its suggestion — assert the node still changed — was right for the original code, which diffed the object against a copy of itself and silently dropped the write. The branch returns an error now, so the spec asserts that instead: a broken invariant must not look like success. The retry-loop half of that same finding was already covered by 115d1a4. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
|
Went through all three CodeRabbit comments. Two were on 077ad94, before the adversarial-review pass, so the question was which had already been closed by it. Answer: one had, two hadn't. All resolved now in c4b0cfb.
#2 — error wrapping. Five bare #3 — taint edit lost. Correct reading of the code: the second loop only appended identities absent from Worth being precise about severity though — this is latent, not live. #1 — untracked branch. The suggestion was to assert the node still changed. That was right for the original code, which diffed the object against a copy of itself — an empty patch that silently dropped the write. The adversarial review caught that and the branch now returns an error, so I inverted the spec: it asserts the error, because the one thing a broken invariant must not do is look like success. All new specs sabotage-verified. Disabling the taint edit-replay fails exactly the one spec that covers it. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@operator/internal/controller/skyhook_controller.go`:
- Around line 1478-1484: Update the taint comparison in the result-building
logic around originalByID so inherited taints are compared by value using
apiequality.Semantic.DeepEqual rather than struct inequality; retain
resurrection for missing identities or genuinely changed taints. Add a
regression test covering unchanged taints with equal non-nil
corev1.Taint.TimeAdded values to ensure concurrent deletion is not undone.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: f36ad85a-53d9-4cd8-ae2d-29931afdcd63
📒 Files selected for processing (2)
operator/internal/controller/node_state_merge_test.gooperator/internal/controller/skyhook_controller.go
CodeRabbit review on #413, and a real bug in the taint fix from c4b0cfb. corev1.Taint carries TimeAdded *metav1.Time, so `originalTaint != taint` compares pointer identity, not the instant. original and modified are separate DeepCopies and DeepCopy allocates a fresh *metav1.Time, so every taint that carries TimeAdded compared as edited even when the pass never touched it — and the edit-replay branch then resurrected taints another writer had concurrently deleted. That is the exact failure mode the surrounding code exists to prevent. Key and Effect are the taint's identity, so Value is the only thing an edit can change; comparing it sidesteps the pointer entirely. TimeAdded is set by the system rather than this operator, so it has no business driving a replay decision either way. The existing specs missed it because every fixture left TimeAdded nil, where two nil pointers compare equal. The new one sets it, and fails against the struct compare. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
|
Correct, and this was a real bug I introduced in c4b0cfb — fixed in 75b3834.
That is precisely the failure mode this function exists to prevent, so the fix I added yesterday for the latent case had reintroduced the live one in a narrower form. Now compares Why the existing specs missed it: every fixture left Worth noting for the record: I had the earlier reviewer explicitly verify
|
cli-e2e has failed on every commit since ff2ca87, consistently, on cli-deployment-policy-reset: the rollout stalls at progressPercent 50 with status waiting and an empty batchState. This is the cause. ff2ca87 added InvalidateStateCache, which set skyhookNode.nodeState to nil so the next State() would re-parse the merged annotation. State() does re-parse when the cache is nil — but IsComplete, NextStage, GetComplete and PackageStatus read node.nodeState DIRECTLY and never go through State(). A nil cache therefore reads as "no package has any state": a node that had just completed reported incomplete, its MarkComplete event never fired, RemoveNodePriority was never called, and skyhook.IsComplete stayed false, so the DeploymentPolicy batch never advanced past the first node. NodeState.Upsert makes it worse still — it allocates a fresh map over a nil one, so the next write on that wrapper would have dropped every other package's entry. Replaced with ReloadState, which re-parses into the cache rather than leaving it nil. That is what the review actually asked for; nilling it was my substitution and it was wrong. The unit test missed it because it asserted through State(), the one accessor that tolerates a nil cache by re-parsing. It now also asserts through PackageStatus, which reads the cache directly, and fails against the nil-cache version. Refs #411 (item 1). Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
…ed value
Review found the justification for rebuilding the patch target from the freshly
read node does not hold. A two-way strategic merge diff is computed from the
snapshot and the pass's object alone, so a key present in neither -- another
NodeWright's nodeState_*, a foreign label, an autoscaler taint -- cannot appear
in the patch at all. The emitted body for a pass that edits one annotation and
two labels is exactly:
{"metadata":{"annotations":{"...nodeState_a":"..."},"labels":{"added":"1","drop":null}}}
Rebuilding from the fresh read is what turned every untouched field into an
explicit write, and applyPassChanges/applyMapChanges/applyTaintChanges existed
only to undo that. The taint half did not fully succeed: its first loop
restamped the pass's value over a taint another writer had edited, and dropped
TimeAdded that the apiserver had stamped on a NoExecute taint. Both were
reachable by a pass that changed no taints at all.
So keep the pass's snapshot as the diff base and splice the fresh
resourceVersion into it for the lock. The base serves two roles -- left-hand
side of the diff, and source of the optimistic-lock precondition -- and
MergeFromWithOptimisticLock reads the version from the base, so the two can be
served by different objects. Only the contended annotation value is re-derived.
spec.unschedulable and spec.taints then need no handling whatsoever: unchanged
by the pass means absent from the diff.
That removes 122 lines and both taint regressions with them. Clobbering a
foreign taint when the pass DOES change a taint is pre-existing on main and is
tracked in #447; the structural fix that would delete the delta machinery
outright is #448.
Also guards the merge on the pass having kept the annotation. Reset() deletes it
outright and means it, so re-merging would resurrect the key it just wiped --
a divergence from main that the fresh-read version shared.
The foreign-metadata assertions move off applyPassChanges unit specs onto the
real saveNodeChanges path, where a stored node carries a foreign label, a second
NodeWright's state key, an autoscaler taint and a cordon that this pass never
saw. Verified to fail with the base swapped back to the fresh read.
Refs #411 (item 1).
Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
Two gaps found auditing what the node-state work actually proves.
The passKeptState guard shipped without a spec. Reset() deletes the annotation
outright, and a delta of pure removals would otherwise be re-marshalled and
written back as "{}" -- resurrecting the key the pass just wiped and leaving a
node that reads as tracked-with-no-packages rather than untracked. Verified to
fail with the guard forced true.
shouldDeleteFinishedJob had four cases and none of them was the one the lost
update actually produces. Absent-entry was covered (a reset or an uninstall) but
not an entry reverted to (this stage, in_progress), which is the regression
shape: a completion clobbered back by the pass. That predicate is the reason a
lost update self-heals rather than stalling, at the cost of re-running the
stage, so it is worth pinning as its own case rather than inferring it from the
absent-entry one.
Refs #411 (item 1).
Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
…t into jobs-migration/411-nodestate-lost-update Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com> # Conflicts: # operator/internal/controller/swap_test.go
Refs #411 (item 1). Does not close it — the other items there are still open. Part of #223, based on
feature/package-as-jobs.The bug
nodewright.nvidia.com/nodeState_<name>is one annotation whose value is a single JSON document covering every package on the node. Three controllers write it, each with its own workqueue:SaveNodesAndSkyhook)JobReconcilerPodReconcilerBefore the Job and Pod watches became their own controllers they rode the heavy pass's queue via the
pod---prefix atMaxConcurrentReconciles: 1, so pod-driven writes could not interleave with the pass. Splitting them removed that guarantee without replacing it. This is a regression introduced by this branch, not a bug onmain— onmainthe node has exactly one operator writer at a time.The heavy pass builds its result from a snapshot taken at cluster-state build time. If
JobReconcilerecords package A complete while the pass is working on package B, the pass then writes its whole snapshot-derived value and reverts A toin_progress.The trigger is the ordinary path, not an exotic interleaving: non-
Serialis the default,RunNextreturns every DAG-ready package, so a multi-package Skyhook routinely has several packages in flight on one node. The window is the whole pass body, and the pass requeues every 2s while incomplete. Single-package Skyhooks are largely immune — the clobber needs the pass and the Job to be touching two different entries.What it costs
Not a stall. Nothing re-records the completion (the Job is already marked
state-recorded), butshouldDeleteFinishedJobdetects exactly this divergence — a processed, Complete Job whose node-state entry still sits at that stage, not complete — and tears the Job down, so the next pass recreates it and the stage runs a second time. Recovery is a couple of 2s passes.The cost is that second run:
in_progress↔complete, with the events and metrics behind them.Two things it is not: a re-run interrupt does not reboot again (the agent flag-files interrupts per
SKYHOOK_RESOURCE_IDon the host root), and nothing is re-evicted (DrainNodeshort-circuits onIsDrainedwhile the node stays cordoned throughout).But that idempotence is narrower than it looks, and it is what the blast radius actually rests on.
check_flag_fileruns the step anyway — flag present or not — for three whole modes:apply,post-interruptIdempotence.Autois the defaultinterruptSKYHOOK_RESOURCE_IDflagconfigupgradeuninstallidempotence: disabledSo a clobbered
config: completeis not a no-op pod cycle — it re-runs the package's config step, which for most packages rewrites config and restarts a service.configis a normal stage in the ordinaryapply -> configsequence, so this is not a corner case.Convergence is also probabilistic rather than bounded: nothing counts re-runs, and each re-run is itself a blob write (
ApplyPackageupsertsin_progress), so a busy multi-package node gets fresh exposure to the same race on every cycle.The net of it is that the operator's safety on this path currently rests on the agent's idempotence rather than on its own state machine — and that guarantee is opt-out, by mode and by a per-step knob any package author can set.
Why a lock alone was not the fix
This was the tempting one-liner, and it does not work. The value is computed long before the write, so an optimistic lock just serializes a stale value into place. To fix it with locking alone the lock would have to span read → write, which for the heavy pass is an entire whole-world reconcile.
It is also what the earlier attempt did: #382 records the measured cost (0 → 156 conflicts across CI runs, since reverted). Those conflicts were pure loss, because each retry re-sent the same stale whole value.
The fix
Derive the pass's delta — which package entries it actually changed — by diffing its starting snapshot against its result, then apply only that on top of whatever the annotation holds at write time, under an optimistic lock with retry. Entries the pass never touched keep whatever another writer put there, so a retry converges instead of clobbering.
Only that one value is re-derived. Everything else the pass changed stays an ordinary strategic-merge diff against the pass's own snapshot, exactly as before this PR. That is what keeps the patch from mentioning any label, annotation, taint or cordon this pass did not touch — a key present in neither the snapshot nor the pass's object cannot appear in the diff at all.
Two details worth review attention:
resourceVersioncomes from the fresh read. The base does two jobs — left-hand side of the diff, and source of the optimistic-lock precondition — andMergeFromWithOptimisticLockreads the version from the base, so the two can be served by different objects.spec.unschedulableandspec.taintsthen need no special handling at all: unchanged by the pass means absent from the diff.SkyhookReconcilergains the uncached reader thatNewJobReconcileralready takes. Without itRetryOnConflictre-reads through the informer, rebuilds the same resourceVersion precondition that just lost, and burns every attempt. Same split, and same reason, aspatchNodeState. It is also what makes the first read safe to serve from cache: a stale cached read merges the delta onto a stale value, but its precondition is stale too, so it 409s and the retry re-reads from the apiserver.An earlier revision of this PR rebuilt the whole patch target from the fresh read and hand-replayed each metadata surface onto it. That was dropped: the premise (that a snapshot-based diff would emit deletions for keys another writer added) does not hold, and the hand-rolled replay it forced is where the only two clobbers in this PR came from. 122 lines went with it.
Scope
Deliberately just the lost update. Not in scope:
spec.taintsislistType=atomic, so the whole list is replaced from the snapshot). Pre-existing onmain, unchanged here.PodReconcilerintoJobReconciler. Every pod failure that matters incrementsjob.Status.Failedand so already produces a Job event, which would remove a third writer entirely. Worth doing, bigger change, and it needs a story forCrashLoopBackOffunderrestartPolicy: OnFailure(interrupt Jobs), where Job status does not change.Testing
make unit-testspasses (504 specs across 8 suites),golangci-lint0 issues.The regression test — "does not revert a completion recorded after the pass took its snapshot" — drives the real
saveNodeChangespath against a client whose stored node has diverged from the pass's snapshot. I verified it fails against the old overwrite behaviour before keeping it, by temporarily restoring the restamp and confirming the failure, so it is not vacuous.That same spec now also pins the foreign-metadata guarantees at the real seam: the stored node carries a label, a second NodeWright's
nodeState_*key, an autoscaler taint and a cordon that this pass never saw, and all four must survive the write. Verified to fail when the diff base is swapped back to the fresh read.Also covered: delta computation (changed / added / removed), preserving a concurrent completion, preserving another NodeWright's key, applying a removal over a changed current value, the conflict retry and its uncached re-read, the missing-snapshot error, and the node-deleted-mid-retry path.
Not covered: real concurrent execution. The race is exercised by construction (a diverged stored object), not by running two controllers at once, which would be flaky.