Skip to content

fix: close the downtime Onebox causes on change and deploy - #222

Merged
vishr merged 34 commits into
mainfrom
fix/zero-downtime-gaps
Oct 18, 2026
Merged

vishr merged 34 commits into
mainfrom
fix/zero-downtime-gaps

Conversation

@vishr

@vishr vishr commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

What this changes

Onebox caused downtime it did not need to when a project changed and deployed. This closes those gaps. The design is in docs/plans/2026-10-17-zero-downtime-gaps.md and the implementation plan in docs/superpowers/plans/2026-10-17-zero-downtime-gaps.md.

Managed proxy

  • The applied-state marker is now a pair, <config digest> <compose digest>. A pure decision picks one action:
    • unchanged;
    • update changed containers only, no restart;
    • recreate Traefik (its container definition changed);
    • restart Traefik (configuration changed).
  • An ob upgrade changes only the discovery image, so it no longer restarts Traefik. Repairing a missing discovery controller or its output no longer restarts Traefik either.
  • A failed or interrupted update writes a pending marker, so the retry is another update, not a configuration apply with a restart. A failed replace keeps its own pending marker and stays labelled as a replace.
  • The update path pulls the discovery image before stopping discovery. If a later step fails, it starts the old discovery container again. It proves the new controller published by stopping discovery, recording the output's inode, and waiting for a new one. It uploads only compose.yaml, never config/ (which can hold .env credentials).
  • A configuration apply renames the live config/ and dynamic/ aside instead of deleting them. Traefik's bind mounts follow the renamed directories, so it keeps its routes until it restarts. The retired directories are removed only after a successful apply.
  • Every path pulls the discovery image before stopping or recreating discovery. If a later step fails, it runs docker start onebox-discovery, then compose up -d discovery when that container is gone.
  • At the start of a run, staged directories from failed uploads are removed; those holding retired configuration stay until an apply succeeds.
  • A running Traefik whose image differs from the local image for its tag counts as a definition change.
  • ob status says which half of the proxy drifted.

Deploy plan

  • The plan prints a proxy: line with the action above, or start Traefik when Traefik is not running.
  • The line is binding. Executing a sealed plan recomputes it after a read-only observation and refuses, as drift, before any host mutation when it differs.

Managed services

  • Every managed service gets a 2-minute shutdown window, as stop_grace_period: 2m0s in the rendered document.
  • ob service apply passes --timeout 120.
  • Destroy runs docker stop -t 120 before docker rm -f.
  • PostgreSQL's shutdown checkpoint and Redis/Valkey's shutdown save are no longer killed after 10s.

Rolling

With the managed proxy, a roll retires a replica only after every replacement is healthy and published under each of the workload's proxy services in onebox.yml. That includes newcomers adopted on resume. It stops a replica only after its address is a server of no service in the document. A container that is not on the proxy network counts as withdrawn, so adding a route, removing one, or changing its protocol retires old replicas normally. A failed, empty or unparsable read counts as neither published nor withdrawn. A newcomer that is never published is removed and the old replicas keep serving; a replica that is never withdrawn is not stopped. Every newcomer is re-checked before each retirement and before success.

Recovery

ob abort and rollback of an interrupted deploy now restore the predecessor before removing the interrupted release's serving containers:

  1. Remove only the non-serving containers.
  2. Rename a running container that holds the transient rollout name to a free slot.
  3. Remove the drain file from predecessor replicas the interrupted roll had drained. A lost lock or connection here aborts.
  4. Roll the predecessor, which drains and retires the rest.
  5. Sweep, verify, then the existing postcondition.

A workload counts as already restored only if none of its containers belongs to the interrupted release.

Docs

  • add-a-database.mdx: the shutdown window.
  • safety-envelope.mdx: the routing gates, the proxy actions, the 3s converge-buffer assumption, and the one-time upgrade restarts.
  • status/capabilities.mdx: the routing gates.

