Skip to content

feat(reconcile): model volume recreation in the plan - #13962

Merged
glours merged 4 commits into
docker:mainfrom
ndeloof:volume-reconcile-plan
Jul 23, 2026
Merged

feat(reconcile): model volume recreation in the plan#13962
glours merged 4 commits into
docker:mainfrom
ndeloof:volume-reconcile-plan

Conversation

@ndeloof

@ndeloof ndeloof commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What I did

Move volume divergence detection and recreation out of the imperative
pre-reconcile path (ensureVolume/removeDivergedVolume) into the
reconciliation plan, activating the dormant planRecreateVolume seam.

A diverged volume now produces an explicit, forward-only plan:
stop → remove containers → remove volume → create volume → create containers.
Container re-creation is delegated to reconcileContainers (affected
services are cleared from the observed snapshot so they are scheduled
fresh, gated on the CreateVolume node), and the recreation cascades to
namespace/volume-sharing dependents.

User confirmation ("recreate, data will be lost") is consulted while
building the plan via reconciler.prompt; declining leaves the volume
untouched. Behavior otherwise matches the previous ensureVolume (no
recreation for a volume with no recorded config-hash, external volumes
validated up front).

Adds an exhaustive reconcile test suite for the volume lifecycle and its
interaction with services (single/scale-N/multi-service recreation,
decline, missing hash, prompt message/error, container mounting several
diverged volumes, partial confirmation, dependent cascade, unmounted
volume) plus an executor test driving the destructive sequence end to end.

Related issue

🤖 Generated with Claude Code

Move volume divergence detection and recreation out of the imperative
pre-reconcile path (ensureVolume/removeDivergedVolume) and into the
reconciliation plan, activating the dormant planRecreateVolume seam.

A diverged volume now produces an explicit, forward-only sequence:
stop containers -> remove containers -> remove volume -> create volume
-> create containers. Container re-creation is delegated to
reconcileContainers (affected services are cleared from the observed
snapshot so they are scheduled fresh, gated on the CreateVolume node),
and the recreation cascades to namespace/volume-sharing dependents.

User confirmation (recreate, data will be lost) is consulted while
building the plan via reconciler.prompt; declining leaves the volume
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested review from a team as code owners July 22, 2026 14:08
@ndeloof
ndeloof requested a review from glours July 22, 2026 14:08

@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 PR successfully moves volume divergence detection and recreation from the imperative pre-reconcile path (ensureVolume/removeDivergedVolume) into the reconciliation plan. The reconcileVolumes/planRecreateVolumes structure is sound: it collects diverged volumes, gates on user confirmation, stops and removes affected containers in dependency order, removes then recreates volumes, and hands container re-creation to reconcileContainers. The stoppedByPlan deduplication correctly prevents double-stops when a container is shared between network and volume recreation paths. The test suite is comprehensive.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/reconcile.go:114observedContainersByService snapshot not updated after containers cleared for volume recreation (confidence: 🟠 weak 52/100)
    r.observed.Containers[svc] is set to nil at line 371 when services are cleared for volume re-creation, but r.observedContainersByService (built once at reconciler init via observed.containersByService()) still holds old containers for those services. When serviceHashWithResolvedRefs is called for a volumes_from dependent of a recreated service, it resolves old container IDs from the stale snapshot. In practice, parentNamespaceRecreated catches this case and forces recreation regardless of hash, so no incorrect decision is made on the happy path — but the two data structures are now diverged, which is a latent inconsistency worth noting for future maintainers.

  • [medium] pkg/compose/reconcile.go:371volumes_from dependents may not have correct creation ordering after owner service volume recreation (confidence: 🟠 weak 45/100)
    When service B is cleared for volume recreation (r.observed.Containers[B] = nil, r.recreatedServices[B] = true), a dependent service A with volumes_from: [B] is NOT cleared from observed state. reconcileContainers correctly forces recreation of A via parentNamespaceRecreated, but whether A's new container creation node depends (transitively) on the CreateVolume → CreateContainer(B) chain depends on whether infrastructureDeps(A) captures r.serviceNodes[B]. If it does (likely given topological processing order), ordering is correct. If not, A could start before B's fresh container is ready. The verifier rated this speculative without seeing infrastructureDeps internals.

