worker: reconnect on dropped DB connection instead of looping 'conn closed' forever (silent month-long outage) #17
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
The
imagen worker(systemd user unitimagen-worker.serviceon mRiver) stopped consumingimagen.jobsentirely. Every job submitted from/imaginesince ~2026-06-07 sat atstatus='pending'withstarted_at=null— never claimed. No image ever came back and nothing surfaced the failure.Root cause
The worker's Postgres connection (
IMAGEN_WORKER_DATABASE_URL, direct over Tailscale to the pooler) dropped and the worker never re-established it. Instead its claim loop caught the error and spun forever, once every poll interval (5s):The process stayed
active (running)for ~28 days in this state, so systemd never restarted it and nothing alerted. A manualsystemctl --user restart imagen-worker.serviceimmediately fixed it — the worker reconnected and drained the backlog on the next poll.Proposed fix
conn closed(or any driver-level connection error), tear down and rebuild the pool / connection rather than loopingcontinuingindefinitely. A pool with health-check + auto-reconnect (or pgx pool ping-before-use) would make this self-healing.activeprocess that can do no work is worse than a crash-loop that's visible.Restart=on-failure+ a watchdog /WatchdogSecon the unit so a wedged worker is noticed.Impact
This masked a real downstream problem (mGPUmanager can't evict ollama for image leases — separate m/mGPUmanager issue) for a month, because no job ever got far enough to hit the VRAM error. Self-healing reconnect + loud failure would have surfaced it in minutes.
Evidence
journalctl --user -u imagen-worker.service(mRiver) — the repeatingconn closed (continuing)linesFixed: self-healing reconnect + fail-loud
Branch
mai/zeus/issue-17-worker, commitd14498a. Root cause confirmed exactly as described:pgxQueue(cmd/imagen/worker.go) held a single*pgx.Connwith no reconnect path. Every method (ClaimNextPending,WaitForJob,MarkDone/Failed,ResetStaleRunning) used that one handle; once it dropped, every op returnedconn closedforever, the loop loggedcontinuingeach poll, and the process stayed systemd-activesoRestart=on-failurenever fired.What I changed
1. Reconnect on a closed connection (proposed fix #1) —
pgxQueueis now self-healing:ensureConnruns before every op. No-op when the handle is live; when it is missing orIsClosed(), it disposes the dead handle and redials + re-issuesLISTEN imagen_jobs(re-establishing LISTEN is the non-obvious part — without it, NOTIFY wake-ups would be silently lost after a reconnect and the worker would degrade to poll-only latency).afterOpforce-closes the handle on a connection-class error so the next call rebuilds deterministically, even if pgx has not yet flippedIsClosed().connBrokenclassifies purely by error:nil/ context cancel+deadline / server-side*pgconn.PgError(the server answered → link healthy) stay healthy; everything else (closed conn, reset transport, EOF) is a broken link worth redialing.DB connection lost; reconnecting/established) log to stderr→journald so a flapping link is visible instead of swallowed.2. Fail loud after N consecutive connection errors (proposed fix #2) — the DB-agnostic loop (
internal/worker/worker.go) now counts cycles that made no DB progress at all (both the claim pass and the wait pass failed). AfterMaxConsecutiveDBErrors(default 12 ≈ 1 min at the 5s poll) it returns a fatal error;mainexits non-zero → the unit's existingRestart=on-failurerestarts it from a clean slate. Any successful claim or poll resets the streak, so a transient blip still costs only a log line (existingTestWorker_TransientClaimErrorDoesNotKillLoopstill passes).3.
Restart=on-failure+ watchdog (proposed fix #3) —scripts/imagen-worker.servicealready hasRestart=on-failure+RestartSec=5; the fail-loud change is what finally makes it effective (before, the process never exited). I deliberately did not addWatchdogSec: it requires the binary to sendsd_notify(WATCHDOG=1)pings, and addingWatchdogSecwithout that would make systemd kill a healthy worker. The observed failure mode (conn-closed spin) is now a visible crash-loop via fail-loud; a true silent hang is separately bounded byJobTimeout(pipeline) and the poll timeout (wait). If we want defence-in-depth against a wedged-but-alive process,Type=notify+WatchdogSec+ an sd_notify pinger is a clean follow-up — say the word and I will open an issue.4. Liveness signal (optional #4) — not added as a separate mechanism; the reconnect logs + fail-loud crash-loop already surface a stuck worker in journald without reading every line. A last-successful-claim timestamp would only help if we later add a metrics/health endpoint (
imagen serveis still a stub) — better bundled with that work than bolted on now.Tests (all green,
go build/vet/test -raceclean)TestWorker_FailsLoudAfterConsecutiveDBErrors— both passes fail every cycle →Runreturns fatal (the core regression).TestWorker_HealthyWaitResetsErrorCounter— a working wait clears the streak so a failing claim alone never trips fail-loud.TestConnBroken— table test over the classifier (ctx errors, wrappedPgError, EOF, the literaldeallocate cached statement(s): conn closed).TestWorker_Integration_ReconnectsAfterDrop(guarded byIMAGEN_WORKER_INTEGRATION=1) — kills the worker's backend viapg_terminate_backend, asserts the queue redials onto a fresh backend PID and thatWaitForJobthen wakes on a real NOTIFY (proving LISTEN was re-established).Deploy note
This touches worker code, so the running daemon on mRiver needs the standard rebuild after merge:
go build -o bin/imagen ./cmd/imagen && systemctl --user restart imagen-worker.service(the daemon runs a stalebin/imagen). No schema/config changes.Commit:
d14498a8e1Merged + deployed live
Head review passed —
go build/go vet/go test ./...all clean on the branch. Merged--no-ffintomainasdb9c91aand pushed.Deploy note actioned (this is on mRiver, where the daemon runs):
go build -o bin/imagen ./cmd/imagen✓systemctl --user restart imagen-worker.service✓ — nowactive (running), PID 459013,imagen worker: ready. Noconn closedloop.The self-healing reconnect + fail-loud path is now the running code, replacing the binary that had been wedged since 2026-06-07.
Merge commit: https://mgit.msbls.de/m/ImaGen/commit/db9c91a
Downstream, for the record: as #17's own impact note called out, this outage masked the mGPUmanager eviction gap for a month — no job ran far enough to hit the VRAM error. With the worker now failing loud, that
m/mGPUmanagerlease-resource gap (ImaGen #15 is code-complete but prod-blocked on it) will resurface as soon as a real restyle job runs. Tracked separately on the mGPUmanager side.