Repository navigation
Worker lifecycle: shutdown leaves jobs stuck PROCESSING and /health hides a dead worker #138
Description
Summary
Two defects in apps/worker combine into a failure mode that is both likely and invisible: a
worker can wedge with jobs stuck PROCESSING, and its health endpoint keeps reporting ok
so Railway never restarts it. If anyone ever reports "the worker silently stopped processing
but looks healthy", this is the explanation.
Found during a code review of an unrelated PR; verified against source.
1. Graceful shutdown never happens — rows stick in PROCESSING
Worker.start() registers its own SIGTERM/SIGINT handler, which calls stop()
un-awaited and flips running = false first. So by the time apps/worker/src/index.ts's
shutdown() runs await worker.stop(), that call early-returns — and
prisma.$disconnect() plus process.exit(0) execute while jobs are still in flight.
Those job rows are left in PROCESSING, which then permanently blocks the Scheduler's dedupe
for that job type.
Contributing factors in the same function: shutdown() is not idempotent (a second signal
double-disconnects and double-closes), has no try/finally (a rejection becomes an unhandled
rejection and process.exit never runs), has no drain deadline, and does not await
healthServer.close().
2. /health returns 200 even when the worker is dead
The handler builds worker.healthCheck() and then unconditionally writes 200 with
status: 'ok', ignoring health.running. Railway's healthcheck therefore never restarts a
stopped worker. Compounding it, lastPollTime is set before the DB call in the poll loop,
so it stays fresh even when every poll throws — the one field that could reveal a broken loop
looks healthy.
3. Smaller items in the same file
parseInt(process.env.PORT ?? process.env.HEALTH_PORT ?? '3003')—PORT=''is not
nullish, so an empty value skips the fallback and yieldsNaN;listen(NaN)binds a random
port instead of failing.- An injected
PORTmoves the listener while the Dockerfile'sHEALTHCHECK/EXPOSEhardcode
3005 — a healthy worker then crash-loops. req.url === '/health'is an exact match, so/health/or any query string 404s.- No
'error'listener on the health server (EADDRINUSE crashes opaquely) and no
unhandledRejection/uncaughtExceptionhandlers; the worker starts consuming jobs even if
the bind failed. JobType.TICKET_CLASSIFYhas no handler and noworker.onregistration, so such jobs are
never claimed and sit forever without being dead-lettered.- No
defaultTimeoutMs, soGITHUB_REACTION_POLL,JOB_CLEANUPandTRACKER_SYNCsilently
get 30s. - The file header claims
ESCALATION"posts notification"; the handler only writes a DB row.
Acceptance criteria
- One shutdown owner: either
Workerhandles signals or the entrypoint does, not both;
in-flight jobs drain (or are released back toPENDING) before$disconnect() -
shutdown()is idempotent, has a drain deadline, and cannot exit 0 on failure -
/healthreturns non-200 whenrunningis false;lastPollTimeupdates only after a
successful poll - Invalid
PORT/HEALTH_PORTfails loudly instead of binding a random port -
TICKET_CLASSIFYis either implemented or removed fromJobType - Tests cover shutdown-with-in-flight-jobs and the unhealthy
/healthpath (apps/worker
currently has zero test files, masked bypassWithNoTests)
Activity
NathanTarbert commentedon Aug 3, 2026
Independently re-verified during the CR of #150, against
mainatf59ebe4. Both defects in this issue reproduce exactly as described, and two reviewers found them without knowing this issue existed:Worker.start()registering its ownSIGTERM/SIGINThandler meansapps/worker/src/index.ts'sawait worker.stop()early-returns on!this.running, soprocess.exit(0)runs with jobs in flight (packages/outpost/queue/src/worker.ts:121,apps/worker/src/index.ts:141)./healthreturns 200 unconditionally (apps/worker/src/index.ts:108), so a stopped worker still reports healthy.One addition not in the original write-up:
stop()can also resolve while a poll is mid-claim, so rows can be leftPROCESSINGeven on an otherwise orderly shutdown.Scheduling problems found in the same review are filed separately as #153 (per-type claiming serializes types and oversubscribes
maxConcurrency) and #154 (unregistered types are never claimed, so the no-handler path is dead andTICKET_CLASSIFYaccumulates silently) — those are distinct from this issue's shutdown/health scope.fix(sync): address review — typed deps, corrupt-config diagnosis, boo…
docs(sync): record that the capability list cannot see env-conditiona…
NathanTarbert commentedon Sep 15, 2026
Closing as superseded — both halves are tracked separately and more precisely.
This issue pairs two defects: shutdown leaving jobs stuck
PROCESSING, and/healthreportingokfor a worker that has stopped working. Each now has its own issue, written after more digging:shutdown()does not actually drain in-flight jobs/healthreports ok for a stopped or wedged worker, which names this issue as the one it revisits#182 is addressed by #262, which is open and green. #181 is not fixed by that PR and stays open: registering the SIGTERM handler before the boot await makes
shutdown()win the race with the worker's own handler, butclaimJobsByTypenever re-checksrunningbetween types, so an in-flight poll keeps claiming afterstop()returns.Nothing here is dropped — this one was the umbrella, and the two children are sharper about what is actually wrong.