servicesUsingVolume only matched services mounting the volume directly, so
a service reaching it through volumes_from was not stopped/removed before
RemoveVolume. Docker materializes the inherited mount on the consumer's
container, so its removal would fail with "volume in use". Compute the
transitive volumes_from closure so every container referencing the volume
is removed first. (network_mode/ipc/pid: service:x share namespaces, not
mounts, and are intentionally excluded.)

Also reassign the result of Labels.Add in createVolume: it mutates in
place only when the map is non-nil, so discarding the return would drop
the config-hash label for a volume with no CustomLabels.

Addresses review feedback: documents why observed.Containers is cleared
without touching the observedContainersByService hashing snapshot, and
strengthens the cascade tests to assert the full plan ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof

ndeloof commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Adjudication of the two findings, plus a related issue they surfaced — addressed in 72f540c.

[medium] volumes_from creation ordering — not a bug for loaded projects. compose-go normalization injects an implicit depends_on for volumes_from (loader/normalize.go), exactly as it does for network_mode/ipc/pid: service:x. infrastructureDeps consumes service.DependsOn, so the dependent's fresh container is transitively ordered after CreateVolume → CreateContainer(owner). Verified end to end; the cascade test now asserts the full plan ordering rather than substrings.

[low] observedContainersByService staleness — intentional, not an inconsistency to fix. That snapshot backs config-hash resolution (serviceHashWithResolvedRefs) and must mirror the state the executor hashed against at create time; clearing observed.Containers is purely a scheduling concern carried by the plan's dependency edges. Added a comment at the clearing site so the divergence isn't "fixed" by a future maintainer.

Real issue surfaced by this reviewservicesUsingVolume only matched services mounting the volume directly, so a volumes_from consumer was not removed before RemoveVolume. Docker materializes the inherited mount on the consumer's container, so the removal would have failed with "volume in use" (the previous imperative removeDivergedVolume had the same blind spot). Fixed by computing the transitive volumes_from closure; a dedicated test now guards the RemoveVolume → RemoveContainer(consumer) edge.

Also reassigned the result of Labels.Add in createVolume (it mutates in place only when the map is non-nil).

@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 edge regressions from the switch to label-filtered observed state, both verified against the old ensureVolume path:

  • A same-named volume created manually or by another project used to be reused with just a warning (inspect by name). It's now invisible to the observed state, so we plan a VolumeCreate on every up: hard failure if the driver differs, spurious
    Creating/Created events forever otherwise. checkVolumes should probably inject those unlabeled-but-existing volumes into the observed state, like it already does for externals.

  • Renaming a volume now hits the diverged path: the old volume is matched by label and VolumeHash includes Name, so with up -y we delete the old volume and its data, where we previously just created the new one and left the old untouched. The
    prompt also names the new volume, which doesn't exist yet. When observed.Name != desired.Name I'd keep the additive behavior, or at least prompt with the name of the volume we're about to delete.

Two edge regressions from the switch to a label-scoped observed state,
both reported against the old ensureVolume path:

- A same-named volume created manually or by another project (no compose
  label) was invisible to the observed state, so a VolumeCreate was
  planned on every up: a hard failure if the driver differed, spurious
  Creating/Created events otherwise. collectObservedState now discovers
  such volumes by name (pre-label Compose semantics) and records them as
  unmanaged matches with an empty config-hash, so the reconciler reuses
  them untouched. The ownership warnings move to warnUnmanagedVolumes,
  driven off the observed state; checkVolumes shrinks to external-only
  validation (checkExternalVolumes).

- Renaming a volume hit the diverged path and, with up -y, deleted the
  old volume and its data (VolumeHash includes Name), where it previously
  just created the new one. When observed.Name != desired.Name the volume
  is now created additively, leaving the old one untouched, with no prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof

ndeloof commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @glours, both fixed in 2ba485b.

  1. Unlabeled/foreign same-named volumecollectObservedState now discovers volumes by name when the label filter misses them, recording them as unmanaged matches with an empty config-hash, so the reconciler reuses them untouched (no spurious VolumeCreate). Warnings kept via warnUnmanagedVolumes; checkVolumes shrinks to external-only validation.

  2. Volume rename — when observed.Name != desired.Name, the new volume is created additively and the old one is left untouched, with no prompt (no more data loss under up -y, no more prompt naming a not-yet-existing volume).

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.19728% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/reconcile.go 95.83% 2 Missing and 2 partials ⚠️
pkg/compose/create.go 90.62% 2 Missing and 1 partial ⚠️
pkg/compose/observed_state.go 84.21% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