No issue was filed first.

Closes #

Why this is correct

  • Every change starts from a test that failed without it. Main ones:
    • Proxy: TestDecideProxy, TestEnsureProxyComposeOnlyChangeSkipsSwapAndRestart, TestEnsureProxyFailedConvergeRetriesAsConverge, TestEnsureProxyFailedReplaceIsPlannedAsReplace, TestEnsureProxyConvergeStopsDiscoveryBeforeSampling, TestEnsureProxyPullsDiscoveryBeforeStopping, TestEnsureProxyRestartsDiscoveryAfterFailedUp, TestEnsureProxyConfigSwapRetiresLiveDirsInsteadOfDeleting, TestTraefikDefinitionChanged.
    • Rolling: TestRollRetiresOldOnlyAfterNewcomerPublished, TestRollRemovesNewcomerNeverPublished, TestRollResumeBlocksAdoptedHealthyUnpublishedNewcomer, TestRetireOldReplicaOffProxyNetworkIsWithdrawn, TestRetireWaitsWhileOldReplicaServesARemovedRoute, TestRetireUnreadableRoutesAreNotWithdrawal.
    • Recovery: TestRecoverySingleReplicaKeepsInterruptedNewcomerServing, TestRecoverySurgesWhenOnlyInterruptedNewcomerServes, TestRestoreReleaseRolesRollsInterruptedOwnedMatchingRevision.
    • Services: TestRenderedServicesCarryStopGrace.
  • Two independent reviews ran over the whole branch. Every finding of the first is fixed. The second reverted 16 individual fixes one at a time in a scratch worktree, and a test caught every revert.
  • A later /code-review found 10 more items:
    • Fixed 7: discovery restore on the apply path, the binding plan line, re-pulled Traefik images, failed-upload cleanup, single admission and the container name constant. New tests: TestEnsureProxyApplyRestoresDiscoveryWhenRecreateFails, TestEnsureProxyRefusesPlannedActionDrift, TestObserveProxyFlagsRepulledTraefikImage, TestEnsureProxyRemovesFailedUploadsButKeepsRetired, TestRollAdmitsEachNewcomerOnce, TestExecuteBindsSealedProxyAction.
    • A final review then found that admitting each newcomer only once let a roll retire every old replica after an admitted newcomer failed under load. That optimization is withdrawn. Converge and replace now always wait for Traefik health before the final marker. New regressions: TestRollStopsWhenAdmittedNewcomerFailsLater, TestRollKeepsLastHealthyOldWhenNewcomersFail, TestEnsureProxyRetryWaitsForHealthBeforeCommit, TestEnsureProxyProceedsWhenPlannedActionMatches, TestExecuteProceedsWhenSealedProxyActionMatches.
    • Kept 3 by design, recorded in spec §6: a never-effective drain fails the roll rather than stopping a routed replica; the failed-apply limitation below; the recovery slot search.
  • No existing assertion was weakened. Where a test encoded the old behavior, it now asserts the new contract. Three tests the review found vacuous were tightened, and the shared fakes now model ownership and routing honestly.
  • internal/app/testdata/contract-verdicts.json: only service-document digests moved (the new stop_grace_period).
  • The proxy update path relies on Compose not recreating proxy when its dependency discovery is recreated. Verified locally on Docker Compose 5.6.0, both with up -d discovery and a full up -d.
  • Ran, each separately (the host was short on memory): go mod tidy -diff, the gofmt check, go vet ./..., go test ./... (2314 passed), golangci-lint run ./... (0 issues), docs-generate-check, env-namespace, and the site build. Together these are what just check runs, plus lint.
  • Not run: the Docker e2e suites (just e2e, just server-e2e) and amd64. Also not run: the check that compose stop discovery leaves Traefik running. The local Docker VM had stopped, so that check is listed for the target hosts below.

Effect on the safety envelope

