CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m8s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m37s
Milestone 422 step 5. `docker compose -f docker-compose.single.yml up -d` gives three containers — FabledCurator, Postgres, Redis — where the stack previously needed seven. THE MULTI-SERVICE STACK IS KEPT. docker-compose.yml still runs the five app services separately and remains the right shape for a Swarm deployment spread across hosts, where per-service rolling rollback and placement constraints matter. This adds a compose file; it deletes none. `entrypoint.sh all` GENERATES the supervisord config from worker_lanes.LANES and execs it as PID 1. Generated rather than checked in because a static .conf would spell out each lane's -Q list, making a FIFTH hand-kept copy of the queue names — after celery_app.task_routes and the three collapsed in steps 1, 2 and 4. Every one of those had already drifted when found. Generating gives a stronger guarantee than "they match today": a lane added to LANES gets a process, and a queue cannot end up with no consumer because someone missed a file. supervisord over s6-overlay: one pip dependency on an image already Python, with per-program stop timeouts and stopasgroup. The process-group part is not a detail — celery's prefork pool forks children, and a TERM reaching only the parent leaves them orphaned holding tasks. s6's advantage (PID-1 signal and zombie handling) comes from `init: true` instead. Nothing in FC talks to the supervisor, so the choice is reversible without touching product code. FOUR LANES, NOT FIVE. The ml lane is skipped: torch and the ML requirements live only in Dockerfile.ml until step 6 merges the images, so an `ml` program here would fail to import on every restart forever. `--with-ml` is the flag step 6 turns on. THREE BUGS FOUND BY READING IT BACK, none of which the first tests caught: 1. `environment=CELERY_QUEUES=default,import,thumbnail,download` — supervisord parses that key as a COMMA-separated list, so it reads as CELERY_QUEUES=default plus three malformed entries and the worker lane would have consumed only `default`. Silent: the worker starts, reports healthy, never picks up an import. Now quoted, and the test asserts the quoted form rather than the bare substring, which passed either way. 2. The generator emitted `entrypoint.sh <lane.name>`, but `maintenance_long` is not a role — compose runs it as the plain `worker` role with different queues. Lane now carries `entrypoint_role`, and a test reads entrypoint.sh to assert every role a lane names actually exists. 3. The `scheduler` role hardcoded --concurrency=1, ignoring CELERY_CONCURRENCY. Harmless while only compose started it and set none; with a generated value being passed, the lane would have sat at 1 until the reconcile noticed, with nothing saying why. The healthcheck asserts BOTH halves — hypercorn answers and every configured lane is answering the broker. That is the failure mode consolidation creates: docker can no longer see the lanes as separate services, so a web-only check would report a healthy container with every lane inside it dead. It deliberately ignores the `enabled` flag: a disabled lane still has a running process with its consumers cancelled, and marking the container unhealthy for turning tagging off would be wrong. stop_grace_period 200s, sized to the slowest lane (maintenance_long at 180s) rather than the average, with a test asserting no program's stopwaitsecs can exceed what compose allows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""The worker lanes: what they are, and how many slots each may be given.
|
|
|
|
Milestone 422 step 1. This module is the ONE place that knows the lane set;
|
|
`models/worker_lane.py` holds only what the operator can change about them.
|
|
|
|
## Why the queues are here and not in the table
|
|
|
|
A lane's queue set is not a preference — it is decided by `celery_app.py`'s
|
|
`task_routes`, which is what puts a backup on `maintenance_long` and a
|
|
thumbnail on `thumbnail`. An operator cannot move a task to another lane, so
|
|
storing the queues as settings would create a row that can disagree with the
|
|
routing table, and nothing would notice until a queue had no consumer.
|
|
|
|
So: queues and display names are code, slots and caps are data. The table
|
|
stores three numbers and a flag, and nothing that could contradict celery.
|
|
|
|
This also collapses a duplicate rather than adding one.
|
|
`service_roster.ROLE_NAMES` was a second copy of "queue set -> the name an
|
|
operator recognises", and it had already drifted: `maintenance_long` is a
|
|
live lane with four task routes pointing at it, and the roster did not know
|
|
its name, so the System tab rendered it as `Worker (maintenance_long)`. That
|
|
map is now derived from `LANES` below, so a lane added here is named
|
|
everywhere at once.
|
|
|
|
## Why the ceiling is derived rather than configured
|
|
|
|
Consolidating the stack into one container (step 5) widens the OOM blast
|
|
radius: today an ml-worker that exhausts memory is killed by Docker on its
|
|
own, and web keeps serving. In one container the kernel picks a victim from
|
|
the whole cgroup, and it may pick hypercorn — so a tagging task can take the
|
|
UI down with it, on exactly the modest hardware least able to spare the
|
|
memory.
|
|
|
|
Operator, 2026-09-22: *"ram isn't an issue for me but some users might run
|
|
this on weaker hardware and I don't want it to kill their servers."*
|
|
|
|
So the maximum is computed from what the container actually has, and the
|
|
operator's own `slots_cap` must fit under it. Three numbers, not two, and the
|
|
ordering is the point:
|
|
|
|
slots <= slots_cap <= derived_ceiling
|
|
(live) (operator) (this module)
|
|
|
|
The operator can always lower their cap. They cannot raise it past what the
|
|
box can hold. The derived ceiling is never stored — a row that outlived a
|
|
change in container limits must not carry a stale one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# --- the lanes ---------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Lane:
|
|
"""A worker lane. `name` is the stable key the settings row is keyed on.
|
|
|
|
Keyed on a lane NAME rather than a container hostname for the reason
|
|
`models/service_seen.py` gives at length: celery's worker names here are
|
|
`celery@<container id>` and are minted fresh on every deploy, so anything
|
|
keyed on them records a death and a birth every time the stack updates.
|
|
"""
|
|
|
|
name: str
|
|
display_name: str
|
|
queues: tuple[str, ...]
|
|
# Which `entrypoint.sh` role starts this lane. NOT always the lane name:
|
|
# `maintenance_long` is the plain `worker` role pointed at a different
|
|
# queue, exactly as docker-compose starts it today (`command: ["worker"]`
|
|
# with CELERY_QUEUES=maintenance_long). Recorded here so the generated
|
|
# supervisord config and the compose file cannot disagree about it.
|
|
entrypoint_role: str
|
|
default_slots: int
|
|
# The cap a lane STARTS with, which is not the ceiling. Set low enough
|
|
# that raising slots within it is an ordinary adjustment, and raising the
|
|
# cap itself is a deliberate act — a cap that begins at the ceiling is a
|
|
# rubber stamp and protects nobody.
|
|
default_slots_cap: int
|
|
default_enabled: bool
|
|
# True when a slot costs a copy of the ML model rather than just a process.
|
|
# The only lane whose ceiling is decided by memory instead of by cores.
|
|
memory_bound: bool = False
|
|
|
|
@property
|
|
def queue_key(self) -> tuple[str, ...]:
|
|
"""The sorted queue set, which is how `service_seen` identifies a
|
|
running worker. The join between what is configured here and what
|
|
`celery inspect` reports."""
|
|
return tuple(sorted(self.queues))
|
|
|
|
|
|
# Defaults are ONE OF EACH, with ML off — operator, 2026-09-22: *"that
|
|
# starting value should be one of each."* Deliberately far below the
|
|
# operator's own production numbers (worker 8, ml 2), which are tuned for
|
|
# their hardware and are not a sane first boot for a stranger.
|
|
#
|
|
# ML ships disabled because enabling it is what triggers the SigLIP download
|
|
# (milestone 422 step 6) — rule 164 allows a feature that needs a fetch only
|
|
# when it is "optional and clearly off", and off-by-default is also what keeps
|
|
# a small box from loading a multi-GB model it was never asked to load.
|
|
LANES: tuple[Lane, ...] = (
|
|
Lane(
|
|
name="worker",
|
|
display_name="Worker",
|
|
queues=("default", "import", "thumbnail", "download"),
|
|
entrypoint_role="worker",
|
|
default_slots=1,
|
|
default_slots_cap=4,
|
|
default_enabled=True,
|
|
),
|
|
Lane(
|
|
name="scheduler",
|
|
display_name="Scheduler",
|
|
queues=("maintenance", "scan"),
|
|
entrypoint_role="scheduler",
|
|
default_slots=1,
|
|
default_slots_cap=2,
|
|
default_enabled=True,
|
|
),
|
|
Lane(
|
|
name="maintenance_long",
|
|
display_name="Long maintenance",
|
|
queues=("maintenance_long",),
|
|
entrypoint_role="worker",
|
|
default_slots=1,
|
|
default_slots_cap=2,
|
|
default_enabled=True,
|
|
),
|
|
Lane(
|
|
name="ml",
|
|
display_name="ML tagging",
|
|
queues=("ml",),
|
|
entrypoint_role="ml-worker",
|
|
default_slots=0,
|
|
default_slots_cap=1,
|
|
default_enabled=False,
|
|
memory_bound=True,
|
|
),
|
|
)
|
|
|
|
LANES_BY_NAME: dict[str, Lane] = {lane.name: lane for lane in LANES}
|
|
LANES_BY_QUEUE_KEY: dict[tuple[str, ...], Lane] = {
|
|
lane.queue_key: lane for lane in LANES
|
|
}
|
|
|
|
|
|
# --- what the container actually has -----------------------------------------
|
|
|
|
# cgroup v2 first, then v1. A container started without an explicit memory
|
|
# limit reports "max" on v2 and a sentinel near 2**63 on v1; both mean "no
|
|
# limit", and the answer then is the host's RAM.
|
|
_CGROUP_V2_MEMORY = Path("/sys/fs/cgroup/memory.max")
|
|
_CGROUP_V1_MEMORY = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
|
_CGROUP_V2_CPU = Path("/sys/fs/cgroup/cpu.max")
|
|
_CGROUP_V1_CPU_QUOTA = Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
|
|
_CGROUP_V1_CPU_PERIOD = Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
|
|
|
|
# A v1 "unlimited" is PAGE_SIZE-aligned LONG_MAX, not a round number, so it is
|
|
# recognised by magnitude rather than by equality. Anything claiming more than
|
|
# a petabyte is a sentinel, not a machine.
|
|
_UNLIMITED_ABOVE = 1 << 50
|
|
|
|
GIB = 1024 ** 3
|
|
|
|
# Memory one ML slot needs: the SigLIP so400m weights plus the runtime holding
|
|
# them. Prefork forks a child per slot and each child loads its own copy, so
|
|
# this multiplies — it is not a one-off cost.
|
|
#
|
|
# UNMEASURED AND DELIBERATELY CONSERVATIVE. This number decides whether a
|
|
# stranger's server survives enabling tagging, so it errs toward refusing a
|
|
# slot that would have fitted rather than granting one that will not. To
|
|
# replace it with a real figure: enable the lane on a container with a known
|
|
# limit, run one tagging task, and read the worker child's peak RSS
|
|
# (`grep VmHWM /proc/<child pid>/status`). Put the measurement in the commit
|
|
# message when you do.
|
|
ML_BYTES_PER_SLOT = 4 * GIB
|
|
|
|
# Held back for hypercorn and the non-ML lanes before any ML slot is offered.
|
|
# In the consolidated container these share one cgroup with ML, and they are
|
|
# the processes an OOM kill must not take (see the module docstring).
|
|
RESERVED_BYTES = 2 * GIB
|
|
|
|
# The floor a cores-derived ceiling never goes below. A single-core box still
|
|
# needs to be able to run its lanes; the ceiling exists to stop absurd values,
|
|
# not to make a small machine unusable.
|
|
MIN_CEILING = 1
|
|
|
|
# What an unreadable limit yields. Low rather than unlimited, on purpose: not
|
|
# knowing how much memory there is must never read as "plenty". An unswept
|
|
# absence is not a verdict.
|
|
UNKNOWN_CEILING = 1
|
|
|
|
|
|
def _read_int(path: Path) -> int | None:
|
|
try:
|
|
raw = path.read_text().strip()
|
|
except OSError:
|
|
return None
|
|
if raw == "max":
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def container_memory_bytes() -> int | None:
|
|
"""The memory this container may use, or None when it cannot be read.
|
|
|
|
None means UNKNOWN, never UNLIMITED. Every caller must treat it as the
|
|
conservative case — the whole point of the ceiling is to protect a machine
|
|
whose size we are unsure of.
|
|
"""
|
|
for path in (_CGROUP_V2_MEMORY, _CGROUP_V1_MEMORY):
|
|
value = _read_int(path)
|
|
if value is not None and value < _UNLIMITED_ABOVE:
|
|
return value
|
|
if value is not None:
|
|
# A sentinel: the cgroup exists but sets no limit, so the real
|
|
# bound is the host's.
|
|
break
|
|
try:
|
|
return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
|
|
except (ValueError, OSError, AttributeError):
|
|
return None
|
|
|
|
|
|
def container_cpu_count() -> int | None:
|
|
"""Effective cores, honouring a cgroup CPU quota.
|
|
|
|
`os.cpu_count()` reports the HOST's cores from inside a container, so a
|
|
quota of 2.0 on a 32-core host would otherwise offer 32 slots. The
|
|
operator's own stack sets `cpus: '4.0'` on ml-worker, so this is a real
|
|
configuration here and not a hypothetical.
|
|
"""
|
|
quota: float | None = None
|
|
try:
|
|
raw = _CGROUP_V2_CPU.read_text().strip().split()
|
|
if raw and raw[0] != "max":
|
|
quota = int(raw[0]) / int(raw[1])
|
|
except (OSError, ValueError, IndexError, ZeroDivisionError):
|
|
pass
|
|
if quota is None:
|
|
q = _read_int(_CGROUP_V1_CPU_QUOTA)
|
|
p = _read_int(_CGROUP_V1_CPU_PERIOD)
|
|
if q is not None and p and q > 0:
|
|
quota = q / p
|
|
if quota is not None and quota > 0:
|
|
return max(1, int(quota))
|
|
return os.cpu_count()
|
|
|
|
|
|
def derived_ceiling(lane: Lane) -> int:
|
|
"""The most slots `lane` may be given on this container.
|
|
|
|
Never stored. Recomputed on every read so a container whose limits changed
|
|
is bounded by what it has NOW rather than by what it had when its row was
|
|
written.
|
|
"""
|
|
if lane.memory_bound:
|
|
total = container_memory_bytes()
|
|
if total is None:
|
|
log.warning(
|
|
"worker_lanes: cannot read a memory limit; capping %s at %d",
|
|
lane.name, UNKNOWN_CEILING,
|
|
)
|
|
return UNKNOWN_CEILING
|
|
usable = total - RESERVED_BYTES
|
|
if usable < ML_BYTES_PER_SLOT:
|
|
# Honestly zero. A box that cannot hold one model alongside the web
|
|
# process must be told it cannot run tagging, not sold a slot that
|
|
# will OOM the container the first time it is used.
|
|
return 0
|
|
return int(usable // ML_BYTES_PER_SLOT)
|
|
|
|
cores = container_cpu_count()
|
|
if cores is None:
|
|
return UNKNOWN_CEILING
|
|
return max(MIN_CEILING, cores)
|
|
|
|
|
|
def ceilings() -> dict[str, int]:
|
|
"""Every lane's ceiling, for the settings API and the UI."""
|
|
return {lane.name: derived_ceiling(lane) for lane in LANES}
|