…ame up

The additive rename path created the new volume but kept the old name in
the observed state, so hasVolumeMismatch never fired: existing containers
stayed on the old volume while fresh replicas mounted the new one
(split-brain), and later runs picked a nondeterministic winner between the
two equally labelled volumes.

Rewrite the observed volume name to the desired one after planning the
"renamed" create, so reconcileContainers migrates the existing containers
onto the new volume in the same up — restoring parity with the old
ensureVolume path — while still leaving the old volume and its data intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>

@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, tests ok 👌

@glours
glours merged commit 69b2f69 into docker:main Jul 23, 2026
42 checks passed
@ndeloof

ndeloof commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the rename case (f16d0bc): the additive fix kept the old name in the observed state, so existing containers stayed on the old volume while fresh replicas mounted the new one (split-brain), and later runs picked a nondeterministic winner between the two equally-labelled volumes.

Now the observed volume name is rewritten to the desired one after planning the renamed create, so reconcileContainers migrates the existing containers onto the new volume within the same up — full parity with the old ensureVolume path — while still leaving the old volume and its data intact. Covered by a targeted test asserting the additive create + container migration (no RemoveVolume, no prompt).

ndeloof added a commit to ndeloof/compose that referenced this pull request Jul 24, 2026
Mirror the volume reconciliation work (docker#13962) for networks: move network
divergence detection and recreation out of the imperative pre-reconcile
path (ensureNetwork/resolveOrCreateNetwork/removeDivergedNetwork) into the
reconciliation plan.

- reconcileNetworks now owns creation of missing networks and, for a
  network whose config-hash diverged, an explicit recreation sequence
  (no user confirmation: recreating a network is not destructive):
  stop containers -> disconnect -> remove network -> create network ->
  reconnect containers. Attached containers keep their identity (they are
  reconnected, not recreated), matching the previous behavior. If a
  container is independently recreated by reconcileContainers, its removal
  is ordered after the reconnect so they don't race.
- Renaming a network creates the new one additively and leaves the old one
  untouched.
- collectObservedState discovers legacy/unlabeled networks by name and
  records them as unmanaged matches (empty config-hash) so the reconciler
  reuses them untouched; ownership warnings move to warnUnmanagedNetworks.
  checkExternalNetworks keeps external-network validation/resolution.
- execCreateNetwork now issues a plain createNetwork; the imperative
  ensureNetwork/resolveOrCreateNetwork/removeDivergedNetwork and the
  connect/disconnect helpers are removed.

Adds reconcile, observed-state and executor tests covering network
create/diverge/rename, the entangled diverge+recreate case, legacy
by-name discovery and the ownership warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
glours pushed a commit that referenced this pull request Jul 27, 2026
Mirror the volume reconciliation work (#13962) for networks: move network
divergence detection and recreation out of the imperative pre-reconcile
path (ensureNetwork/resolveOrCreateNetwork/removeDivergedNetwork) into the
reconciliation plan.

- reconcileNetworks now owns creation of missing networks and, for a
  network whose config-hash diverged, an explicit recreation sequence
  (no user confirmation: recreating a network is not destructive):
  stop containers -> disconnect -> remove network -> create network ->
  reconnect containers. Attached containers keep their identity (they are
  reconnected, not recreated), matching the previous behavior. If a
  container is independently recreated by reconcileContainers, its removal
  is ordered after the reconnect so they don't race.
- Renaming a network creates the new one additively and leaves the old one
  untouched.
- collectObservedState discovers legacy/unlabeled networks by name and
  records them as unmanaged matches (empty config-hash) so the reconciler
  reuses them untouched; ownership warnings move to warnUnmanagedNetworks.
  checkExternalNetworks keeps external-network validation/resolution.
- execCreateNetwork now issues a plain createNetwork; the imperative
  ensureNetwork/resolveOrCreateNetwork/removeDivergedNetwork and the
  connect/disconnect helpers are removed.

Adds reconcile, observed-state and executor tests covering network
create/diverge/rename, the entangled diverge+recreate case, legacy
by-name discovery and the ownership warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
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