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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user