Loading
PLAT-3188: Recover Celery workers from dropped DB connections
Jira: PLAT-3188. This blocks the October bulk resend under PLAT-3134.
Problem
web.py caches one DB connection per thread and only replaces it at the end of a web request. Celery workers have no requests, so a connection the server has closed stays in use forever.
On 2026-11-08 00:24 Postgres closed its connections. Until the workers were restarted on 2026-11-11, every task on the API pod's workers failed with InterfaceError('connection already closed'). For process-events-batch, the retries, salvage_batch and the quarantine save all failed on the same connection, so the batches were dropped after a 202 (Failed to save to fallback storage was logged ~4,400 times).
Changes
- Reconnect before each task (
api.reset_stale_connection,task_prerunhook intasks/celery_app.py): drop the thread's connection if it's closed or fails aSELECT 1, so web.py opens a new one. It's only pinged when idle, so an open transaction is never touched. - Connection errors retry instead of losing the batch (
tasks/events_ingestion.py): backoff of 1, 2, 4, 8 min, then every 10 min (~4.5h). The backoff counts data-error retries too, so a connection lost during salvage starts at 8 min. Countdowns are kept under RabbitMQ's 30 minconsumer_timeout; if a backlog pushes a retry past it, RabbitMQ redelivers it, which is harmless as the inserts are idempotent. They never enter the salvage path.salvage_batchnow raises connection errors instead of quarantining them.- A connection error is an
InterfaceError, or anOperationalErrorwith no SQLSTATE, class08, or57P01–57P03. OtherOperationalErrors, such as a statement timeout (57014) or out of memory (53200), still go through the normal retry → salvage path. - If retries run out, one CRITICAL line with the marker
EVENTS_BATCH_LOSTgives the task id, uploader, measures and timestamp range, so the batch can be resent. The same marker is logged when salvage can't quarantine events for another reason (e.g. read-only after failover):salvage_batchraisesQuarantineErrorwith the lost events. Timestamps are ordered by instant, so mixed UTC offsets (a resend across the DST change) give the right range.
- A connection error is an
- JSON logging in workers and beat (
setup_logginghook) with aJsonFormatterthat escapes quotes and newlines and keeps tracebacks in the entry. This replaces the_log_safeworkaround. The level comes fromAPI_DEBUG, so-l infowas removed from the supervisord commands.kombuandamqpare capped at WARNING, andcelerystays at INFO in debug mode. - Workers pod logs reach Cloud Logging:
supervisord-workers.confsetstdout_logfileetc. on[group:]sections, which supervisor ignores. The Celery output of beat and the cache, popularity and report workers was going to files in/tmp. Those settings are now per program. - Bulk worker node name
metrics-bulk@%h, socelery inspect/controlreplies no longer collide withsave_to_database_tasks. - Supervisor
autorestartper program insupervisord.conf: it was set on[group:api_and_tasks], which supervisor ignores, so the Celery workers weren't restarted after a clean exit. - Supervisor socket (
/tmp/supervisor.sock,0700) in both configs, sosupervisorctlworks in the pods. - Removed a leftover debug
print().
Testing
- Full unit suite passes locally against Postgres and Redis (333 tests).
tests/test_db_reconnect.pykills the backend withpg_terminate_backend(as the outage did) and checks that the connection is replaced, both directly and through a task'stask_prerun. It also checks that a real statement timeout isn't classed as a connection error.- Task tests cover the backoff, the separate retry limit, the
EVENTS_BATCH_LOSTline, and salvage raising connection errors. - Both supervisord configs parse with supervisor 4.2.5, and every program logs to
/dev/stdout//dev/stderr. - A worker started locally against an unreachable broker logs valid single-line JSON (including Celery's multi-line connection errors).
Before deploy
- Update the
FALLBACK_EVENTS_PENDINGalert filter: it matchestextPayload:"FALLBACK_EVENTS_PENDING", and after this MR the line arrives asjsonPayload.message. A bare"FALLBACK_EVENTS_PENDING"search term matches both. - Add a log-based alert on
EVENTS_BATCH_LOST("metrics-api: event batch lost", CRITICAL, same notification channel).
After deploy
- Check worker logs from both pods show up as
jsonPayload, and thatFALLBACK_EVENTS_PENDINGstill matches. - Check
supervisorctl statusworks in both pods.
Edited by Rowan Hatherley