Skip to content

feat: scale and watch rebuild use the unified plan - #14291

Merged
ndeloof merged 6 commits into
docker:mainfrom
ndeloof:scale-watch-unified-plan
Oct 15, 2026
Merged

ndeloof merged 6 commits into
docker:mainfrom
ndeloof:scale-watch-unified-plan

Conversation

@ndeloof

@ndeloof ndeloof commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

docker compose scale and watch's rebuild-on-change step now run their Create and Start phases as a single plan, the same switchover #14290 did for up -d.

Context

Both Scale and watch's rebuild step share the same old pattern detached up used to: a create() call to converge the project, immediately followed by a separate start() call that re-lists containers from scratch and runs the older imperative engine to start them — two snapshots of the daemon instead of one.

What the PR brings

A new shared helper, createAndStart, runs preparePlan(..., ScopeCreateStart) + executePlan once — the same thing upDetached does for up -d, minus --wait handling (neither caller here ever uses it). Scale's conversion is an exact equivalent: it already passed the same project to both calls.

watch's rebuild needed more care: it called create() on the full project but start() on a project narrowed to the rebuilt service(s) and their dependents (WithSelectedServices), so only that subset gets started/health-checked, not the whole app. The narrowed project is now used for both phases. This does shrink the create phase's scope a little, deliberately — verified that narrowing only trims which services get reconciled (drift-checked, pulled/validated), not network/volume/external-resource handling, and not orphan detection (excluded services move to DisabledServices, still recognized as known, never misflagged). For a rebuild scoped to specific services, that narrower blast radius is arguably more correct than re-checking the whole project's drift on every file-watch trigger — not a literal byte-for-byte port of today's behavior, a conscious, narrower one.

Narrowing the create phase has one consequence the reconciler now accounts for: a project narrowed with WithSelectedServices keeps every network and volume of the model but moves the services left out to DisabledServices, so a drifted network or volume still used by such a service's live container looked unused, and its removal failed with "in use" (for a volume, after asking the user to confirm a data-destroying recreation that could not succeed). Such a resource is now kept as is, with a warning and no prompt.

The narrowed scope is pinned at the two layers it lives in: the plan of a narrowed project never touches a drifted service outside the selection (nor removes it as an orphan), and the observation keeps the disabled services' containers out of the orphans, so the orphan warning does not fire on every rebuild.

Verified against the full e2e suite (TestScale*, TestWatch*) on a locally built binary, not just unit tests.

Follows #14290 (merged). Part of #14081.

🤖 Generated with Claude Code

@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 6, 2026 15:26

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The global wait timeout incorrectly covers all plan operations and changes established timeout semantics.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Unifies Create and Start planning for detached up, scale, and watch rebuilds.

Changes:

  • Adds shared plan preparation and Create+Start execution.
  • Migrates scale and watch rebuild to the unified plan.
  • Refreshes renamed-container state and adds regression tests.
File Description
pkg/​compose/​create.go Adds plan preparation and shared Create+Start helper.
pkg/​compose/​up.go Runs detached up through one plan.
pkg/​compose/​scale.go Migrates scale to unified execution.
pkg/​compose/​watch.go Narrows and unifies rebuild execution.
pkg/​compose/​reconcile.go Associates recreate renames with their service.
pkg/​compose/​executor_ops.go Refreshes renamed-container execution state.
pkg/​compose/​executor_start_test.go Adds deadline and rename regressions.

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

Comment thread pkg/compose/up.go Outdated

@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

Comment thread pkg/compose/up.go Outdated
@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from dde9b6a to e107b5a Compare October 6, 2026 15:43
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/watch.go 66.66% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from e107b5a to ffa77f3 Compare October 6, 2026 16:48
@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 16:48

@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

@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from ffa77f3 to a398600 Compare October 6, 2026 17:36
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 6, 2026 17:37

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Start nodes can run before all Create nodes succeed, allowing partially started applications after a later create failure.

Review effort: Balanced
Findings: 1 High severity · 2 Low severity

Open (3)
Resolved since last review (1)

Comment thread pkg/compose/executor.go Outdated
Comment thread pkg/compose/executor.go Outdated
Comment thread pkg/compose/service_containers.go

@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

