App reports healthy while the database is unreachable and Horizon is dead #164
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#164
Loading…
Reference in a new issue
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?
Incident
The app served HTTP 500 on every authenticated page for ~37 hours. Root cause was infrastructure: the
ffr_dbMariaDB container was absent from the production stack (docker ps -ashowed onlyffr_appandffr_redis;docker inspect ffr_db→no such object;getent hosts dbfailed 20/20). Themariadb:11image had to be re-pulled, suggesting the container and its image were removed by a prune while the two running containers were skipped.Resolved by
docker compose up -d db. Theffr_db_datavolume survived, data was intact, andmigrate:statusconfirmed all 24 migrationsRanincluding000024_fix_belga_feed_offset.No code change would have prevented the outage. What the code did wrong was hide it for 37 hours, and then fail to recover. This ticket covers the three defects that allowed that.
1.
/uphealthcheck passes while the database is unreachableDockerfile:70runscurl -f http://localhost:8000/up, andbootstrap/app.php:16configureshealth: '/up'. Laravel's built-in health endpoint dispatchesDiagnosingHealthand returns 200 without touching the database, so a missing DB reads as a healthy app. The container reportedhealthyfor the entire outage — no alert, no restart. The failure was found by loading a page by hand.Fix: register a
DiagnosingHealthlistener that verifies DB connectivity (e.g.DB::connection()->getPdo()), so/upfails when the database is unreachable.Note: a separate
/healthroute atroutes/web.php:44returns a static{"status":"ok"}and is not what the healthcheck uses. Decide whether to converge or remove it.Tests:
/upreturns non-200 when the DB connection throws; 200 when healthy.2. Exception logging writes to the database, so DB outages are recorded nowhere
LogExceptionToDatabase::handle()persists every exception viaLog::create(). When the database is the thing that failed, the insert fails identically and the exception is lost. The only surviving output was the wrapper fromapp/Listeners/LogExceptionToDatabase.php:35:That names the failed insert, not the original error. The real cause — a
QueryExceptionfromSessionGuard::userFromRecaller()during auth — was only recoverable because the nested exception text happened to embed it. Diagnosis depended on that accident.The existing comment at line 34 already flags the gap:
// Optionally log to file or other non-database destinationFix: in the catch block, write the original exception through Laravel's file/stderr logger (
Log::channel(...)) rather than onlyerror_log()with the wrapper message. Preserve exception class, file, line and trace.Tests: when
Log::create()throws, the original exception's details reach the fallback channel.3. Container boots with no database, and nothing supervises Horizon
/start-prod.sh(Dockerfile:73-110) waits for the DB in afor i in $(seq 1 30)loop withsleep 2— 60 seconds — then falls through unconditionally. There is noexitand no failure path. With nodbcontainer it waited 60s, then ranconfig:cache,migrate,db:seedandphp artisan horizon &against an unreachable database.Consequences observed:
horizon:status→Horizon is inactive. The worker died at boot and never recovered when the DB returned 37 hours later — nothing supervises it.publishingqueue. Redis stayed healthy throughout, so the scheduler'sSchedule::job()calls all enqueued successfully and reportedDONEevery 5 minutes while nothing consumed them.ArticleDiscoveryJobwas enqueued onto a queue with no worker.The backlog itself is not dangerous —
PublishNextArticleJobguards onarticle_publishing_interval, a daily cap,ShouldBeUnique, andwhereDoesntHave(...articlePublications...)— so it drains as mostly early returns.Fix: fail fast when the DB never becomes reachable so the container exits and the restart policy retries, rather than booting a broken app. Supervise Horizon so a dead worker restarts.
Note: this is a Dockerfile change and cannot be verified by the PHPUnit/Pint/PHPStan gates, which run inside the already-built container. It needs a real image build and deploy to prove.
Common theme
All three let a dead dependency masquerade as a working app. #1 is why nobody was told, #2 is why there was no evidence, #3 is why it never recovered.
500 error on serverto App reports healthy while the database is unreachable and Horizon is deadFixed in #167, merged to
release/v1.4.3:9247db3— Log exceptions to a non-database fallback59d12d6— Fail the health check when the database is unreachable2244170— Exit on database timeout and supervise the workersb792ee7— Add the v1.4.3 changelog section and bump the README pinEach fix was verified red before green:
/upreturned 200 against a mocked-dead database without the listener, and the log fallback expectation was never called against the original listener. Gates green on all four commits (Pint 361 files, PHPStan clean, 752 Unit + 521 Feature tests), CI runs #106 and #107success.One caveat on the startup-script fix (
2244170). It cannot be verified by the test gates — those run inside the already-built dev container and never execute/start-prod.sh. What was verified: POSIX syntax viash -n, and the supervisor restart loop empirically. That second check caught a real bug: underset -e, an unguarded failing command kills the background subshell, so the first version ofsupervise()ran the command once and the loop died — it would have supervised nothing. Fixed with"$@" || echo ....The first real execution of the script is the
v1.4.3image build and deploy. If the database-waitexit 1or the supervisor loop misbehaves, the symptom is a container that will not start;v1.4.2is the rollback target.The underlying outage cause is still unexplained. These fixes improve detection and recovery — none of them prevents the
ffr_dbcontainer from going missing again.journalctl -u docker --since '3 days ago' | grep ffr_dbmay show what removed it.