Skip to content

executor: run start-phase operations (epic #14081, lot 1) - #14274

Merged
ndeloof merged 7 commits into
docker:mainfrom
ndeloof:executor-start-phase
Oct 6, 2026
Merged

ndeloof merged 7 commits into
docker:mainfrom
ndeloof:executor-start-phase

Conversation

@ndeloof

@ndeloof ndeloof commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of #14081 — Lot 1, item 3 ("executor runs start-phase operations").

What this PR does, in one sentence

It teaches the plan executor to actually run the start-phase operations the reconciler has already known how to describe since #14200 — waiting on a dependency's condition, running pre_start hooks, starting a container with the right injections, running post_start hooks — closing the gap between "the plan can say it" and "the plan can do it".

Context

#14200 taught the reconciler to plan the start phase: OpWaitCondition, OpRunPreStart, an enriched OpStartContainer, OpRunPostStart. Nobody consumed them — the executor's dispatch had no case for any of the four, so a plan carrying them would fail with "unknown operation type". The vocabulary existed; nothing could speak it.

What the PR brings

The executor now runs every start-phase operation — by reusing the exact primitive the imperative engine (start.go/service_containers.go) already uses for it, rather than reimplementing it: a wait delegates to the same polling/condition-check functions waitDependencies calls, pre_start/post_start hooks run through the same runPreStart/runHook, and a start injects secrets/configs through the same injectSecrets/injectConfigs before ContainerStart. Behavior cannot drift between the two engines because there is only one implementation of each behavior.

Two things follow from that shared-primitive design:

  • One progress line per replica. A replica's pre_start → start → post_start chain reports a single Starting → Started progression — exactly what startServiceContainer reports today — rather than three separate, confusing lines. pkg/api/event.go now documents this per-resource progression contract, so the promise is written down, not just implied by the code.
  • A replica the plan itself creates has no daemon-observed container yet. The enriched OpStartContainer resolves its target from the CreateContainer node's result when there is no prior observation — the same mechanism OpRenameContainer already relies on.

Guardrail unchanged from #14200: inert by construction. create() — the only caller of executePlan — never builds a plan with start-phase scope, so none of this runs outside tests yet. It lands now, proven correct in isolation, so the next bricks (the executor lot's remaining piece, then the actual migration lots) can build on a tested foundation instead of growing it and wiring a consumer in the same change.

Why this is the right next brick

This is the second item of Lot 1 of epic #14081 (issue checklist): the vocabulary from #14200 gets an engine to run it before anything is asked to depend on it working.

🤖 Generated with Claude Code

@ndeloof
ndeloof requested review from a team as code owners September 30, 2026 10:51

@docker-agent docker-agent 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.

Assessment: 🟢 APPROVE

@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.16901% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/executor_ops.go 61.19% 15 Missing and 11 partials ⚠️
pkg/compose/executor_events.go 93.02% 2 Missing and 1 partial ⚠️
pkg/compose/executor.go 93.75% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@ndeloof

ndeloof commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 3cecc1e: both execStartContainer's enriched branch and execRunPostStart now bail out on ctx.Err() first, mirroring the guard execCreateHookContainer already had — closing the exact race described (a failed pre_start/start no longer lets its dependent run). Pinned by TestExecutePlanFailedPreStartGatesStart, which reproduces it deterministically.

@docker-agent docker-agent 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.

This review covers the commits pushed since ed531fc6b35d (the incremental diff for this cycle).

Assessment: 🟡 NEEDS ATTENTION

The fix correctly applies the ctx.Err() guard pattern (already used by execCreateHookContainer) to execStartContainer and execRunPostStart, and the regression test is well-structured. However, the guard does not fully close the race it targets: close(done[node.ID]) in run() fires inside the failing goroutine before errgroup calls cancel(), so there is a real window where a dependent goroutine sees the done-channel ready but ctx.Err() still returns nil.

Comment thread pkg/compose/executor_ops.go
Comment thread pkg/compose/executor_ops.go
@ndeloof

ndeloof commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — you're right that the ctx.Err() guard narrows the window rather than closing it (close(done[...]) still runs inside the failing goroutine before errgroup's cancel()). That structural fix (carrying each node's success/failure through what dependents wait on, uniformly across every op type — including the already-merged execCreateHookContainer) is exactly what the epic's #14081 "failed-dependency semantics" comment already reserves for a dedicated brick of the executor lot, not this PR's diff. 341a5ac36 spells that out explicitly at both guard sites and in the regression test, so it can't be mistaken for the full fix later.

@docker-agent docker-agent 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.

This review covers only the commits pushed since 3cecc1e.

Assessment: 🟢 APPROVE

The incremental diff is purely comment/documentation changes — no code logic was modified. The two comment blocks in executor_ops.go now accurately describe the ctx.Err() checks as "interim narrowing" rather than "guards against" the race, explicitly acknowledge the window where the race still exists, and point to #14081 for the structural fix. The test doc comment in executor_start_test.go updates to matching language.

These comment updates are a direct and accurate response to the two prior review threads on this file (the ctx.Err() race at lines 160 and 287). The technical claims in the new comments are factually correct and consistent with the code.

Comment thread pkg/compose/executor_ops.go
@ndeloof
ndeloof requested a review from docker-agent October 1, 2026 14:03

@docker-agent docker-agent 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.

This review covers only the commits pushed since 341a5ac.

Assessment: 🟢 APPROVE

The incremental diff adds a single ctx.Err() guard to execRunPreStart — the same interim-narrowing pattern already present at execStartContainer (line 160) and execRunPostStart (line 296), with an explicit comment acknowledging the residual race window. The guard is correct Go, introduces no new behavior relative to the existing two guards, and is consistent with the codebase convention. The comment accurately describes both the interim nature of the fix and the remaining race window. No bugs introduced by the added lines.

Wire the plan executor to the start-phase vocabulary the reconciler
already produces (docker#14200): OpWaitCondition, OpRunPreStart, the enriched
OpStartContainer, and OpRunPostStart all had no case in executeNode
and hit "unknown operation type".

Every operation reuses the exact imperative primitive it mirrors —
waitDependency, runPreStart, runHook, injectSecrets/injectConfigs — so
behavior and events cannot drift between the two engines: execWaitCondition
resolves the dependency's live containers and hands off to the same
polling/condition-check functions waitDependencies uses; the enriched
OpStartContainer resolves its target either from the observed container
or, for a replica the plan itself creates, from the CreateContainer
node's result (the mechanism OpRenameContainer already relies on), then
injects secrets/configs before ContainerStart; OpRunPreStart re-checks
the daemon first so a replica that started in the observe-to-execute
window skips a redundant hook run.

A replica's pre_start/start/post_start chain shares one event group,
reporting a single Starting→Started progression exactly like
startServiceContainer does today — Starting fires specifically at
StartContainer, not at the (silent) pre_start hooks preceding it.
pkg/api/event.go documents this per-resource progression contract.

Still inert in production: create() (executePlan's only caller never
builds a plan with start-phase scope, so none of this runs outside
tests until a later brick in epic docker#14081 wires a real caller.
EOF
)

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
A node's done-channel closes strictly before errgroup cancels its
derived context on that same error - the close happens synchronously
inside the failing node's goroutine, ctx cancellation only after it
returns. A dependent blocked in run()'s
select { case <-done[dep.ID]: ...; case <-ctx.Done(): ... } therefore
always sees its dependency's done-channel ready first, regardless of
whether that dependency failed for a genuine reason or was cancelled.

execCreateHookContainer already guarded against exactly this. The two
other new dispatch functions from the previous commit did not:
execStartContainer's enriched branch could call ContainerStart right
after its OpRunPreStart failed for real (e.g. "no hook runner
container found"), contradicting pre_start.go's documented guarantee
that a non-zero hook exit gates service start - and symmetrically for
execRunPostStart after a failed OpStartContainer.

Both now bail out on ctx.Err() first, mirroring the existing guard.
Pinned by a regression test reproducing the race deterministically: no
ContainerStart expectation is registered, so the unguarded code calls
it from inside the errgroup worker goroutine, where gomock's missing-
expectation failure hangs the test binary instead of failing it
cleanly - a stronger tell than a plain assertion failure would have
been.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
docker-agent's review of the previous commit's fix correctly pointed
out that the ctx.Err() guard added to execStartContainer/execRunPostStart
narrows the race it targets but does not eliminate it: close(done[...])
still runs inside the failing node's own goroutine, strictly before
errgroup's cancel() on that goroutine's returned error. A dependent
unblocked from <-done[dep.ID] can observe ctx.Err() == nil for a brief
window even though its dependency just failed for a genuine reason.

This is exactly the structural gap the epic (docker#14081) already flagged
in its "failed-dependency semantics" comment before this PR existed:
the real fix carries each node's success/failure through what
dependents wait on (e.g. a chan error) instead of inferring it from
ctx.Err(), uniformly across every operation type - including the
already-merged execCreateHookContainer, which has the same limit.
That is a dedicated brick of the executor lot, not squeezed into this
PR's diff.

No behavior change: this documents the residual window explicitly at
both guard sites and in the regression test, so the next reader (and
the next PR) doesn't mistake "narrows" for "closes".

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Same interim narrowing as execStartContainer and execRunPostStart: the
node hangs off the replica's create node, so when that create fails the
pre_start hooks could still fire in the window before errgroup cancels
the context. The structural fix stays tracked in docker#14081.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof force-pushed the executor-start-phase branch from 459b3bb to b1a06a5 Compare October 2, 2026 06:40
@ndeloof
ndeloof requested a review from docker-agent October 2, 2026 06:40

@docker-agent docker-agent 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.

Assessment: 🟡 NEEDS ATTENTION

Comment thread pkg/compose/executor_ops.go
Comment thread pkg/compose/executor_events.go
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested review from docker-agent and removed request for docker-agent October 2, 2026 07:04

@docker-agent docker-agent 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.

This review covers only the commits pushed since b1a06a5de263.

Assessment: 🟢 APPROVE

The single change in this incremental diff — replacing startMx.Unlock() with defer startMx.Unlock() immediately after startMx.Lock() in the enriched execStartContainer path — is correct. The defer fires at the same point (return err) on the non-panic path, so runtime behavior is unchanged; it adds panic safety that the bare path already had. No new issues found.

Comment thread pkg/compose/executor_events.go
…vent

Two review findings on the same root cause:

- docker-agent: a failing OpRunPreStart emitted a bare Error with no
  preceding Working/Starting event, since triggersWorking only fires
  on OpStartContainer. onNodeError now emits the group's Working event
  first when it hasn't fired yet, keeping the progression well-formed.

- glours (following up on the docker-agent thread): worse than a
  missing event -- OpStartContainer's own goroutine was unblocked by
  <-done[dep.ID] and ran anyway, since close(done[node.ID]) races
  ahead of errgroup's cancel() (same TOCTOU class as the ctx.Err()
  guards already tracked under docker#14081, but deterministic here with the
  default unset --parallel limiter: no semaphore wait gives cancel()
  time to land first). It fired a second, spurious Starting right
  after the sibling's Error, with no further event ever closing it.
  Reproduced 143/143 iterations against this PR's own regression test.

run() now records each node's terminal error in a shared, mutex-guarded
map as it closes done[node.ID], and a dependent checks that map right
after its dependency wait, treating a genuine dependency failure
exactly like a context cancellation: no slot acquired, no event
emitted, its own failure recorded and propagated to its own
dependents the same way. This closes the event-ordering race outright
for this specific chain, rather than narrowing it like the existing
ctx.Err() guards -- those stay in place and still guard actual engine
calls, unrelated to event emission.

TestExecutePlanFailedPreStartGatesStart now asserts the full event
sequence (Creating, Created, Starting, Error — nothing after),
verified by reverting the executor.go fix locally and confirming the
test catches the reintroduced spurious Starting.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Two independent reviews (Copilot CLI + a fresh-context agent) of the
previous commit converged on the same finding: execStartContainer's,
execRunPreStart's, and execRunPostStart's ctx.Err() guards still
claimed the TOCTOU window "cannot be closed" and deferred the real fix
to docker#14081 -- stale now that run()'s failedDependency check closes it
for a direct dependency before executeNode is ever reached. Reworded
to say what's actually still open: defense in depth for ctx canceled
by something other than a direct dependency (an unrelated branch of
the same plan, or an external cancellation).

Also: recordFailure stored the already-wrapped "dependency failed: %w"
error, compounding the prefix at every hop of a multi-node chain.
Store the original error instead, wrapping only the value returned
from this node's own goroutine.

And a stale reference to a test name that was never the one actually
added (TestExecutePlanFailedPreStartGatesStart, not ...GroupDoesNotRestart).

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 10:02

@docker-agent docker-agent 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.

This review covers only the commits pushed since de7ce858ad13.

Assessment: 🟢 APPROVE

The incremental changes implement the structural "failed-dependency semantics" fix that previous review cycles deferred (closing the ctx.Err() race window documented in the prior review threads). The implementation is sound:

  • The failures map is written before close(done[node.ID]) under failuresMu, so a dependent that unblocks always sees the failure if one was recorded — the critical ordering invariant is correct.
  • recordFailure and close(done[node.ID]) are both called on the acquireSlot failure path (the diff adds the missing close call there), preventing dependent goroutine deadlocks.
  • Storing the original (unwrapped) error in failures on the failedDependency path correctly prevents cascading "dependency failed:" prefix accumulation across multi-node chains.
  • The working bool field on groupState is always accessed under gt.mu, so there are no data races; and the onNodeError Working-before-Error guard keeps event progressions well-formed when OpRunPreStart fails before ContainerStart is reached.
  • The ctx.Err() guards in executor_ops.go are now accurately described as defense-in-depth rather than interim narrowings, since the structural fix is in place.

@glours glours 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.

The failures/failedDependency mechanism in run() genuinely closes the done-before-cancel race for direct dependencies — traced the happens-before (recordFailure always precedes close(done[node.ID]) on every exit path) and the multi-hop unwrapped-error forwarding (each hop re-records what it read verbatim, no wrapping, so no compounding "dependency failed: " prefixes). The working flag fix on groupState correctly keeps the Working→Error progression well-formed when OpRunPreStart fails before triggering it. Both hold up under code inspection.

One gap: the multi-hop forwarding claim (create→pre_start→start→post_start keeps the original error legible at any depth) is only tested at one hop (TestExecutePlanFailedPreStartGatesStart) — nothing exercises a node relaying an already-short-circuited failure to a further dependent. The code is correct as traced, but a regression (e.g. wrapping inside the failedDependency branch before recordFailure) would ship silently today. Worth a follow-up test with a 3-4 node chain asserting the final error is still the root cause, unwrapped — not a blocker on this PR.

@ndeloof
ndeloof merged commit 524a36d into docker:main Oct 6, 2026
63 of 64 checks passed
@ndeloof
ndeloof deleted the executor-start-phase branch October 6, 2026 12:11
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.

3 participants