@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 18:09
@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from a398600 to a3c864e Compare October 6, 2026 18:09
@ndeloof
ndeloof requested a balanced review from Copilot October 6, 2026 18:09

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Multi-replica restart ordering remains incorrect, and unified execution changes create-failure semantics without coverage or documentation.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity · 2 Low severity

Open (5)

Comment thread pkg/compose/reconcile.go Outdated
Comment thread pkg/compose/create.go

@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

@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from a3c864e to 4cde09d Compare October 6, 2026 18:38
@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 18:38

@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

@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from 4cde09d to f60136c Compare October 7, 2026 06:14
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 7, 2026 06:14

Copilot AI 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.

🔵 Needs a closer look

The stacked lifecycle and concurrency changes are extensive, and the new watch rebuild scope lacks targeted regression coverage.

2 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread pkg/compose/watch.go

@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

Same create() + start() two-snapshot pattern upDetached already
replaced for `up -d`, now shared via a new createAndStart helper for
the other two callers that had it:

- Scale: exact equivalent, same project object was already passed to
  both calls.

- watch's rebuild(): the create() call used the full project (scoped
  only via CreateOptions.Services/Recreate policy), but start() used a
  project narrowed to services + their dependents
  (WithSelectedServices). The narrowed project is now computed once
  and used for both phases instead.

  This does shrink the create phase's own scope a little, deliberately:
  WithSelectedServices only trims the project's Services map
  (unrelated services move to DisabledServices, not vanish -- orphan
  detection still recognizes their containers via
  DisabledServiceNames(), so nothing gets misflagged) -- Networks,
  Volumes, Configs, Secrets are untouched, so external-resource
  resolution is unaffected. What does narrow: config-drift checking
  (RecreateDependencies policy) and the image pull/validate pre-pass no
  longer consider services outside this rebuild's blast radius. For a
  file-watch-triggered rebuild of specific services this is arguably
  more correct than today (a watch rebuild of one service shouldn't be
  re-validating or drift-checking the rest of the project on every
  keystroke) -- not a literal byte-for-byte port, a deliberate,
  narrower scope.

  The old code also returned a create() failure immediately but logged
  and swallowed a start() failure; a single combined call can't keep
  that distinction. Traced as inert either way: the sole caller,
  rebuildScheduler.run(), discards rebuild()'s return value
  unconditionally -- only the logged message differs, not control flow.
  AttachTo: services, also dropped from the old StartOptions, was
  already dead in this call (only read by start() when Project is nil,
  which it never was here).

Verified with the full e2e suite (TestScale*, TestWatch* in
particular) against a locally built binary, plus the existing unit
suite and lint. No new failures; the only 3 remaining are the same
pre-existing, environment-specific ones from the previous PR in this
stack (macOS Docker Desktop restricted-host-mount errors, a
model-pull/registry issue).

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…ce outside the selection still uses it

A project narrowed with WithSelectedServices (watch's rebuild, which
this PR moves to a narrowed Create phase, and `up`/`scale --no-deps`)
keeps every network and volume of the model but moves the services left
out to DisabledServices. The reconciler looked for the consumers of a
resource in project.Services only, so a drifted network or volume used
by a disabled service looked unused: the plan removed it without
detaching that service's container, and the removal failed with
"in use". For a volume, the user was first asked to confirm a
data-destroying recreation that could not succeed.

A same-name drifted network or volume that a disabled service still
holds through a live container is now kept as is, with a warning, and no
prompt is shown. A disabled service without a live container does not
block anything, and a rename is unaffected (it never removes the live
resource).

Also correct the comment in rebuild, which claimed narrowing left the
convergence of networks and volumes unaffected.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof force-pushed the scale-watch-unified-plan branch from fd1f9dd to 3844eac Compare October 15, 2026 13:05
@ndeloof
ndeloof requested a review from docker-agent October 15, 2026 13:05
@ndeloof
ndeloof marked this pull request as ready for review October 15, 2026 13:08
@ndeloof
ndeloof requested review from a team as code owners October 15, 2026 13:08
@ndeloof
ndeloof requested a review from glours October 15, 2026 13:08
watch's rebuild narrows the project to the rebuilt services and their
dependents for the create phase too. Nothing locked that contract: a
future change of the selection or of the observed state could silently
bring back the convergence of the whole project, or report the services
left out as orphans.

