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
182 lines
7.2 KiB
Python
182 lines
7.2 KiB
Python
"""The learned roster: which of FabledCurator's parts have checked in, and when.
|
|
|
|
Milestone 365. `celery inspect` answers "who is here"; this answers "who is
|
|
missing", which nothing in the application could do before — see
|
|
`models/service_seen.py` for why the identity is a queue set and not a
|
|
worker hostname.
|
|
|
|
## Who does the observing, and why it is the web process
|
|
|
|
Three candidates, and the choice matters more than the code:
|
|
|
|
* **A celery beat sweep.** Rejected. If the scheduler dies, the sweep stops,
|
|
every row goes stale, and the page reports that everything is down when one
|
|
thing is. An alarm that cannot distinguish "one part died" from "the
|
|
observer died" is worse than no alarm.
|
|
* **A background task in web.** Rejected on a detail of how this deploys:
|
|
hypercorn runs `--workers 4`, so a `before_serving` loop would be FOUR
|
|
concurrent inspect loops hammering the broker, forever, per container.
|
|
* **Refresh on demand, rate-limited by the data itself.** Taken. Whichever web
|
|
process happens to serve a health request refreshes the roster if it is
|
|
older than REFRESH_TTL, and otherwise reads what is already there.
|
|
|
|
The third has the property the other two lack: **the observer is the thing
|
|
serving the page.** If web is down you get a browser error rather than a
|
|
confidently green page, which is the honest failure. It also self-limits
|
|
without coordination — the TTL lives in the row everybody can see.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import ServiceSeen
|
|
from .worker_lanes import LANES
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# How stale the roster may be before a health request refreshes it. Comfortably
|
|
# under the staleness thresholds that decide a service is missing, so the
|
|
# verdict is never limited by how often anyone looked.
|
|
REFRESH_TTL_SECONDS = 20.0
|
|
|
|
# celery inspect is a broker round trip and this sits on a request path, so it
|
|
# gets a deadline (rule 156). A broker that has stopped answering must make the
|
|
# roster stale — which is a true statement about the system — not hang the one
|
|
# page that exists to explain it.
|
|
INSPECT_TIMEOUT_SECONDS = 2.0
|
|
|
|
# Queue set -> the name an operator recognises. Sorted-tuple keys, because the
|
|
# order celery reports them in is not guaranteed.
|
|
#
|
|
# DERIVED from `worker_lanes.LANES` (milestone 422 step 1) rather than written
|
|
# out here. It was a hand-kept second copy of the same fact, and it had already
|
|
# drifted: `maintenance_long` is a live lane with four task routes pointing at
|
|
# it and a dedicated worker in the operator's stack, and this map did not know
|
|
# it — so the System tab labelled it `Worker (maintenance_long)`. One list of
|
|
# lanes now names them everywhere.
|
|
#
|
|
# A deployment that slices CELERY_QUEUES differently still falls through to the
|
|
# raw queue list rather than being given a name this code invented for it: a
|
|
# wrong-but-confident label on a status page is worse than an ugly true one.
|
|
ROLE_NAMES: dict[tuple[str, ...], str] = {
|
|
lane.queue_key: lane.display_name for lane in LANES
|
|
}
|
|
|
|
|
|
def role_display_name(queues: tuple[str, ...]) -> str:
|
|
known = ROLE_NAMES.get(queues)
|
|
if known:
|
|
return known
|
|
return "Worker (" + ", ".join(queues) + ")"
|
|
|
|
|
|
def _inspect_celery_sync() -> dict[tuple[str, ...], dict]:
|
|
"""celery inspect, grouped by queue set rather than by worker.
|
|
|
|
Returns {queue_set: {"hostnames": [...], "active": int}}. Two replicas of
|
|
one role collapse into one entry on purpose — the question is whether the
|
|
role is being served, not how many containers exist.
|
|
"""
|
|
from ..celery_app import celery as celery_app
|
|
|
|
insp = celery_app.control.inspect(timeout=INSPECT_TIMEOUT_SECONDS)
|
|
active_queues = insp.active_queues() or {}
|
|
active_tasks = insp.active() or {}
|
|
|
|
grouped: dict[tuple[str, ...], dict] = {}
|
|
for hostname, queues in active_queues.items():
|
|
key = tuple(sorted({q["name"] for q in queues}))
|
|
entry = grouped.setdefault(key, {"hostnames": [], "active": 0})
|
|
entry["hostnames"].append(hostname)
|
|
entry["active"] += len(active_tasks.get(hostname, []))
|
|
for entry in grouped.values():
|
|
entry["hostnames"].sort()
|
|
return grouped
|
|
|
|
|
|
async def touch_service(
|
|
session: AsyncSession, *, key: str, kind: str, display_name: str, details: dict
|
|
) -> None:
|
|
"""Record that a part checked in just now.
|
|
|
|
Upsert rather than read-modify-write: several web processes and several
|
|
agents can be doing this at once, and the last writer is simply the most
|
|
recent sighting. `first_seen_at` is deliberately NOT updated — it is the
|
|
one field that answers "has this ever run", which the learned-roster design
|
|
depends on.
|
|
"""
|
|
stmt = pg_insert(ServiceSeen).values(
|
|
key=key, kind=kind, display_name=display_name, details=details,
|
|
)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[ServiceSeen.key],
|
|
set_={
|
|
"kind": stmt.excluded.kind,
|
|
"display_name": stmt.excluded.display_name,
|
|
"details": stmt.excluded.details,
|
|
"last_seen_at": func.now(),
|
|
},
|
|
)
|
|
await session.execute(stmt)
|
|
|
|
|
|
async def refresh_celery_roster(session: AsyncSession) -> None:
|
|
"""Inspect the broker and record what answered. Never raises.
|
|
|
|
A failure here means the roster does not advance, and the rows going stale
|
|
is then a TRUE report about a broker nobody can reach. Letting the
|
|
exception out would instead break the health endpoint, which is the one
|
|
thing that must keep answering when the stack is unwell.
|
|
"""
|
|
try:
|
|
grouped = await asyncio.wait_for(
|
|
asyncio.to_thread(_inspect_celery_sync),
|
|
timeout=INSPECT_TIMEOUT_SECONDS * 2,
|
|
)
|
|
except Exception:
|
|
log.warning("service roster: celery inspect failed; roster not refreshed", exc_info=True)
|
|
return
|
|
|
|
for queues, entry in grouped.items():
|
|
await touch_service(
|
|
session,
|
|
key="celery:" + ",".join(queues),
|
|
kind="celery",
|
|
display_name=role_display_name(queues),
|
|
details={
|
|
"queues": list(queues),
|
|
"hostnames": entry["hostnames"],
|
|
"replicas": len(entry["hostnames"]),
|
|
"active": entry["active"],
|
|
},
|
|
)
|
|
|
|
|
|
async def refresh_if_stale(session: AsyncSession) -> None:
|
|
"""Refresh the celery roster if nobody has for REFRESH_TTL_SECONDS.
|
|
|
|
Rate-limited by the data rather than by a lock: the gate is the newest
|
|
last_seen_at across the celery rows, which every web process can see. Two
|
|
processes racing through the gate costs one redundant inspect and writes
|
|
the same values twice, so the benign outcome needs no coordination to
|
|
prevent.
|
|
"""
|
|
newest = (
|
|
await session.execute(
|
|
select(func.max(ServiceSeen.last_seen_at)).where(ServiceSeen.kind == "celery")
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
if newest is not None:
|
|
age = (await session.execute(select(func.now()))).scalar_one() - newest
|
|
if age.total_seconds() < REFRESH_TTL_SECONDS:
|
|
return
|
|
|
|
await refresh_celery_roster(session)
|