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:
@@ -1,48 +1,49 @@
|
||||
"""worker_lane — how many slots the operator wants each worker lane to have.
|
||||
"""worker_lane — the most workers the operator will let each lane use.
|
||||
|
||||
Milestone 422 step 1. One row per lane in `services/worker_lanes.LANES`.
|
||||
Milestone 422 step 1, reshaped 2026-09-23. One row per lane in
|
||||
`services/worker_lanes.LANES`, and ONE COLUMN an operator sets.
|
||||
|
||||
## What is NOT in here
|
||||
## Why there is only one number now
|
||||
|
||||
The lane's queues and its display name. Those are decided by `celery_app.py`'s
|
||||
`task_routes` — an operator cannot move a backup off `maintenance_long` — so
|
||||
storing them would be a row that can contradict the routing table, with
|
||||
nothing to notice until a queue had no consumer. They live in
|
||||
`services/worker_lanes.py`; this table holds only what an operator may change.
|
||||
There were three — `slots`, `slots_cap` and `autoscale` — because the manual
|
||||
dial was built first and the autoscaler arrived last, beside a control that
|
||||
already existed rather than in place of it.
|
||||
|
||||
The DERIVED CEILING is also absent, and that is deliberate rather than an
|
||||
omission. It is computed from the container's cgroup limits on every read, so
|
||||
a row written on a 32GB host and later run in a 4GB container is bounded by
|
||||
the 4GB — a stored ceiling would quietly authorise what the box can no longer
|
||||
hold.
|
||||
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."*
|
||||
|
||||
## The three numbers
|
||||
So `slots` is gone. How many workers a lane is running right now is a
|
||||
MEASUREMENT — read live from the worker, moved by the autoscaler, never
|
||||
stored. Storing it made it look like a preference, which 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.
|
||||
|
||||
slots <= slots_cap <= derived_ceiling
|
||||
(live) (this row) (computed)
|
||||
`autoscale` is gone for the same reason: it gated the mechanism behind a
|
||||
choice, and a lane nobody opted in simply never gave its slots back.
|
||||
|
||||
Operator's distinction, 2026-09-22: the derived value is *a cap on the cap*.
|
||||
`slots_cap` is theirs and is always lowerable; it simply may not exceed what
|
||||
the container can hold. The CHECK constraint below enforces the left half,
|
||||
which is a fact about the row; the right half is enforced at write, because
|
||||
it depends on a value no database column holds.
|
||||
`enabled` is gone too, and is now DERIVED: a cap of zero means no consumers.
|
||||
"Off" and "may use no workers" were two spellings of one fact, free to
|
||||
disagree.
|
||||
|
||||
## enabled
|
||||
## What remains
|
||||
|
||||
Whether the lane consumes its queues at all. This is how ML ships off
|
||||
(milestone 422 step 6): `enabled=false` with `slots=0`, so a fresh install
|
||||
never loads a model or reaches HuggingFace, and turning tagging on in
|
||||
Settings is what triggers the fetch.
|
||||
1 <= live pool <= slots_cap <= derived_ceiling
|
||||
(autoscaler) (this row) (computed)
|
||||
|
||||
Not a substitute for `slots=0`. A lane can be enabled with zero slots while
|
||||
it is being resized, and the two answer different questions: `enabled` is
|
||||
intent, `slots` is capacity.
|
||||
The floor is one PROCESS, not zero: billiard will not run an empty pool, and
|
||||
the parked process is what `add_consumer` lands on when the cap goes back up.
|
||||
|
||||
The DERIVED CEILING is deliberately absent from this table. It is computed
|
||||
from the container's cgroup limits on every read, so a row written on a 32GB
|
||||
host and later run in a 4GB container is bounded by the 4GB — a stored
|
||||
ceiling would quietly authorise what the box can no longer hold.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Integer,
|
||||
@@ -57,16 +58,10 @@ from .base import Base
|
||||
class WorkerLane(Base):
|
||||
__tablename__ = "worker_lane"
|
||||
__table_args__ = (
|
||||
# Bare names — Base.metadata's naming convention prepends
|
||||
# Bare name — Base.metadata's naming convention prepends
|
||||
# ck_worker_lane_. Pre-prefixing here doubles it, which is what
|
||||
# alembic 0088 had to rename four constraints for (#3275).
|
||||
CheckConstraint("slots >= 0", name="slots_non_negative"),
|
||||
CheckConstraint("slots_cap >= 0", name="cap_non_negative"),
|
||||
# The invariant that makes the cap mean anything. Enforced in the
|
||||
# database rather than only in the service, because a row that
|
||||
# violates it is not a rejected request — it is a lane that will be
|
||||
# reconciled UP to a value the operator capped.
|
||||
CheckConstraint("slots <= slots_cap", name="slots_within_cap"),
|
||||
)
|
||||
|
||||
# The lane name from services/worker_lanes.LANES — never a container
|
||||
@@ -74,21 +69,13 @@ class WorkerLane(Base):
|
||||
# are `celery@<container id>`, minted fresh on every deploy.
|
||||
name: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
|
||||
slots: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
|
||||
# Whether the autoscaler may raise this lane's slots on its own, up to
|
||||
# slots_cap and never past it (milestone 422 step 7).
|
||||
# The most workers this lane may use. Zero means off — no consumers, so
|
||||
# the lane takes no work and (for ML) downloads no model.
|
||||
#
|
||||
# OFF by default and per-lane rather than global. It is the one part of
|
||||
# this milestone that acts unattended, so it is opt-in per lane the
|
||||
# operator has actually thought about — a global switch would turn it on
|
||||
# for lanes whose behaviour under load nobody has watched, including `ml`,
|
||||
# where every extra slot is another copy of a multi-GB model.
|
||||
autoscale: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default="false",
|
||||
)
|
||||
# There is no upper CHECK here, because the bound it would need is the
|
||||
# derived ceiling, and no column holds that: it depends on the cgroup the
|
||||
# container is running in right now. Enforced at write instead.
|
||||
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
Reference in New Issue
Block a user