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 yields NaN; listen(NaN) binds a random
port instead of failing.
- An injected
PORT moves the listener while the Dockerfile's HEALTHCHECK/EXPOSE hardcode
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/uncaughtException handlers; the worker starts consuming jobs even if
the bind failed.
JobType.TICKET_CLASSIFY has no handler and no worker.on registration, so such jobs are
never claimed and sit forever without being dead-lettered.
- No
defaultTimeoutMs, so GITHUB_REACTION_POLL, JOB_CLEANUP and TRACKER_SYNC silently
get 30s.
- The file header claims
ESCALATION "posts notification"; the handler only writes a DB row.
Acceptance criteria
Summary
Two defects in
apps/workercombine into a failure mode that is both likely and invisible: aworker can wedge with jobs stuck
PROCESSING, and its health endpoint keeps reportingokso 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 ownSIGTERM/SIGINThandler, which callsstop()un-awaited and flips
running = falsefirst. So by the timeapps/worker/src/index.ts'sshutdown()runsawait worker.stop(), that call early-returns — andprisma.$disconnect()plusprocess.exit(0)execute while jobs are still in flight.Those job rows are left in
PROCESSING, which then permanently blocks the Scheduler's dedupefor that job type.
Contributing factors in the same function:
shutdown()is not idempotent (a second signaldouble-disconnects and double-closes), has no try/finally (a rejection becomes an unhandled
rejection and
process.exitnever runs), has no drain deadline, and does not awaithealthServer.close().2.
/healthreturns 200 even when the worker is deadThe handler builds
worker.healthCheck()and then unconditionally writes 200 withstatus: 'ok', ignoringhealth.running. Railway's healthcheck therefore never restarts astopped worker. Compounding it,
lastPollTimeis 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 notnullish, so an empty value skips the fallback and yields
NaN;listen(NaN)binds a randomport instead of failing.
PORTmoves the listener while the Dockerfile'sHEALTHCHECK/EXPOSEhardcode3005 — a healthy worker then crash-loops.
req.url === '/health'is an exact match, so/health/or any query string 404s.'error'listener on the health server (EADDRINUSE crashes opaquely) and nounhandledRejection/uncaughtExceptionhandlers; the worker starts consuming jobs even ifthe bind failed.
JobType.TICKET_CLASSIFYhas no handler and noworker.onregistration, so such jobs arenever claimed and sit forever without being dead-lettered.
defaultTimeoutMs, soGITHUB_REACTION_POLL,JOB_CLEANUPandTRACKER_SYNCsilentlyget 30s.
ESCALATION"posts notification"; the handler only writes a DB row.Acceptance criteria
Workerhandles signals or the entrypoint does, not both;in-flight jobs drain (or are released back to
PENDING) before$disconnect()shutdown()is idempotent, has a drain deadline, and cannot exit 0 on failure/healthreturns non-200 whenrunningis false;lastPollTimeupdates only after asuccessful poll
PORT/HEALTH_PORTfails loudly instead of binding a random portTICKET_CLASSIFYis either implemented or removed fromJobType/healthpath (apps/workercurrently has zero test files, masked by
passWithNoTests)