Skip to content

fix(operator): never resurrect a package entry on interrupt completion (#426) - #435

Merged
lockwobr merged 4 commits into
feature/package-as-jobsfrom
jobs-migration/426-interrupt-resurrect
Aug 13, 2026
Merged

fix(operator): never resurrect a package entry on interrupt completion (#426)#435
lockwobr merged 4 commits into
feature/package-as-jobsfrom
jobs-migration/426-interrupt-resurrect

Conversation

@ayuskauskas

Copy link
Copy Markdown
Collaborator

Important

Base this work on feature/package-as-jobs, not main.

The Jobs migration (#223) is integrating on that branch. main does not yet execute
packages as Jobs, so a PR opened against main will be missing the code this depends
on. feature/package-as-jobs merges to main once #305 lands.

That branch squash-merges, so if your branch is stacked on another PR in this epic,
replay only your own commits when restacking:
git rebase --onto origin/feature/package-as-jobs <last-inherited-commit>

Closes #426. Part of #223, based on feature/package-as-jobs (not main).

#426 was single-reporter and never cross-verified, so this starts with a failing spec rather than a fix.

Reproduced first

promotes the sibling without resurrecting a package whose entry was removed fails on the parent commit: node state comes back holding tuning|1.0.0 after the entry was removed.

What it is

An interrupt Job is package-agnostic — one per (node, stage, interrupt type), deduped across packages — so its completion also has to promote siblings sitting at (interrupt, skipped). shouldRecordCompletion's interrupt branch authorizes the write on the strength of such a sibling, before looking at this Job's own package:

if isInterruptJob(job) {
    for _, s := range state {
        if s.Stage == StageInterrupt && s.State == StateSkipped {
            return true, nil        // ← nothing here is about pkg
        }
    }
}
status, present := state[pkg.GetUniqueName()]
return present && status.Stage == pkg.Stage && status.State != StateComplete, nil

recordJobCompletion then treated that as license for its own package too. HandleCompletePod's interrupt branch only calls ProgressSkipped and reports updated == false, so the fallback Upsert always ran — and Upsert creates. A package rerun, a reset, or a finalizer-driven uninstall that removed the entry while the interrupt Job was completing got it back at (interrupt, complete).

The second-order effect is the damaging one: with the entry present and complete, shouldDeleteFinishedJob's recordedDone arm keeps the Job, so the stage never re-runs. The rerun silently does nothing until the failure TTL expires.

The fix

The self-write is gated on entryAwaitsCompletion — present, still at this Job's stage, not already complete — extracted so shouldRecordCompletion's non-interrupt tail and this guard cannot drift apart. Promotion is untouched: the sibling still lands at complete in the same spec.

Node state is re-read after HandleCompletePod rather than before, since promotion can move this package's own entry.

This is the same invariant the Pod watch already states for itself (shouldRecordPodErroring: "this watch is not an authority that may create or resurrect a node-state entry") — the Job path is the completion authority, but "authority" was never meant to include creating an entry that state says is gone.

Considered and rejected

Moving the presence check above the interrupt branch — the one-line version — also drops the sibling promotion whenever this Job's own package was removed, which can leave packages at (interrupt, skipped) until another interrupt cycle. That trades a resurrection for a possible stall.

Verification

Controller suite 303/303 (302 before, +1 new), make lint 0 issues, go build ./... clean. No chainsaw fixture touches this path. Not run: e2e (no cluster locally) — CI covers it.

An interrupt Job is package-agnostic — one per node, stage and interrupt
type, deduped across packages — so its completion also has to promote
siblings left at (interrupt, skipped). shouldRecordCompletion's interrupt
branch authorizes the write on the strength of such a sibling, before
looking at the Job's own package at all.

recordJobCompletion then took that as license for its own package too:
HandleCompletePod's interrupt branch only promotes and reports no update,
so the fallback Upsert always ran, and Upsert creates. A rerun, reset or
finalizer-driven uninstall that removed the entry while the interrupt Job
was completing therefore got it back at (interrupt, complete) — and with
the entry present and complete, the rerun predicate keeps the Job, so the
stage never runs again. The rerun the user asked for silently does
nothing until the failure TTL.

The self-write is now gated on entryAwaitsCompletion (present, at this
stage, not complete), extracted so shouldRecordCompletion's non-interrupt
tail and this guard cannot drift. Promotion is untouched. State is
re-read after HandleCompletePod, since promotion can move this package's
own entry.

Closes #426.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@github-actions github-actions Bot added component/operator Skyhook operator (controller-manager) component/ci CI workflows, GitHub Actions, and repo tooling labels Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: cad2c3a3-0fa3-41ef-badd-f8a013b02b84

📥 Commits

Reviewing files that changed from the base of the PR and between 0db7d28 and 93caf2a.

📒 Files selected for processing (2)
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • operator/internal/controller/job_controller_test.go

📝 Walkthrough

Walkthrough

The controller now uses entryOpenAtStage for completion eligibility. It re-reads node state after HandleCompletePod and updates only existing, current, incomplete entries. Interrupt completion skips deleted NodeWright resources. Tests cover removed, advanced, absent, and superseded package states. The design documents the restriction against recreating package entries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Suggested labels: component/tests

Suggested reviewers: lockwobr, rice-riley

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary fix: preventing package-entry resurrection during interrupt Job completion.
Description check ✅ Passed The description directly explains issue #426, the reproduction, the fix, test coverage, and verification results.
Linked Issues check ✅ Passed The PR confirms issue #426 and prevents self-entry resurrection while preserving sibling promotion, with specifications for the required completion cases.
Out of Scope Changes check ✅ Passed The changes remain within interrupt completion and related node-state eligibility, event reporting, resource handling, tests, and documentation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jobs-migration/426-interrupt-resurrect

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Aug 11, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31542544617

Warning

No base build found for commit e6796dd on feature/package-as-jobs.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 79.074%

Details

  • Patch coverage: 6 uncovered changes across 1 file (23 of 29 lines covered, 79.31%).

Uncovered Changes

File Changed Covered %
operator/internal/controller/job_controller.go 28 22 78.57%
Total (2 files) 29 23 79.31%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 13524
Covered Lines: 10694
Line Coverage: 79.07%
Coverage Strength: 8.15 hits per line

💛 - Coveralls

…n event

Follow-ups from review of the previous commit.

A deleted NodeWright reads as (nil, nil) from GetSkyhook, and
wrapper.Convert dereferences it, so HandleCompletePod's interrupt branch
panicked when the CR was removed while a completed interrupt Job was
still unprocessed — the same window the entry guard is about. The
uninstall branch two blocks down already nil-checks; this one now does
too. Pre-existing, but it is the same failure family, and the new spec
panics without the guard.

The success event could also lie once the self-write is gated: an
interrupt Job that reaches the recorder purely on a sibling's promotion
left skyhookNode.Changed() true, so the operator announced
"Package [x:1.0.0] state complete" for a package whose entry a reset had
just cleared. The event now follows what was actually written.

Also folds shouldRecordPodErroring onto entryAwaitsCompletion — it was a
third verbatim copy of the same predicate in the same package — and notes
on recordJobErroring why its erroring-exclusion is deliberately not the
same helper.

Specs added for the CR-gone panic, the upgrade branch's RemoveState path
(previously untested), and no-regression from a later stage on the
interrupt path, which is the direction only the new guard covers.

The design doc now records that the create-nothing rule binds the Job
path too, not just the Pod watch.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@github-actions github-actions Bot added the doc Documentation change (PR path label; doc issues use the Documentation type) label Aug 11, 2026
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Ran an independent Opus review over the first commit. Verdict was that the fix is sound — it traced the guard across every stage and both Job kinds, and separately confirmed the two things I was least sure of: skyhookNode.State() returns the cached map that ProgressSkipped/Upsert mutate (so the re-read is not stale), and the upgrade branch's RemoveState only ever removes other-version keys, since NodeState is keyed name|version, so the fallback still fires there.

It found three things worth acting on, now in 2bac8006.

1. A nil-CR panic in the branch this PR hardens. dal.GetSkyhook reads a deleted CR as (nil, nil) and wrapper.Convert dereferences it, so HandleCompletePod's interrupt branch panicked when the NodeWright was removed while a completed interrupt Job was still unprocessed — the same window as the resurrection. Pre-existing, but the same failure family, and the uninstall branch two blocks down already nil-checks. Verified: the new spec panics with invalid memory address or nil pointer dereference on the parent commit, passes with the guard.

2. The success event could lie — introduced by my change. Once the self-write is gated, an interrupt Job reaching the recorder purely on a sibling's promotion still had skyhookNode.Changed() == true, so the operator emitted Package [tuning:1.0.0] state complete for the package a reset had just cleared. The event now follows what was actually written, with a promotion-worded message for the other case.

3. The "cannot drift" extraction was 2/3 done. shouldRecordPodErroring was a third verbatim copy of the predicate in the same package; folded onto entryAwaitsCompletion. Deliberately not folded: recordJobErroring, which excludes an already-erroring entry for idempotence rather than an already-complete one — there's now a note there so nobody unifies them later.

Two nits taken as written rather than as code: !updated is redundant today (every branch setting it leaves the entry absent or at another stage), so it keeps a comment saying the two guards are independent rather than being dropped; and the design doc now records that the create-nothing rule binds the Job path too, not only the Pod watch.

One flagged trade left alone: if the Job's own package is the one at (interrupt, skipped), promotion advances it and the fallback no longer runs, so Restarts keeps the skip's 0 instead of job.Status.Failed. State and Stage are identical; it is cosmetic, and closing it would mean writing over an entry promotion just moved.

Specs added for the CR-gone panic, the upgrade branch (previously untested), and no-regression from a later stage on the interrupt path — the direction only the new guard covers.

Suite 306/306, make lint 0 issues, build clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Line 131: Update the prose around the Job path and interrupt Jobs to use the
grammatical phrase “write a package entry that node state says is gone” and
replace “on the strength of” with “because,” without changing the described
invariant or behavior.

In `@operator/internal/controller/job_controller_test.go`:
- Line 470: Strengthen the assertion in the completion-path test around
getNodeState so it verifies both StagePostInterrupt and StateComplete, or
compares the expected complete PackageStatus, ensuring the retained stage cannot
mask an incorrect state.

In `@operator/internal/controller/pod_controller.go`:
- Line 245: Update the completion check around entryAwaitsCompletion so error
recording uses an error-specific predicate that excludes StateErroring, matching
recordJobErroring and preserving idempotency for re-served terminal events.
Restore or extract an entryAwaitsErroring helper and use it for this return path
without changing non-error completion eligibility.
🪄 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: 959e3670-78a0-4538-9bb2-5ee348825ed6

📥 Commits

Reviewing files that changed from the base of the PR and between 0af4ba8 and 2bac800.

📒 Files selected for processing (4)
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • operator/internal/controller/job_controller.go
  • operator/internal/controller/job_controller_test.go
  • operator/internal/controller/pod_controller.go

Comment thread docs/designs/2026-07-10-package-execution-as-jobs.md Outdated
Comment thread operator/internal/controller/job_controller_test.go Outdated
Comment thread operator/internal/controller/pod_controller.go Outdated
Review pointed out that reusing a predicate called entryAwaitsCompletion
for the Pod watch's erroring write reads as the wrong thing, which is the
same complaint that renamed isParkedJob. It is now entryOpenAtStage —
present, at this stage, not complete — which is what both callers
actually require, with the doc comment saying so and recording why an
already-erroring entry is deliberately still open (a rising restart count
must land; an identical write is dropped by the Changed() check).

Also tightens the no-regression spec to assert State as well as Stage,
and fixes a garbled sentence in the design doc.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
operator/internal/controller/job_controller.go (1)

339-377: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add recorder coverage for interrupt completion.

HandleCompletePod returns false for interrupt completion, including sibling promotion. Assert that the removed-self case emits only the generic interrupt event, not a package-specific completion event.

🤖 Prompt for 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.

In `@operator/internal/controller/job_controller.go` around lines 339 - 377, Add
recorder coverage for the interrupt-completion path in HandleCompletePod where
the package entry was removed after sibling promotion. Assert that it emits only
the generic interrupt completion event and does not emit the package-specific
JobComplete event.
🤖 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 `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Around line 131-132: Revise the prose around the Job path restriction to say
it cannot write an entry node state that the package says is gone, explicitly
including “package.” Replace the idiomatic “This bites” phrasing and the
indirect “on the strength of” wording with direct, precise language while
preserving the existing explanation and behavior.

In `@operator/internal/controller/job_controller_test.go`:
- Around line 470-472: Update the reconciliation test assertions around
JobReconcile to verify sibling promotion: use sibling.GetUniqueName() to assert
its entry reaches StateComplete, while retaining the existing assertions that
pkgRef remains at StagePostInterrupt and StateComplete. This must distinguish
actual reconciliation behavior from pkgRef’s pre-existing state.

---

Outside diff comments:
In `@operator/internal/controller/job_controller.go`:
- Around line 339-377: Add recorder coverage for the interrupt-completion path
in HandleCompletePod where the package entry was removed after sibling
promotion. Assert that it emits only the generic interrupt completion event and
does not emit the package-specific JobComplete event.
🪄 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: 36eb7fa6-640a-45fc-bd0a-485aa4c2d018

📥 Commits

Reviewing files that changed from the base of the PR and between 2bac800 and 0db7d28.

📒 Files selected for processing (4)
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • operator/internal/controller/job_controller.go
  • operator/internal/controller/job_controller_test.go
  • operator/internal/controller/pod_controller.go

Comment thread docs/designs/2026-07-10-package-execution-as-jobs.md Outdated
Comment thread operator/internal/controller/job_controller_test.go Outdated
… path

The spec seeded the entry at (post-interrupt, complete) and asserted it
was still there, which a reconcile that did nothing at all would also
satisfy. It now asserts the sibling was promoted and the Job carries the
state-recorded marker, so the interrupt completion path demonstrably ran
while the entry was left alone.

Also inserts the relative pronoun the design doc sentence was missing.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@lockwobr
lockwobr merged commit 1b80e00 into feature/package-as-jobs Aug 13, 2026
34 checks passed
@lockwobr
lockwobr deleted the jobs-migration/426-interrupt-resurrect branch August 13, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/ci CI workflows, GitHub Actions, and repo tooling component/operator Skyhook operator (controller-manager) doc Documentation change (PR path label; doc issues use the Documentation type)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants