Files
FabledCurator/entrypoint.sh
T
bvandeusenandClaude Opus 5 b2da3acce9
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m43s
CI / integration (push) Successful in 2m12s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
feat: wait for Postgres and Redis before starting work (4295)
Operator, 2026-09-23: *"it's a single container that need to connect
successfully to redis and postgres before starting work shouldn't that simply
be a check (with retries) at the start of the container."*

Yes, and the consolidated layout makes it necessary rather than tidy.

Swarm has no ordering primitive — it ignores `depends_on` outright — so every
service in a stack starts at once and this container has always raced its own
database on a cold deploy. The multi-service stack hid how sharp that is: a
`web` task that failed `alembic upgrade head` against a still-initialising
Postgres simply died, and Swarm restarted it until it worked. Nobody ever saw
a problem worth naming.

Consolidation removes that safety net. Each supervisord program gets
`startretries=3`, so three quick failures put the program in FATAL and leave
it there — supervisord keeps running, the container keeps running, and the
application never starts. It would present as a permanently unhealthy
container whose image was fine and whose database merely took twenty seconds
to initialise, which is a miserable thing to debug on a first deploy.

A TCP connect, not a query: the same probe ci.yml's integration lane and the
build smoke already use. It answers the question actually being asked — is
something listening — and cannot fail for a reason that retrying will never
fix. A real query would be a stronger readiness signal and a worse gate,
since a wrong password or a missing database is not transient, and a loop
waiting for one to heal turns a five-second misconfiguration into a
two-minute timeout with a misleading message. Those belong to alembic, which
runs seconds later and says exactly what is wrong.

Targets are derived from the same env the application reads, so the wait
cannot drift from what the app will actually connect to — a gate checking a
different host than the app uses is worse than no gate.

Bounded at 120s (rule 156), reporting every few attempts so `docker logs` on
a waiting container says what it is waiting for. Skipped for `shell`, which
exists precisely for when something else is broken and you want a prompt
rather than a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 09:17:58 -04:00

