Repository navigation
Fix dangling processing jobs by adding TCP keepalives #1072
Description
Summary
Celery workers may be creating "Zombie Tasks" due to a 1-hour RabbitMQ connection hard close caused by the OpenStack network's idle timeout. The existing Linux default TCP Keepalive is too slow, leading to failed task completion reports.
(issue description written with the help of Gemini based on the problem analysis)
Detailed Description
Our Celery implementation is experiencing two distinct connection issues, the most severe of which is the 1-hour "Hard Close".
- The "Hard Close" (Zombie Task Maker):
- Cause: The OpenStack networking layer (Neutron) has an Infrastructure Idle Timeout of exactly 1 hour (3600 seconds).
- Mechanism: When long-running "Master Tasks" wait silently for subtasks or perform local work for over an hour, the connection is considered "idle" and silently severed by the firewall. When the task eventually tries to report "Success," the network rejects the communication, crashing the worker and leaving the task permanently stuck in a "Started" state in the database.
- Symptom: Logs show
client unexpectedly closed TCP connectionorConnectionResetError: [Errno 104].
- The "Soft Close" (CPU Blocker):
- Cause: Thread Starvation in the worker process (Python's GIL) during heavy CPU tasks.
- Mechanism: The worker cannot send its application-level heartbeat (
CELERY_BROKER_HEARTBEAT = 30). RabbitMQ closes the connection after 3 missed heartbeats (90 seconds). - Symptom: Logs show
missed heartbeats from client, timeout: 30s.
Importance: The Zombie Task issue is critical as it corrupts task status tracking in the database and prevents long-running jobs from accurately reporting completion.
Possible Fix Implementation
The primary fix is to configure OS-Level TCP Keepalives to probe the connection every 60 seconds, which is less than the 1-hour firewall timeout.
- Specific Files/Areas:
settings.py(for Celery configuration). - Approximate timeline: Immediate deployment (after testing) as it is a critical connection stability fix.
Implementation Tasks:
- Update
settings.pyby replacing the existingCELERY_BROKER_TRANSPORT_OPTIONSblock with the following, which explicitly sets the TCP keepalive parameters and removes the self-destructingsocket_timeout.
import socket # Required at the top of your settings.py
CELERY_BROKER_TRANSPORT_OPTIONS = {
# 1. THE CRITICAL FIX: Custom TCP Keepalives
"socket_keepalive": True,
"socket_settings": {
# Start sending Keepalive packets after 60 seconds of silence.
# This forces traffic on the wire, preventing the OpenStack 1-hour timeout.
socket.TCP_KEEPIDLE: 60,
# If no response, retry every 10 seconds.
socket.TCP_KEEPINTVL: 10,
# Give up and close connection after 9 failed attempts.
socket.TCP_KEEPCNT: 9,
},
# 2. Connection Stability Settings
"socket_connect_timeout": 40, # Max time to establish connection
"retry_on_timeout": True, # Retry operations if they time out
"max_connections": 20, # Connection pool limit per process
# 3. SAFETY REMOVAL: "socket_timeout"
# We REMOVED "socket_timeout: 120" to prevent workers self-destructing during long blocking operations.
}
# 4. Application Heartbeat (Keep this active)
CELERY_BROKER_HEARTBEAT = 30- Verify the Configuration after deploying changes and restarting workers (see Additional context below).
Future Improvement (Architectural Refactoring)
For multi-hour jobs composed of a single long master task and many fast subtasks, refactor them into Celery Chords. This will allow the Master task to shut down immediately after dispatch, eliminating the idle connection issue entirely and improving resource utilization.
How to Reproduce the Bug
Environment (please complete the following information)
- OS and/or device: Worker VM on Compute Canada (Arbutus OpenStack)
- Browser: [N/A]
- Version: [e.g. Production, Development, etc.]
Steps to reproduce the behavior:
- Start a "Master Task" (Processing Job) that is designed to be silent (no network I/O) for a period longer than 1 hour (3600 seconds) while it waits for subtasks or performs local calculations.
- After the 1-hour mark, the OpenStack firewall silently severs the connection to the RabbitMQ broker.
- When the Master Task finishes its work and attempts to report its
SUCCESSstatus back to the broker/backend, the network operation fails. - See the worker crash with
ConnectionResetErrorand observe the task status in the database permanently stuck at "Started" (Zombie Task).
Additional context
To verify the fix is working on the OS level, SSH into a Worker VM after deployment and run the connection timer check.
# This lists TCP connections to port 5672 with timer details
sudo ss -tonp | grep 5672
Expected Output: Look for a value counting down from ~60 seconds in the timer column: timer:(keepalive,59sec,0). If you see a value like 7199sec or 'off', the new settings were not applied correctly.
Activity
mihow commentedon Jul 29, 2026
Claude says: Flagging a problem with this closed issue so it is not read as solved. This replaces an earlier version of this comment, whose reasoning was wrong even though its conclusion holds. The corrected basis is much stronger and is measured rather than argued.
The shipped fix appears to be a no-op
PR #1073 replaced
socket_timeout: 120withsocket_settings: {TCP_KEEPIDLE: 60, TCP_KEEPINTVL: 10, TCP_KEEPCNT: 9}on the broker transport options.py-amqp's own built-in defaults are byte-for-byte identical (
amqp/transport.py:31-37):and
SO_KEEPALIVEis set unconditionally atamqp/transport.py:199, independent of any option passed in.Measured on real sockets through py-amqp's actual connect path, with a control run passing
socket_settings=None: both produceSO_KEEPALIVE=1,TCP_KEEPIDLE=60,TCP_KEEPINTVL=10,TCP_KEEPCNT=9. Identical.So the 60-second keepalive was already active before this change shipped and would remain active if the block were deleted tomorrow. The hourly disconnects have been happening the entire time with 60-second keepalive in force. That is the strongest available statement that keepalive does not prevent them, and unlike my earlier argument it does not depend on any claim about heartbeats or connection age.
Retracting my earlier reasoning
My previous version argued that heartbeats every 30 seconds meant these connections were never idle. That was wrong, and I want it off the record rather than quietly edited away.
Heartbeats are driven by a timer callback in the worker's event loop. There is no producer-side heartbeat driver — a web process publishing a task uses a pooled connection that nothing ticks, so it can sit genuinely idle indefinitely. That population includes the application server, which I had cited as evidence against the idle model when it is in fact the population the idle model fits best. A consumer whose event loop is saturated is also unable to tick, which is consistent with the in-flight task counts observed on the wedged workers.
I also mischaracterised this issue. It describes two mechanisms, and the second — thread starvation preventing the application-level heartbeat from being sent — is the one that best fits the evidence I was citing. Summarising it as a purely idle-timeout diagnosis was a misreading on my part.
What is genuinely unresolved
Whether the underlying disconnect is governed by connection age or by an idle timer that keepalive probes fail to refresh is not settled, and my earlier comment overstated it. I cited 975 broker-side closes at roughly 59 minutes; I have not established that those were uniformly distributed rather than a selected subset, and without a duration histogram across all closes in the window that number cannot carry the weight I gave it. That histogram is cheap to produce and is the right next step for anyone picking this up.
Where that leaves this issue
The change here is inert, so the exposure it was meant to close is still open — but the practical conclusion no longer depends on resolving age versus idle. A client has to survive a disconnect it cannot prevent, and on 2026-07-29 the result-processing workers did not: eight stopped consuming and stayed that way for about seven hours while reporting themselves healthy. That non-recovery is a distinct failure from the one described here, which is about a completion report being lost, and it is tracked in #1382 and #1383.