CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 36s
CI and images / integration (push) Successful in 2m26s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m39s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
Operator, 2026-09-23: *"clean up the stale service_seen rows"*.
**They were not stale.** They were phantoms, written on purpose, and they will
come back on every install that turns a lane off — so the rows are the smaller
half of this.
A celery worker was attributed to its lane by the queues it was CONSUMING. A
lane at cap 0 has its consumers cancelled, so it answers `active_queues()`
with an empty list, matches no lane, and is dropped. Three consequences, all
on the operator's screen at once:
1. The lanes table reported the lane **not answering** — the signal for a
crashed worker, not for one the operator turned off.
2. The roster grew a phantom row named **`Worker ()`** — the empty queue set
rendered as a display name — shown "running" beside the real lane's row
going stale, because nothing updated it any more.
3. **The container went unhealthy.** `healthcheck._lanes_ok` requires every
lane present. ML ships at cap 0, so a fresh install was permanently
unhealthy and Swarm restarts an unhealthy task forever.
That third one is the severe one, and its docstring asserted the opposite of
what the code did — *"a disabled lane still runs its process with its
consumers cancelled, so it answers inspect and is healthy"*. It answers. It
was not attributed. A comment can be right about the intent and wrong about
the program, and this one had been wrong since the consolidated container
shipped.
`worker_lanes.lane_for_node` attributes by NODE NAME instead: identity travels
with the process rather than with what it happens to be doing.
`gen_supervisord` already sets `CELERY_NODENAME={lane.name}` per program — the
information was there and nothing read it. Falls back to the queue set for a
deployment that names no node, and `docker-compose.yml` now sets one per
service so the multi-service stack gets it too.
The roster keys on the LANE's queue set when the node resolves, which is the
same string the row already had while it was consuming — so an existing row
keeps updating rather than a second one appearing.
Migration 0106 deletes the one key the bug produced, `celery:`. Deliberately
NOT a retention sweep: the roster never forgets on purpose, so a quiet row is
what it is FOR, and only a row that cannot correspond to anything real is safe
to remove. An `agent:agent` row, if one exists, is left alone — nothing here
can tell an abandoned agent id from a second agent that is genuinely down, and
hiding a dead GPU agent is the one thing the roster must not do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
"""The container's healthcheck. Picks the right check from the role it runs.
|
|
|
|
Exit 0 healthy, non-zero unhealthy.
|
|
|
|
## Why this is in the IMAGE and not in every compose file
|
|
|
|
Because the image is the only thing that knows what it is running. A
|
|
deployment had to declare a healthcheck per service, which meant every
|
|
compose file, stack file and README repeated the same knowledge:
|
|
|
|
web -> curl /api/health
|
|
worker -> celery inspect ping -d celery@$HOSTNAME
|
|
all -> both, for every lane
|
|
|
|
Three checks, written out by hand, once per service, in every file anyone
|
|
ever wrote — and none of them wrong until a role changed. Operator, 2026-09-23:
|
|
*"why isn't the healthcheck built into the image or base on what command runs
|
|
if one is passed in."* There was no reason. The role is already a fact the
|
|
container holds; asking the deployment to restate it is the same duplication
|
|
the lane table exists to remove one level down.
|
|
|
|
So `entrypoint.sh` records the role it started, the Dockerfile declares ONE
|
|
`HEALTHCHECK` that runs this, and a stack file says nothing at all. Declaring
|
|
one anyway still works — docker lets a service override the image's — which
|
|
is the escape hatch for a deployment that genuinely wants something else.
|
|
|
|
## What each role is asked
|
|
|
|
* **web** — hypercorn answers `/api/health`. No database: the endpoint is a
|
|
no-DB 200 that proves the app booted and is serving after `alembic upgrade
|
|
head`, which is what a rolling deploy needs to know.
|
|
* **worker / scheduler / ml-worker** — THIS container's celery node answers a
|
|
ping over the broker. Not "some worker answered": the node name is pinned
|
|
to this container, or a healthy sibling would keep a dead one looking alive.
|
|
* **all** — both halves, for every lane in the table. The failure mode
|
|
consolidation creates is that docker can no longer see the lanes as
|
|
separate services, so a web-only check reports a healthy container with
|
|
every worker dead.
|
|
* **shell / alembic / anything else** — nothing to check. These are one-shot
|
|
or interactive; a liveness probe on them has no meaning, so it passes
|
|
rather than inventing a verdict.
|
|
|
|
## An unrecorded role passes rather than failing
|
|
|
|
If the role file is missing, the entrypoint did not run — someone used
|
|
`--entrypoint` or ran a bare command. That is a debugging shape, and a
|
|
healthcheck that cannot tell what it is looking at must not assert that the
|
|
thing is broken (snippet #3969: an unswept read is not a verdict). It says so
|
|
on stdout and exits 0.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# Written by entrypoint.sh at boot. /tmp because it is the one path writable
|
|
# by every role without assuming a volume, and the value is per-container
|
|
# state that must NOT survive into a new container.
|
|
ROLE_FILE = os.environ.get("FC_ROLE_FILE", "/tmp/fc-role")
|
|
|
|
WEB_URL = "http://localhost:8080/api/health"
|
|
WEB_TIMEOUT = 5.0
|
|
|
|
# A broker round trip, so it gets a deadline (rule 156). Generous relative to
|
|
# `inspect`'s 2s elsewhere: this runs every 30s with retries, and a transient
|
|
# blip flagging a worker unhealthy would roll back a deployment that is fine.
|
|
PING_TIMEOUT = 10.0
|
|
|
|
CELERY_ROLES = {"worker", "scheduler", "ml-worker"}
|
|
# Roles with nothing to probe. Listed rather than treated as the default, so
|
|
# an unknown role takes the "I cannot tell" path and says so.
|
|
NO_CHECK_ROLES = {"shell", "bash", "alembic"}
|
|
|
|
|
|
def current_role() -> str | None:
|
|
"""The role this container was started with, or None if nothing recorded."""
|
|
env = os.environ.get("FC_ROLE")
|
|
if env:
|
|
return env.strip()
|
|
try:
|
|
with open(ROLE_FILE) as fh:
|
|
return fh.read().strip() or None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _web_ok() -> tuple[bool, str]:
|
|
try:
|
|
with urllib.request.urlopen(WEB_URL, timeout=WEB_TIMEOUT) as resp:
|
|
if resp.status == 200:
|
|
return True, ""
|
|
return False, f"web returned {resp.status}"
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
return False, f"web unreachable: {exc}"
|
|
|
|
|
|
def _this_node_ok() -> tuple[bool, str]:
|
|
"""Ping THIS container's celery node, by name.
|
|
|
|
Pinned to this node deliberately. A bare `ping()` is answered by any
|
|
worker on the broker, so in a stack with several replicas a dead one
|
|
would go on reporting healthy for as long as a sibling was alive — the
|
|
healthcheck would be measuring the cluster, not the container it is in.
|
|
"""
|
|
from ..celery_app import celery as celery_app
|
|
|
|
node = f"{os.environ.get('CELERY_NODENAME', 'celery')}@{socket.gethostname()}"
|
|
try:
|
|
replies = celery_app.control.ping(destination=[node], timeout=PING_TIMEOUT)
|
|
except Exception as exc: # noqa: BLE001 — a probe reports, never raises
|
|
return False, f"could not reach the broker: {exc}"
|
|
if not replies:
|
|
return False, f"{node} did not answer a ping"
|
|
return True, ""
|
|
|
|
|
|
def _lanes_ok() -> tuple[bool, str]:
|
|
"""Every lane in the table is answering.
|
|
|
|
Deliberately ignores whether a lane is ON: a lane at cap 0 still runs its
|
|
process with its consumers cancelled, so it answers `inspect` and is
|
|
healthy. Health is "is the process alive"; whether it should be consuming
|
|
is a settings question the sizing pass owns, and conflating them would
|
|
make turning a lane off mark the container unhealthy.
|
|
|
|
That was not merely a risk — it was happening. Until 2026-09-23 a worker
|
|
was attributed to its lane by the queues it was CONSUMING, and a lane with
|
|
its consumers cancelled reports none, so it read as absent and this check
|
|
failed. ML ships at cap 0, so a fresh install was permanently unhealthy
|
|
and Swarm restarts an unhealthy task forever. The docstring above said the
|
|
right thing while the code did the opposite; `worker_lanes.lane_for_node`
|
|
is what makes it true.
|
|
"""
|
|
from ..services.worker_control import inspect_lanes_sync
|
|
from ..services.worker_lanes import LANES
|
|
|
|
live = inspect_lanes_sync()
|
|
missing = sorted(lane.name for lane in LANES if not live[lane.name].present)
|
|
if missing:
|
|
return False, "lanes not answering: " + ", ".join(missing)
|
|
return True, ""
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
role = current_role()
|
|
|
|
if role is None:
|
|
# Not a failure. See the module docstring: the entrypoint did not run,
|
|
# so there is no role to check against and no basis for a verdict.
|
|
print("no role recorded; nothing to check")
|
|
return 0
|
|
|
|
if role in NO_CHECK_ROLES:
|
|
print(f"{role}: nothing to check")
|
|
return 0
|
|
|
|
checks = []
|
|
if role == "all":
|
|
checks = [_web_ok, _lanes_ok]
|
|
elif role == "web":
|
|
checks = [_web_ok]
|
|
elif role in CELERY_ROLES:
|
|
checks = [_this_node_ok]
|
|
else:
|
|
print(f"unknown role {role!r}; nothing to check")
|
|
return 0
|
|
|
|
for check in checks:
|
|
ok, detail = check()
|
|
if not ok:
|
|
print(detail, file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|