plan engine: reconciler plans the start phase (epic #14081, lot 1) - #14200
plan engine: reconciler plans the start phase (epic #14081, lot 1)#14200ndeloof wants to merge 1 commit into
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
2 inline findings (both moderate confidence). No high-severity issues found.
5d61f8c to
dca392d
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🔴 CRITICAL
One high-severity finding in the new start-phase planning logic.
dca392d to
a2603d6
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The start-phase planning logic is well-structured and the implementation is correct. All hypotheses raised during analysis were dismissed as false positives:
seenmap instartPhaseReplicas—seen[resID] = trueis set unconditionally before theOpStartContainertype check, so exceptional-state containers are correctly excluded from the second loop.service_startedon fully-running dependency underScopeStart— producing no prerequisite edge is the intended behavior and matches the imperative engine's semantics: if the dependency is already running, theservice_startedcondition is trivially satisfied and no ordering constraint is needed. The code comment at the call site makes this explicit.
The inert-by-construction design (gated behind an option no command passes yet), faithful parity with the imperative engine, and comprehensive test coverage are all solid.
There was a problem hiding this comment.
🟡 Changes recommended
Start-phase planning currently treats observed running containers as guaranteed-to-be-running at start execution time, which misses containers that the create phase will stop (e.g., during network recreation), leading to incorrect start plans.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR extends Docker Compose’s plan engine vocabulary so a plan can describe the application Start phase (dependency condition waits, pre_start/post_start hooks, and replica start ordering) in addition to the existing Create phase, while keeping the default behavior inert via a new reconcile scope.
Changes:
- Introduces
ReconcileScopeand start-phase planning (planStartPhase) that emits start-phase nodes and dependency-condition wait nodes (deduplicated per service+condition). - Adds
PlanNode.Phase(CreatevsStart) and new operation types (OpWaitCondition,OpRunPreStart,OpRunPostStart), with plan rendering marking start-phase nodes. - Adds unit tests that golden-check start-phase planning behavior across dependencies, hooks, replica ordering, and scope modes.
File summaries
| File | Description |
|---|---|
| pkg/compose/reconcile.go | Adds reconcile scope selection and start-phase plan construction (replica chains, deduped waits, hooks), plus new reconciler tracking maps. |
| pkg/compose/plan.go | Adds start-phase operation types and PlanPhase to tag nodes, and updates plan string rendering to mark start nodes. |
| pkg/compose/reconcile_start_test.go | Adds coverage to lock down start-phase plan output and semantics across key scenarios. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
a2603d6 to
8960d0e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The start-replica ordering comparator in startPhaseReplicas uses subtraction and can overflow, risking incorrect ordering on some platforms or extreme replica numbers.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Two medium-severity findings in the newly-introduced start-phase planning code. Both are LIKELY (moderate confidence, 67/100) and concern correctness of the DAG-building logic under specific conditions.
8960d0e to
0573024
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The change is gated behind a new scope (default remains create-only) and is backed by focused unit tests, with only a minor maintainability nit noted.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The plan engine's start-phase extension is well-structured and carefully reasoned. The new code is inert by construction (gated behind an option no existing command passes), and the comprehensive test suite locks every subtlety of the existing imperative engine's behavior.
Key design points reviewed and found sound:
- Dependency ordering:
visitInDependencyOrdercorrectly traverses leaves-first sostartChainEnds[dep]is always populated before any dependent is visited. - Replica deduplication: the
seenmap correctly gates the second loop overcontainerNodes, preventing double-counting of observed+planned replicas. - Exceptional-state replicas: create-phase bare restarts (paused, dead) are correctly counted as
anyRunningand excluded from start-phase replicas, matching the imperative engine's second-snapshot behavior. - Pre_start scoping: the
!anyRunninggate faithfully reproduces the imperative engine — pre_start is skipped when any replica is already running. - Scale-down safety:
removedByPlancorrectly prevents condemned replicas from receiving start-phase nodes. - Wait node deduplication: shared
OpWaitConditionnodes correctly merge prerequisites and propagateRequiredupgrades. - Short-circuit safety: the
waitlesscondition instartPhaseDependenciescorrectly short-circuits onerr != nilbefore accessingdepServicefields. - Replica start ordering: numeric sort via
cmp.Compare(a.number, b.number)is correct and lexicography-safe.
33c166b to
2d84811
Compare
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one correctness/maintainability issue to address (an exported-type comment mismatch that can mislead readers and may trip exported-comment lint rules).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
2d84811 to
5439032
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The start-phase planning is gated behind an explicit scope option (default remains byte-identical) and the new behavior is thoroughly unit-tested for intended semantics and parity.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The start-phase planning logic is well-structured and faithfully reproduces the imperative engine's semantics. The new planStartPhase / planServiceStart code correctly handles:
- Dependency conditions (service_started as plain edges, others as deduplicated OpWaitCondition nodes)
- Replica ordering (numeric sort with
cmp.Compare, tested with 11 replicas) - Scale-down avoidance (
removedByPlancorrectly gates condemned replicas) - Exceptional-state containers (counted as running for pre_start gating, excluded from start-phase replicas)
- Stopped-by-plan containers (network recreate path, ordered after reconnect)
- Scope guard (inert by default, CreateStart/Start scopes opt in)
Two minor points are noted below.
5439032 to
cdb5ca9
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The start-phase plan vocabulary is well-structured and carefully guarded behind ReconcileScope — no existing command passes a non-zero scope, so today's behavior is byte-identical (verified by TestPlanStart_DefaultScopeIsInert). The implementation faithfully reproduces every imperative-engine subtlety: dependency-condition re-evaluation even when everything is running, exceptional-state replicas counting as running to gate pre_start, scale-down victims excluded from the start phase, and numeric replica ordering to avoid lexicographic bugs with 10+ replicas.
The new test file covers all the critical invariants — paused containers, recreate chains, network-stopped-then-restarted containers, optional vs. required conditions, and the error path for missing containers under ScopeStart.
There was a problem hiding this comment.
🟢 Approval recommended
The changes are inert by default (ScopeCreate remains unchanged) and the new Start-phase planning behavior is well covered by targeted unit tests.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
This PR carefully implements start-phase planning for the Compose plan engine. The implementation is faithful to the imperative engine's semantics, well-documented, and thoroughly tested.
What was reviewed:
planStartPhase/planServiceStart: dependency ordering, replica chaining, pre/post-start hook sequencingstartPhaseReplicas: all container-state branches (running, exceptional-state, stopped-by-plan, scale-down condemned), numeric sort viacmp.ComparestartPhaseDependencies/waitConditionNode: condition deduplication, BestEffort upgrade, target mergingplannedReplica: type switch correctness, unresolved-target default- All 12 new test cases: logic verified against the code
Notable strengths:
- The nested
containerNodes map[string]map[int]*PlanNodeindex (replacing the previous flat-map approach) eliminates the O(services × replicas) scan slices.Clone(prev)before the pre_start block correctly prevents backing-array aliasingcmp.Compare(a.number, b.number)for replica ordering is correct and overflow-safe- All previously identified issues from earlier review cycles have been resolved
pre_start hooks run in ephemeral containers. When a run fails, the runner is deliberately retained for post-mortem inspection, and the next run purges it — but that purge lived entirely inside the imperative primitive, invisible to the reconciliation plan. It is now a plan operation: when pre_start is going to run again (hooks declared, no replica running at observation — the imperative gating), the plan emits one best-effort RemoveContainer per stale runner, dropping its anonymous volumes, exactly the warn-only semantics of the imperative purge that remains in place as backstop. Observed state learns to tell hook containers apart: they carry no container-number label and previously classified as a service replica numbered 0. They now land in a dedicated HookContainers bucket the reconciler plans purges from. The imperative primitive is also split into its lifecycle-free execution piece (execPreStartHook: start, wait, log streaming, retain-on-failure) and the create/remove pieces around it, recomposed identically in runPreStart — locked by the existing characterization tests. This prepares moving hook-container creation and post-success removal into the plan once the start phase lands (docker#14200). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
pre_start hooks run in ephemeral containers. When a run fails, the runner is deliberately retained for post-mortem inspection, and the next run purges it — but that purge lived entirely inside the imperative primitive, invisible to the reconciliation plan. It is now a plan operation: when pre_start is going to run again (hooks declared, no replica running at observation — the imperative gating), the plan emits one best-effort RemoveContainer per stale runner, dropping its anonymous volumes, exactly the warn-only semantics of the imperative purge that remains in place as backstop. Observed state learns to tell hook containers apart: they carry no container-number label and previously classified as a service replica numbered 0. They now land in a dedicated HookContainers bucket the reconciler plans purges from. The imperative primitive is also split into its lifecycle-free execution piece (execPreStartHook: start, wait, log streaming, retain-on-failure) and the create/remove pieces around it, recomposed identically in runPreStart — locked by the existing characterization tests. This prepares moving hook-container creation and post-success removal into the plan once the start phase lands (docker#14200). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
pre_start hooks run in ephemeral containers. When a run fails, the runner is deliberately retained for post-mortem inspection, and the next run purges it — but that purge lived entirely inside the imperative primitive, invisible to the reconciliation plan. It is now a plan operation: when pre_start is going to run again (hooks declared, no replica running at observation — the imperative gating), the plan emits one best-effort RemoveContainer per stale runner, dropping its anonymous volumes, exactly the warn-only semantics of the imperative purge that remains in place as backstop. Observed state learns to tell hook containers apart: they carry no container-number label and previously classified as a service replica numbered 0. They now land in a dedicated HookContainers bucket the reconciler plans purges from. The imperative primitive is also split into its lifecycle-free execution piece (execPreStartHook: start, wait, log streaming, retain-on-failure) and the create/remove pieces around it, recomposed identically in runPreStart — locked by the existing characterization tests. This prepares moving hook-container creation and post-success removal into the plan once the start phase lands (docker#14200). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
The plan learns the start vocabulary — inert until a caller opts in (ReconcileOptions.Scope, zero value keeps today's create-only plans byte-identical): - OpWaitCondition, one node per (awaited service, condition), deduplicated across dependents like networkNodes deduplicates networks; required:false marks the shared node best-effort, one required dependent upgrades it. service_started needs no node — a plain DAG edge to the dependency's chain end expresses it. Health is deliberately re-observed at execution time: the plan encodes what to wait for, never a stale observation. - OpRunPreStart, emitted at plan time only when no replica was running at observation — the imperative gating — targeting the lowest-numbered replica. - OpRunPostStart per container, after its start. - replica chains: inject+start+post_start of replica n+1 depends on the end of replica n's chain, today's sequential start order made visible in golden plans; startChainEnds points at the chain end so a service_started dependent waits for the whole service, matching InDependencyOrder semantics. - scope Start plans starting observed exited/created containers without converging them (the future compose start); scope CreateStart appends the start phase to the create plan, start nodes resolving their target from the create node that materializes the replica (CreateNodeID, the mechanism OpRenameContainer already uses). Lifecycle parity with the imperative engine is load-bearing and golden-locked: - dependency conditions are evaluated even when nothing has to start (waitDependencies runs for every visited service before looking at what to start), so an up with everything running still fails on an unhealthy required dependency; - an exceptional-state replica takes NO start-phase node: its bare create-phase restart leaves it running when the start phase looks, so the imperative engine neither re-starts nor injects — and it gates pre_start like any running replica; - startChainEnds carries the end-of-visit node set (waits included when nothing started), so a service_started dependent begins only once the dependency's whole visit completed, matching InDependencyOrder; - under scope Start, a scale>0 service with no container at all fails the plan with startService's exact error. The "service:<name>:<number>" resource-ID format is built and parsed in one place (serviceReplicaID/serviceReplicaPrefix/startGroupID), and the replica sort deliberately carries the plan's determinism over the unordered containerNodes iteration. Golden tests only; no executor support yet and no caller passes the scope. Epic docker#14081, Lot 1 — reconciler (first item). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
cdb5ca9 to
03e3eb1
Compare
What this PR does, in one sentence
It teaches Compose's plan engine to describe starting an application — until now it could only describe creating it — without changing any current behavior: this is vocabulary, and nobody speaks it yet.
Context
Today
upworks in two steps with two different engines: first the plan engine computes and executes "what to create" (deterministic, testable, comparable against a golden file), then a second, imperative engine improvises the start as it goes — waiting for the database to be healthy, runningpre_starthooks, starting containers in dependency order, runningpost_start. That second engine is neither inspectable nor testable the same way, and the seam between the two is a known source of bugs (two different snapshots of the daemon state, two event-emission systems).What the PR brings
The plan can now express everything that start phase does: "wait for
dbto be healthy", "runweb's hooks", "start replica 1 then 2", "apponly starts oncedbis ready". The whole start logic becomes data that can be displayed, reviewed and verified before being executed — instead of behavior that only exists at the moment it happens.Two guardrails frame the PR:
compose create,up— everything produces exactly the same plans as before, byte for byte.Why this is the right first brick
This is the first item of Lot 1 of epic #14081: lay down the vocabulary and prove its fidelity before wiring anyone onto it. The next PRs will make the executor speak this vocabulary, then switch
up,startandscaleover one consumer at a time — each staying small and revertible, because the hard part (saying exactly the same thing as the existing engine) will already have been validated here.Split out of #14156 to keep the review focused.
🤖 Generated with Claude Code