Skip to content

Commit d051219

Browse files
authored
Merge pull request #62 from taskbadger/sk/celery-track-opt-out
Honour taskbadger_track=False in Celery
2 parents 9986eec + 28da9b2 commit d051219

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,18 @@ def slow_job():
125125

126126
Unless `stale_timeout` is given explicitly it is set to twice the interval. All running tasks are
127127
pinged from a single background thread, started the first time a task with a heartbeat runs.
128+
129+
### Skipping tracking for a single Celery call
130+
131+
Pass `taskbadger_track=False` to leave one execution untracked. This overrides auto-tracking as
132+
well as the `taskbadger.Task` base class:
133+
134+
```python
135+
noisy_job.apply_async(args, taskbadger_track=False)
136+
```
137+
138+
Canvas primitives don't go through `apply_async` on the task itself, so pass it in the headers:
139+
140+
```python
141+
noisy_job.map(items).apply_async(headers={"taskbadger_track": False})
142+
```

taskbadger/celery.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@
2525

2626
KWARG_PREFIX = "taskbadger_"
2727
TB_KWARGS_ARG = f"{KWARG_PREFIX}kwargs"
28-
IGNORE_ARGS = {TB_KWARGS_ARG, f"{KWARG_PREFIX}task", f"{KWARG_PREFIX}task_id", f"{KWARG_PREFIX}record_task_args"}
28+
# Per-execution tracking switch carried on the message headers. Not a task field, so it
29+
# must never end up in the kwargs passed to `create_task`.
30+
TB_TRACK = f"{KWARG_PREFIX}track"
31+
IGNORE_ARGS = {
32+
TB_KWARGS_ARG,
33+
TB_TRACK,
34+
f"{KWARG_PREFIX}task",
35+
f"{KWARG_PREFIX}task_id",
36+
f"{KWARG_PREFIX}record_task_args",
37+
}
2938
TB_TASK_ID = f"{KWARG_PREFIX}task_id"
3039
TB_HEARTBEAT_INTERVAL = f"{KWARG_PREFIX}heartbeat_interval"
3140
TB_STALE_TIMEOUT = f"{KWARG_PREFIX}stale_timeout"
@@ -61,6 +70,12 @@ class Task(celery.Task):
6170
keeps tasks with a `stale_timeout` from going stale. Unless `taskbadger_stale_timeout`
6271
is also given it is set to twice the interval.
6372
73+
A single execution can opt out of tracking with `taskbadger_track=False`, either as an
74+
argument to `apply_async` or in its `headers`. This also overrides auto-tracking, and
75+
works for canvas tasks (`.map()` / `.starmap()`), which only accept it via `headers`.
76+
It is per-execution only — to exclude a task permanently use
77+
`CelerySystemIntegration(excludes=[...])` rather than setting it on the task.
78+
6479
Access to the task is provided via the `taskbadger_task` property of the Celery task.
6580
The task ID may also be accessed via the `taskbadger_task_id` property. These may
6681
be `None` if the task is not being tracked (e.g. Task Badger is not configured or
@@ -99,7 +114,15 @@ def apply_async(self, *args, **kwargs):
99114
tb_kwargs.update(self._get_tb_kwargs(args[1]))
100115

101116
if Badger.is_configured():
102-
headers["taskbadger_track"] = True
117+
# An explicit `taskbadger_track=False` opts this execution out of tracking and
118+
# must survive to the signal handlers. It arrives either as a `taskbadger_`
119+
# prefixed argument (already extracted into `tb_kwargs`) or straight in the
120+
# headers, hence the `setdefault` for the latter.
121+
track = tb_kwargs.pop("track", None)
122+
if track is None:
123+
headers.setdefault(TB_TRACK, True)
124+
else:
125+
headers[TB_TRACK] = track
103126
headers[TB_KWARGS_ARG] = tb_kwargs
104127
if "record_task_args" in tb_kwargs:
105128
headers["taskbadger_record_task_args"] = tb_kwargs.pop("record_task_args")
@@ -186,7 +209,10 @@ def task_publish_handler(sender=None, headers=None, body=None, **kwargs):
186209

187210
celery_system = Badger.current.settings.get_system_by_id("celery")
188211
auto_track = celery_system and celery_system.track_task(sender)
189-
manual_track = headers.get("taskbadger_track")
212+
manual_track = headers.get(TB_TRACK)
213+
if manual_track is False:
214+
# explicit opt-out for this execution, which also overrides auto-tracking
215+
return
190216
if not manual_track and not auto_track:
191217
return
192218

@@ -271,7 +297,12 @@ def _maybe_create_task(signal_sender):
271297
# Badger wasn't configured at publish time but has stale config in worker.
272298
headers = signal_sender.request.headers or {}
273299
is_canvas_task = task_name in ("celery.map", "celery.starmap")
274-
if not is_canvas_task and not headers.get("taskbadger_track"):
300+
track_header = headers.get(TB_TRACK)
301+
if track_header is False:
302+
# explicit opt-out, which canvas tasks honour too: they are only ever created
303+
# here, so this is the one place their header is checked
304+
return
305+
if not is_canvas_task and not track_header:
275306
return
276307

277308
# NOW it's safe to check Badger configuration

tests/test_celery.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919
from taskbadger import Action, EmailIntegration, StatusEnum
2020
from taskbadger.celery import Task, task_publish_handler
21-
from taskbadger.mug import Badger
21+
from taskbadger.mug import Badger, Settings
22+
from taskbadger.systems.celery import CelerySystemIntegration
2223
from tests.utils import task_for_test
2324

2425

@@ -297,6 +298,85 @@ def test_celery_publish_handler_task_not_registered_locally():
297298
assert headers["taskbadger_task_id"] == create.return_value.id
298299

299300

301+
@pytest.mark.parametrize(("track_header", "expect_created"), [({}, True), ({"taskbadger_track": False}, False)])
302+
def test_celery_publish_handler_opt_out_beats_auto_track(track_header, expect_created):
303+
"""An explicit `taskbadger_track=False` header opts a single execution out, even
304+
when auto-tracking would otherwise pick the task up."""
305+
settings = Settings("https://taskbadger.net", "token", "org", "proj", systems={"celery": CelerySystemIntegration()})
306+
Badger.current.bind(settings)
307+
try:
308+
with mock.patch("taskbadger.celery.create_task_safe") as create:
309+
create.return_value = task_for_test()
310+
headers = {"id": "abc123", "task": "auto.tracked.task", **track_header}
311+
task_publish_handler(sender="auto.tracked.task", headers=headers, body=[[], {}, {}])
312+
finally:
313+
Badger.current.bind(None)
314+
315+
assert create.called is expect_created
316+
317+
318+
@pytest.mark.usefixtures("_bind_settings")
319+
def test_celery_track_attr_not_passed_to_create(celery_session_app):
320+
"""`taskbadger_track` is a tracking switch, not a task field, so it must never reach
321+
`create_task` — which would raise on the unexpected kwarg and lose the task."""
322+
323+
@celery_session_app.task(base=Task, name="track.attr.task", taskbadger_track=False)
324+
def track_attr_task():
325+
return 1
326+
327+
with mock.patch("taskbadger.celery.create_task_safe") as create:
328+
create.return_value = task_for_test()
329+
headers = {"id": "abc123", "task": "track.attr.task", "taskbadger_track": True}
330+
task_publish_handler(sender="track.attr.task", headers=headers, body=[[], {}, {}])
331+
332+
assert "track" not in create.call_args.kwargs
333+
334+
335+
@pytest.mark.usefixtures("_bind_settings")
336+
def test_celery_task_opt_out(celery_session_app, celery_session_worker):
337+
"""`headers={"taskbadger_track": False}` prevents tracking of a single execution."""
338+
339+
@celery_session_app.task(bind=True, base=Task)
340+
def add_opt_out(self, a, b):
341+
assert self.taskbadger_task_id is None, "task should not be tracked"
342+
return a + b
343+
344+
celery_session_worker.reload()
345+
346+
with (
347+
mock.patch("taskbadger.celery.create_task_safe") as create,
348+
mock.patch("taskbadger.celery.update_task_safe") as update,
349+
):
350+
result = add_opt_out.apply_async((2, 2), headers={"taskbadger_track": False})
351+
assert result.get(timeout=10, propagate=True) == 4
352+
353+
create.assert_not_called()
354+
update.assert_not_called()
355+
356+
357+
@pytest.mark.usefixtures("_bind_settings")
358+
def test_celery_task_opt_out_kwarg(celery_session_app, celery_session_worker):
359+
"""`taskbadger_track=False` also works as a `taskbadger_`-prefixed option, the way the
360+
other per-call options are passed, and never leaks into the create_task kwargs."""
361+
362+
@celery_session_app.task(bind=True, base=Task)
363+
def add_opt_out_kwarg(self, a, b):
364+
assert self.taskbadger_task_id is None, "task should not be tracked"
365+
return a + b
366+
367+
celery_session_worker.reload()
368+
369+
with (
370+
mock.patch("taskbadger.celery.create_task_safe") as create,
371+
mock.patch("taskbadger.celery.update_task_safe") as update,
372+
):
373+
result = add_opt_out_kwarg.apply_async((2, 2), taskbadger_track=False)
374+
assert result.get(timeout=10, propagate=True) == 4
375+
376+
create.assert_not_called()
377+
update.assert_not_called()
378+
379+
300380
@pytest.mark.usefixtures("_bind_settings")
301381
def test_celery_task_custom_queue(celery_session_app, celery_session_worker):
302382
@celery_session_app.task(bind=True, base=Task)
@@ -542,6 +622,30 @@ def task_map_fn(self, a):
542622
assert Badger.current.session().client is None
543623

544624

625+
@pytest.mark.usefixtures("_bind_settings")
626+
def test_task_map_opt_out(celery_session_worker):
627+
"""Canvas tasks honour the opt-out too. They are created in the worker rather than
628+
at publish time, so the header is checked there."""
629+
630+
@celery.shared_task(bind=True, base=Task)
631+
def task_map_opt_out_fn(self, a):
632+
return a * 2
633+
634+
celery_session_worker.reload()
635+
636+
map_canvas = task_map_opt_out_fn.map(list(range(3)))
637+
638+
with (
639+
mock.patch("taskbadger.celery.create_task_safe") as create,
640+
mock.patch("taskbadger.celery.update_task_safe") as update,
641+
):
642+
result = map_canvas.apply_async(headers={"taskbadger_track": False})
643+
assert result.get(timeout=10, propagate=True) == [0, 2, 4]
644+
645+
create.assert_not_called()
646+
update.assert_not_called()
647+
648+
545649
@pytest.mark.usefixtures("_bind_settings")
546650
def test_task_starmap(celery_session_worker):
547651
"""Tasks executed via starmap canvas primitive should be tracked."""

0 commit comments

Comments
 (0)