181 lines
7.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Defaults to the whole application (see the Dockerfile's CMD). Kept in step
# with that CMD deliberately: they are two doors to the same decision, and a
# disagreement between them would only show up as `docker run --entrypoint`
# behaving differently from `docker run`.
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}"
# RECORD THE ROLE, for the image's own HEALTHCHECK to read.
#
# The container is the only thing that knows what it was asked to run, and
# before this every compose and stack file had to restate it as a healthcheck
# of its own. The Dockerfile now declares one check that dispatches on this.
#
# Written ONCE, by the outermost invocation. The `all` role starts the other
# roles through this same script under supervisord, and those children must
# not overwrite the container's role with their own — a lane starting would
# turn the composite check into a web-only one, silently. FC_ROLE is exported,
# so a child sees it set and skips.
#
# Best effort: a read-only /tmp is not a reason to refuse to boot. The
# healthcheck treats a missing file as "nothing to check" rather than as a
# failure, for the same reason.
if [ -z "${FC_ROLE:-}" ]; then
export FC_ROLE="$ROLE"
printf '%s\n' "$ROLE" > "${FC_ROLE_FILE:-/tmp/fc-role}" 2>/dev/null || true
fi
# WAIT FOR POSTGRES AND REDIS before doing anything that needs them.
#
# Swarm has no ordering primitive — it ignores `depends_on` entirely — so
# every service in a stack starts at once and this container races its own
# database on every cold deploy.
#
# The multi-service stack hid how sharp that is: a `web` task that failed
# `alembic upgrade head` against a still-initialising Postgres simply died,
# and Swarm restarted it until it worked. Consolidation removes that. Each
# supervisord program gets `startretries=3`, so three quick failures put the
# program in FATAL and leave it there — supervisord keeps running, the
# container keeps running, and the application never starts. It would present
# as a permanently unhealthy container whose image was fine and whose
# database merely took twenty seconds to come up.
#
# Skipped for `shell`, which exists precisely for the case where something
# else is broken and you want a prompt rather than a gate.
case "$ROLE" in
shell|bash) ;;
*) python -m backend.app.scripts.wait_for_deps ;;
esac
shift || true
case "$ROLE" in
web)
echo "[entrypoint] Running alembic upgrade head"
alembic upgrade head
echo "[entrypoint] Starting hypercorn on :8080"
# create_app is a factory — the `()` tells hypercorn to call it once
# and serve the returned Quart (ASGI) app, rather than treating the
# function itself as the application (which it then mis-invokes as WSGI).
# Default 4 workers (was 2): each worker is one asyncio loop, and a large
# file download occupies its worker for the transfer — 2 was too few once the
# GPU agent + the browser's thumbnail grid hit /images concurrently (they
# queued behind each other). Env-tunable via HYPERCORN_WORKERS.
exec hypercorn \
--bind 0.0.0.0:8080 \
--workers "${HYPERCORN_WORKERS:-4}" \
--access-logfile - \
"backend.app:create_app()"
;;
worker)
QUEUES="${CELERY_QUEUES:-default,import,thumbnail}"
CONCURRENCY="${CELERY_CONCURRENCY:-2}"
echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \
-Q "$QUEUES" \
--concurrency="$CONCURRENCY"
;;
scheduler)
QUEUES="${CELERY_QUEUES:-maintenance,scan}"
# Honours CELERY_CONCURRENCY like the `worker` role does. It was hardcoded
# to 1, which was harmless while only compose started this lane and set no
# concurrency for it — but the generated supervisord config (milestone 422
# step 5) passes one, and a value silently ignored at boot would leave the
# lane at 1 until the reconcile sweep noticed, with nothing saying why.
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--beat \
--loglevel=info \
-Q "$QUEUES" \
--concurrency="$CONCURRENCY"
;;
ml-worker)
# NO MODEL DOWNLOAD HERE (milestone 422 step 6). This used to run
# download_models before celery started, which made every boot of this
# role reach HuggingFace for ~3.5GB. Rule 164 permits a runtime fetch only
# for a feature that is "optional and clearly off" — so the fetch moved to
# the moment the operator ENABLES the lane, where it is visible, retryable
# and attributable, instead of being a silent precondition of starting.
#
# The worker therefore starts with no model present, which is correct: it
# is not consuming the ml queue until the lane is enabled, and enabling it
# is what enqueues ensure_models.
QUEUES="${CELERY_QUEUES:-ml}"
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting ML Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \
-Q "$QUEUES" \
--concurrency="$CONCURRENCY"
;;
all)
# The single-container layout (milestone 422 step 5): hypercorn plus one
# celery process per lane, under supervisord, in one container beside
# Postgres and Redis.
#
# The config is GENERATED from services/worker_lanes.LANES rather than
# checked in, so the processes this container runs and the lanes the
# application believes in cannot disagree — see the generator's docstring
# for why a static .conf would have been a fifth copy of the queue names.
#
# supervisord is PID 1 here and never reads the database. Every lane boots
# at its LANES default; the reconcile sweep raises it to whatever the
# operator stored, within one tick. That ordering is deliberate: settings
# adjust a baseline that already works, and can never prevent a boot.
CONF="${SUPERVISOR_CONF:-/tmp/supervisord.conf}"
echo "[entrypoint] Generating $CONF from the lane table"
python -m backend.app.scripts.gen_supervisord > "$CONF"
echo "[entrypoint] Starting supervisord (web + worker lanes)"
exec supervisord -c "$CONF"
;;
shell|bash)
exec /bin/bash "$@"
;;
alembic)
exec alembic "$@"
;;
*)
echo "[entrypoint] Unknown role: $ROLE" >&2
echo "[entrypoint] Valid roles: all | web | worker | scheduler | maintenance_long | ml | ml-worker | shell | alembic" >&2
exit 1
;;
esac