Skip to content

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

Open
ndeloof wants to merge 4 commits into
docker:mainfrom
ndeloof:executor-start-phase
Open

ndeloof wants to merge 4 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

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>
@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 76.31579% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/executor_ops.go 63.63% 14 Missing and 10 partials ⚠️
pkg/compose/executor_events.go 92.30% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

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

This branch has not been deployed

No deployments
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