Commit Graph
4 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 efde3b188f refactor: the image carries its own healthcheck and picks it by role (4295)
CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
extension / lint (push) Successful in 18s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m42s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"why isn't the healthcheck built into the image or
base on what command runs if one is passed in. why is it manually declared in
the stack here."*

No good reason. The container is the only thing that knows what it was asked
to run, and every compose file, stack file and README had to restate it:

    web         -> urllib /api/health
    worker      -> celery inspect ping -d celery@$HOSTNAME
    all         -> both, for every lane

Three checks written by hand, once per service, in every file anyone ever
wrote — none of them wrong until a role changed, and all of them silently
wrong after. The same duplication the lane table exists to remove one level
down, and I built it without noticing.

`entrypoint.sh` now records the role it started. The Dockerfile declares ONE
`HEALTHCHECK` that reads it and asks the right question: HTTP for web, a
self-addressed celery ping for a worker lane, both-for-every-lane for `all`,
and nothing for shell/alembic, which are one-shot and have no liveness to
probe. `docker-compose.single.yml` and the consolidated stack declare none.
A service that wants something else can still declare its own; docker prefers
it, so the escape hatch is the default docker behaviour rather than a flag.

Two details that are load-bearing:

  * The role is written ONCE, by the outermost invocation. `all` starts the
    other roles through this same script under supervisord, and a child
    overwriting the container's role would turn the composite check into a
    web-only one — silently, and only on the consolidated path. FC_ROLE is
    exported so a child sees it set and skips.
  * The celery ping is addressed to THIS node, not a bare ping. A bare one is
    answered by any worker on the broker, so in a stack with replicas a dead
    container would report healthy for as long as a sibling lived — the check
    would be measuring the cluster rather than the container it is inside.

`healthcheck_all.py` is deleted; its two probes moved into the dispatcher
rather than being a second copy beside it.

An unrecorded role PASSES. The entrypoint always writes the file, so the only
way to miss it is bypassing the entrypoint — a debugging shape, where a check
that cannot tell what it is looking at must not assert the thing is broken
(snippet #3969). Said on stdout rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 09:07:39 -04:00
bvandeusenandClaude Opus 5 43ac737516 feat: the whole application is what the image runs by default (4296)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
extension / lint (push) Successful in 19s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 1m41s
CI / integration (push) Successful in 2m7s
Build images / smoke-web (push) Failing after 12m36s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"I also want to see that we remove the need for the
command line of the configuration in the consolidated version."*

`CMD` was `web`, so the single-container layout only worked if you knew to
ask for it by name. A compose file that forgot `command: ["all"]` got a web
server with nothing processing its queues — a gallery that loads, accepts an
import, and never finishes one. Nothing errors; it just never progresses.

Now `docker run fabledcurator` with no command starts hypercorn plus every
lane under supervisord. `entrypoint.sh`'s own default moves with it, since
the two are doors to the same decision and a disagreement would only show up
as `--entrypoint` behaving differently from a plain run.
`docker-compose.single.yml` drops its `command:` line; `["all"]` still works
and still means the same thing.

The multi-service stack is untouched — every service there names its role
explicitly, which is what makes it the multi-service stack.

## And CI now actually boots it

This is the gap I should have named when I reported milestone 422 at 7/7 and
did not. Measured, not inferred: the smoke booted role `web` only
(build.yml:1454), nothing in CI ran `all`, `docker-compose.single.yml` was
read as TEXT by one test checking stop_grace_period and never run, and
test_gen_supervisord asserts the generated config against the lane table
without ever handing it to supervisord.

So the shape this milestone is NAMED for had started nowhere. Steps 5-7 were
marked done on evidence that did not cover it, and the operator is about to
collapse their production stack onto exactly that.

The smoke now boots the image with NO command — checking the Dockerfile CMD,
the entrypoint default and the role together, the way an adopter gets it —
and asserts `healthcheck_all`, which was itself never executed. That check
passes only when hypercorn answers AND every lane in the table answers the
broker; a web-only check goes green with every worker dead, which is the
failure mode consolidation creates. It then prints `supervisorctl status`, so
a lane that is merely restart-looping is visible rather than inferred.

Cheap because ml ships at 0 slots and disabled: nothing loads a model, and
the lane answers `inspect` with its consumers cancelled, which is what
healthy means for a disabled lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 08:48:11 -04:00
bvandeusenandClaude Opus 5 e579333455 docs: record that the ml :ro loss is a ruled non-issue (4295)
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
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / integration (push) Successful in 2m16s
Operator, 2026-09-22: "I don't care about the :ro loss thank you for calling
it out repeated but I don't care." Raised three times across the milestone
body, this file's header and two reports. Recorded as settled at the point
someone would rediscover it, so it is not raised a fourth time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-22 08:38:49 -04:00
bvandeusenandClaude Opus 5 172e33de9a feat: run web and every worker lane in one container (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
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
2026-09-22 08:32:27 -04:00