Two tests: the plan of a narrowed project never touches a drifted
service outside the selection, nor removes it as an orphan under
--remove-orphans; and the observation of a narrowed project keeps the
containers of the disabled services out of the orphans, so the "Found
orphan containers" warning does not fire on every rebuild.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>

@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/scale.go
@ndeloof
ndeloof requested a review from docker-agent October 15, 2026 13:13

@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

The unified-plan switchover for scale and watch rebuild is clean. The createAndStart helper correctly mirrors upDetached's ScopeCreateStart path. The heldOutsideSelection guard is structurally sound: networkConsumers/volumeConsumers and r.observed.Containers consistently use service.Name, so the container lookup is always correct. Silently skipping the data-loss prompt for a volume held by a disabled service is deliberate and correct (the removal would fail anyway). The rebuild error-propagation change is an improvement.

One low-confidence observation about the dropped AttachTo field is noted inline — likely benign given the scheduler context, but worth a quick review.

Comment thread pkg/compose/watch.go

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

Note: This review covers only the commits pushed since 3844eac8d894c13baf3ddae170b32953cbd191fc. Earlier commits were already reviewed.

Assessment: 🟡 NEEDS ATTENTION

This incremental diff adds two new unit tests. One test has a real coverage gap that would let a reconciler regression slip through undetected.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/reconcile_test.go:2228 — strings.Contains(resourceID, "api") is an overly broad substring match; a resource incidentally containing "api" in its name would fire a false-positive assertion failure (not verified; unverified low-severity finding)
  • [low] pkg/compose/reconcile_test.go:2207 — opts.Recreate = api.RecreateForce makes the driftedAPI image setup irrelevant; the hash-diverged recreation path is not exercised (not verified; unverified low-severity finding)

Comment thread pkg/compose/reconcile_test.go
The narrowed-selection test only checked the selected service itself, so
a plan silently dropping its dependent would have passed. Also match the
disabled service by its resource prefix rather than a substring, and
drop the drifted image setup that force recreate made irrelevant.

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

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

Assessment: 🟢 APPROVE

The incremental diff is 43 lines, all in pkg/compose/reconcile_test.go, addressing the three issues flagged in previous review cycles:

  1. Removed misleading drifted-API setup — the stale-image config was irrelevant because opts.Recreate = api.RecreateForce forces recreation regardless of drift; using apiSvc directly is correct.
  2. Tightened the negative assertion — strings.HasPrefix(node.Operation.ResourceID, "service:api:") is strictly more precise than the old strings.Contains(..., "api") substring check, with no false negatives given the test's observed state (empty Networks/Volumes, api disabled).
  3. Added the missing positive assertion for worker — recreated["service:worker:1"] closes the gap where a silent reconciler regression dropping worker from the plan would have passed the test undetected.

All three changes are correct and no new issues were introduced.

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

Two items on 3844eac, inline.

Comment thread pkg/compose/watch.go
Comment thread pkg/compose/reconcile.go Outdated
The rebuild now reports "Failed to recreate and restart service(s) after
update" instead of "Application failed to start after update", which
TestRebuildOnDotEnvWithExternalNetwork still asserted was absent: the
string no longer existed anywhere, so the check could never fail and the
test no longer detected a failed rebuild.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
declaredServices duplicated (*types.Project).AllServices(), already in
the pinned compose-go: Services plus DisabledServices in a fresh map.

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

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

Incremental review — this review covers only the commits pushed since 35fb07580112. Earlier commits were already reviewed.

Assessment: 🟢 APPROVE

The incremental diff is a clean refactor: replacing the local declaredServices() helper (which explicitly built project.Services ∪ project.DisabledServices) with r.project.AllServices() from the compose-go library. The maps import is correctly removed. Two inline notes are raised about the correctness of AllServices() semantics — both are assessed as unlikely in practice (the naming convention, the usages in config.go/completion.go, and the PR's stated intent all point to the correct union), but flagged since the compose-go source is not directly accessible in this review context.

Comment thread pkg/compose/reconcile.go
Comment thread pkg/compose/reconcile.go
@ndeloof
ndeloof requested a review from glours October 15, 2026 15:01

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

LGTM

@ndeloof
ndeloof merged commit 7b36d08 into docker:main Oct 15, 2026
120 of 125 checks passed
@ndeloof
ndeloof deleted the scale-watch-unified-plan branch October 15, 2026 15: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.

Assessment: 🟢 APPROVE

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