feat: worker lanes become rows — slots, a settable cap, a derived ceiling (4291)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m45s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m13s
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m45s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m13s
Milestone 422 step 1. The data model the rest of the milestone reads. No
behaviour change: nothing consumes these rows yet, and every lane still boots
at its CELERY_CONCURRENCY env value.
Three numbers, not two, per the operator's distinction — the derived value is
a cap ON the cap:
slots <= slots_cap <= derived_ceiling
(live) (operator) (computed)
They can always lower their own cap; they cannot raise it past what the
container can hold. The ceiling is never stored, so a row written on a 32GB
host and later run in a 4GB container is bounded by the 4GB.
`services/worker_lanes.py` is the one place that knows the lane set.
`models/worker_lane.py` holds only what an operator may change.
Two deviations from the step as written, both deliberate:
QUEUES ARE NOT A COLUMN. The step body said the row carries its `-Q` list,
but a lane's queues are decided by celery_app's task_routes, not by
preference — an operator cannot move a backup off maintenance_long. Storing
them would create a row that can contradict the routing table, with nothing
to notice until a queue had no consumer. So queues are code, slots are data.
`test_every_routed_queue_has_a_lane_that_serves_it` reads the real routing
table and fails if a route is ever added without a lane.
ROLE_NAMES IS NOW DERIVED, not left alone. It was a hand-kept second copy of
"queue set -> display name" and had already drifted: maintenance_long is a
live lane with four task routes and a dedicated worker in the operator's
stack, and the roster did not know its name — so the System tab labelled it
`Worker (maintenance_long)`. Adding a lane table beside it would have made
three copies.
The ceiling honours cgroup limits rather than the host's. `os.cpu_count()`
reports the HOST's cores from inside a container, so a 4-core quota on a
32-core host would otherwise offer 32 slots — and the operator's own stack
sets `cpus: '4.0'` on ml-worker, so that is real configuration, not a
hypothetical. Memory reads cgroup v2 then v1, and recognises v1's
PAGE_SIZE-aligned LONG_MAX sentinel by magnitude rather than treating it as
petabytes.
Every uncertain case fails LOW. An unreadable limit yields UNKNOWN_CEILING,
never unlimited — not knowing how much memory there is must not read as
plenty. A box too small to hold one model beside the web process gets an ML
ceiling of 0 rather than a floor of 1: offering a slot that OOMs the
container the first time it is used is exactly what this exists to prevent.
ML_BYTES_PER_SLOT is 4 GiB and is UNMEASURED — flagged as such in the code,
with the method for replacing it with a real figure. It decides whether a
stranger's server survives enabling tagging, so it errs toward refusing a
slot that would have fitted.
Seeded one-of-each with ml at 0 and disabled (alembic 0103). ML off is step
6's requirement arriving early: enabling the lane is what triggers the SigLIP
download, and rule 164 permits a runtime fetch only for a feature that is
optional and clearly off. The seed values are literals rather than an import
of LANES — a migration is a statement about one moment, and importing the
live defaults would silently change what this revision does on a fresh
database in 2027.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""worker_lane — settings-backed slots for each celery lane.
|
||||
|
||||
Milestone 422 step 1. One row per lane, holding only what an operator can
|
||||
change: how many slots it runs, the ceiling they have set for themselves, and
|
||||
whether it consumes its queues at all.
|
||||
|
||||
## What is deliberately not a column
|
||||
|
||||
**The queues.** They are decided by `celery_app.py`'s `task_routes`, not by
|
||||
preference, so a stored copy could contradict the routing table with nothing
|
||||
to notice until a queue had no consumer. They live in
|
||||
`services/worker_lanes.py`.
|
||||
|
||||
**The derived ceiling.** Computed from the container's cgroup limits on every
|
||||
read. A row written on a 32GB host and later run in a 4GB container must be
|
||||
bounded by the 4GB; a stored ceiling would quietly authorise what the box can
|
||||
no longer hold.
|
||||
|
||||
## The seeded values
|
||||
|
||||
Written out literally rather than imported from `worker_lanes.LANES`. A
|
||||
migration is a statement about one moment in the schema's history — if it
|
||||
imported the live defaults, changing them in 2027 would silently change what
|
||||
this 2026 revision does on a fresh database. The two are allowed to diverge
|
||||
afterwards, and that is correct: `LANES` supplies defaults for a lane added
|
||||
later, this file records what was seeded today.
|
||||
|
||||
lane slots cap enabled
|
||||
worker 1 4 yes
|
||||
scheduler 1 2 yes
|
||||
maintenance_long 1 2 yes
|
||||
ml 0 1 NO
|
||||
|
||||
One of each, per the operator (2026-09-22: *"that starting value should be one
|
||||
of each"*), and far below their own production numbers — worker 8 and ml 2 are
|
||||
tuned for their hardware and are not a sane first boot for a stranger.
|
||||
|
||||
**ml ships at zero and disabled**, which is milestone 422 step 6's requirement
|
||||
arriving early: enabling the lane is what triggers the SigLIP download, and
|
||||
rule 164 permits a runtime fetch only for a feature that is "optional and
|
||||
clearly off". Seeding it on would make every fresh install reach HuggingFace.
|
||||
|
||||
The caps start low on purpose. A cap that begins at the ceiling is a rubber
|
||||
stamp; starting at 4/2/2/1 means raising slots within the cap is ordinary and
|
||||
raising the cap is a deliberate act.
|
||||
|
||||
## Existing installs
|
||||
|
||||
Nothing is migrated FROM. The `CELERY_QUEUES` / `CELERY_CONCURRENCY` env vars
|
||||
stay exactly as they are and remain the baseline each lane boots at; these
|
||||
rows are the adjustment applied on top (step 3). So this migration changes no
|
||||
behaviour on a running stack — it only makes the numbers storable.
|
||||
|
||||
Revision ID: 0103
|
||||
Revises: 0102
|
||||
Create Date: 2026-09-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0103"
|
||||
down_revision: Union[str, None] = "0102"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# (name, slots, slots_cap, enabled) — see the docstring for why these are
|
||||
# literals and not an import.
|
||||
_SEED = (
|
||||
("worker", 1, 4, True),
|
||||
("scheduler", 1, 2, True),
|
||||
("maintenance_long", 1, 2, True),
|
||||
("ml", 0, 1, False),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
worker_lane = op.create_table(
|
||||
"worker_lane",
|
||||
sa.Column("name", sa.String(length=32), nullable=False),
|
||||
sa.Column("slots", sa.Integer(), nullable=False),
|
||||
sa.Column("slots_cap", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("name", name=op.f("pk_worker_lane")),
|
||||
# Bare constraint names: Base.metadata's naming convention prepends
|
||||
# ck_worker_lane_, and pre-prefixing doubles it — the defect alembic
|
||||
# 0088 had to rename four constraints for (#3275). op.f() marks these
|
||||
# as already-final so autogenerate does not propose renaming them.
|
||||
sa.CheckConstraint("slots >= 0", name=op.f("ck_worker_lane_slots_non_negative")),
|
||||
sa.CheckConstraint("slots_cap >= 0", name=op.f("ck_worker_lane_cap_non_negative")),
|
||||
# The invariant that makes the cap mean anything, in the database
|
||||
# rather than only in the service: a row violating it is not a
|
||||
# rejected request, it is a lane that step 3's reconcile will drive UP
|
||||
# to a number the operator capped.
|
||||
sa.CheckConstraint("slots <= slots_cap", name=op.f("ck_worker_lane_slots_within_cap")),
|
||||
)
|
||||
# No index beyond the primary key, deliberately — four rows, forever. Same
|
||||
# reasoning as service_seen, and the lesson of #3301, which removed seven
|
||||
# indexes that were write cost buying nothing.
|
||||
op.bulk_insert(
|
||||
worker_lane,
|
||||
[
|
||||
{"name": name, "slots": slots, "slots_cap": cap, "enabled": enabled}
|
||||
for name, slots, cap, enabled in _SEED
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The rows go with the table. They are settings with shipped defaults, not
|
||||
# operator data that predates this revision — a downgrade returns the stack
|
||||
# to reading its concurrency from env, which is where it reads it from
|
||||
# today anyway.
|
||||
op.drop_table("worker_lane")
|
||||
Reference in New Issue
Block a user