fix(ci): restart the api only after the worker has finished migrating
Some checks failed
deploy / Build prerendered web (push) Blocked by required conditions
deploy / Build api + worker (static musl) (push) Successful in 5m33s
deploy / Deploy moments-worker to frootmig (push) Successful in 18s
deploy / Deploy moments-api to nikola (push) Successful in 22s
deploy / Deploy web to oolon (push) Has been cancelled

The worker owns migrations — it connects as moments_rw, while the api is
SELECT-only and would fail with `permission denied for schema public` if
it tried. Nothing ordered the two deploy jobs, though, and they run
against different hosts (nikola, frootmig), so the systemd ordering the
comment in moments-api/src/main.rs appeals to cannot reach between them.

On the run that shipped the `events.repo` migration, the worker happened
to restart 5 seconds before the api. Reversed, the api would have come up
querying a column that did not exist yet and errored on every request
touching it — and `/v1/healthz` returns a static "ok" without touching
the database, so the deploy's own health probe would have passed it as
fine.

deploy-api now needs deploy-worker. That ordering is only worth anything
if the worker job stays open until migrations are actually done, and it
didn't: the unit is Type=simple, so `systemctl restart` returns once the
process is exec'd and `is-active` is true immediately, neither of which
says anything about migrations. So the job now waits for the worker to
log "worker started", which it does only after awaiting store.migrate().
The search is scoped to the unit's current InvocationID so a previous
start's line cannot satisfy the wait, and it gives up loudly after 120s
or as soon as the process exits.

The wait deliberately avoids `journalctl | grep -q`: a consumer that
exits on first match closes the pipe, journalctl dies of SIGPIPE, and
under `pipefail` the match reads as a failure — which is exactly what the
first draft did, and it would have failed every deploy on a worker that
started perfectly. journalctl --grep does the filtering instead and the
result is tested for emptiness, with no pipe in the pipeline.

Verified by extracting the gate out of the workflow and running it
against fake systemctl/journalctl: started -> exit 0 on the first check;
process gone -> exit 1 immediately with "worker exited before reporting
startup"; no InvocationID -> exit 1 immediately; never logs but stays
active -> loops and fails on timeout. Job graph re-checked acyclic with
every `needs:` resolving to a real job (binaries -> worker -> api -> web).

Closes #8
This commit is contained in:
2026-08-17 13:04:19 +03:00
parent ef8521b9fb
commit 7e186f76ae
2 changed files with 65 additions and 12 deletions

View File

@@ -106,11 +106,11 @@ jobs:
# raced the api/worker deploys and the crawler snapshot silently disagreed
# with the live api for a whole day, until the nightly refresh: it won the
# race once (a visibility fix reached both together) and lost it once (an
# api field the snapshot then lacked). deploy-worker is in the list too,
# not just deploy-api — the worker owns migrations, so a schema change
# isn't applied until it has restarted.
# api field the snapshot then lacked). deploy-worker is named as well as
# deploy-api even though deploy-api already implies it — stating it keeps
# this job correct on its own terms if that chain is ever shortened.
#
# The cost is a serial deploy: binaries -> api/worker -> web. Worth it;
# The cost is a serial deploy: binaries -> worker -> api -> web. Worth it;
# the alternative is a snapshot whose correctness depends on which runner
# finished first.
needs: [deploy-api, deploy-worker]
@@ -133,7 +133,13 @@ jobs:
deploy-api:
name: Deploy moments-api to nikola
needs: build-binaries
# The worker owns migrations (it connects as moments_rw; the api is
# SELECT-only and cannot run them), so the schema has to be current before
# a new api starts answering with it. These are separate hosts — nikola and
# frootmig — so the systemd ordering the comment in moments-api/src/main.rs
# refers to cannot reach across them; the dependency has to live here.
# deploy-worker holds itself open until migrations have actually completed.
needs: [build-binaries, deploy-worker]
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
@@ -328,6 +334,45 @@ jobs:
sudo /usr/bin/systemctl restart moments-worker.service
sudo /usr/bin/systemctl is-active --quiet moments-worker.service'
# The unit is Type=simple, so `restart` returns once the process is
# exec'd and `is-active` is true immediately — neither says anything
# about migrations. deploy-api waits on this job precisely so that the
# schema is current before the new api answers a request, so the job
# has to stay open until migrations are actually done.
#
# moments-worker awaits store.migrate() before it logs "worker started",
# so that line is the signal. Scoping the search to the unit's current
# InvocationID means a previous start's line can't satisfy the wait.
- name: Wait for migrations to finish
run: |
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
invocation="$(systemctl show --property=InvocationID --value moments-worker.service)"
if [ -z "${invocation}" ]; then
echo "no InvocationID for moments-worker.service" >&2
exit 1
fi
for attempt in $(seq 1 60); do
# Deliberately no `| grep -q`: a consumer that exits on first
# match closes the pipe, journalctl dies of SIGPIPE, and under
# `pipefail` the match reads as a failure. Let journalctl filter
# itself and test whether anything came back. `|| true` because
# --grep exits 1 when nothing matched yet.
started="$(journalctl _SYSTEMD_INVOCATION_ID="${invocation}" \
--no-pager --grep "worker started" || true)"
if [ -n "${started}" ]; then
echo "migrations complete after ${attempt} check(s)"
exit 0
fi
if ! systemctl is-active --quiet moments-worker.service; then
echo "worker exited before reporting startup" >&2
exit 1
fi
sleep 2
done
echo "timed out after 120s waiting for worker startup" >&2
exit 1'
- name: Capture startup journal
if: always()
run: |