feat: change a lane's slots on a running system, over the broker (4292)
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 25s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m8s
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 25s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m8s
Milestone 422 step 2. `GET /api/system/workers` reports every lane joined to its live pool; `POST /api/system/workers/<name>` changes it. NO DOCKER SOCKET. Milestone 365 deferred "acting on the state" because restarting a dead worker needs a socket the web container deliberately does not have. That holds for restarting a CONTAINER; it does not hold for changing how much work a RUNNING worker does. celery's pool_grow / pool_shrink / add_consumer / cancel_consumer send a message over the Redis the app already uses, and the worker resizes itself. No new privilege, no new surface, and the security question that deferred this is never raised. PERSIST AND PUSH, in one call, in that order. pool_grow is not durable — a restart drops every lane to its env concurrency — so a UI that only pushed would lose the setting on the next deploy with nothing to show for it (lesson #4202). Storing alone would describe nothing until something restarted. A failed PUSH is not a failed setting: 200 with `applied: false` and a reason, so the UI says "saved, not yet live" rather than "that didn't work". Step 3's reconcile carries it when the lane answers again. PER-REPLICA DELTAS. `pool_grow(n, destination=[...])` adds n to EACH destination, so while `worker` runs `replicas: 2` a single delta from an aggregate is wrong for both. `slots` therefore means what CELERY_CONCURRENCY means — one process's pool — and each replica is driven to it from its OWN current size, so replicas that drifted apart converge rather than moving in lockstep. I wrote this wrong first: the docstring claimed per-replica while the code computed one delta from the max across replicas. LaneLiveState now carries `pools` per hostname and exposes `pool` as a property. A replica already at the target is sent nothing at all — the reachable fixed point step 3's periodic reconcile needs, or it re-issues a grow of zero every tick forever (lesson #4183). A replica that answered inspect but not stats is NAMED in the error rather than skipped silently, since otherwise it would run at a size the UI claims it does not. `present=False` is not "zero slots", it is "nothing answered" — kept distinct throughout, because step 3 skips an absent lane rather than correcting it. /workers now also reports pool size (from `insp.stats()`) and RESERVED count. Celery prefetches, so tasks that have left the Redis list but not started are invisible to LLEN: a lane can read depth 0 with thirty tasks held in worker memory. `pending` is depth + reserved. The UI is misleading without this and step 7's autoscaler would be simply wrong. Also kills the THIRD copy of the queue list: system_activity's _QUEUE_NAMES, whose own comment admitted the coupling ("must match celery_app.task_routes") and which sat alongside task_routes and the ROLE_NAMES copy step 1 collapsed. Now derived from LANES. The rendered order changes to lane grouping, which is the better shape for a lane-oriented UI. Separate blueprint rather than folding into system_activity, which states in its first line that it is read-only and answers a different question — its /workers is keyed on celery HOSTNAME and reports which nodes answered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -47,10 +47,15 @@ autoscaler.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .worker_lanes import LANES, LANES_BY_QUEUE_KEY, Lane
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import WorkerLane
|
||||
from .worker_lanes import LANES, LANES_BY_QUEUE_KEY, Lane, derived_ceiling
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,11 +79,22 @@ class LaneLiveState:
|
||||
|
||||
present: bool = False
|
||||
replicas: int = 0
|
||||
# Per-process pool size. Equal across replicas unless one has drifted.
|
||||
pool: int | None = None
|
||||
active: int = 0
|
||||
reserved: int = 0
|
||||
hostnames: list[str] = field(default_factory=list)
|
||||
# Pool size PER HOSTNAME, not aggregated. The resize below computes each
|
||||
# replica's own delta from its own current pool, so replicas that have
|
||||
# drifted apart converge instead of being moved in lockstep from a shared
|
||||
# baseline — which is what an aggregate here would silently reintroduce.
|
||||
pools: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def pool(self) -> int | None:
|
||||
"""One number for the UI. `max` rather than a sum: `slots` means the
|
||||
pool size of ONE process (see the module docstring), so the largest
|
||||
replica is the honest answer to "what is this lane set to". None when
|
||||
no replica reported — unknown, never zero."""
|
||||
return max(self.pools.values()) if self.pools else None
|
||||
|
||||
|
||||
def _lane_for_queues(queues: tuple[str, ...]) -> Lane | None:
|
||||
@@ -124,7 +140,7 @@ def inspect_lanes_sync() -> dict[str, LaneLiveState]:
|
||||
# answer, which leaves pool=None — unknown, not zero.
|
||||
pool = (stats.get(hostname) or {}).get("pool", {}).get("max-concurrency")
|
||||
if isinstance(pool, int):
|
||||
state.pool = pool if state.pool is None else max(state.pool, pool)
|
||||
state.pools[hostname] = pool
|
||||
|
||||
for state in out.values():
|
||||
state.hostnames.sort()
|
||||
@@ -150,16 +166,22 @@ def set_lane_slots_sync(lane: Lane, target: int) -> tuple[bool, str | None]:
|
||||
live = inspect_lanes_sync()[lane.name]
|
||||
if not live.present:
|
||||
return False, "lane is not running"
|
||||
if live.pool is None:
|
||||
if not live.pools:
|
||||
return False, "worker did not report its pool size"
|
||||
|
||||
control = celery_app.control
|
||||
for hostname in live.hostnames:
|
||||
delta = target - live.pool
|
||||
unreported = [h for h in live.hostnames if h not in live.pools]
|
||||
for hostname, current in live.pools.items():
|
||||
delta = target - current
|
||||
if delta > 0:
|
||||
control.pool_grow(delta, destination=[hostname])
|
||||
elif delta < 0:
|
||||
control.pool_shrink(-delta, destination=[hostname])
|
||||
if unreported:
|
||||
# Resized what could be resized, and said which could not. Silence
|
||||
# here would leave a replica running at a size the UI claims it is
|
||||
# not, with nothing anywhere recording the gap.
|
||||
return False, f"no pool size reported by {', '.join(sorted(unreported))}"
|
||||
return True, None
|
||||
except Exception as exc: # noqa: BLE001 — reported, never raised at a caller
|
||||
log.warning("worker_control: could not resize %s", lane.name, exc_info=True)
|
||||
@@ -194,3 +216,176 @@ def set_lane_enabled_sync(lane: Lane, enabled: bool) -> tuple[bool, str | None]:
|
||||
"enable" if enabled else "disable", lane.name, exc_info=True,
|
||||
)
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
# --- the settings half, which is async ----------------------------------------
|
||||
#
|
||||
# Sync celery control above, async DB below, in one module. Same split
|
||||
# `service_roster` already runs (`_inspect_celery_sync` beside `touch_service`)
|
||||
# — the boundary is the transport, not the concern, and "control the workers"
|
||||
# is one concern.
|
||||
|
||||
|
||||
async def _rows_by_name(session: AsyncSession) -> dict[str, WorkerLane]:
|
||||
"""Every lane's row, creating any that are missing from its LANES defaults.
|
||||
|
||||
Self-heals rather than depending on a migration having run for a lane
|
||||
added later: alembic 0103 seeded the four that existed on 2026-09-22, and
|
||||
a fifth added to LANES afterwards gets its row the first time anything
|
||||
asks. Without this, a new lane would read as absent and the UI would
|
||||
simply not show it.
|
||||
"""
|
||||
rows = {
|
||||
row.name: row
|
||||
for row in (await session.execute(select(WorkerLane))).scalars()
|
||||
}
|
||||
missing = [lane for lane in LANES if lane.name not in rows]
|
||||
for lane in missing:
|
||||
row = WorkerLane(
|
||||
name=lane.name,
|
||||
slots=lane.default_slots,
|
||||
slots_cap=lane.default_slots_cap,
|
||||
enabled=lane.default_enabled,
|
||||
)
|
||||
session.add(row)
|
||||
rows[lane.name] = row
|
||||
if missing:
|
||||
await session.commit()
|
||||
return rows
|
||||
|
||||
|
||||
async def lane_view(session: AsyncSession) -> list[dict]:
|
||||
"""Every lane: what is configured, what is live, what it may grow to.
|
||||
|
||||
One call rather than making the UI join three sources. `pending` is the
|
||||
honest backlog — Redis depth PLUS reserved — because celery prefetches and
|
||||
LLEN alone reads 0 while a worker holds tasks in memory.
|
||||
"""
|
||||
rows = await _rows_by_name(session)
|
||||
live = await asyncio.to_thread(inspect_lanes_sync)
|
||||
depths = await asyncio.to_thread(_queue_depths_sync)
|
||||
|
||||
out = []
|
||||
for lane in LANES:
|
||||
row = rows[lane.name]
|
||||
state = live[lane.name]
|
||||
# None for a queue the broker did not answer for, which must not be
|
||||
# silently summed as zero — an unknown depth is not an empty one.
|
||||
known = [depths.get(q) for q in lane.queues]
|
||||
depth = sum(d for d in known if d is not None) if any(
|
||||
d is not None for d in known
|
||||
) else None
|
||||
out.append({
|
||||
"name": lane.name,
|
||||
"display_name": lane.display_name,
|
||||
"queues": list(lane.queues),
|
||||
"slots": row.slots,
|
||||
"slots_cap": row.slots_cap,
|
||||
"ceiling": derived_ceiling(lane),
|
||||
"enabled": row.enabled,
|
||||
"memory_bound": lane.memory_bound,
|
||||
"live": {
|
||||
"present": state.present,
|
||||
"replicas": state.replicas,
|
||||
"pool": state.pool,
|
||||
"active": state.active,
|
||||
"reserved": state.reserved,
|
||||
},
|
||||
"queue_depth": depth,
|
||||
"pending": None if depth is None else depth + state.reserved,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _queue_depths_sync() -> dict[str, int | None]:
|
||||
"""Redis LLEN per queue. None for one that did not answer — see lane_view.
|
||||
|
||||
Sync; the caller threads it. A per-queue try/except so one bad queue does
|
||||
not cost the whole report, matching `api/system_activity._read_queues_sync`.
|
||||
"""
|
||||
import redis
|
||||
|
||||
from ..config import get_config
|
||||
|
||||
out: dict[str, int | None] = {}
|
||||
try:
|
||||
client = redis.Redis.from_url(get_config().celery_broker_url)
|
||||
except Exception:
|
||||
log.warning("worker_control: no broker for queue depths", exc_info=True)
|
||||
return {q: None for lane in LANES for q in lane.queues}
|
||||
for lane in LANES:
|
||||
for queue in lane.queues:
|
||||
try:
|
||||
out[queue] = int(client.llen(queue))
|
||||
except Exception: # noqa: BLE001 — a hiccup must not break the UI
|
||||
out[queue] = None
|
||||
return out
|
||||
|
||||
|
||||
class LaneUpdateRefused(ValueError):
|
||||
"""A requested value is outside what the lane may hold. Carries the reason
|
||||
the UI shows — a greyed control with no explanation reads as a bug."""
|
||||
|
||||
|
||||
async def set_lane(
|
||||
session: AsyncSession,
|
||||
lane: Lane,
|
||||
*,
|
||||
slots: int | None = None,
|
||||
slots_cap: int | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> dict:
|
||||
"""Store the operator's choice, then push it to the running lane.
|
||||
|
||||
BOTH, in one call, and the order matters. `pool_grow`/`pool_shrink` are
|
||||
not durable — a restart drops every lane back to its env concurrency — so
|
||||
a UI that only pushed would have its setting evaporate on the next deploy
|
||||
with nothing to show for it (lesson #4202: the live change does not
|
||||
survive, and nothing says so). Storing alone would be a number that
|
||||
describes nothing until something restarts.
|
||||
|
||||
A failed PUSH is not a failed setting. The value is saved either way and
|
||||
step 3's reconcile carries it when the lane answers again; the result says
|
||||
`applied: false` with a reason so the UI can say "saved, not yet live"
|
||||
rather than "that didn't work".
|
||||
"""
|
||||
rows = await _rows_by_name(session)
|
||||
row = rows[lane.name]
|
||||
|
||||
new_cap = row.slots_cap if slots_cap is None else slots_cap
|
||||
new_slots = row.slots if slots is None else slots
|
||||
new_enabled = row.enabled if enabled is None else enabled
|
||||
|
||||
ceiling = derived_ceiling(lane)
|
||||
if new_cap < 0 or new_slots < 0:
|
||||
raise LaneUpdateRefused("slots and cap cannot be negative")
|
||||
if new_cap > ceiling:
|
||||
raise LaneUpdateRefused(
|
||||
f"cap {new_cap} is above what this container can hold "
|
||||
f"({ceiling} for {lane.display_name})"
|
||||
)
|
||||
if new_slots > new_cap:
|
||||
raise LaneUpdateRefused(f"slots {new_slots} is above the cap {new_cap}")
|
||||
|
||||
row.slots_cap = new_cap
|
||||
row.slots = new_slots
|
||||
row.enabled = new_enabled
|
||||
await session.commit()
|
||||
|
||||
applied, error = True, None
|
||||
if enabled is not None:
|
||||
applied, error = await asyncio.to_thread(
|
||||
set_lane_enabled_sync, lane, new_enabled,
|
||||
)
|
||||
if applied and slots is not None:
|
||||
applied, error = await asyncio.to_thread(set_lane_slots_sync, lane, new_slots)
|
||||
|
||||
return {
|
||||
"name": lane.name,
|
||||
"slots": row.slots,
|
||||
"slots_cap": row.slots_cap,
|
||||
"ceiling": ceiling,
|
||||
"enabled": row.enabled,
|
||||
"applied": applied,
|
||||
"apply_error": error,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user