Skip to content

Implement graceful versiond host evacuation and replacement - #1517

Closed
snevolin wants to merge 77 commits into
gonka-ai:upgrade-v0.2.15from
snevolin:sn/versiond-host-evacuation-0.2.15
Closed

Implement graceful versiond host evacuation and replacement#1517
snevolin wants to merge 77 commits into
gonka-ai:upgrade-v0.2.15from
snevolin:sn/versiond-host-evacuation-0.2.15

Conversation

@snevolin

@snevolin snevolin commented Jul 29, 2026

Copy link
Copy Markdown

Summary

This PR targets upgrade-v0.2.15 and implements Track B: graceful
evacuation, replacement, addition, and decommission of a whole versiond
host
, as described in:

It also gives edge-api the same readiness-first shutdown contract and ships a
safe, topology-aware v4-to-v5 upgrade path for existing join deployments.

The main changes are:

  • replace the versiond and edge-api nginx pool routers with unprivileged
    HAProxy 3.2 images;
  • discover replicas through DNS and admit them through active readiness checks;
  • preserve stable escrow routing with consistent hashing per protocol version;
  • make SIGTERM on versiond a bounded host drain that preserves accepted
    requests and complete SSE streams;
  • make edge-api leave rotation before draining accepted chain queries;
  • formalize host, child-generation, supervised-process, and devshardd
    lifecycle behavior as table-driven state machines;
  • enforce shared Postgres storage for HA traffic at startup and request time;
  • add a topology-aware upgrade driver with readiness gates, traffic isolation,
    functional rollback, and restart-safe PostgreSQL migration;
  • add full-stack evacuation coverage and release/deployment contract tests.

Track A, the same-version SHA rolling update of an individual devshardd
generation, is described and reviewed in
#1490. Track B builds on those
generation and process-lifecycle primitives at the whole-host level.


Runtime architecture

clients / gateway
        |
        v