Yes. This changes what Onebox does to a running production system.

  • One-time on upgrade:
    • The first deploy or ob proxy apply restarts Traefik once (the legacy one-digest marker takes the configuration path).
    • The first ob service apply recreates each managed service once, to install the shutdown window.
    • The first deploy does so for PostgreSQL services with declared extensions.
  • Afterwards:
    • Traefik restarts only on a configuration change and is recreated only when its own definition changes.
    • A roll can now stop with an error instead of proceeding, when discovery does not publish a newcomer or withdraw a drained replica within 30s.
    • Recovery runs in the new order above.
  • Known limitation: after a failed proxy apply, a roll that skips the proxy step (ob secrets push, recovery) can read a routing document Traefik is not serving. Run ob proxy apply first; ob status shows the proxy as drifted in that state.
  • Before release, on each target host:
    • Record docker compose version.
    • Confirm that up -d of a recreated dependency keeps its depends_on dependent's container.
    • Confirm that compose stop of a dependency leaves the dependent running.
    • Confirm that docker inspect -f '{{.Image}}' of a container equals docker image inspect -f '{{.Id}}' of its tag, and that up -d recreates a container after its tag is re-pulled to a new image.
  • /status/capabilities updated.

Checklist

  • just check passes locally (its steps run one at a time, as listed above).
  • Tests cover the new behaviour, including the failure paths.
  • Generated documentation is current (just check verifies this).
  • I have accepted the CLA, or will when the bot asks on my first pull request.

https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9

vishr added 30 commits October 17, 2026 12:05
Spec and implementation plan for removing downtime Onebox causes without
need: Traefik restarts on every ob upgrade and on discovery repair, killed
database shutdowns, rolls that retire the last routed replica, and recovery
that removes serving containers before restoring the predecessor.
Separate staged configuration bytes from the Compose payload so later proxy
convergence can distinguish configuration reloads from controller upgrades.
Validate and serialize both SHA-256 digests as one applied identity while
existing engine paths consume the serialized marker.

On rollout, an existing single-digest marker differs once and follows the
configuration path; subsequent applies compare the two-digest marker. This
slice retains current restart and convergence behavior until later tasks.
Separate host observation from the decision to repair containers or apply configuration so compose drift can converge without restarting Traefik. Treat missing, malformed, and legacy identity markers as requiring configuration apply.

Operators see no rollout behavior change in this preparatory commit: EnsureProxy and status remain unchanged. The new decision reports unchanged, container-only updates, or a Traefik restart for later integration.
Keep Traefik and its configuration mounted while converging compose-only
changes or repairing discovery, so ob upgrades and discovery repair no
longer drop routes. Require fresh discovery publication before advancing
and invalidate the applied marker before uploads; commit it atomically
only after the required publication and health gates succeed.

Operators see no Traefik restart for discovery repairs or ob upgrades that
only change discovery. The first apply on each host after upgrading still
restarts once because the legacy one-digest marker takes the configuration
path. Configuration changes continue to reload Traefik by recreation or
restart.

Compose assumption verified on Docker Compose 5.6.0: dependent container kept when its dependency was recreated
The user verified both separate service up commands and full up behaved
the same; the experiment was not rerun for this task.
Compare parsed config and Compose identities so Compose-only changes and legacy markers cannot appear synchronized. Share staging with proxy apply to keep local status identity aligned with the payload.

Operators see config and Compose digest prefixes when synchronized, and the drifted halves plus ob proxy apply when divergent. JSON retains its identity fields and unreadable reads remain incomplete rather than asserting drift.
The healthy log truncated the identity at its sha256 prefix, hiding useful digest characters and the Compose identity. Operators now see the first eight hex characters of both config and Compose digests after a successful proxy rollout.
Make the managed proxy action visible before operators approve a deployment. Stage and observe the proxy without mutating the host, bind the action into the plan digest, and print it immediately after the compose diff or no-op explanation.
The 10s kill window could force PostgreSQL crash recovery or Redis AOF replay after a restart. Share a two-minute grace period across rendered runtimes, service apply, and best-effort stop before removal.

