CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 24s
CI and images / integration (push) Failing after 24s
CI and images / backend-lint-and-test (push) Failing after 34s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Operator, 2026-09-23: *"auto should be always on, not a setting, so that idle
instances quiet down when not running. the number that is visible and
something the user can tweak and manage should be the cap itself the number of
running workers is handled by the autoscaling function which is always on."*
They are right, and the reason it was not built this way is worth stating: the
manual dial came first (steps 2-4) and the autoscaler came last (step 7), as
an opt-in BESIDE a control that already existed. Nothing ever asked whether
the dial should still exist once something could move it automatically. Each
step was defensible; the result was three operator settings over one number.
## `slots`, `enabled` and `autoscale` are gone
`slots` was a MEASUREMENT wearing a preference's clothes. How many workers a
lane runs is read live and moved every minute; storing it meant the operator
had to keep two numbers in agreement and the autoscaler had to be told it was
allowed to touch one of them.
`autoscale` gated the mechanism behind a choice, so a lane nobody opted in
never gave its workers back — which is why an idle instance never quieted
down.
`enabled` is derived: a cap of zero means no consumers. "Off" and "may use no
workers" were two spellings of one fact, stored separately, free to disagree.
## Two sweeps become one
`reconcile_lanes_sync` drove the pool to the stored `slots`; `autoscale_lanes_
sync` moved it away from that same number; and most of step 7's hardest
reasoning — a stored value that is a FLOOR, a target of `max(stored, current)`
— existed only to stop them fighting. Delete the stored number and the problem
is not solved, it is absent.
`size_lanes_sync` runs every minute and owns both consumers and pool size. It
also subsumes what the reconcile was for: a worker restarted at its ENV
concurrency is corrected on the next tick rather than after five.
Growth is immediate, shrink is one worker per tick. Deliberately asymmetric —
"always on" is only pleasant if the ramp keeps up, and +1/minute would take
four minutes to answer a burst. Being one worker too large for a minute costs
a sleeping process; being too small costs work not happening. For ML the
asymmetry matters most: every new slot reloads a multi-GB model, so the slow
shrink is what stops a quiet patch from paying that cost again a minute later.
## The caps ship at one, and zero for ML
Per the operator. Conservative on purpose — and a conservative default nobody
knows how to raise is just a slow product, which is the other half of what
they asked for:
"there needs to be something that tells the user to bump those numbers to
improve processing rate or they'd never know the controls exist."
So a lane running everything its cap allows while work piles up says so, in
its own row, with the headroom named: *"4,060 waiting and all 1 worker busy.
Raise the cap to run more at once — this machine allows up to 7."*
It fires only when raising the cap would actually help. Not when the lane is
keeping up, not when the sizing pass has room it has not taken, and not at the
machine ceiling — where "raise the cap" is advice nobody can take.
## Migration 0105 rewrites the caps rather than carrying them
The old defaults (4/2/2/1) bounded a manual control and were loose because
moving within them was the ordinary act. The number now means "the most
workers this lane may use", which is a different promise; carrying the old
figure over would quadruple the worker lane on every existing install at the
moment this deploys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
760 lines
32 KiB
Python
760 lines
32 KiB
Python
"""Read and change a lane's live pool, over the broker.
|
|
|
|
Milestone 422 step 2. The half of the milestone that does something.
|
|
|
|
## No docker socket is involved, and that is the point
|
|
|
|
Milestone 365 put "acting on the state" out of scope because restarting a
|
|
dead worker needs a docker socket the web container deliberately does not
|
|
have. That is true of RESTARTING a container. It is not true of changing how
|
|
much work a RUNNING worker does: celery's remote control sends a message over
|
|
the broker and the worker resizes its own pool. Same Redis the app already
|
|
uses, no new privilege, no new surface.
|
|
|
|
pool_grow / pool_shrink how many slots a lane runs
|
|
add_consumer / cancel_consumer whether it consumes its queues at all
|
|
|
|
The operator ruled the socket out independently (2026-09-22: *"this feature
|
|
is a very invasive idea in my mind and I'd like to avoid it"*), and nothing
|
|
here raises the question.
|
|
|
|
## The setting is PER PROCESS, not per lane total
|
|
|
|
`pool_grow(n, destination=[...])` adds n slots to EACH destination it names.
|
|
While the stack still runs several containers per lane — the operator's
|
|
production `worker` is `replicas: 2` — a single delta applied to a lane's
|
|
total would be wrong for every replica.
|
|
|
|
So `slots` means what `CELERY_CONCURRENCY` means: the pool size of one
|
|
process. The reconcile below drives EACH replica to that number
|
|
independently, computing its own delta from that replica's current pool, so
|
|
replicas that have drifted apart (one restarted, one was grown) converge
|
|
rather than being moved in lockstep from a shared baseline.
|
|
|
|
After step 5 there is one process per lane and the distinction disappears.
|
|
It matters now, and getting it wrong now would be invisible — the totals
|
|
would simply be double what the UI claimed.
|
|
|
|
## Why reserved() is read alongside the queue depth
|
|
|
|
Celery PREFETCHES: a worker pulls more messages than it can run and holds
|
|
them in memory. Those have already left the Redis list, so `LLEN` — which is
|
|
what `/api/system/activity/queues` reports — can read 0 while thirty tasks
|
|
are waiting inside a worker. Any judgement about backlog that uses only LLEN
|
|
under-reports, which matters for the UI and is disqualifying for step 7's
|
|
autoscaler.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import TaskRun, WorkerLane
|
|
from .worker_lanes import (
|
|
LANES,
|
|
LANES_BY_QUEUE_KEY,
|
|
MIN_POOL_SLOTS,
|
|
Lane,
|
|
derived_ceiling,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# celery control is a broker round trip on a request path, so it gets a
|
|
# deadline (rule 156) — the same reasoning and the same budget as
|
|
# service_roster's inspect. A broker that stopped answering must make this
|
|
# report "not present", which is true, rather than hang the page.
|
|
CONTROL_TIMEOUT_SECONDS = 2.0
|
|
|
|
|
|
# The WORST case of `inspect_lanes_sync`, for callers that need a deadline.
|
|
#
|
|
# One broadcast plus three targeted reads. The targeted three normally return
|
|
# as soon as the named nodes answer; each can still cost a full timeout if a
|
|
# node disappears mid-read, so the bound stays four.
|
|
CONTROL_ROUND_TRIPS = 4
|
|
|
|
# Slack for the `asyncio.to_thread` handoff. A budget equal to the work is a
|
|
# budget that fails under load — the roster carried exactly that bug into the
|
|
# operator's first consolidated deploy and logged a TimeoutError per refresh
|
|
# while the inspect calls underneath were working fine.
|
|
CONTROL_SLACK_SECONDS = 3.0
|
|
|
|
INSPECT_BUDGET_SECONDS = (
|
|
CONTROL_TIMEOUT_SECONDS * CONTROL_ROUND_TRIPS + CONTROL_SLACK_SECONDS
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class LaneLiveState:
|
|
"""What `celery inspect` says about one lane right now.
|
|
|
|
`present=False` is NOT "zero slots" — it is "nothing answered". A lane
|
|
whose worker is restarting, or whose broker is unreachable, must read as
|
|
unknown rather than as stopped: an unswept absence is not a verdict
|
|
(snippet #3969). The reconcile in step 3 skips an absent lane rather than
|
|
correcting it, which is only safe because this distinction is kept.
|
|
"""
|
|
|
|
present: bool = False
|
|
replicas: int = 0
|
|
active: int = 0
|
|
reserved: int = 0
|
|
hostnames: list[str] = field(default_factory=list)
|
|
# The queues this lane is actually consuming right now, across replicas.
|
|
# Distinct from the lane's CONFIGURED queues: `cancel_consumer` stops a
|
|
# worker consuming one without changing what it was started with, which
|
|
# is how `enabled=false` is implemented. The reconcile needs this to tell
|
|
# "already disabled" from "needs disabling" — without it, it would re-send
|
|
# add_consumer for every queue on every tick forever (lesson #4183).
|
|
consuming: set[str] = field(default_factory=set)
|
|
# 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
|
|
|
|
@property
|
|
def capacity(self) -> int:
|
|
"""Total slots across replicas — how many tasks this lane can run at
|
|
once. Distinct from `pool`, and the two must not be confused: `pool`
|
|
is the DIAL (one process's size, what grow/shrink move), `capacity` is
|
|
the CAPABILITY. Asking "is this lane saturated" compares `active`,
|
|
which is summed across replicas, against this — against `pool` it
|
|
would call two half-busy replicas of 4 saturated at 4 active."""
|
|
return sum(self.pools.values())
|
|
|
|
|
|
def _lane_for_queues(queues: tuple[str, ...]) -> Lane | None:
|
|
return LANES_BY_QUEUE_KEY.get(tuple(sorted(queues)))
|
|
|
|
|
|
def inspect_lanes_sync() -> dict[str, LaneLiveState]:
|
|
"""Live state per lane name. Sync — callers wrap in asyncio.to_thread.
|
|
|
|
Never raises. Every lane is present in the result; ones nothing answered
|
|
for carry `present=False`, so a caller cannot accidentally read a missing
|
|
lane as an empty one by iterating only what came back.
|
|
"""
|
|
out = {lane.name: LaneLiveState() for lane in LANES}
|
|
try:
|
|
from ..celery_app import celery as celery_app
|
|
|
|
# ONE broadcast, then three TARGETED reads.
|
|
#
|
|
# A broadcast with no `destination` cannot know how many replies to
|
|
# expect, so it waits out its whole timeout rather than returning on
|
|
# the last one. Four of those is four full timeouts — about eight
|
|
# seconds — and `lane_view` sits on the Settings card, so that was the
|
|
# load time of the Worker lanes page every time it was opened.
|
|
#
|
|
# Naming the destinations lets celery stop as soon as those nodes have
|
|
# answered, which for workers in this same container is milliseconds.
|
|
# The worst case is unchanged: a node that vanishes between the
|
|
# broadcast and the targeted reads costs a full timeout waiting for a
|
|
# reply that is not coming.
|
|
insp = celery_app.control.inspect(timeout=CONTROL_TIMEOUT_SECONDS)
|
|
active_queues = insp.active_queues() or {}
|
|
|
|
# Nothing answered — and the three reads below exist only to describe
|
|
# what did. Returning here also makes the broker-down case FAST
|
|
# (one timeout, not four), which is exactly when the healthcheck and
|
|
# the card need an answer rather than a long wait.
|
|
if not active_queues:
|
|
return out
|
|
|
|
targeted = celery_app.control.inspect(
|
|
destination=sorted(active_queues),
|
|
timeout=CONTROL_TIMEOUT_SECONDS,
|
|
)
|
|
stats = targeted.stats() or {}
|
|
active = targeted.active() or {}
|
|
reserved = targeted.reserved() or {}
|
|
except Exception:
|
|
log.warning("worker_control: celery inspect failed", exc_info=True)
|
|
return out
|
|
|
|
for hostname, queues in active_queues.items():
|
|
lane = _lane_for_queues(tuple(q["name"] for q in queues))
|
|
if lane is None:
|
|
# A deployment slicing CELERY_QUEUES differently. Reported by the
|
|
# roster under its raw queue list; it simply has no lane row to
|
|
# control, which is honest rather than an error.
|
|
continue
|
|
state = out[lane.name]
|
|
state.present = True
|
|
state.replicas += 1
|
|
state.hostnames.append(hostname)
|
|
state.active += len(active.get(hostname, []))
|
|
state.reserved += len(reserved.get(hostname, []))
|
|
state.consuming.update(q["name"] for q in queues)
|
|
|
|
# `pool.max-concurrency` is the number pool_grow/pool_shrink move and
|
|
# the number the UI shows. Absent on a worker whose stats did not
|
|
# answer, which leaves pool=None — unknown, not zero.
|
|
pool = (stats.get(hostname) or {}).get("pool", {}).get("max-concurrency")
|
|
if isinstance(pool, int):
|
|
state.pools[hostname] = pool
|
|
|
|
for state in out.values():
|
|
state.hostnames.sort()
|
|
return out
|
|
|
|
|
|
def effective_slots(target: int) -> int:
|
|
"""What a pool can actually be set to. Never below one process.
|
|
|
|
Used wherever a target is COMPARED as well as wherever one is sent: a
|
|
reconcile that compares against the unclamped number sees a difference
|
|
that no control message can ever close, and re-sends it every tick.
|
|
"""
|
|
return max(MIN_POOL_SLOTS, target)
|
|
|
|
|
|
def set_lane_slots_sync(
|
|
lane: Lane, target: int, live: LaneLiveState | None = None,
|
|
) -> tuple[bool, str | None]:
|
|
"""Drive every replica of `lane` to `target` slots. Returns (applied, err).
|
|
|
|
Per-replica deltas rather than one shared delta: see the module docstring.
|
|
A replica already at the target is issued nothing at all, which is what
|
|
makes step 3's periodic reconcile converge instead of re-sending a grow of
|
|
zero forever (lesson #4183 — an enforcer without a reachable fixed point
|
|
re-does its own work every tick).
|
|
|
|
`applied=False` is not a failure of the SETTING. The caller has already
|
|
stored the value; this says only that the live push did not land, and the
|
|
reconcile will carry it when the lane answers again.
|
|
"""
|
|
target = effective_slots(target)
|
|
try:
|
|
from ..celery_app import celery as celery_app
|
|
|
|
if live is None:
|
|
live = inspect_lanes_sync()[lane.name]
|
|
if not live.present:
|
|
return False, "lane is not running"
|
|
if not live.pools:
|
|
return False, "worker did not report its pool size"
|
|
|
|
control = celery_app.control
|
|
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)
|
|
return False, str(exc)
|
|
|
|
|
|
def set_lane_enabled_sync(
|
|
lane: Lane, enabled: bool, live: LaneLiveState | None = None,
|
|
) -> tuple[bool, str | None]:
|
|
"""Start or stop `lane` consuming its queues, without killing the process.
|
|
|
|
`cancel_consumer` rather than a shutdown: a stopped consumer keeps its
|
|
worker alive and answering `inspect`, so a disabled lane stays visible and
|
|
can be turned back on. A killed worker would read as absent, which is the
|
|
same signal as a crash — and the whole point of the roster (#365) is that
|
|
those two must not look alike.
|
|
"""
|
|
try:
|
|
from ..celery_app import celery as celery_app
|
|
|
|
if live is None:
|
|
live = inspect_lanes_sync()[lane.name]
|
|
if not live.present:
|
|
return False, "lane is not running"
|
|
control = celery_app.control
|
|
for queue in lane.queues:
|
|
if enabled:
|
|
control.add_consumer(queue, destination=live.hostnames)
|
|
else:
|
|
control.cancel_consumer(queue, destination=live.hostnames)
|
|
return True, None
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning(
|
|
"worker_control: could not %s %s",
|
|
"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_cap=lane.default_slots_cap)
|
|
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)
|
|
# A deadline, because this is a request path and `to_thread` on its own is
|
|
# an await with no bound (rule 156). `inspect_lanes_sync` never raises and
|
|
# every inner call has its own timeout, so the only way past the budget is
|
|
# the thread not being scheduled — and a page that renders "not answering"
|
|
# is a better answer than one that does not render.
|
|
try:
|
|
live = await asyncio.wait_for(
|
|
asyncio.to_thread(inspect_lanes_sync),
|
|
timeout=INSPECT_BUDGET_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
log.warning(
|
|
"worker_control: inspect exceeded %ss; reporting every lane as "
|
|
"not answering", INSPECT_BUDGET_SECONDS,
|
|
)
|
|
live = {lane.name: LaneLiveState() for lane in LANES}
|
|
depths = await asyncio.to_thread(_queue_depths_sync)
|
|
oldest = await _oldest_running_by_queue(session)
|
|
|
|
now = datetime.now(UTC)
|
|
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_cap": row.slots_cap,
|
|
"ceiling": derived_ceiling(lane),
|
|
# DERIVED, never stored. A cap of zero means no consumers, so
|
|
# "off" and "may use no workers" cannot disagree.
|
|
"enabled": row.slots_cap > 0,
|
|
"memory_bound": lane.memory_bound,
|
|
"optional": lane.optional,
|
|
# What raising this lane's cap will download, so the UI can say
|
|
# WHICH model and how big BEFORE the first slot is asked for
|
|
# rather than after a multi-GB fetch has started. `measured`
|
|
# travels with the numbers: the UI must not present an estimate
|
|
# as a fact.
|
|
"models": [
|
|
{
|
|
"repo": m.repo,
|
|
"download_bytes": m.approx_download_bytes,
|
|
"resident_bytes": m.approx_resident_bytes,
|
|
"measured": m.measured,
|
|
}
|
|
for m in lane.models
|
|
],
|
|
"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,
|
|
# How long the oldest still-running task on this lane has been
|
|
# going, in minutes. The operator asked for a trigger here — grow
|
|
# a lane whose tasks run past some duration — and it stayed a
|
|
# REPORT: a long task does not finish sooner because the lane
|
|
# gained a slot, so scaling on it would spend memory to change
|
|
# nothing. Shown so they can see a lane wedged on one slow job,
|
|
# which is the genuinely useful half of the idea.
|
|
"oldest_running_minutes": _minutes_since(
|
|
min(
|
|
(oldest[q] for q in lane.queues if q in oldest),
|
|
default=None,
|
|
),
|
|
now,
|
|
),
|
|
})
|
|
return out
|
|
|
|
|
|
async def _oldest_running_by_queue(session: AsyncSession) -> dict[str, datetime]:
|
|
"""When the longest-running unfinished task on each queue started.
|
|
|
|
Read from `task_run`, which is OUR OWN table on OUR OWN wall clock, and
|
|
deliberately not from celery's `inspect active()`. Those entries carry a
|
|
`time_start` taken from the WORKER's `time.monotonic()` — a clock with an
|
|
arbitrary origin per process. Subtracting it from this process's wall
|
|
clock produces a number that looks like a duration and is meaningless, and
|
|
it would be meaningless in the direction that matters: plausible.
|
|
|
|
`task_run` also already carries the per-queue staleness thresholds the
|
|
recovery sweep uses, so a row still `running` here is one the system
|
|
itself considers legitimately in flight rather than abandoned.
|
|
"""
|
|
result = await session.execute(
|
|
select(TaskRun.queue, func.min(TaskRun.started_at))
|
|
.where(TaskRun.status == "running", TaskRun.finished_at.is_(None))
|
|
.group_by(TaskRun.queue)
|
|
)
|
|
return {queue: started for queue, started in result if started is not None}
|
|
|
|
|
|
def _minutes_since(started: datetime | None, now: datetime) -> int | None:
|
|
"""Whole minutes, or None when nothing is running. Never negative: a row
|
|
written by a container whose clock is a few seconds ahead must read as 0
|
|
rather than as a task that starts in the future."""
|
|
if started is None:
|
|
return None
|
|
return max(0, int((now - started).total_seconds() // 60))
|
|
|
|
|
|
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_cap: int) -> dict:
|
|
"""Store the operator's cap for `lane`, then make the live lane obey it.
|
|
|
|
ONE value, since 2026-09-23. It used to take `slots`, `slots_cap`,
|
|
`enabled` and `autoscale`, which was four ways of saying two things — and
|
|
two of them were the caller's job to keep in agreement with each other.
|
|
|
|
## What happens live, and what does not
|
|
|
|
A cap CHANGE is pushed immediately in one direction only: lowering it
|
|
shrinks the pool now, because a cap the operator just lowered should not
|
|
be exceeded for up to a minute. Raising it does NOT grow the pool here —
|
|
a cap is permission, not a request, and growing on permission would put
|
|
slots on a lane with nothing to do. The sizing pass adds them on its next
|
|
tick if there is work, which is the whole point of it being always on.
|
|
|
|
Consumers follow the cap in both directions and immediately: zero means
|
|
off, and off must take effect when it is asked for.
|
|
|
|
A failed PUSH is not a failed setting. The value is saved either way and
|
|
the sizing pass carries it within a minute; the result says `applied:
|
|
false` with a reason so the UI can say "saved, not yet live" rather than
|
|
"that didn't work" (lesson #4202 — a live change that does not survive,
|
|
with nothing saying so).
|
|
"""
|
|
rows = await _rows_by_name(session)
|
|
row = rows[lane.name]
|
|
|
|
ceiling = derived_ceiling(lane)
|
|
if slots_cap < 0:
|
|
raise LaneUpdateRefused("a cap cannot be negative")
|
|
if slots_cap > ceiling:
|
|
raise LaneUpdateRefused(
|
|
f"a cap of {slots_cap} is above what this container can hold "
|
|
f"({ceiling} for {lane.display_name})"
|
|
)
|
|
|
|
was_on = row.slots_cap > 0
|
|
row.slots_cap = slots_cap
|
|
await session.commit()
|
|
|
|
now_on = slots_cap > 0
|
|
applied, error = True, None
|
|
if now_on != was_on:
|
|
applied, error = await asyncio.to_thread(set_lane_enabled_sync, lane, now_on)
|
|
if applied and not now_on:
|
|
# Down to the floor at once. The pool cannot be emptied, so "off" is
|
|
# one parked process with its consumers cancelled.
|
|
applied, error = await asyncio.to_thread(
|
|
set_lane_slots_sync, lane, MIN_POOL_SLOTS,
|
|
)
|
|
elif applied and now_on:
|
|
# Only DOWNWARD. See the docstring: raising a cap is permission, and
|
|
# the sizing pass decides whether there is work to spend it on.
|
|
live = await asyncio.to_thread(inspect_lanes_sync)
|
|
current = live[lane.name].pool
|
|
if current is not None and current > slots_cap:
|
|
applied, error = await asyncio.to_thread(
|
|
set_lane_slots_sync, lane, slots_cap, live=live[lane.name],
|
|
)
|
|
|
|
# Raising the cap off zero is what triggers the model download (milestone
|
|
# 422 step 6). Never at boot: that made every start of the ML role reach
|
|
# HuggingFace for ~3.5GB, and rule 164 permits a runtime fetch only for a
|
|
# feature that is optional and clearly OFF.
|
|
#
|
|
# On the TRANSITION, so re-saving a cap on a lane already running does not
|
|
# re-enqueue. And only when the consumer change landed: enqueueing onto a
|
|
# queue nothing is consuming would leave the task pending with no
|
|
# explanation until the lane returns.
|
|
fetching = False
|
|
if now_on and not was_on and lane.models and applied:
|
|
fetching = _enqueue_model_fetch()
|
|
|
|
return {
|
|
"name": lane.name,
|
|
"slots_cap": row.slots_cap,
|
|
"ceiling": ceiling,
|
|
"enabled": now_on,
|
|
"applied": applied,
|
|
"apply_error": error,
|
|
# Tells the UI to say a download has started rather than leaving the
|
|
# operator to wonder why a lane they just turned on is busy.
|
|
"fetching_models": fetching,
|
|
}
|
|
|
|
|
|
def _enqueue_model_fetch() -> bool:
|
|
"""Queue the model download. Returns whether it was accepted.
|
|
|
|
Import inside the function: `backend.app.tasks.ml` pulls in torch, and web
|
|
must not pay that import cost on a module that every settings request
|
|
touches.
|
|
|
|
Never raises. A broker that will not take the task is worth reporting, but
|
|
the SETTING has already been stored and the lane is already enabled — so
|
|
failing the whole request here would roll back nothing and tell the
|
|
operator their change did not happen when it did.
|
|
"""
|
|
try:
|
|
from ..tasks.ml import ensure_models
|
|
|
|
ensure_models.delay()
|
|
return True
|
|
except Exception: # noqa: BLE001 — reported, never raised at a caller
|
|
log.warning("worker_control: could not enqueue the model fetch", exc_info=True)
|
|
return False
|
|
|
|
|
|
|
|
# --- the sizing pass: one sweep, always on ------------------------------------
|
|
#
|
|
# This replaced BOTH `reconcile_lanes_sync` (step 3) and `autoscale_lanes_sync`
|
|
# (step 7) on 2026-09-23. They were two enforcers over one number, and the
|
|
# whole of step 7's hardest reasoning — a stored value that is a FLOOR, a
|
|
# target of `max(stored, current)` so the reconcile does not undo what the
|
|
# autoscaler added — existed only to stop them fighting. Delete one of them and
|
|
# the problem is not solved, it is absent.
|
|
#
|
|
# Operator: *"auto should be always on, not a setting, so that idle instances
|
|
# quiet down when not running. the number that is visible and something the
|
|
# user can tweak and manage should be the cap itself the number of running
|
|
# workers is handled by the autoscaling function which is always on."*
|
|
#
|
|
# So there is one pass, it runs every minute, it reads the live pool rather
|
|
# than any stored number, and the only thing it obeys is the cap.
|
|
#
|
|
# It also subsumes what the reconcile existed for. `pool_grow` is not durable:
|
|
# a worker restarted by its supervisor comes back at its ENV concurrency,
|
|
# silently below what the lane should be running. This pass reads the live
|
|
# pool every minute and sizes from the backlog, so that worker is corrected on
|
|
# the next tick — sooner than the five-minute reconcile managed, and without a
|
|
# second sweep that could disagree with this one.
|
|
|
|
# How much work justifies a slot. `pending` is depth + reserved, so it already
|
|
# counts what celery has prefetched into worker memory — one task, one slot.
|
|
#
|
|
# Growth is IMMEDIATE and shrink is one slot per tick, deliberately asymmetric.
|
|
# A backlog of four thousand should not take an hour to reach the cap, and a
|
|
# lane that idles for one minute should not drop every process it has: the
|
|
# cost of being one slot too large for a minute is a sleeping process, and the
|
|
# cost of being too small is work not happening. For ML the asymmetry matters
|
|
# most — every new slot reloads a multi-GB model, so the slow shrink is what
|
|
# stops a quiet patch from paying that cost again a minute later.
|
|
SHRINK_STEP = 1
|
|
|
|
|
|
@dataclass
|
|
class LaneSizing:
|
|
"""What the pass did to one lane, and why — in the operator's terms.
|
|
|
|
A reason on every outcome including "held", because a sizing pass that
|
|
only speaks when it acts is one nobody can debug when it does not.
|
|
"""
|
|
|
|
lane: str
|
|
action: str # "grew" | "shrank" | "held" | "skipped"
|
|
slots: int
|
|
reason: str
|
|
|
|
|
|
def wanted_slots(cap: int, active: int, pending: int | None) -> int:
|
|
"""How many workers this lane has work for right now, within its cap.
|
|
|
|
One slot per task in flight or waiting, floored at one process and
|
|
ceilinged by the cap. `pending` of None means the broker did not answer
|
|
for this lane's queues — an unknown backlog is not an empty one (snippet
|
|
#3969), so it contributes nothing rather than being read as zero.
|
|
|
|
A cap of zero still returns one: billiard cannot run an empty pool, and
|
|
the parked process is what `add_consumer` lands on when the cap goes back
|
|
up. "Off" is expressed by cancelling consumers, not by emptying the pool.
|
|
"""
|
|
if cap <= 0:
|
|
return MIN_POOL_SLOTS
|
|
return max(MIN_POOL_SLOTS, min(cap, active + (pending or 0)))
|
|
|
|
|
|
def size_lanes_sync(caps: dict[str, int]) -> list[LaneSizing]:
|
|
"""Size every lane to its backlog, within the cap. The whole control loop.
|
|
|
|
`caps` is lane name -> slots_cap, read from the database by the caller.
|
|
This function touches no database: the celery task that schedules it owns
|
|
the session, and keeping the DB out of here is what lets it be called from
|
|
anywhere that already knows the caps.
|
|
|
|
## It must converge and then go quiet
|
|
|
|
One `inspect` for all lanes, and `set_lane_slots_sync` issues nothing to a
|
|
replica already at its target. A settled system therefore performs one
|
|
broker round trip plus one LLEN sweep per tick and sends no control
|
|
messages at all — the reachable fixed point lesson #4183 is about. An
|
|
enforcer that re-sent a grow of zero every tick would churn forever and
|
|
bury a real correction in its own noise.
|
|
|
|
## An absent lane is SKIPPED, not corrected
|
|
|
|
`present=False` means nothing answered — a worker restarting, or an
|
|
unreachable broker. It does NOT mean zero slots. Deciding from that would
|
|
be a verdict drawn from an unswept read, and here it is worse than
|
|
useless: there is nothing to send the message to.
|
|
"""
|
|
live = inspect_lanes_sync()
|
|
depths = _queue_depths_sync()
|
|
out: list[LaneSizing] = []
|
|
|
|
for lane in LANES:
|
|
cap = caps.get(lane.name)
|
|
if cap is None:
|
|
continue
|
|
state = live[lane.name]
|
|
if not state.present:
|
|
out.append(LaneSizing(lane.name, "skipped", 0, "lane is not answering"))
|
|
continue
|
|
|
|
# Consumers first, and only when they DISAGREE. Sending add_consumer
|
|
# for every queue on every tick of a settled system is the exact churn
|
|
# above, and invisible: add_consumer on a queue already consumed is
|
|
# harmless and reports success.
|
|
should_consume = cap > 0
|
|
if should_consume != state.consuming.issuperset(lane.queues):
|
|
ok, err = set_lane_enabled_sync(lane, should_consume, live=state)
|
|
if not ok:
|
|
out.append(LaneSizing(
|
|
lane.name, "held", state.pool or 0,
|
|
f"could not {'start' if should_consume else 'stop'} "
|
|
f"consuming: {err}",
|
|
))
|
|
continue
|
|
|
|
current = state.pool
|
|
if current is None:
|
|
out.append(LaneSizing(
|
|
lane.name, "held", 0, "worker did not report its pool size",
|
|
))
|
|
continue
|
|
|
|
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
|
|
)
|
|
pending = None if depth is None else depth + state.reserved
|
|
want = wanted_slots(cap, state.active, pending)
|
|
|
|
if want > current:
|
|
new = want
|
|
verb = "grew"
|
|
elif want < current:
|
|
# One at a time on the way down. See SHRINK_STEP.
|
|
new = max(want, current - SHRINK_STEP)
|
|
verb = "shrank"
|
|
else:
|
|
out.append(LaneSizing(
|
|
lane.name, "held", current,
|
|
f"{pending if pending is not None else '?'} waiting, "
|
|
f"{state.active} busy, cap {cap}",
|
|
))
|
|
continue
|
|
|
|
ok, err = set_lane_slots_sync(lane, new, live=state)
|
|
if not ok:
|
|
out.append(LaneSizing(
|
|
lane.name, "held", current, f"could not resize: {err}",
|
|
))
|
|
continue
|
|
out.append(LaneSizing(
|
|
lane.name, verb, new,
|
|
f"{pending if pending is not None else '?'} waiting, "
|
|
f"{state.active} busy, cap {cap}",
|
|
))
|
|
return out
|