Skip to content

fix: keep processing jobs when the notification listener fails - #677

Merged
MoonBoi9001 merged 22 commits into
mainfrom
mb9/keep-worker-running-when-job-notifications-fail
Jul 20, 2026
Merged

fix: keep processing jobs when the notification listener fails#677
MoonBoi9001 merged 22 commits into
mainfrom
mb9/keep-worker-running-when-job-notifications-fail

Conversation

@MoonBoi9001

@MoonBoi9001 MoonBoi9001 commented Jul 20, 2026

Copy link
Copy Markdown
Member

This PR keeps a worker processing jobs when its database notification connection fails, and makes the failure visible. Each worker loop polls the queue every second and separately holds a Postgres LISTEN/NOTIFY connection that only wakes it sooner when work arrives, so losing that connection should cost about a second of latency and nothing else.

It did not. An error on that connection crashed the loop, and one crash brought down all of them: a single dropped connection halted every job while the process still looked healthy. Failing to open it at startup did the same. Underneath, the database driver quietly rebuilt dropped connections forever, with nothing above debug to show for it.

A worker now degrades to polling when the connection fails, starts that way if it cannot subscribe at startup, and retries on a pace that backs off to 30 seconds while the database stays unhealthy. Rebuild attempts are bounded in time and counted, so a connection that keeps dropping surfaces as an error and a warning instead of being rebuilt forever.

A worker loop polls the queue every second and also holds a database connection that wakes
it sooner when a job arrives. An error on that connection used to stop every loop and halt all
job processing, so the loop now drops it, keeps polling, and reconnects on a later tick.
A worker keeps a database notification connection to wake sooner than its 1-second job poll.
A failed wait returns instantly, so re-subscribing on that same tick to a connection that fails
just as fast spun the loop at full CPU. Re-subscribing now waits for the next poll-paced tick.
A worker loop gave up entirely if it could not open its database notification connection at
startup, and because the loops are supervised together that stopped every one of them. It now
starts in poll-only mode and picks the notifications up once the database is reachable.
The rule that stops a broken notification connection from being retried in a tight loop lived
inline in the worker loop, where no test could reach it. It now sits in a small function the
wait step feeds, so the pacing is covered by unit tests.
A worker that loses its database notification connection falls back to checking for jobs on a
timer, but only said so once, then reported each retry at debug. It now repeats a warning about
every 60 failed attempts, so an outage that never recovers stays visible in normal logs.
Waiting on a job notification alongside a stop signal and a timer drops the losing wait, which
looks like it could discard a notification. The database driver's read step is built to survive
exactly that, so the note explains why, to save the next reader the same investigation.
The test that checks a worker still wakes on its timer when notifications are unavailable slept
for real, making it slow and dependent on machine timing. It now advances a simulated clock, so
it proves the same thing instantly and repeatably.
The code deciding when to repeat a warning tested a remainder by hand, which the project's lint
settings reject in favour of the standard library call that says the same thing.
The note saying no job notification is ever lost claimed more than holds. It is
true while the connection is up, but one can still go missing if the connection
drops mid-reconnect, which the 1 second fallback turns into a short delay.
The rule spacing out attempts to reopen the job notification connection named a case it has no
way to observe: a dropped connection is retried inside the database driver, never surfacing as
an error, so the pacing never applies to it.
The interval between warnings about an unavailable job notification connection was described as
about a minute, which only holds when attempts fail quickly. A database that accepts but never
answers stretches each attempt to 30 seconds, and the gap to half an hour.
The database driver quietly rebuilds a dropped notification connection and waits again, so a
proxy killing those connections could churn them indefinitely with nobody the wiser. Repeated
drops in quick succession are now reported to the caller, which can pace and log the retry.
Counting failures misjudged how often to repeat a warning: a connection recovering between each
failure reset the count, so every cycle looked like the first and warned again. Warnings now
follow a 60 second cooldown, and each one is answered by exactly one recovery message.
A lost LISTEN connection was rebuilt inside sqlx before it reported the loss, so a caller
that cancels the wait on a timer discarded that work every second and never learned the
connection was down. Reconnect here instead, under a 30 second deadline, counting attempts.
Each of the 8 worker loops retried the subscription once a second for as long as it kept
failing, so a struggling database was asked for 8 new connections a second. Space attempts
out from 1 second, doubling to 30, and start over once one survives 30 seconds.
A connection that fails dozens of times a minute logged the same single line every 60
seconds as a one-off blip would. Carry the number of failures the warning covers, and the
number of attempts a recovery took, so the two are told apart at a glance.
The loss was blamed on a cancellation landing inside the re-subscription. It happens
whether or not the wait is cancelled, because PostgreSQL only holds notifications for
sessions that are listening at the time one is sent.
The loop's subscribe handling had no test, so removing the re-subscribe step or making a
failed startup subscribe fatal both went unnoticed. Move that handling into a Subscription
type the tests can drive with a queue that has no database behind it.
Worker loops space out attempts to re-open their job-available notification connection, 1 second
doubling to 30, but a failed attempt never fed that pacing. A database refusing connections was
asked again on every 1 second tick by all 8 loops instead of less and less often.
The wording said 3 reconnects within 60 seconds, but each failure only has to land within 60
seconds of the previous one to continue the run, so 3 of them can span nearly 120 seconds.
@MoonBoi9001
MoonBoi9001 merged commit 0fbc686 into main Jul 20, 2026
11 checks passed
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