Operators see every service recreated once on its first service apply after upgrading; deploy also recreates PostgreSQL services with declared extensions. As with #220, this one-time recreation installs the new container setting.
Read the published discovery document so rollout gates can confirm every expected HTTP and TCP backend before shifting traffic. Share endpoint selection with discovery and compare parsed IPs so IPv6 and multiple routes follow the published addresses.

Operators receive bounded publication and withdrawal errors with discovery log or ob resume guidance. Failed, empty, malformed, and cancelled reads cannot establish routing presence or absence. This adds the routing readers; rollout integration follows separately.
Health alone does not confirm that discovery has published a replacement. Admit fresh and adopted newcomers before retiring replicas or declaring convergence, and remove a fresh newcomer if publication fails.

Operators keep existing replicas serving when admission fails and can resume without losing adopted containers. Managed routed workloads wait for the published backend before the existing converge buffer and drain.
Wait for published routes to withdraw a draining replica before sending any signal or stopping it. Health alone does not prove discovery has removed the endpoint.

Operators see rollout stop with a discovery error and a still-running draining replica when withdrawal cannot be confirmed, so they can fix discovery and resume safely.
…ners

Keep interrupted running rolling replicas available while restoring the
predecessor, then sweep the interrupted release before verification.
Remove non-serving containers and transient name collisions first.

Operators aborting or automatically rolling back a partial rollout retain
serving capacity until predecessor restoration; failures remain retryable
with the activation checkpoint intact.
A workload whose containers match the predecessor's revision but belong to
the interrupted release is not restored: the sweep would remove its only
containers. Recovery rolls it instead, which drains and retires them.
A matching workload revision can still belong to the interrupted release.
Roll those containers into the predecessor before the exact release sweep
so recovery cannot retain serving replicas and then remove them.

Operators aborting or automatically rolling back keep serving capacity
through restoration even when the two releases share a workload revision.
Track interrupted ownership separately in the recovery fixture so a
surviving container remains visible to the sweep and postcondition.
Document publication and withdrawal gates, recovery ordering, and the proxy action shown in deploy plans so operators can see when serving containers remain available and when Traefik restarts. Mark the accepted implementation complete while preserving its plan reference.
Compare the second YAML decode against io.EOF with errors.Is, and use
strings.Contains in the marker-invalidation test, so just lint passes.
Withdrawal survives route changes, a failed converge retries as a converge,
converge proves a fresh controller, the plan names a Traefik replacement,
the config path keeps routes until the restart, and single-replica recovery
keeps serving.
Check old endpoints against every published HTTP and TCP service so route
removal or protocol changes cannot bypass retirement safety. A container
outside the proxy network is already withdrawn, while newcomer admission
still rejects its missing address.

Operators can roll route changes safely: old replicas stop only after
discovery removes their endpoint, and stalled discovery leaves them draining
with the existing resume guidance.
The predecessor's running replica is undrained and adopted, so the roll
retires the interrupted newcomer without a surge.
Preserve an interrupted rolling replica while freeing its transient name,
then undrain the predecessor's running replicas before restoration admits
them. Choose a slot unoccupied by any container, including stopped ones.

Operators recovering a one-replica workload keep its healthy endpoint until
the predecessor passes health and managed publication admission, after which
the interrupted replica drains and retires through the normal roll.
Undrain recovery previously swallowed transport and fencing errors, allowing a stale runner to continue restoring predecessor replicas after losing authority. Return mutation errors immediately while retaining best-effort handling for remote command failures.

Operators now receive the recovery error before further replicas or restore actions run. Cover transport failures, host-side fence rejection, and continued undrain after an ordinary remote exit.
Keep the applied config digest in an atomic pending marker so an interrupted discovery converge retries without restarting Traefik. Stop discovery before sampling its output inode, require fresh publication, and upload only Compose to avoid sending config credentials on that path.

