feat: reconcile every running lane back to its stored slots (4293)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m23s

Milestone 422 step 3. `pool_grow` is not durable: a worker restarted by its
supervisor comes back at its ENV concurrency, silently below whatever the
operator set, and nothing on step 2's write path would ever notice. Storing
the value made it survivable; this makes it survive.

A BEAT TASK, NOT A HOOK IN WEB — a deliberate deviation from the step as
written, for a reason already recorded in this codebase. Step 3 said "web
applies the stored values after it starts". It cannot: service_roster.py
documents that hypercorn runs --workers 4, so anything in before_serving
becomes four concurrent loops per container hammering the broker forever.

service_roster's own answer — refresh on demand from whichever request
arrives — was also rejected, because the two solve different problems. A
stale ROSTER only misleads someone looking at it, so recomputing when they
look is exactly right. A lane running at the wrong size is doing less work
than it was told to whether or not anyone is watching, and the case that
matters is a deploy at 3am followed by a backlog nobody is awake to see.

So: unattended, every 5 minutes, on the quick `maintenance` lane beside the
other recovery sweeps. Accepted cost — a dead scheduler stops reconciliation,
but a dead scheduler already stops every other sweep and the roster reports
it, so this adds no new blind spot.

A BUG I WROTE AND CAUGHT BEFORE COMMITTING. The first version called
set_lane_enabled_sync unconditionally, so a settled system re-sent
add_consumer for every queue on every tick — forever. Harmless per call
(add_consumer on an already-consumed queue does nothing), unbounded in
aggregate, and completely invisible. That is lesson #4183's failure mode
exactly, in the very function whose docstring cites it.

Worse, my test would not have caught it: it asserted only on grew/shrank.
LaneLiveState now carries `consuming` — which queues a lane is actually
serving, distinct from the queues it was configured with — so the reconcile
compares before acting. The test now asserts ALL FOUR control families are
silent on a settled tick, plus a new case for an already-disabled lane, which
is the other half of the same fixed point.

An absent lane is SKIPPED, not corrected. present=False means nothing
answered, not zero slots; correcting it would be a conclusion from an unswept
read (snippet #3969), and there would be nothing to send the message to. One
lane failing does not stop the others.

One inspect serves every lane: the two setters now take an optional
pre-fetched LaneLiveState, so a tick costs one broker round trip rather than
one per lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-22 08:08:38 -04:00
co-authored by Claude Opus 5
parent a9c1b421a7
commit 5f8c63f61b
4 changed files with 291 additions and 5 deletions
+50
View File
@@ -1348,3 +1348,53 @@ def sync_memberships() -> str:
if res.get("suggested") is not None:
parts.append(f"suggested={res['suggested']}")
return " ".join(parts) or "no platforms"
@celery.task(name="backend.app.tasks.maintenance.reconcile_worker_lanes")
def reconcile_worker_lanes() -> dict:
"""Drive every running lane back to the slots the operator set.
Milestone 422 step 3. `pool_grow` is not durable: a worker restarted by
its supervisor comes back at its ENV concurrency, silently below whatever
was configured, and nothing on step 2's write path would ever notice.
## Why a beat task and not a hook in web
Step 3 was written as "web applies the stored values after it starts". It
cannot, and `services/service_roster.py` already records why: hypercorn
runs `--workers 4`, so anything in `before_serving` becomes FOUR
concurrent loops per container, all hammering the broker forever.
The other option was service_roster's own answer — refresh on demand from
whichever request happens to arrive. Rejected here because the two are
solving different problems. A stale ROSTER only misleads someone who is
looking at it, so recomputing it when they look is exactly right. A lane
running at the wrong size is doing less work than it was told to whether
or not anyone is watching — and the case that matters is a deploy at 3am
followed by a backlog nobody is awake to notice.
So: unattended, on the quick `maintenance` lane (its module routes it
there), beside the other recovery sweeps. The accepted cost is that a dead
scheduler stops reconciliation — but a dead scheduler already stops every
other sweep, and the roster reports it, so this adds no new blind spot.
## Quiet when settled
One broker round trip per tick, and no control messages at all once every
lane matches. Returns the lanes it actually moved, so the log shows a
correction rather than a heartbeat.
"""
from ..models import WorkerLane
from ..services.worker_control import reconcile_lanes_sync
with _sync_session_factory()() as session:
desired = {
row.name: (row.slots, row.enabled)
for row in session.execute(select(WorkerLane)).scalars()
}
if not desired:
# Migration 0103 seeds these, so an empty table means it has not run
# yet. Nothing to assert — and inventing defaults here would let this
# task disagree with the seed it is supposed to be enforcing.
return {"changed": [], "skipped": [], "failed": {}}
return reconcile_lanes_sync(desired)