Skip to content

A hung dependency restarts the container instead of hanging the site - #79

Merged
ralyodio merged 1 commit into
mainfrom
worktree-dep-watchdog
Sep 22, 2026
Merged

ralyodio merged 1 commit into
mainfrom
worktree-dep-watchdog

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Twice now Redis has gone away and tipoffwatch has stayed down until a person noticed:
2026-09-13, and again on 2026-09-22 while I was clearing the volume. Both times the
shape was identical:

  • /healthz → 200 in 50ms
  • / → nothing at all, until curl gave up
  • Railway → service Online, every liveness signal green
  • restarting Redis did not fix it; only redeploying the web service did

Why it cannot recover on its own

One line in the shared client. BullMQ requires maxRetriesPerRequest: null, and that turns
a disconnect into a hang: a command issued while the socket is down is queued until it
returns
rather than rejected.

Verified rather than assumed — a bare ping() against a dead port, with the app's own
client options:

bare ping() against a dead redis -> HUNG      (never settled)
watchdog gave up: true
reason names redis: true

The page render does catch Redis errors and fall through to Postgres, but there is no error
to catch, so the request simply stops. /healthz touches neither dependency, which is why
nothing ever went red.

What this adds

genrewatch already solved this for Postgres in its PR #23, after the same failure on its
pool on 2026-09-07. The rule it established is the important part: the probe must go
through the client the requests actually use.
A separate connection is the one thing
guaranteed to look healthy during this failure.

So that module is ported here, given a subject, and both dependencies get a watchdog:

probe gives up after
the database pool select 1 on the shared sql handle 3 consecutive failures
redis PING on the shared ioredis connection 4 consecutive failures

Redis gets one more failure than the pool on purpose. A healthy Redis here is routinely
unreachable for a while, because it restarts by reading an RDB off the volume before
accepting anything — 26 seconds now, 124 before the event streams were trimmed in #78. Boot
already throws if Redis is absent, so an impatient watchdog would turn one Redis deploy into
a deploy loop on this service.

Both watchdogs run for either role: the web side reads the page cache and writes passkey
challenges through the same client the workers queue on, and a wedged worker stops every
score updating without anything going red. They are stopped first on SIGTERM, so a clean
drain is never mistaken for a wedge.

Every knob has a working default (DB_WATCHDOG_*, REDIS_WATCHDOG_*), so no service needs
a new variable.

On exiting

It reads as drastic for a web server and it is the cheapest correct move: the failure is
process-local state no request can repair, a restart demonstrably clears it (both outages
ended with one), and Railway replaces the container in about a minute. Hanging forever is
not the safer option — it is the outage.

Testing

test/db-watchdog.test.js — 10 pass, 0 fail. Seven cases ported from genrewatch
(gives up only on consecutive failures, a success clears the count, a rejection counts the
same as a hang, it does not keep firing after giving up, it refuses to start without a
probe, it does not hold the process open). Three new ones for this repo's half: the reason
names redis, a PING queued forever is caught, and a Redis that is merely slow to load its
RDB does not trigger a restart.

Also: 77 pass / 0 fail across the watchdog plus the config, nichedb and odds files;
bun build apps/web/src/main.js bundles clean (1160 modules), which is what checks the new
connection import actually resolves; biome check clean on all three files.

🤖 Generated with Claude Code

Twice now Redis has gone away and tipoffwatch has stayed down until a person
noticed: 2026-09-13, and again on 2026-09-22 while the volume was being cleared.
Both times the shape was identical. `/healthz` answered 200 in 50ms, `/` returned
nothing at all, Railway reported the service Online, and restarting Redis did not
fix it -- the wedge is on this side of the socket, so only redeploying the web
service brought the site back.

The reason it cannot recover on its own is one line in the shared client. BullMQ
requires `maxRetriesPerRequest: null`, and that turns a disconnect into a hang: a
command issued while the socket is down is queued until it returns rather than
rejected. Verified rather than assumed -- a bare `ping()` against a dead port with
the app's own options never settles. The page render catches Redis errors and
falls through to Postgres, but there is no error to catch, so the request simply
stops. `/healthz` touches neither dependency, which is why every liveness signal
Railway has stayed green through both outages.

genrewatch already solved this for Postgres in its PR #23, after the same failure
on its pool: probe through the client the requests actually use, and exit when it
stops answering. A separate connection is the one thing guaranteed to look
healthy during this failure, which is why the probe has to share the client.

So the watchdog is ported here and given a subject, and both dependencies get
one: `select 1` on the shared `sql` handle, `PING` on the shared ioredis
connection. Exiting reads as drastic for a web server and is the cheapest correct
move -- the failure is process-local state no request can repair, a restart
demonstrably clears it, and Railway replaces the container in about a minute.
Hanging forever is not the safer option, it is the outage.

Redis gets one more failure than the pool, four probes rather than three. A
healthy Redis here is routinely unreachable for a while, because it restarts by
reading an RDB off the volume before accepting anything: 26 seconds now, 124
before the event streams were trimmed. Boot already throws if Redis is absent, so
an impatient watchdog would turn one Redis deploy into a deploy loop on this
service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit bb89ced into main Sep 22, 2026
2 checks passed
@ralyodio
ralyodio deleted the worktree-dep-watchdog branch September 22, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant