fix: each lane needs its own celery node name, or three of four vanish (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m6s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 1m52s
Build images / smoke-web (push) Successful in 56s
Build images / promote (push) Skipped

Found by the all-role smoke on its very first execution (run 7319), which is
the whole argument for having added it one commit ago.

Celery's default node name is `celery@<hostname>`. In the single-container
layout all four lanes share one hostname, so all four registered as the SAME
node. Celery says so itself:

    DuplicateNodenameWarning: Received multiple replies from node name:
    celery@72adc5b706a7

`inspect` collapses four replies into one dict key and the last one wins, so
three lanes read as absent — and WHICH three varies between calls:

    lanes not answering: maintenance_long, ml, worker
    lanes not answering: maintenance_long, scheduler, worker

Fatal twice over:

  * The composite healthcheck can never pass. In Swarm that is a container
    that never goes healthy — restart loop, then an automatic rollback of a
    deploy whose image was fine.
  * `pool_grow`/`pool_shrink` take a `destination` of node names. The UI dial
    and the autoscaler would have resized whichever lane happened to answer
    rather than the one asked for — silently, and differently each time.

Every celery role now starts with `-n "${CELERY_NODENAME:-celery}@%h"`, and
the generated supervisord config sets that per lane. The lanes become
worker@<cid>, scheduler@<cid>, maintenance_long@<cid>, ml@<cid> — distinct,
so inspect keeps four entries and `destination` addresses what it names.
`inspect_lanes_sync` maps hostname to lane by QUEUES, so nothing there
changes; it just stops having three of its four entries overwritten.

Unset, it falls back to `celery` — exactly celery's own default — so every
service in the multi-service stack is byte-identical to before, including the
`celery@$HOSTNAME` healthcheck in docker-compose.yml and in the operator's
Swarm stack.

The test asserts DISTINCTNESS across the whole lane table rather than a fixed
string per lane. The property that broke is that no two collide, and stating
it that way keeps holding when a lane is added.

This is the bug I said a live deploy was needed to find, found in CI instead
for the price of one `docker run` — and it would have met the operator as a
rollback loop on their first consolidated deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 08:52:56 -04:00
co-authored by Claude Opus 5
parent 43ac737516
commit 828c6a5ae3
3 changed files with 83 additions and 1 deletions
+8 -1
View File
@@ -98,7 +98,14 @@ def _program(lane: Lane, *, slots: int) -> str:
# the lane would consume only its first queue. Silent: the worker # the lane would consume only its first queue. Silent: the worker
# starts, reports healthy, and simply never picks up `import`. # starts, reports healthy, and simply never picks up `import`.
f'environment=CELERY_QUEUES="{",".join(lane.queues)}",' f'environment=CELERY_QUEUES="{",".join(lane.queues)}",'
f"CELERY_CONCURRENCY={slots}", f"CELERY_CONCURRENCY={slots},"
# A UNIQUE celery node name per lane, and the reason is not cosmetic.
# These processes share one hostname, so celery's default
# `celery@<hostname>` made all four the SAME node: inspect collapsed
# their replies, three lanes read as absent, and which three varied
# per call (run 7319). The healthcheck could never pass, and
# pool_grow's `destination` would have addressed an arbitrary lane.
f"CELERY_NODENAME={lane.name}",
"autostart=true", "autostart=true",
"autorestart=true", "autorestart=true",
# A lane that dies instantly and repeatedly is a broken image, not a # A lane that dies instantly and repeatedly is a broken image, not a
+28
View File
@@ -6,6 +6,31 @@ set -euo pipefail
# disagreement between them would only show up as `docker run --entrypoint` # disagreement between them would only show up as `docker run --entrypoint`
# behaving differently from `docker run`. # behaving differently from `docker run`.
ROLE="${1:-all}" ROLE="${1:-all}"
# CELERY NODE NAME. Every celery role below starts with `-n $CELERY_NODENAME@%h`.
#
# Celery's default node name is `celery@<hostname>`, and in the single-
# container layout all four lanes share one hostname — so all four registered
# as the SAME node. celery's own words for it, observed on run 7319:
#
# DuplicateNodenameWarning: Received multiple replies from node name:
# celery@72adc5b706a7
#
# `inspect` then collapses four replies into one dict key and the last one
# wins, so three lanes read as absent and WHICH three varies per call:
#
# lanes not answering: maintenance_long, ml, worker
# lanes not answering: maintenance_long, scheduler, worker
#
# That is fatal twice over. The composite healthcheck can never pass, so the
# container is permanently unhealthy; and `pool_grow(destination=[hostname])`
# addresses a lane BY that name, so the UI dial and the autoscaler would have
# resized whichever lane happened to answer rather than the one asked for.
#
# The generated supervisord config sets this per lane. Unset — which is every
# service in the multi-service stack — it falls back to `celery`, exactly
# celery's own default, so `celery@$HOSTNAME` healthchecks there still work.
: "${CELERY_NODENAME:=celery}"
shift || true shift || true
case "$ROLE" in case "$ROLE" in
@@ -32,6 +57,7 @@ case "$ROLE" in
CONCURRENCY="${CELERY_CONCURRENCY:-2}" CONCURRENCY="${CELERY_CONCURRENCY:-2}"
echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY" echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q "$QUEUES" \
--concurrency="$CONCURRENCY" --concurrency="$CONCURRENCY"
@@ -47,6 +73,7 @@ case "$ROLE" in
CONCURRENCY="${CELERY_CONCURRENCY:-1}" CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES concurrency=$CONCURRENCY" echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--beat \ --beat \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q "$QUEUES" \
@@ -68,6 +95,7 @@ case "$ROLE" in
CONCURRENCY="${CELERY_CONCURRENCY:-1}" CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting ML Celery worker queues=$QUEUES concurrency=$CONCURRENCY" echo "[entrypoint] Starting ML Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q "$QUEUES" \
--concurrency="$CONCURRENCY" --concurrency="$CONCURRENCY"
+47
View File
@@ -184,3 +184,50 @@ def test_every_program_writes_to_the_container_stdout_with_its_lane_named():
assert cp.getint(section, "stdout_logfile_maxbytes") == 0, section assert cp.getint(section, "stdout_logfile_maxbytes") == 0, section
assert cp.get(section, "redirect_stderr") == "true", section assert cp.get(section, "redirect_stderr") == "true", section
assert f"[{name}] " in cp.get(section, "command"), section assert f"[{name}] " in cp.get(section, "command"), section
def test_every_lane_gets_its_own_celery_node_name():
"""The bug that made the single-container layout unusable, found the first
time CI actually booted it (run 7319).
Celery's default node name is `celery@<hostname>`, and these processes
share one hostname — so all four lanes registered as the SAME node:
DuplicateNodenameWarning: Received multiple replies from node name:
celery@72adc5b706a7
`inspect` then collapses four replies into one dict key, last one wins.
Three lanes read as absent, and WHICH three varied between calls:
lanes not answering: maintenance_long, ml, worker
lanes not answering: maintenance_long, scheduler, worker
Fatal twice: the composite healthcheck can never pass, so the container is
permanently unhealthy and Swarm restart-loops it; and `pool_grow` takes a
`destination` of node names, so the UI dial and the autoscaler would have
resized whichever lane happened to answer rather than the one asked for.
Asserted as DISTINCTNESS across the whole table rather than as a fixed
string per lane — the property is that no two lanes collide, which is what
actually broke, and it keeps holding when a lane is added.
"""
cp = _parse()
names = {}
for section in cp.sections():
if not section.startswith("program:"):
continue
lane = section.split(":", 1)[1]
if lane == "web":
continue # hypercorn, not a celery node
env = cp.get(section, "environment")
pairs = dict(
p.split("=", 1) for p in env.split(",") if "=" in p and not p.startswith('"')
)
assert "CELERY_NODENAME" in pairs, f"{lane} has no CELERY_NODENAME: {env}"
names[lane] = pairs["CELERY_NODENAME"]
assert len(set(names.values())) == len(names), (
f"two lanes share a celery node name, which is the collapse itself: {names}"
)
for lane, node in names.items():
assert node == lane, f"{lane} announces itself as {node!r}"