feat: one number per lane — the cap — and the autoscaler is the mechanism (4295)
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
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
This commit is contained in:
@@ -54,13 +54,13 @@ def test_every_lane_gets_a_program():
|
||||
assert programs == expected
|
||||
|
||||
|
||||
def test_the_ml_lane_runs_even_though_it_ships_disabled():
|
||||
def test_the_ml_lane_runs_even_though_it_ships_off():
|
||||
"""It holds a PROCESS and no model. `add_consumer` needs a running worker
|
||||
to reach, so without this the UI switch would have nothing to switch —
|
||||
to reach, so without this raising the cap would have nothing to reach —
|
||||
and nothing is downloaded by starting it, which is what lets rule 164
|
||||
permit the fetch at all."""
|
||||
assert _parse().has_section("program:ml")
|
||||
assert LANES_BY_NAME["ml"].default_enabled is False
|
||||
assert LANES_BY_NAME["ml"].default_slots_cap == 0
|
||||
|
||||
|
||||
# --- the coupling this generator exists to guarantee -------------------------
|
||||
@@ -152,12 +152,12 @@ def test_no_program_waits_longer_than_the_compose_stop_grace_period():
|
||||
# --- what runs, and how much ------------------------------------------------
|
||||
|
||||
|
||||
def test_a_zero_slot_lane_still_gets_a_running_process():
|
||||
"""ML ships at 0 slots and disabled — but `add_consumer` needs something to
|
||||
reach. With no process there would be nothing for the UI switch to switch,
|
||||
and enabling tagging could not work at all."""
|
||||
def test_a_lane_capped_at_zero_still_gets_a_running_process():
|
||||
"""ML ships at cap 0 — but `add_consumer` needs something to reach. With
|
||||
no process there would be nothing for the cap to switch back on, and
|
||||
enabling tagging could not work at all."""
|
||||
cp = _parse()
|
||||
assert LANES_BY_NAME["ml"].default_slots == 0
|
||||
assert LANES_BY_NAME["ml"].default_slots_cap == 0
|
||||
env = cp.get("program:ml", "environment")
|
||||
assert "CELERY_CONCURRENCY=1" in env
|
||||
|
||||
@@ -275,29 +275,28 @@ def test_supervisorctl_can_reach_supervisord():
|
||||
def test_each_program_starts_at_the_smallest_pool_the_control_path_allows():
|
||||
"""The two ends of the same floor, asserted together.
|
||||
|
||||
`gen_supervisord` starts every lane at `max(1, default_slots)` because
|
||||
billiard will not run a pool of zero. `worker_control` has the same floor
|
||||
for the opposite reason: it cannot SHRINK to zero either —
|
||||
`gen_supervisord` starts every lane at one process because billiard will
|
||||
not run a pool of zero. The sizing pass has the same floor for the
|
||||
opposite reason: it cannot SHRINK to zero either —
|
||||
|
||||
[ml] pidbox command error:
|
||||
ValueError("Can't shrink pool. All processes busy!")
|
||||
|
||||
Live, 2026-09-23. ML starts at one process and stores zero, so the
|
||||
Live, 2026-09-23. ML started at one process and stored zero, so the
|
||||
reconcile tried 1 -> 0 on every tick, billiard refused, and
|
||||
`set_lane_slots_sync` — which returns True on SENDING the message —
|
||||
reported the lane changed forever (lesson #4183, on the default
|
||||
configuration of every install).
|
||||
|
||||
Two constants, in two files, that must agree or the container cannot
|
||||
settle. Asserted through `effective_slots` rather than against a literal
|
||||
1, so raising the floor moves both ends at once.
|
||||
One constant now, read from the same module by both, rather than two that
|
||||
must agree. Asserted through it rather than against a literal 1, so
|
||||
raising the floor moves both ends at once.
|
||||
"""
|
||||
from backend.app.services.worker_control import effective_slots
|
||||
from backend.app.services.worker_lanes import MIN_POOL_SLOTS
|
||||
|
||||
cp = _parse()
|
||||
for lane in LANES:
|
||||
env = cp.get(f"program:{lane.name}", "environment")
|
||||
want = effective_slots(lane.default_slots)
|
||||
assert f"CELERY_CONCURRENCY={want}," in env, (
|
||||
assert f"CELERY_CONCURRENCY={MIN_POOL_SLOTS}," in env, (
|
||||
f"{lane.name} starts at a size the control path cannot reach"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user