A hung dependency restarts the container instead of hanging the site - #79
Merged
Merged
Conversation
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>
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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→200in 50ms/→ nothing at all, until curl gave upWhy it cannot recover on its own
One line in the shared client. BullMQ requires
maxRetriesPerRequest: null, and that turnsa 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 ownclient options:
The page render does catch Redis errors and fall through to Postgres, but there is no error
to catch, so the request simply stops.
/healthztouches neither dependency, which is whynothing 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:select 1on the sharedsqlhandlePINGon the shared ioredisconnectionRedis 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 cleandrain is never mistaken for a wedge.
Every knob has a working default (
DB_WATCHDOG_*,REDIS_WATCHDOG_*), so no service needsa 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
PINGqueued forever is caught, and a Redis that is merely slow to load itsRDB 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.jsbundles clean (1160 modules), which is what checks the newconnectionimport actually resolves;biome checkclean on all three files.🤖 Generated with Claude Code