public edge proxy
        |
        +-- /v1/* --------> edge-api-router (HAProxy, round-robin)
        |                         |
        |                         +-- edge-api-0
        |                         +-- edge-api-1
        |
        +-- /devshard/* --> versiond-router (HAProxy, consistent hash)
                                  |
                                  +-- versiond-0
                                  |      +-- devshardd child for v1
                                  |      +-- devshardd child for v2
                                  |      +-- ... one child per approved version
                                  |
                                  +-- versiond-1
                                         +-- the same version set

HA-eligible devshardd generations share Postgres across versiond hosts.
Pre-HA SQLite versions remain pinned to their explicit legacy owner.

Each versiond supervises one devshardd child per approved protocol version;
the diagram does not imply a one-to-one versiond/devshardd relationship.

Why the service routers use HAProxy

Before this change, two nginx OSS containers routed the service pools:

  • versiond-router kept /devshard/* sessions sticky by escrow ID and selected
    HA or legacy backends by protocol version;
  • edge-api-router distributed stateless /v1/* Tier A reads round-robin.

Their startup scripts rendered VERSIOND_HOSTS and EDGE_API_HOSTS into static
upstream lists. nginx could refresh the IP of a listed hostname, but it could
not discover a newly added replica or remove a departed one from that list. It
also had no active application-readiness contract, so a listening but starting,
degraded, or draining process could still receive new traffic.

The HAProxy routers implement the pool behavior directly in the data plane:

  • server-template follows all addresses published under the shared
    versiond-pool and edge-api-pool DNS aliases;
  • starting or stopping a replica changes DNS membership without a router
    configuration change;
  • init-state fully-down keeps a newly discovered address out of rotation until
    its first successful check;
  • versiond has one consistent-hash backend per declared protocol version;
  • hash-key addr makes the ring independent of DNS answer order and HAProxy
    slot assignment, so router restarts do not re-home every escrow;
  • edge-api retains round-robin balancing;
  • established connections remain on their original backend when readiness is
    withdrawn, while new work is sent only to healthy replicas.

The HAProxy configuration preserves the relevant nginx behavior: SSE streaming,
forwarding headers, legacy-version pinning, bounded request bodies, and retries
that never replay non-idempotent application work.


Routing and readiness

versiond router

Pool membership and route eligibility are separate questions:

  1. Docker DNS supplies the current addresses under VERSIOND_POOL_HOST.
  2. Each declared version receives its own backend and its own readiness query.
  3. A host missing or losing one version leaves only that version's ring and can
    continue serving every other healthy route.

The versiond health check is intentionally two-stage so v5 HAProxy can safely
run during a mixed-version cutover or a rollback to v4:

GET /readyz[?version=<v>]
    200 -> v5 readiness is satisfied
    404 -> pre-v5 capability; continue with the legacy route check
    other -> backend is DOWN

GET /healthz or GET /<v>/healthz
    200 -> backend is UP
    other -> backend is DOWN

A v5 503 is authoritative and removes a starting or draining host. Accepting
the pre-v5 404 never admits a process by itself: the real host or version route
must still return 200.

VERSIOND_VERSIONS declares the routable version window. Once that list is
non-empty, an undeclared version fails closed with 503 instead of falling
through to a host by accident. Versions in VERSIOND_NON_HA_VERSIONS use the
same per-version check but remain pinned to VERSIOND_LEGACY_HOST, because their
SQLite sessions cannot fail over.

The request hash is derived inside HAProxy. Session paths use the escrow ID, so
every request for the same live session reaches the same versiond; non-session
paths hash by normalized path. Client headers cannot choose the backend.

versiond endpoints

All readiness endpoints are on the traffic listener:

Endpoint Contract
GET /healthz unchanged legacy JSON array of per-version child state
GET /readyz host-level capacity for versionless or coarse routing
GET /readyz?version=<v> a current, ready route for exactly <v>

Child readiness is monitored continuously. A running child that loses its chain
subscription loses its readiness vouch and leaves the corresponding pool. The
host-level Converged condition latches after the full desired set has run once,
so an ordinary same-name child replacement does not evict every host at once.
Reconcile errors remain observable without hiding routes that are still serving.

edge-api router

edge-api-router discovers EDGE_API_POOL_HOST, balances healthy replicas
round-robin, and checks GET /readyz. edge-api readiness is a bounded, cached
chain probe, so an instance that cannot reach the chain leaves rotation and
rejoins automatically when the dependency recovers.

HA storage guard

The HA overlay sets GONKA_HA=true, which makes devshardd refuse to start
unless its storage is fail-closed Postgres. HAProxy also removes any
client-supplied Devshard-Ha header and stamps its own trusted value:

  • an explicit HA deployment keeps the guard latched during partial outages;
  • without that declaration, the safety fallback derives the header from the
    number of usable servers in the backend selected for the request;
  • a pinned single-owner legacy backend strips the header.

devshardd validates the header again against its active storage. This catches
partial rollouts and accidental multi-host scaling even when the deployment flag
was omitted.


Graceful shutdown

versiond host evacuation

The operator uses the ordinary container lifecycle. For example, from
deploy/join:

source ./config.env && \
docker compose -f docker-compose.yml -f docker-compose.versiond.yml \
  stop versiond2

The safe ordering is owned by versiond:

operator / Docker       versiond                  HAProxy             devshardd
       |                   |                         |                     |
       | SIGTERM           |                         |                     |
       |------------------>| announcing              |                     |
       |                   | /readyz = 503            |                     |
       |                   | admission still open    |                     |
       |                   |                         | check fails         |
       |                   |                         | host leaves pool    |
       |                   | draining                |                     |
       |                   | admission closed       | no new requests     |
       |                   | wait proxy leases      |                     |
       |                   | POST /drain -------------------------------->|
       |                   | wait child idle                              |
       |                   | SIGTERM / reap ----------------------------->|
       |                   | stop HTTP and exit                           |

VERSIOND_DRAIN_ANNOUNCE defaults to five seconds. During that window the host
already fails readiness but still accepts work, giving HAProxy enough time to
withdraw it before admission closes. A host that never reached serving skips
the announcement and drains directly because it was never eligible for traffic.

Every proxied request acquires a host admission lease before forwarding. The
lease is released only when the handler finishes; for SSE that means the full
lifetime of the stream. A child generation has its own target lease, so a
rolling replacement cannot reap the exact process still serving that response.

New requests go to a surviving host. HA sessions can recover there from shared
Postgres. A legacy SQLite owner cannot be evacuated while
VERSIOND_NON_HA_VERSIONS is non-empty.

Temporary stop, replacement, addition, and permanent decommission are described
in the host evacuation runbook.
Permanent membership is represented by the persisted Compose replica count,
for example VERSIOND2_REPLICAS=0, so a later Docker daemon restart cannot
silently recreate a decommissioned service.

edge-api shutdown

edge-api follows the same readiness-first sequence:

SIGTERM -> /readyz=503 -> announce -> close listener
        -> wait accepted queries -> force on budget/repeated signal -> exit

The default five-second EDGE_API_DRAIN_ANNOUNCE lets HAProxy remove the
replica before Echo closes the listener. Accepted queries then receive up to
EDGE_API_SHUTDOWN_BUDGET to complete. A repeated signal shortens announcement
or forces remaining connections, and Docker's stop grace remains the final
backstop.

Budgets and escalation

versiond fixes one absolute deadline when shutdown begins. Announcement, poll
unwind, host leases, child drain, child process grace, and HTTP shutdown all
spend from that same budget; sequential phases cannot add independent maximums.

Setting Default Meaning
VERSIOND_DRAIN_ANNOUNCE 5s fail readiness while still accepting
VERSIOND_HOST_SHUTDOWN_BUDGET 25m one internal absolute deadline
VERSIOND_STOP_GRACE_PERIOD 30m Docker's outer SIGKILL reserve
EDGE_API_DRAIN_ANNOUNCE 5s edge-api readiness announcement
EDGE_API_SHUTDOWN_BUDGET 2m accepted edge-api request drain
EDGE_API_STOP_GRACE_PERIOD 3m edge-api outer SIGKILL reserve

Repeated SIGTERM is idempotent; SIGINT explicitly forces versiond teardown.
Budget expiry forces remaining HTTP work and children, then confirms process
reap before exit. The poll worker receives at most 10% of the remaining host
budget, capped at five seconds. If it ignores cancellation, the manager drain
barrier already prevents new generations and restarts, so shutdown continues.


State machines

1. versiond host lifecycle

starting -> serving -> announcing -> draining -> stopping -> stopped
    |          |           |            |           |
    +----------+-----------+------------+-----------+-> forcing -> stopped
  • starting: not ready and not accepting;
  • serving: ready, accepting, and reconciling children;
  • announcing: unready but still accepting while HAProxy removes the host;
  • draining: admission and desired-state changes are closed;
  • stopping: child and HTTP graceful shutdown is running;
  • forcing: deadline or explicit escalation is closing remaining work;
  • stopped: HTTP has stopped and every child has been reaped.

The transition table owns legality, admission policy, and readiness policy.
starting -> announcing is forbidden because a host that never served must not
open admission while shutting down.

2. Child generation lifecycle

preparing -> starting -> running -> retiring -> draining -> stopping -> stopped
     |           |          |
     +-----------+----------+----> failed ----> starting

The generation FSM owns route publication, exact-target retirement, drain, and
restart. retiring removes that generation from new routing before lifecycle
drain starts. The manager rechecks the generation under its mutex immediately
before every process fork, so host drain and generation retirement form a real
start barrier rather than a timing assumption.

3. Supervised OS process lifecycle

running --stop requested--> terminating --grace/force--> killing
   |                              |                         |
   +---------- process exited ----+-------------------------+--> exited

This FSM owns process-group SIGTERM, escalation to SIGKILL, and reap. Only
the process-exited event produced by cmd.Wait reaches exited and closes
completion.

4. devshardd request lifecycle

starting --chain ready--> serving --chain disconnected--> disconnected
                              ^                               |
                              +----------- chain ready -------+

starting / serving / disconnected --drain requested--> draining
draining --any lifecycle event------------------------> draining

Readiness, admission, drain state, and the in-flight counter come from one table
under one mutex. draining is terminal and rejects new non-lifecycle work.


Safe v4-to-v5 deployment transition

The steady-state readiness and drain contracts protect v5 operation, but an
existing v4 installation cannot safely reach that state through a blind
full-model Compose recreate. The deployment contracts are materially different:
v4 uses static nginx upstreams and has no /readyz; v5 uses DNS-discovered
HAProxy pools and active readiness. The HA PostgreSQL mount also changes from an
anonymous image volume to persistent bind-mounted PGDATA.

deploy/join/upgrade-devshard-v5.sh provides the compatibility bridge. The
operator runs one command from deploy/join:

./upgrade-devshard-v5.sh

The driver sources config.env, checks its dependencies, and detects two
independent topology axes from the existing containers:

Axis Standard HA / multi
versiond versiond shared Postgres, versiond2, and versiond-router
edge-api edge-api edge-api2, edge-api3, and edge-api-router

The standard Quickstart therefore remains a single-service upgrade and does not
create HA-only services. HA overlays are loaded only for installations that
already use them. Every replacement is targeted with --no-deps; unrelated
node, chain, proxy, bridge, Tmkms, explorer, and ML services remain running.

Ordered cutover

detect topology and source state
        |
        +-- capture immutable rollback images and all routable versions
        |
        +-- HA only: disk preflight -> atomic PostgreSQL migration
        |
        +-- versiond HA:
        |      isolate versiond2 in old nginx
        |      -> replace and wait ready/routes
        |      -> install HAProxy router
        |      -> replace legacy owner versiond
        |
        +-- edge-api multi:
               isolate edge-api2 in old nginx
               -> replace and wait ready
               -> install HAProxy router
               -> replace remaining replicas one at a time

The temporary nginx barrier removes the active replacement from the rendered
upstream and reloads nginx without terminating established connections. An
after-render hook persists that isolation across a router container restart.
Once the first v5 replica is healthy, HAProxy's active checks protect later
replacements.

Before cutover-sensitive replacements the script captures the immutable source
image ID, whether the service was running, and every version route that was
available. EXIT, INT, TERM, and HUP use the same compensation path. A
failed replacement before the readiness-aware router is installed restores the
captured image and original running/stopped state, or stops a newly introduced
service, then requires three consecutive functional probes. After HAProxy owns
an edge-api pool, an unready replacement is stopped while the surviving replicas
continue serving. Versiond rollback must restore every baseline version;
edge-api rollback must execute the chain-backed /v1/versions query; router
rollback must route the union of all replica baselines.

The mixed-version HAProxy check described above lets a restored v4 versiond
rejoin only when its real routes work. A v5 process that explicitly returns
503 remains out of rotation. Temporary rollback tags are retained after a
failed operation and removed only after the complete upgrade succeeds.

PostgreSQL migration and recovery

The v4 HA overlay inherits the Postgres image's anonymous
/var/lib/postgresql/data volume. v5 stores PGDATA under the stable
DEVSHARD_POSTGRES_DATA_DIR bind mount.

Before recreating PostgreSQL, the upgrade driver mounts the source read-only,
measures it with du, checks the target filesystem with df, and requires the
source size plus a 10% reserve. The entrypoint copies into a staging directory,
validates PG_VERSION, syncs the copy, and atomically publishes it. The source
volume remains unchanged as a physical recovery copy.

Startup fails closed when versiond artifacts imply an existing installation but
the source database is missing; it never silently initializes an empty HA
database. A recovery overlay can reattach an explicitly selected detached v4
volume and reuse the same restart-safe preflight and atomic migration. Partial
staging is reclaimable, completed staging is reusable, and the source is
detached only after bind-mounted PGDATA is verified.

The exact procedure and recovery boundaries are documented in the
0.2.15-v5 release guide.


Failure policy

Failure Result
One host loses one version removed only from that version's backend
Host disappears from DNS its HAProxy slot becomes unresolved
New host appears in DNS starts down and joins only after checks pass
Every eligible host is unready router fails closed with 503
Oracle/archive reconcile fails while old routes serve routes remain available; failure stays observable
Shutdown budget expires remaining work is forced and every child is reaped
Replacement fails before router cutover captured source state is restored, or a new service is stopped
HAProxy-protected edge replica fails unready replica is stopped; healthy pool remains available
PostgreSQL migration fails both source and target are preserved; database remains stopped for recovery

Test coverage

The full-stack
TestVersiondHostEvacuation
uses two real versiond containers, HAProxy, shared Postgres, and a paused
OpenAI-compatible SSE response. It verifies:

  1. stable escrow placement across a router restart;
  2. readiness withdrawal before admission closes;
  3. zero-tolerance continuity probes through the sticky path;
  4. new requests avoiding the target while its accepted SSE stream completes;
  5. session recovery on the Postgres-backed survivor;
  6. graceful process exit without Docker's SIGKILL backstop;
  7. DNS and pool withdrawal after the target stops;
  8. a restarted host remaining out of rotation behind an oracle barrier;
  9. readiness-gated rejoin and restoration of sticky placement.

Focused coverage includes:

  • transition matrices, invalid edges, and ordering invariants for all four FSMs;
  • shutdown races, repeated signals, force paths, stuck poll workers, and process
    reap under go test -race;
  • child-readiness withdrawal, stale-vouch expiry, per-version routing, and
    undeclared-version refusal;
  • deterministic hash-ring stability under adverse DNS ordering;
  • HA header derivation from the selected backend and Postgres storage guards;
  • real HAProxy render/runtime checks, including mixed v4/v5 readiness;
  • edge-api chain readiness and graceful/forced shutdown;
  • upgrade-driver topology, barrier, signal-compensation, rollback, and complete
    version-baseline tests;
  • real-Docker PostgreSQL in-place migration, detached-volume recovery, and
    restartability tests;
  • Compose image/health contracts and a pull-first smoke test of all four release
    images.

Relevant commands:

make -C versiond-router test-render
make -C edge-api-router test-render
(cd versioned && go test -race ./... && go vet ./...)
(cd edge-api && go test -race ./... && go vet ./...)
(cd devshard && go test ./...)
make -C versioned e2e
make -C devshard/testenv citest-versiond-host-evacuation
bash deploy/join/upgrade-devshard-v5_test.sh
bash deploy/join/versiond-compose-config_test.sh
bash deploy/join/devshard-postgres-migration-preflight_test.sh
bash deploy/join/devshard-postgres-entrypoint_test.sh
bash deploy/join/legacy-router-upgrade-barrier_test.sh
bash deploy/join/release-image-smoke_test.sh --contract

Dedicated workflows run the host-evacuation acceptance test, real PostgreSQL
upgrade/recovery tests, and release-image smoke checks for the files that affect
those contracts.


Deployment and documentation

Important deployment settings:

  • VERSIOND_POOL_HOST and EDGE_API_POOL_HOST resolve to every replica in the
    applicable pool;
  • VERSIOND_VERSIONS declares every version the HA router may serve;
  • VERSIOND_LEGACY_HOST is required while
    VERSIOND_NON_HA_VERSIONS is non-empty;
  • VERSIOND_STOP_GRACE_PERIOD exceeds
    VERSIOND_HOST_SHUTDOWN_BUDGET;
  • EDGE_API_STOP_GRACE_PERIOD exceeds the edge-api announce and shutdown
    budgets;
  • the four v5 deployment services use the published
    0.2.15-devshard-v5 image tag.

Supersedes #1489. This branch applies Track B to upgrade-v0.2.15 and includes
the release transition needed to reach the new DNS-discovered, readiness-gated
runtime safely from existing v4 join deployments.

snevolin added 2 commits July 29, 2026 06:47
Track B: graceful evacuation, replacement, addition and permanent
decommission of a whole versiond host.

- table-driven host lifecycle and full-response admission leases in versiond
- one absolute, configurable shutdown budget for proxy drain, child drain,
  HTTP shutdown, escalation and child reap
- table-driven child-generation, supervised-process and devshardd lifecycle
  machines
- persistent router membership with immutable membership IDs and an explicit
  one-host-at-a-time transfer
- forward-reconciling router control plane with revisioned nginx config
  projections, durable completion receipts and an audit outbox
- local gonka-routerctl and resumable SSH-based gonka-hostctl workflows
- full-stack coverage for a sticky long-running SSE request, evacuation,
  replacement, decommission and re-addition

Replay of sn/versiond-host-evacuation (3f68fde..2064415, 40 commits)
onto upgrade-v0.2.15. The original history could not be rebased: two upstream
backmerges carried manual conflict resolutions that a flattening rebase drops.
The resulting tree is byte-identical to the source branch.
0.2.14 is released, so its release guide is restored to the base state.
Add devshard/docs/release-0.2.15-v5.md following the v4 guide structure and
carry the graceful-shutdown section and rollout checklist item over verbatim.
snevolin added 2 commits July 29, 2026 07:47
Move the Docker host evacuation acceptance job into a dedicated
workflow with path filters for Track B code, devshardd lifecycle code,
the test harness, and its build inputs.

Cancel superseded runs for the same pull request so unrelated testenv
changes and stale pushes do not consume the 40-minute runner budget.
Stop triggering the 40-minute acceptance test for every root
Makefile change. The shared Testermint workflow already exercises the
devshardd-build target, while the dedicated workflow retains triggers
for Track B code, its test harness, and direct build metadata.
@snevolin
snevolin force-pushed the sn/versiond-host-evacuation-0.2.15 branch from d5825e6 to 1927922 Compare July 29, 2026 02:52
snevolin added 24 commits July 29, 2026 08:06
versiond-router had no Go code and no persisted state before this work, so
router state schema 1, WAL schemas 1-4 and hostctl journal schemas 1-2 only
ever existed inside this branch. No deployment wrote them.

Remove the migration paths and pin every persisted format at schema 1:

- delete router state and operation-journal migrations; decode the current
  schema directly and reject any other version
- delete the legacy rollback recovery policy, the pre-image journal fields it
  needed, and the config restore it performed
- delete the audit-to-receipt-index import and the ActionAgnostic/Conflict
  receipt fields that only that import could set
- delete the hostctl journal migration and the host_idle resume alias

Torn-tail audit repair stays: it protects the append path independently of the
removed import.
Host evacuation was built around nginx OSS not being able to discover that a
versiond is draining: since it cannot health-check upstreams, something had to
tell it. That something was a 12.9k-line control plane — a durable router FSM,
membership IDs, transfer ownership, a WAL, a receipt index, an audit outbox, and
two SSH-driven CLIs — whose entire job was to move an upstream to `down` before
anyone sent SIGTERM.

Move both routers to HAProxy and let them observe instead:

  * membership comes from DNS. VERSIOND_POOL_HOST / EDGE_API_POOL_HOST is one
    name with an A record per running instance (a Compose network alias), read
    through `server-template` + `resolvers`. Starting or stopping a container is
    the whole operation; no config change, no reload.
  * health comes from `GET /readyz` once a second. A host that is draining,
    still converging, or cut off from the chain takes no traffic and rejoins on
    its own.

versiond makes that safe with a new `announcing` state: on SIGTERM it fails
/readyz while still accepting for VERSIOND_DRAIN_ANNOUNCE (5s), so the router
removes it before admission closes. Readiness moved to the traffic listener,
which drops the loopback admin listener entirely and is what a Kubernetes
readinessProbe will consume unchanged.

Readiness also had to be relaxed: `Converged` now latches once every desired
version has run. Without that, a routine same-name SHA bump — published to every
host at once — would un-converge the whole pool simultaneously and evict it.

HA storage safety gains a startup half: with GONKA_HA set, devshardd refuses to
boot on storage a sibling cannot see, instead of booting and failing every
HA-marked request. The per-request Devshard-Ha guard stays, because a partial
rollout can leave a process that started before the deployment became HA.

edge-api gets the same treatment: a cached /readyz that reports chain
reachability, so an instance that loses the chain leaves the round-robin
rotation rather than answering.

What remains of the control plane is `gonka-drain`, an 80-line shell guard over
the HAProxy Runtime API for quiescing a host without stopping it. It addresses
hosts by container name or IP and refuses to drain the last one still serving.

Failover discipline is preserved from the nginx config: retry on connect
failure, empty response, and upstream 502; never on 503, which is what a
draining host and the storage guard answer; and `disable-l7-retry` for
non-idempotent methods so an inference is never executed twice.

Net effect on the PR: +6.0k/-0.8k across 75 files, down from +18k.

Verified end-to-end against real HAProxy 3.0 containers: sticky hashing by
escrow, legacy version pinning, /devshard prefix handling, Devshard-Ha
stamping, health-based eviction and re-entry, DNS-driven join and removal,
the last-host drain guard, and edge-api round-robin with health eviction.
`make test-render` renders every supported shape and validates each result with
the HAProxy the routers ship.
Adding /readyz to edge-api put it behind an active health check without giving
it anything to announce: SIGTERM went straight into Echo.Shutdown with a
hardcoded 10s, /readyz kept answering 200 while the chain was reachable, and no
Compose file set stop_grace_period. Replacing an instance therefore cut every
query still running at ten seconds, and the router only found out afterwards.

Mirror the versiond sequence:

  * BeginDrain latches /readyz to 503 with reason "draining", checked before the
    chain probe so it does not wait out the readiness cache;
  * keep serving for EDGE_API_DRAIN_ANNOUNCE (5s) so the router's 1s check
    removes the instance before it stops accepting, with a second signal cutting
    the wait short;
  * then Shutdown under EDGE_API_SHUTDOWN_BUDGET (2m, matching the router's
    default read timeout — the process should wait exactly as long as the hop in
    front is still willing to wait), and Close with a diagnostic if it expires.

Malformed durations now fail at boot rather than silently falling back to a
default that is only wrong during an outage.

Every Compose file that runs edge-api gets stop_grace_period: 3m, including the
single-instance base. Without it Docker's 10s default would SIGKILL the process
in the middle of the drain it just learned to do.

Verified against the built binary with a chain endpoint that accepts and never
answers: /readyz reports "draining" at t+1s while /healthz still serves; a query
in flight survives past the old 10s cutoff and keeps running to t+28s (announce
plus budget); at budget expiry the process closes it and logs
"graceful shutdown did not finish" instead of waiting for SIGKILL.
Three gaps in the previous commit, from review:

The single-instance Compose never passed the settings. Compose forwards only
what a service lists, so EDGE_API_DRAIN_ANNOUNCE and EDGE_API_SHUTDOWN_BUDGET in
config.env reached nothing and the built-in defaults were always used. Both are
now declared on the base edge-api service, and the announce window is in
config.env alongside the budget it pairs with.

A second signal could only cut the announce window short. Once Shutdown started,
nothing read the signal channel — and signal.Notify had already taken SIGTERM
away from the runtime, so an operator staring at a stuck two-minute drain had
nothing left but SIGKILL. Shutdown now runs in a goroutine while the caller
selects on {finished, budget expired, another signal}, and either failure mode
closes the remaining connections.

Escalation goes to http.Server.Close rather than echo.Close, which turned out to
matter: echo.Shutdown holds startupMutex for its entire run, so escalating
through Echo deadlocks against the very drain it is meant to interrupt. The
end-to-end test below is what found that — the fix was written first and hung.

The sequence itself had no test. It is now extracted as drainAndShutdown and
covered against a real server on a real port with a blocking handler:

  * an in-flight request survives the drain and completes, while /readyz already
    reports "draining" — both halves of the guarantee in one test, so either one
    regressing fails CI;
  * budget expiry ends the wait and says why;
  * a signal during Shutdown forces the close (this is the deadlock guard);
  * BeginDrain is called before Shutdown, not after.
Two follow-ups from review.

The budget was only enforced through the error Shutdown chose to return, so it
depended on Shutdown reaching the point where it looks at its context. A Shutdown
blocked before that — on a lock, say, which is exactly how the echo.Close
deadlock behaved — would have run past the budget unbounded. Watch ctx.Done
directly alongside the result and the signal, so the ceiling holds whether or not
the thing it bounds cooperates.

Shutdown also reports a failed listener close the same way it reports a deadline,
and wrapping both as "shutdown budget expired" would send the next operator to
tune a setting that has nothing to do with it. Only context.DeadlineExceeded is
now called a budget expiry; anything else keeps its own cause.

Both guards were checked against a reverted implementation: without the ctx.Done
branch the blocking-Shutdown test hangs to the test timeout, and without the
cause check the listener-failure test fails.
A server's position on a consistent-hash ring defaults to its numeric slot id.
With server-template the slots are filled from a DNS answer whose order nothing
guarantees, so a plain router restart could hand the same hosts different slots —
and move every session at once. That is the one failure the sticky pool exists to
prevent, and it would have happened silently on a restart nobody thought of as
risky.

Measured with four backends and the same addresses in different slot orders,
default keying:

  forward order:  .5 .2 .2 .2 .4 .2 .5 .3
  reversed order: .2 .5 .5 .5 .3 .5 .2 .4

Not one of the eight escrows stayed put. With hash-key addr the mapping is
identical under forward, reversed and shuffled slot order. hash-key is a server
keyword and is already available in the HAProxy 3.0 the routers ship, so no image
bump is needed.

Two guards, at the two costs they are worth:

  * make test-render asserts the pool's servers carry hash-key addr, so removing
    it fails on every PR;
  * the evacuation acceptance test now restarts the router and requires the
    escrow to come back to the same host, which is the behaviour rather than the
    spelling.

Moving onto this router re-homes sessions once, since the ring is not the one
nginx computed. HA sessions recover from shared Postgres, so that costs a lookup.
Two problems with the previous commit's guard, from review.

The acceptance test restarted the router and then kept talking to the port it
had before. The harness publishes random host ports, and Docker hands out a new
one when the container comes back, so the suite failed with connection refused —
reproducibly, in CI. Endpoints are re-resolved after the restart now.

That test also could not prove anything: a restart does not force DNS to answer
in a different order, so the very regression it was written for would have
sailed through it. It stays as a smoke test, with a comment saying so, and the
proof moves somewhere it can be made deterministic.

`make test-hash-ring` takes the hashing directives out of the rendered config,
puts the same four addresses into the server slots in forward, reversed and
shuffled order, and requires every escrow to reach the same address in all
three. It needs no upstreams: HAProxy logs which server it selected even when
the connection is refused, and that selection is the entire question.

Checked in both directions. With `hash-key addr` all three orders agree; with it
removed from the template the test fails and names the sessions that moved. The
first attempt at that negative check passed by accident — the extraction matched
`hash-key addr` inside a comment — so it now reads only the server-template line.

test-render depends on it, so CI and `make -C devshard ci-testenv-unit` pick it
up with no workflow change.
The rejoin check required observing the target DOWN right after start, which is
a race the test loses whenever the child comes up quickly — reproducibly, per
review. Waiting for a transient is no way to check that an unready host gets no
traffic; the host has to be held unready.

It now comes back pointed at an oracle that refuses connections. versiond boots,
appears in DNS and stays there, but never learns what to run, so /readyz keeps
failing for exactly as long as the test holds the barrier — no transient to
catch. Lifting the barrier restores the real oracle and the host rejoins.

Verified the barrier directly against a versiond container: alive with /healthz
200 and /readyz 503 at 6s and still at 16s, reconcile failing on every poll. It
is a state, not a window.

The barrier is deliberately scoped to one host, which is why PatchComposeEnvKey
would not do — it rewrites the key in every service, and repointing the survivor
too would just empty the pool. PatchComposeServiceEnv edits one service block and
returns the value it replaced, so the restore is the file's own value rather than
one reconstructed from config. It has its own unit test, including that a sibling
service and an unrelated block with the same key are left alone.

This also pins something worth pinning: one host that cannot converge does not
make the pool unready. The survivor keeps its oracle, keeps serving, and the test
asserts the session stays reachable on it throughout.
/readyz gated on Conditions.Degraded, which is set by any reconcile error —
including "oracle fetch failed". Every versiond reads the same oracle, so that
error does not arrive on one host: it arrives on all of them, within one poll
interval of each other. An unreachable oracle or one bad archive would therefore
have emptied the pool, and the router would have answered 503 for every version,
while every child was still running and serving normally.

That is the same correlated-failure shape the Converged latch already fixes, and
it contradicts what the endpoint is for: readiness answers "can this host serve
now", not "did the last control-plane poll succeed". Reconcile failures keep
being reported through Degraded, /healthz and the logs, which is where a
deployment problem belongs.

The cost is named rather than hidden, in code and in the docs: a host that has
served before and then fails to install a newly approved version stays in the
pool, so requests for that one version fail on it instead of moving to a host
that installed it. Fixing that properly needs per-version readiness, which one
balancer health check cannot express. Should a condition turn up under which
accepting traffic is genuinely unsafe, it gets its own typed condition — not a
ride on the generic reconcile error.

The acceptance-test barrier is unaffected and was re-checked against a real
container: a host pointed at a dead oracle has never converged and has no child,
so it stays 503 on /readyz — alive, /healthz 200, still 503 at 18s — with
Available and Converged now carrying that on their own.
Two docs said a failed reconcile is reported "through the Degraded condition, in
/healthz and in the logs". Only the last of those is true: /healthz serves
health.StatusEntry — name, port, status, sha256, binary_version — and nothing
else. Promising a field that is not there is worse than saying nothing, because
an operator can build an alert on it and only find out during the outage it was
meant to catch.

Corrected to what actually happens: the failure is kept in versiond's internal
Degraded condition and logged at ERROR, and /healthz is deliberately unchanged
because that array is a contract existing clients parse.

The gap is recorded as a follow-up rather than papered over. Reconcile failures
have no machine-readable exposure today; that belongs in a metric, not bolted
onto the legacy JSON, and versiond has no metrics endpoint of its own yet.
A new SHA that will not download reaches Degraded through the reconcile result,
not through ReportReconcileError, so the existing regression test for an
unreachable oracle did not cover it. Both triggers are fleet-wide — every host
reads the same oracle and the same archive — and both must leave a serving host
in the pool.

The test asserts what the balancer consumes after a failed install: the old child
is still running so Available holds, Converged is not retracted, and the failure
is still reported as Degraded. That downloadAndSwap keeps the old child is already
covered by TestDownloadAndSwap_NewChildNotReadyKeepsOldServing; this pins the
conditions that follow from it.
The docs said a reconcile failure is logged at ERROR. An unreachable oracle is,
but an empty version list sets the same Degraded condition and logs at WARN, so
anyone grepping for the promised level would miss half the cases. Say only that
it is logged.
A single readiness flag per host cannot say "I am missing one of several
versions", so the choice was between letting a host serve a version it does not
have, and evicting it entirely. Both are wrong, and the second is worse: the
cause of a missing version is a shared archive, so it is missing on every host at
once and the whole pool would leave over a version most traffic does not use.

So stop asking the host-level question. versiond now answers
/readyz?version=<v> from the same route table the proxy uses, and the router
keeps one backend per version in VERSIOND_VERSIONS, health-checked with that
version's own question. Same hosts in every backend; what differs is which of
them pass. A host that cannot run v5 leaves v5's ring and keeps serving v4.

Approving a new version becomes safe for the same reason: until a host has v6
running it is simply not in v6's pool, so v6 traffic goes only where it can be
served and v4 carries on untouched.

The per-version answer needs no convergence latch and no view of the desired set,
which is what makes it precise — either a running child serves that version here
or it does not. The host-level /readyz stays as the fallback for versions the
router was not told about, so listing a version buys precision and is never a
prerequisite for serving it.

All three pool backends are rendered from one pool-backend.cfg.template, so the
routing policy — hashing, hash-key addr, retry discipline, header handling —
cannot drift between them. test-render asserts each rendered backend carries all
five policy directives, which is the check that would catch a fragment edited for
one pool and not the others.

Verified end to end against the shipped image with two upstreams that disagree
about which versions they serve: v4 spreads over both, v5 reaches only the host
that has it, an undeclared version falls back to the host-level pool, and
gonka-drain can inspect any of them. make test-version-routing pins exactly that,
and fails when the version dispatch is removed.
Three holes in the previous commit, from review.

gonka-drain drained one backend. HAProxy server state belongs to a backend/server
pair, and a host now sits in every pool, so draining it from versiond_ha_pool
left it serving every declared version — the command reported success and did
almost nothing. It now plans across all pools, refuses if any of them would be
emptied, applies, and rolls back if one apply fails, so a host is never left half
out. Verified: draining one of two hosts removes it from v4 and v5 at once, and
putting it back restores both.

An undeclared version fell back to the host-level pool, which is the coarse check
per-version pools exist to replace: the request reached whichever host the hash
picked and 404'd there if that host lacked the version. So the claim that
approving a new version was automatically safe was wrong, and the docs said so.
While any version is declared, an undeclared one is now refused at the router
with a 503 that names the setting to fix. Non-version paths like /healthz are
exempt, and an empty VERSIOND_VERSIONS disables the mechanism entirely and keeps
the previous behaviour. The two-phase rollout — declare, then approve — is
documented as the procedure it is.

Version names had two contracts. The router accepted only [A-Za-z0-9._-] and
refused to *boot* on anything else, while the chain accepts any non-empty name
and versiond only bars path separators. A legal approved version could therefore
take the router down. The name is now taken as governance wrote it and only the
HAProxy identifier is derived from it, with a loud failure if two names derive
the same one — a silent collision would merge two versions' pools.

test-version-routing covers the new refusal and that /healthz survives it; the
render test covers name derivation and collisions.
Six problems, from review.

The acceptance test failed on the drain refusal wording. The message and the
assertion now say the same thing, and the message names the backend it is
protecting.

The strict guard called the first path segment a version, so it refused
/metrics, /stats, /devshard/healthz and the session observability routes — all
of which versiond serves without a version. It now classifies against that
grammar (proxy.go: isVersionlessObsPath) on the canonical path, so the prefix
form is covered too. Checked against each of those paths.

gonka-drain skipped versiond_legacy, so draining the legacy owner reported
success while legacy traffic kept arriving. Every backend the host appears in is
now included, which makes the legacy owner undrainable — its backend has one
server, and emptying it would fail every pinned version. That is the honest
answer, and it is now the one the command gives. A legacy backend with nothing
pinned to it is skipped, since no request can reach it.

The last-server check counted the backend rather than the target: a host that
serves v4 but not v5 could not be drained, because v5's pool had one server —
which was not the host being drained. The guard now applies only where the target
is itself taking traffic.

Version names went through a lossy tr, so v5+cuda and v5-cuda derived the same
backend, and '+' in the health-check query decodes to a space on the versiond
side, leaving the host down forever. One grammar now applies —
[A-Za-z0-9][A-Za-z0-9._-]* — used verbatim as backend name, query value and map
key, with anything else refused at startup. Narrowing the chain's own validation
to match is recorded as a follow-up.

The rollback was not one: it applied the opposite action instead of restoring
what was there, and two concurrent drains could each see a live peer and then
leave none. Admin states are snapshotted per server and restored exactly, and the
whole plan-and-apply is under a lock. Verified: concurrent drains leave one host
serving, and the loser is told why.

The documented rollout said "restart the router", which does not pick up an
environment change, and recreating it cut live streams. The procedure is now
`up -d --force-recreate`, and the router stops on SIGUSR1 — HAProxy's soft stop —
so the outgoing container finishes its streams.
…prove

Seven problems, from review.

The acceptance test asked the host-level /readyz while waiting on a per-version
pool; those do not turn 200 at the same moment. The harness was also flattening
every backend into one list, so a wait settled on whichever backend printed last.
RouterSlot now carries its backend, the waits name the pool they mean, and
TryVersiondReady asks the same question the router asks.

Replacing the router to declare a version refused every new connection from the
moment the old container was told to stop until it finished its longest stream —
minutes, up to stop_grace_period, not the "short gap" the docs claimed. Added
gonka-reload: render, check, then SIGUSR2, which is HAProxy's master-worker
reload. The listener never closes and established streams stay on the old worker.
Measured over 1200 requests at 92/s, one reload cost a single 503, a window under
about 10ms. Server states are handed to the new worker first, or it would re-probe
from scratch — and would also forget which hosts an operator had drained.

Version names are no longer restricted to what happens to be a valid HAProxy
identifier, which the chain does not promise. Each of the three uses now gets a
form that suits it: the map key is the name as written, because it is matched
against the path segment; the health-check query is percent-encoded, because '+'
there decodes to a space; the backend identifier gets a hash appended when the
name is not already one. v5+cuda routes end to end. A name that cannot appear
literally in a path segment is still refused, because the path would not match
it — that residue is documented rather than papered over.

gonka-drain matched slot names per backend, but a slot is local to its backend:
versiond1 is a different host in each. Draining by slot could leave the legacy
backend serving. The target is resolved to one address first and every backend is
matched on that address alone.

The versionless ACL was unanchored, so /metrics-v9 and /healthz-v9 slipped past
the undeclared-version guard. Each alternative is anchored now, and both are
refused while /metrics and /healthz still work.

Duplicate detection matched with a regex, so 'v1.2' collided with 'v1x2'. It
compares the map's first field exactly.

gonka-drain read the legacy map from disk, which misses a pin added through the
Runtime API. It reads the live map from HAProxy.
The last review's closing point is the one that matters: gonka-reload put a
second source of truth back into the router. Four of its findings are the same
finding — a reload re-rendered the config from the environment and so discarded
runtime map pins, left drains applied in some pools and not others, resurrected
stale drains from a state file on disk, and reported success before the new
worker was serving. That is a control plane growing back, one convenience at a
time, in the PR that deleted one.

So the reload is gone, along with the server-state file it needed. Declaring a
version changes the deployment and replaces the container, as any other router
config change does. To keep a governance approval from needing that at all, the
join overlay now declares a window — v4 through v8 — because a pool for a version
nobody runs has no healthy members and costs nothing but its checks. The README
says this plainly instead of offering an in-place path that cannot be made
consistent.

Separately, a real routing bug: a host was selectable the moment DNS mentioned
it, before its first health check had run — a window straight onto a versiond
that is still starting, which is what the acceptance test kept catching. HAProxy
3.1 added `init-state fully-down` for exactly this; the routers move to 3.2,
still LTS, and every pool server now starts down and has to earn its place.
Measured with a host whose listener starts six seconds late: the router answers
503 until the checks pass at t+9s, and never routes to it before.

Also from review: gonka-drain treated "this host is in no backend" as success,
which is worst for a host that has just entered DNS and is about to join — it now
fails and says so. Duplicate version detection compared with awk's ==, which is
numeric when both sides look like numbers, so 1, 01 and 1.0 were one version.

The rolling-update suite drained the legacy owner, which is correctly refused. It
does not need legacy pinning, so it clears it, and the legacy backend then has
nothing routed to it and stays out of the drain.
Readiness for a version was answered from the route table alone, which only says
a child process is running. devshardd reports itself unready when its chain
subscription drops, and versiond went on answering 200 for that version — the
router kept the host in the pool and kept sending it work. ServesVersion now
re-asks the child, cached for under a second so a per-second balancer check does
not become a per-second probe of the child, and bounded so a child that has
stopped answering is not a serving one.

gonka-drain loses `out` and `in`. HAProxy identifies a server by its slot in a
server-template and slots are reused: a drained host that leaves DNS frees its
slot, and the next host to arrive inherits the drain — kept out of rotation with
nothing to show why. Admin state belongs to the identity of a process, and the
router has no such identity, only an address DNS lent it. Taking a host out of
rotation is stopping its versiond, which is graceful by construction and cannot
be inherited. `status` stays, read-only.

The rolling-update suite pinned traffic by draining the other host; it stops it
instead, and the old-generation check now looks only at the host that flipped,
since a stopped host has no health to report.

Docs caught up with the code. rolling-update.md still specified a transactional
router FSM, hostctl checkpoints, membership IDs and schema migrations for tests
that no longer exist, and claimed them as the implemented status of Track B. The
release guide said the join overlay declares v4 when it declares v4 through v8,
and said in one place that a host failing to install a version leaves that
version's pool and in another that it stays. The README still demanded the old
name grammar that the entrypoint no longer enforces.
The cached probe had the shape the review described. It read the child under the
lock, released it, spent up to two seconds on HTTP, then wrote the answer keyed
by version name — so a swap in that window let a departed generation decide for
its replacement, in either direction, and concurrent callers could land their
answers out of order. Nothing invalidated the cache on a route rebuild either,
and the test that came with it deleted the entry by hand, so it never exercised
any of that.

Both that and the fan-out are the same design mistake: answering a question by
doing I/O inside the answer. A monitor now runs per generation, refreshing that
generation's own flag once a second, and ServesVersion is a pure read. The flag
lives on the child, so it is bound to the generation by construction rather than
by a check — a late answer can only be written to the child that was asked, and a
swap simply ends one monitor and starts the next. A balancer asking every second
cannot become a probe every second, and no number of concurrent callers fans out.

The flag also carries when it was last refreshed, and an answer older than five
seconds is not an answer: a monitor that has stopped must not leave the version
frozen at "ready". Tests cover losing and regaining readiness through the real
monitor, staleness, and that the current generation alone decides.

Docs: the release guide still offered gonka-drain for quiescing and still said a
host failing to install a version stays in that version's pool; rolling-update.md
still credited gonka-drain with refusing to empty the pool; and the evacuation
invariants skipped from 6 to 8 after the earlier renumbering.
Five findings from review, four of them seams where the monitor met the rest of
the manager.

The route could be seen before the flag. close(c.ready) wakes the swap path,
which publishes the new generation itself, and c.serving was stored only after
the unlock — so a per-version check landing in that window got 503, and with
fall 1 one such answer evicts the host. A fleet-wide rolling update could blink
the whole pool. The flag is now seeded before the lock is even taken, so nothing
can observe the route without the flag.

A crash-restart could leave two monitors on one child. The monitor's context was
the generation's, and a monitor caught inside its two-second probe while the
process died and came back would see running again and keep writing over the new
attempt's answers. The context is now scoped to the process attempt, cancelled
and awaited right after proc.Wait() — the probe is context-aware, so the await is
prompt. The contract is pinned by a test: after cancel+await, a flipped child
provokes no further writes.

The empty-VERSIOND_VERSIONS mode never saw live readiness: the coarse /readyz
gated on Available, which only says a child process exists. Conditions gains
Serving — at least one running child whose vouch is current, computed by the same
predicate ServesVersion uses so the two answers cannot drift — and versiondReady
requires it. Deliberately "at least one", not "every": requiring every child
would let one version's fleet-correlated unreadiness empty the pool and take the
healthy versions with it, which is the same trap as gating on Degraded. The
reviewer's single-child scenario is covered identically either way; per-version
pools remain the precise tool.

The legacy /healthz fallback logged a warning per probe, and the monitor probes
every second. It logs once per generation now, from the callers that know the
generation — which also quiets the startup poller, the other repeat offender.

Docs: invariant 9 claimed an unconverged host is not routed to at all; it is not
routed to through the host-level pool, while per-version pools serve what it
already has, on purpose. The three descriptions of the coarse /readyz now
mention the live-readiness requirement.
Serving means "at least one live-ready child", so with VERSIOND_VERSIONS empty a
host whose v5 child went unready keeps answering 200 as long as v4 is healthy,
and v5 requests keep landing on it hash-dependently — the exact failure the
per-version pools exist to prevent, reachable through a supported configuration.

Making the coarse answer stricter is not the fix; "every child ready" is the
correlated-eviction trap again. The fix is not letting an HA deployment route
blind: GONKA_HA with an empty VERSIOND_VERSIONS now fails at startup, with the
message naming both remedies. The escape hatch is deliberate and says what it
accepts — VERSIOND_ROUTER_ALLOW_COARSE_READINESS=1 — because one legitimate user
exists: local-test-net mints version names dynamically inside test scenarios and
cannot declare them up front. The join overlay already declares v4 through v8
and never hits the check. test-render pins refusal, override and the non-HA
pass-through.

Two documentation corrections from the same review. The evacuation doc promised
the vouch is withdrawn "within a second"; it normally takes one probe interval,
a probe may run up to its 2s timeout, and an unrefreshed answer expires after
5s — it now says that. The README still claimed a host downloading an archive or
restarting a child reports 503; after first convergence the latch holds, and a
restarting child leaves only its own version's pool.
The review found the override fail-open: any non-empty value, 0 and false
included, unlocked coarse readiness. That is an instance of a class, and the
class was worse than the instance. Every boolean env here was parsed by each
consumer separately, and the two consumers of GONKA_HA disagreed: the router
read non-emptiness, so false meant on; devshardd read a known-values switch
whose default was off, so a typo silently disabled the storage boot guard —
the one value the variable exists to enable. The same deployment setting could
be simultaneously on for routing and off for safety.

One grammar now, on both sides: 1/true/yes are on, empty/0/false/no are off,
anything else refuses to start and names the variable. Each entrypoint parses
its booleans once, at the top, into plain variables — GONKA_HA, the coarse
override, RENDER_ONLY — so no use site can reinvent the parse; devshardd's
HADeployment returns an error for values outside the grammar and the boot
guard propagates it instead of guessing "off".

Pinned from both directions. Go: every off spelling passes on sqlite, every on
spelling refuses it, garbage refuses to boot naming the grammar. test-render:
0/false/NO do not unlock the override, garbage overrides and garbage GONKA_HA
are refused with the grammar named, and GONKA_HA=false renders without the
Devshard-Ha header — the spelling that used to mean on. Reverting the parser to
non-emptiness fails the suite on the first check.
GONKA_HA=' true ' was on for devshardd and a startup failure for the router —
the one grammar still had two readings at its edges. Both bool_env helpers now
trim leading and trailing whitespace only, matching Go's TrimSpace: deleting
all whitespace would accept 't rue', which Go rejects, and recreate the
divergence in the opposite direction.

The render tests pin ' true ' as on, header stamped, and ' no ' as off, header
stripped; both were added before the fix and failed against the untrimmed
parser.
Confirmed from review, and worse than stated. edge-api gives its chain probe two
seconds, but neither router set `timeout check`, and without it `inter 1s` is
HAProxy's entire check budget — connect and read. A chain answering in one to
two seconds is slow, not down, yet every check against it fails, and with
fall 1 every edge-api leaves the pool at once, since they all share the chain
node. The part the review could not see from outside: the probe ran on the
request's context, so the checker aborting at 1s cancelled the probe, and the
resulting context.Canceled was cached as "chain unreachable" for the next three
seconds of checks. The pool did not just blink on a slow moment; the abort kept
it down.

Both routers now set `timeout check 3s`, above edge-api's 2s probe budget, with
the relationship written on both sides of the boundary — the constant in
readiness.go names the router setting and the template comment names the
constant, since no compiler spans the two. versiond-router gets the same 3s: its
/readyz is a memory read, so the tolerance costs nothing, and a host that cannot
answer a memory read in three seconds is genuinely wedged.

The probe itself now runs on its own context with the readiness budget — the
answer is about the chain, not about the caller's patience — and is
singleflighted, so an expired cache under concurrent checks costs one chain
query instead of one per caller. Tests pin both: eight concurrent checks share
one probe, and a hanging probe is cut at its own budget, verdict cached, not at
whatever deadline the checker happened to have. Render suites pin the presence
of `timeout check` in both routers.
snevolin added 13 commits August 4, 2026 21:48
Use the same v5 edge-api and versiond tags in the base and HA Compose
models.

Add a fast pin contract and a pull-first registry smoke that validates
router configs and readiness endpoints on the published artifacts.
Capture the current image before each replacement and restore it when a
readiness wait fails before router cutover. Stop failed replicas once
HAProxy can exclude them from the live pool.

Switch multi-edge traffic to HAProxy after the first ready v5
replica. Exercise rollback, stop, and ordering paths in CI.
Measure the attached v4 cluster and target filesystem before the first
PostgreSQL recreate. Reuse the same preflight for detached recovery.

Repeat the check in the migration entrypoint so manual Compose runs fail
before creating a partial staging copy when free space is insufficient.
Renew the anonymous mount after recovered bind PGDATA has been verified.
This prevents Compose from carrying the v4 recovery volume forward.

Assert both mount identities in the Docker integration test.
Keep the old volume as a rollback copy.
Detect versiond and edge-api topology from existing containers so the
standard base-only Quickstart never loads HA services or PostgreSQL.

Keep both axes independent, add base versiond readiness, and retain
explicit mode flags only for recovery diagnostics.
Remove the first v5 replica from legacy nginx before recreating it,
then cut over to HAProxy as soon as that replica passes readiness.

Track the active replacement and compensate ordinary failures plus HUP,
INT, and TERM so an interrupted wait cannot leave an unready service
in rotation.
Mount the persistent target explicitly for both migration source modes
so completed staging or published PGDATA bypasses another space check.

Cover detached-volume retries with real Docker and allow the recovery
runbook to resume through the expected PostgreSQL container.
Probe the v4 /healthz contract repeatedly before accepting an image
rollback. Stop a restored service when it does not become stable.

Document PostgreSQL's separate preservation contract. Migration keeps
both copies but never switches storage history backwards automatically.
Persist the legacy nginx isolation barrier across container restarts and
verify rollback with service-specific functional probes.

Treat incomplete PostgreSQL staging as reclaimable during preflight and
clarify targeted cutover versus full-stack maintenance.
Capture every running and routable version before replacing a versiond
service, then require the complete baseline after restoring its image.

Cover a single-host rollback that restores v3 but loses v4 and reject it
instead of accepting one healthy child.
Use service startup budgets for rollback verification and restore stopped
supervisors before capturing a settled availability baseline.

Parse health payloads with jq, encode version paths, and verify router
rollback against the union of every replica's baseline.
Use capability-aware HAProxy health checks so restored pre-v5
supervisors rejoin only when their real version routes are healthy.

Preserve stopped service state, keep rollback armed through route
postconditions, and reconcile persisted barriers before router
baseline capture.
Bring the upstream bridge 0.2.15 image update into the host
evacuation branch without rewriting its existing history.
@snevolin
snevolin marked this pull request as ready for review August 4, 2026 21:49

Copy link
Copy Markdown
Author

Closing this PR so the functionality can be split into smaller, independent PRs that can be reviewed, tested, and merged separately. The work will be resubmitted as focused changes.

@snevolin

snevolin commented Aug 18, 2026

Copy link
Copy Markdown
Author

This umbrella PR was closed so its functionality could be split into smaller, independently reviewable and mergeable changes.

The focused changes extracted from this work are now available as:

All target upgrade-v0.2.15 and include focused tests. The merge-order sections in dependent PRs identify which smaller capabilities must land first, while each concern remains independently reviewable.

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