Retire live config and dynamic directories into staging so existing bind mounts keep their contents until the restarted proxy is healthy. Defer staging cleanup until a successful apply, including on retry, so a failed apply preserves the routes still mounted by Traefik. Cover the failure paths and tighten the publication and discovery fakes.
Compare the parsed live and rendered Traefik service and network definitions so a deploy plan names a container recreation instead of promising an update without a restart. Report a start when Traefik is absent, and treat an unreadable live definition conservatively.

Run replacements through the existing converge path, preserving the pending config identity, Compose-only upload and fresh discovery publication. Let Compose recreate the proxy and wait for its health before committing the final identity.
State when Traefik restarts or is recreated so operators can use the proxy plan line to assess interruption. Make the routing guarantee explicit about its 3s loading assumption and call out the one-time proxy and managed-service applies required after this upgrade.
A deploy converges the proxy too, so the first deploy after upgrading
restarts Traefik, and deploy recreates PostgreSQL services with declared
extensions.
A failed replace stays labelled a replace, discovery is pulled before it is
stopped and restarted if a later step fails, the unchanged path cleans stale
staging, and rolls after a failed proxy apply are an accepted, documented
limitation.
…abelled

Keep a distinct pending replacement identity so moving Compose before a failed up cannot make a retry promise that Traefik will stay in place. Make the discovery image available before stopping its container, and attempt a guarded start on any later error without replacing the original failure.

Clean stale staging when the final identity is already applied as well as after a successful apply. Cover failed replacement planning, pull ordering, discovery fallback and unchanged cleanup. Add the recovery surge regression for a sole interrupted newcomer and remove the unused route-specific withdrawal helper and its test.
The binary now gates rolling retirement on discovery's published routes, so
the capabilities page says so.
Discovery is restored on every proxy path, the planned proxy action binds
execution, a re-pulled Traefik image counts as a definition change, failed
uploads are cleaned at the start of a run, and a roll admits each newcomer
once.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
…y action

Bind the sealed proxy action for deploy and no-op execution, and refuse drift after read-only observation before cleanup or directory preparation. Compare running Traefik image IDs with the configured local image so a re-pulled tag is planned as a replacement.

Prepare the discovery image on apply as well as converge, and recover a failed recreation by trying start and then Compose up when the container is gone. Clean abandoned uploads before preparation while retaining staged directories still mounted by Traefik until a successful apply.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
vishr added 4 commits October 17, 2026 19:55
Remember successful health and publication admission within each roll invocation instead of repeating it for every remaining replica. Record newly created replicas only after both gates succeed, and start a fresh admission set when a roll is retried so adopted replicas are still checked.

Keep retirement and failure cleanup unchanged, and use the shared discovery container name in routing error guidance.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
Withdraw single admission, which let a roll retire every old replica after
an admitted newcomer failed; always check Traefik's health before the
marker; pull discovery before the apply path invalidates anything.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
A newcomer can fail after its first admission when an old replica leaves and traffic shifts. Remove the admission cache so each retirement and successful completion requires current health and publication evidence for every newcomer.

Cover both a steady three-replica roll that loses NEW1 after its first retirement and a three-to-two roll whose newcomers fail before the final old replica retires. Remove the withdrawn once-only admission test.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
A retry can keep the same Traefik container after a failed replacement. Require current health before committing the final identity on apply, converge and replace, so an unhealthy retry retains its pending marker instead of reporting success.

Pull discovery for configuration applies immediately after drift validation, before changing the marker or directories. Give fallback recovery its own 30s context and neutral logs, and compare the image named by rendered Compose. Add matching-plan success controls while retaining drift refusal assertions.

Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
@vishr
vishr merged commit 4a6e0cc into main Oct 18, 2026
5 checks passed
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.

1 participant