Skip to content

Commit b9e6749

Browse files
committed
Apply dispatch pub/sub no-op to thread-local backends (v2.6.57)
Celery stores the result backend in a threading.local unless result_backend_thread_safe is set, so each thread gets its own backend instance. The 2.6.55/2.6.56 no-op only patched the import thread's instance; the cleanup/integrity producer threads created fresh unpatched backends and kept subscribing to result channels (verified live and reproduced locally with a stack trace through send_task). disable_dispatch_result_subscription now wraps Celery._get_backend so every backend instance any thread creates carries the no-op. Validated end-to-end: the API-triggered cleanup repro went from 2,633 standing result-channel subscriptions to zero.
1 parent e2443f1 commit b9e6749

5 files changed

Lines changed: 56 additions & 7 deletions

File tree

CHANGELOG.MD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).
77

8+
## [2.6.57] - 2026-06-11
9+
10+
### Fixed
11+
12+
- **The dispatch pub/sub no-op now actually applies to the threads that dispatch - the 2.6.55/2.6.56 fix never took effect in production.** Celery's `Celery._backend` is stored in a `threading.local` unless `result_backend_thread_safe` is set, so every thread gets its OWN backend instance. The no-op was patched onto the backend created on the import thread, while the cleanup/integrity producer threads (spawned per run by the maintenance routes) lazily created fresh, unpatched backends and kept subscribing - verified live: the first 2.6.56 cleanup run still showed 3,000-5,000 standing subscriptions and ~70-second Redis forced disconnects. Root-caused with a local reproduction (the run is clean when driven from the main thread, subscribes when driven from a `threading.Thread`, confirmed via a stack trace through `send_task -> backend.on_task_call`). `disable_dispatch_result_subscription()` now wraps `Celery._get_backend` so every backend instance created by any thread carries the no-op; validated end-to-end in a local stack where the API-triggered cleanup previously accumulated 2,633 result-channel subscriptions and now accumulates zero. The regression test now asserts the no-op holds in a freshly spawned thread. This retroactively explains all three production runs today: 2.6.53 stalled (get() subscriptions plus dispatch subscriptions), 2.6.54 was clean (its per-dispatch `cancel_for` ran on the same thread-local objects that subscribed), and 2.6.56 regressed (dispatch subscription alive in the producer thread with the unsubscribe removed).
13+
814
## [2.6.56] - 2026-06-11
915

1016
### Fixed

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ services:
6363

6464
# PixelProbe application
6565
pixelprobe:
66-
image: ttlequals0/pixelprobe:${PIXELPROBE_VERSION:-2.6.56}
66+
image: ttlequals0/pixelprobe:${PIXELPROBE_VERSION:-2.6.57}
6767
container_name: pixelprobe-app
6868
environment:
6969
# Security
@@ -131,7 +131,7 @@ services:
131131

132132
# P1 Celery Worker for distributed task processing
133133
celery-worker:
134-
image: ttlequals0/pixelprobe:${PIXELPROBE_VERSION:-2.6.56}
134+
image: ttlequals0/pixelprobe:${PIXELPROBE_VERSION:-2.6.57}
135135
container_name: pixelprobe-celery-worker
136136
command: python celery_worker.py
137137
environment:

pixelprobe/utils/celery_utils.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,39 @@ def check_celery_available():
2525
return celery_enabled
2626

2727

28+
def _noop_on_task_call(producer, task_id):
29+
return None
30+
31+
2832
def disable_dispatch_result_subscription(celery_instance):
2933
"""Stop apply_async subscribing the producer to result pub/sub channels.
3034
3135
send_task calls backend.on_task_call -> SUBSCRIBE per dispatched task.
3236
Producers read stored result meta (safe_task_get) and never drain that
33-
socket, so its replies pile up until Redis force-closes the connection
34-
(client-output-buffer-limit, observed every ~3min during cleanup runs).
37+
socket, so unread data piles up until Redis force-closes the connection
38+
(client-output-buffer-limit, observed every ~70s during cleanup runs).
3539
AsyncResult.get() still works: it subscribes itself (add_pending_result)
3640
and reconciles from stored meta. Producer-side GroupResult.join_native()
3741
would NOT resolve promptly without the dispatch-time subscription - the
3842
only group joins live worker-side (discovery), where this hook was
3943
already skipped via task_join_will_block().
44+
45+
Celery creates a SEPARATE backend instance per thread (Celery._backend
46+
routes through threading.local unless result_backend_thread_safe), so
47+
patching one instance misses every other thread - the cleanup producer
48+
thread kept subscribing after the first version of this fix. Wrapping
49+
_get_backend patches every instance any thread creates.
4050
"""
41-
celery_instance.backend.on_task_call = lambda producer, task_id: None
51+
orig_get_backend = celery_instance._get_backend
52+
53+
def _get_backend_without_dispatch_subscription():
54+
backend = orig_get_backend()
55+
backend.on_task_call = _noop_on_task_call
56+
return backend
57+
58+
celery_instance._get_backend = _get_backend_without_dispatch_subscription
59+
# Patch the calling thread's already-created instance too
60+
celery_instance.backend.on_task_call = _noop_on_task_call
4261

4362

4463
def is_db_connection_corruption(exc) -> bool:

pixelprobe/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Default version - this is the single source of truth
55

66

7-
_DEFAULT_VERSION = '2.6.56'
7+
_DEFAULT_VERSION = '2.6.57'
88

99

1010
# Allow override via environment variable for CI/CD, but default to the hardcoded version

tests/unit/test_maintenance_service.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,17 +305,41 @@ class TestDispatchResultSubscriptionDisabled:
305305
nothing in the producer reads that socket, so Redis kills the connection
306306
on output-buffer overrun (observed every ~3min during cleanup runs)."""
307307

308-
def test_on_task_call_is_noop_instance_override(self):
308+
@staticmethod
309+
def _patched_celery():
309310
from celery import Celery
310311
from pixelprobe.utils.celery_utils import disable_dispatch_result_subscription
311312

312313
celery = Celery('t', broker='redis://localhost:6379/0',
313314
backend='redis://localhost:6379/0')
314315
disable_dispatch_result_subscription(celery)
316+
return celery
315317

318+
def test_on_task_call_is_noop_instance_override(self):
319+
celery = self._patched_celery()
316320
assert 'on_task_call' in vars(celery.backend)
317321
assert celery.backend.on_task_call(MagicMock(), 'task-1') is None
318322

323+
def test_on_task_call_is_noop_in_new_threads(self):
324+
# Celery._backend is thread-local (one backend instance per thread
325+
# unless result_backend_thread_safe); a single-instance patch missed
326+
# the cleanup producer thread in production (2026-06-11, v2.6.56).
327+
import threading
328+
329+
celery = self._patched_celery()
330+
seen = {}
331+
332+
def probe():
333+
backend = celery.backend
334+
seen['fresh_instance'] = backend is not celery._backend_cache
335+
seen['noop'] = 'on_task_call' in vars(backend)
336+
337+
t = threading.Thread(target=probe)
338+
t.start()
339+
t.join()
340+
assert seen['fresh_instance'] is True
341+
assert seen['noop'] is True
342+
319343

320344
class TestEnvInt:
321345

0 commit comments

Comments
 (0)