Skip to content

manager: stop the jobs orchestrator when a manager is demoted - #3295

Open
richarddavenport wants to merge 4 commits into
moby:masterfrom
richarddavenport:53632-jobs-orchestrator-leak
Open

richarddavenport wants to merge 4 commits into
moby:masterfrom
richarddavenport:53632-jobs-orchestrator-leak

Conversation

@richarddavenport

@richarddavenport richarddavenport commented Sep 11, 2026 •

Copy link
Copy Markdown

A replicated-job can run its payload once per ex-leader, then repeat. Two bugs combine to cause it.

becomeFollower() doesn't stop the jobs orchestrator, so a manager that has held leadership keeps
one running for the life of the daemon, and several such managers each create a task for the same job
iteration (moby/moby#53632). Separately, ReconcileService returns an error when its unsigned
task-count arithmetic underflows, which abandons the rest of the reconcile, so a job that has
overshot TotalCompletions is never reconciled again (moby/moby#42742). The first causes the
overshoot; the second makes it permanent.

The first commit is a refactor with no behaviour change. Each leader-only component's teardown is
registered with onBecomeFollower() next to the code that starts it, and becomeFollower() runs and
clears them. It registers exactly the thirteen components becomeFollower() already stopped, now in
start order, which nothing depends on. Manager.Stop() is untouched. The second commit adds the
missing registration for the jobs orchestrator, and is the actual fix.

The third commit plumbs a context.Context into ReconcileService, which the fourth needs for its
log line and which clears two TODO(dperny) comments. The fourth makes the task-count subtractions
saturating and downgrades the error return to a warning, so an overshot job creates no new tasks and
the reconcile continues. Dropping that early return leaves the restart loop reachable for a job that
has already met TotalCompletions, so the restart list is cleared in that case.

golangci-lint run ./manager/..., make dep-validate and gofmt -s are clean, and each commit
builds and tests on its own. go test ./... passes except
TestManagerRespectsDispatcherRootCAUpdate in ./node, which fails the same way on clean master.
Two new specs cover the reconciler change and were verified to fail without it: an overshot job still
marks a previous iteration's task for removal, and a job at TotalCompletions hands no surplus
failed task to the restart supervisor.

Observed on a five-manager swarm running 27.3.1 with MaxConcurrent: 1, TotalCompletions: 1. Two
demoted managers logged error reconciling replicated job … node lost leader status for hours, and
one deploy produced two tasks for JobIteration 0, Slot 0 23ms apart. To reproduce, move leadership
off a manager without restarting its daemon, then run a replicated job, and compare
JobIteration.Index, Slot and CreatedAt with docker inspect.

- Description for the changelog

Stop the jobs orchestrator when a manager loses leadership, and keep an over-completed replicated job
reconcilable instead of erroring out permanently.

@corhere corhere left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TestBecomeFollowerStopsAhatBecomeLeaderStarts is effectively a specialized linter, which would be very sensitive to innocent source changes. I would much prefer to see the manager code refactored so all services started when becoming leader are stopped when becoming follower, by construction. My suggestion: roll your own defer-alike so teardown code can all live in becomeLeader immediately following the code which starts each service.

func (m *Manager) becomeLeader() {
    // ...
    go func(volumeManager *csi.Manager) {
        volumeManager.Run(ctx)
    }(m.volumeManager)
    m.onBecomeFollower(func() {
        m.volumeManager.Stop()
        m.volumeManager = nil
    })
}

func (m *Manager) becomeFollower() {
    cleanups := m.becomeFollowerCleanups
    m.becomeFollowerCleanups = nil
    for _, f := range cleanups {
        f()
    }
}

@corhere

corhere commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

And please do not reference any issues or PRs in any moby repo from any commit message as GitHub's UI gets very noisy with back-links from forks. Linking from the PR description is fine, though.

@codecov-commenter

codecov-commenter commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 35.06494% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 14.76%. Comparing base (5a92899) to head (4b1b562).
⚠️ Report is 31 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #3295   +/-   ##
=======================================
  Coverage   14.76%   14.76%           
=======================================
  Files         200      198    -2     
  Lines       93027    93070   +43     
=======================================
+ Hits        13736    13743    +7     
- Misses      77961    77972   +11     
- Partials     1330     1355   +25     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@richarddavenport
richarddavenport force-pushed the 53632-jobs-orchestrator-leak branch from 7312d09 to 38943b8 Compare September 14, 2026 19:34
@richarddavenport

Copy link
Copy Markdown
Author

Done as sketched. becomeLeader() registers each component's teardown with onBecomeFollower() right after starting it, and becomeFollower() drains the list. The source-parsing test is gone; a small test covers the registry itself. Components now stop in start order rather than the previous hand-ordered list. None of them depends on another, so I left it at that. Manager.Stop() is untouched.

Commit messages no longer reference moby issues.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Core demotion and continued-reconciliation behaviors are not directly covered by regression tests.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Stops leader-only job orchestration after demotion and prevents replicated-job task-count underflow.

Changes:

  • Registers and runs leader-component cleanup callbacks.
  • Adds saturating task-count subtraction and overshoot logging.
  • Updates tests for cleanup ordering and over-completed jobs.
File summaries
File Description
manager/manager.go Adds demotion cleanup registry, including jobs orchestrator teardown.
manager/manager_test.go Tests cleanup execution order and idempotency.
manager/orchestrator/jobs/replicated/reconciler.go Prevents unsigned subtraction underflow.
manager/orchestrator/jobs/replicated/reconciler_test.go Updates overshoot expectations.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread manager/manager_test.go
Comment on lines +439 to +443
func TestBecomeFollowerRunsRegisteredCleanups(t *testing.T) {
m := &Manager{}
var ran []string
m.onBecomeFollower(func() { ran = append(ran, "first") })
m.onBecomeFollower(func() { ran = append(ran, "second") })

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not adding one. A real demotion test needs a live raft cluster: becomeFollower stops thirteen components that block on a doneChan only closed by Run, and roleManager.Run dereferences its raft node immediately. No manager test drives a leadership transition today.

An earlier revision of this PR asserted the invariant by parsing the source instead. That was rejected in review in favour of pairing each start with its stop in one place, which is what the first commit does.

Comment on lines +521 to +522
reconcileErr := r.ReconcileService("someService")
Expect(reconcileErr).To(HaveOccurred())
Expect(reconcileErr.Error()).To(ContainSubstring("underflow"))
Expect(reconcileErr).ToNot(HaveOccurred())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. The spec now leaves a task from a previous job iteration in the store and asserts it is marked for removal, which happens after the point that used to return early.

@corhere corhere left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks great; thank you! There are just a couple items that need to be addressed before I can approve.

And I have a request: please split the first commit into two.

  1. refactor to use onBecomeFollower
  2. add the missing Stop() call

// tasks, both still need to happen. log it and carry on creating zero new
// tasks.
if completeTasks+runningTasks > rj.TotalCompletions {
log.L.WithFields(log.Fields{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please plumb a context.Context into this function so that the log can be emitted with all the relevant context attached.

Suggested change
log.L.WithFields(log.Fields{
log.G(ctx).WithFields(log.Fields{

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, as commit 3. ReconcileService takes a context on both reconcilers and on the interface. That also cleared the two TODO(dperny) comments asking for a real context at the restart.Restart() calls.

"totalCompletions": rj.TotalCompletions,
}).Warn("replicated job has more tasks than TotalCompletions; creating no new tasks")
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Dropping the early return makes the restartTasks loop reachable when runningTasks > MaxConcurrent, so a job that has already met TotalCompletions would restart surplus failed tasks.

Suggested change
if completeTasks >= rj.TotalCompletions {
// The job has already reached its goal. Restarting any failed tasks
// now would risk overshooting the desired number of completions.
restartTasks = nil
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied. Added a spec that fails without it: a job at TotalCompletions with a surplus failed task must hand nothing to the restart supervisor.

becomeLeader() starts the components that only run on the leader, and
becomeFollower() stops them again. The two lists sit two hundred lines apart and
are kept in step by hand, so a component can be added to one and forgotten in
the other.

Register each component's teardown with onBecomeFollower() immediately after the
code that starts it, and have becomeFollower() run and clear the registered
teardowns. Both functions are already called under m.mu, so the slice needs no
lock of its own.

No functional change: the same thirteen components are stopped, and the same
ones nilled out. They are now stopped in the order they were started, where
becomeFollower() previously kept its own hand-maintained order. Each component
watches the store independently and none calls into another, so the order has no
effect.

Manager.Stop() is left alone. It stops each component directly under a nil
guard, so it works whether or not becomeFollower() has run.

Signed-off-by: Richard Davenport <richard.davenport@mbpnetwork.com>
becomeLeader() starts the jobs orchestrator alongside the other leader-only
components, but becomeFollower() never stopped it. Every other leader-only
component -- the replicated and global orchestrators, the task reaper, the
scheduler, the constraint and volume enforcers, the role and key managers -- is
stopped and nilled out there. The jobs orchestrator was the only one that
survived demotion.

A manager that has held leadership and then lost it therefore kept a live jobs
orchestrator for the lifetime of the daemon. Only Manager.Stop() shut it down,
which in practice means a dockerd restart. Cancelling the context does not help:
in this package the context carries logging information only, and Stop() is the
sole shutdown path.

In a cluster where several managers have held leadership at some point, this
leaves multiple jobs orchestrators reconciling the same service concurrently.
Each one creates its own task for the same job iteration, so a replicated job
declared with MaxConcurrent: 1 and TotalCompletions: 1 starts one task per live
orchestrator, in the same slot, milliseconds apart. Observed on a five-manager
swarm running 27.3.1, where two demoted managers produced two tasks for
iteration 0 slot 0, both of which ran the job's payload concurrently.

Orchestrator.Stop() closes stopChan inside a sync.Once, so it is safe against
repeated calls and cannot hit the double-close problem fixed for the task reaper
in 2491.

Signed-off-by: Richard Davenport <richard.davenport@mbpnetwork.com>
Neither reconciler had a context to log or trace with, and both called
restart.Restart() with context.Background() under a TODO asking for the real
one.

Take a context.Context in ReconcileService and pass it down. Every caller
already has one to hand. This clears both TODOs, and lets the next commit log
with the reconciliation's own context attached.

No functional change.

Signed-off-by: Richard Davenport <richard.davenport@mbpnetwork.com>
ReconcileService computes how many new tasks to create with unsigned
subtraction:

    possibleNewTasks := rj.MaxConcurrent - runningTasks
    allowedNewTasks  := rj.TotalCompletions - completeTasks - runningTasks

If more tasks exist for the current job iteration than the service asked for,
these underflow, and the guard below them returns an error rather than creating
a preposterous number of tasks. Refusing to create the tasks is right; returning
an error is not, because it abandons the rest of the reconciliation. The removal
of tasks belonging to previous job iterations and the restarting of failed tasks
both happen after that point, so a service that has overshot TotalCompletions
once is never reconciled again -- the count cannot come back down, so every
subsequent pass takes the same early return.

Make both subtractions saturating and downgrade the guard to a warning. An
overshot job now creates zero new tasks, which is what the arithmetic was trying
to express, while the rest of the reconcile continues to run.

Dropping the early return leaves the restart loop reachable for a job that has
already met TotalCompletions, where it would restart surplus failed tasks and
overshoot further. Clear the restart list in that case.

This does not prevent the overshoot; it stops the overshoot from being
permanent.

The existing spec asserted the error. It now asserts the new behaviour, and that
the reconcile really does continue: a task belonging to a previous job iteration
is still marked for removal. A second spec covers the restart list being cleared
once the job has met its goal.

Signed-off-by: Richard Davenport <richard.davenport@mbpnetwork.com>
@richarddavenport
richarddavenport force-pushed the 53632-jobs-orchestrator-leak branch from 38943b8 to 4b1b562 Compare September 16, 2026 15:08
@richarddavenport

Copy link
Copy Markdown
Author

Split as asked, plus one more. Four commits:

  1. the onBecomeFollower refactor — no behaviour change, it registers exactly the thirteen components becomeFollower already stopped
  2. the missing jobs orchestrator Stop() — four lines
  3. the context plumbing, split out because it touches the interface and the global reconciler
  4. the overshoot recovery, with the restart guard

Each builds and passes its package tests on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants