Skip to content

Commit 4de064c

Browse files
authored
Merge pull request #58 from taskbadger/sk/keep-alive
Keep long-running tasks from going stale
2 parents b36f823 + 704f619 commit 4de064c

14 files changed

Lines changed: 1021 additions & 19 deletions

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,30 @@ taskbadger.init(
9898
- **`task.configure(...).defer(...)` is not tracked.** Procrastinate's `configure()` returns a separate `JobDeferrer` whose methods bypass our wrapper. Use `task.defer(...)` directly for tracked deferrals. Tasks deferred via `configure().defer()` will run normally but will not appear in TaskBadger.
9999
- **`task.batch_defer*` is not tracked.** Same reason as `configure().defer()`.
100100
- **Tasks added via `app.add_tasks_from(blueprint)` after `ProcrastinateSystemIntegration` is constructed are not auto-instrumented.** Construct the integration after all blueprints are registered, or apply `@track` to those tasks explicitly.
101+
102+
### Keeping long-running tasks fresh
103+
104+
A task with a `stale_timeout` is marked `stale` by Task Badger if it goes too long without an
105+
update. Set `heartbeat_interval` (seconds) to have the SDK ping the task for you while it runs,
106+
rather than updating it from the task body.
107+
108+
For Procrastinate, on the task or on `ProcrastinateSystemIntegration(...)`:
109+
110+
```python
111+
@track(heartbeat_interval=60)
112+
@app.task
113+
async def slow_job():
114+
...
115+
```
116+
117+
For Celery, on `CelerySystemIntegration(...)`, on the task, or per call with
118+
`slow_job.apply_async(taskbadger_heartbeat_interval=60)`:
119+
120+
```python
121+
@app.task(base=taskbadger.Task, taskbadger_heartbeat_interval=60)
122+
def slow_job():
123+
...
124+
```
125+
126+
Unless `stale_timeout` is given explicitly it is set to twice the interval. All running tasks are
127+
pinged from a single background thread, started the first time a task with a heartbeat runs.

integration_tests/tasks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import time
2+
13
from celery import shared_task
24

35
import taskbadger.celery
46

7+
HEARTBEAT_INTERVAL = 1
8+
# long enough to sample the task's `updated` time while it is still running
9+
SLOW_ADD_DURATION = 6
10+
511

612
@shared_task(bind=True, base=taskbadger.celery.Task)
713
def add(self, x, y):
@@ -14,3 +20,10 @@ def add(self, x, y):
1420
def add_auto_track(self, x, y):
1521
assert self.request.taskbadger_task_id is not None, "missing task ID on self.request"
1622
return x + y
23+
24+
25+
@shared_task(bind=True, base=taskbadger.celery.Task, taskbadger_heartbeat_interval=HEARTBEAT_INTERVAL)
26+
def slow_add(self, x, y):
27+
"""Runs long enough to go stale without a heartbeat, and never updates itself."""
28+
time.sleep(SLOW_ADD_DURATION)
29+
return x + y

integration_tests/test_celery.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
22
import random
3+
import time
34

45
import pytest
56

7+
import taskbadger
68
from taskbadger import StatusEnum
79

8-
from .tasks import add, add_auto_track
10+
from .tasks import HEARTBEAT_INTERVAL, add, add_auto_track, slow_add
911

1012

1113
@pytest.fixture(autouse=True)
@@ -41,3 +43,30 @@ def test_celery_auto_track(celery_session_app, celery_session_worker):
4143
a, b = random.randint(1, 1000), random.randint(1, 1000)
4244
result = add_auto_track.delay(a, b)
4345
assert result.get(timeout=10, propagate=True) == a + b
46+
47+
48+
def test_celery_heartbeat(celery_session_app, celery_session_worker):
49+
"""The worker pings the task while it runs, so it doesn't go stale."""
50+
a, b = random.randint(1, 1000), random.randint(1, 1000)
51+
result = slow_add.delay(a, b)
52+
53+
running = _wait_for_status(result.taskbadger_task_id, StatusEnum.PROCESSING)
54+
assert running.stale_timeout == HEARTBEAT_INTERVAL * 2
55+
56+
time.sleep(HEARTBEAT_INTERVAL * 2)
57+
pinged = taskbadger.get_task(running.id)
58+
59+
assert result.get(timeout=30, propagate=True) == a + b
60+
# still running, so the task can only have been touched by the heartbeat
61+
assert pinged.status == StatusEnum.PROCESSING
62+
assert pinged.updated > running.updated, "task was not pinged while it was running"
63+
64+
65+
def _wait_for_status(task_id, status, timeout=15):
66+
deadline = time.monotonic() + timeout
67+
while time.monotonic() < deadline:
68+
task = taskbadger.get_task(task_id)
69+
if task.status == status:
70+
return task
71+
time.sleep(0.2)
72+
pytest.fail(f"task '{task_id}' did not reach status '{status}'")

integration_tests/test_procrastinate.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
pyproject.toml.
99
"""
1010

11+
import datetime
1112
import logging
1213
import os
1314
import random
15+
import time
1416

1517
import procrastinate
1618
import psycopg
@@ -26,6 +28,10 @@
2628
"postgresql://postgres:postgres@localhost:5432/procrastinate",
2729
)
2830

31+
HEARTBEAT_INTERVAL = 1
32+
# long enough for the heartbeat to fire while the task is still running
33+
SLOW_TASK_DURATION = 3
34+
2935

3036
@pytest.fixture(autouse=True)
3137
def _check_log_errors(caplog):
@@ -102,6 +108,38 @@ def add_manual(a, b):
102108
assert fetched.data == {"result": a + b}
103109

104110

111+
def test_heartbeat(app):
112+
"""The worker pings the task while it runs, so it doesn't go stale."""
113+
114+
@track(heartbeat_interval=HEARTBEAT_INTERVAL)
115+
@app.task(name="slow", queue="taskbadger_int_hb")
116+
def slow():
117+
# `run_worker` blocks the test, so sample the task's `updated` time from
118+
# inside the body. Fetched directly to bypass the integration's cache.
119+
tb_id = current_task().id
120+
before = taskbadger.get_task(tb_id).updated
121+
time.sleep(SLOW_TASK_DURATION)
122+
after = taskbadger.get_task(tb_id).updated
123+
current_task().update(data={"before": before.isoformat(), "after": after.isoformat()})
124+
125+
job_id = slow.defer()
126+
app.run_worker(
127+
queues=["taskbadger_int_hb"],
128+
wait=False,
129+
install_signal_handlers=False,
130+
listen_notify=False,
131+
)
132+
133+
args = _fetch_job_args(job_id)
134+
fetched = taskbadger.get_task(args["__taskbadger_task_id__"])
135+
136+
assert fetched.status == StatusEnum.SUCCESS
137+
assert fetched.stale_timeout == HEARTBEAT_INTERVAL * 2
138+
before = datetime.datetime.fromisoformat(fetched.data["before"])
139+
after = datetime.datetime.fromisoformat(fetched.data["after"])
140+
assert after > before, "task was not pinged while it was running"
141+
142+
105143
def test_auto_track_via_system(app):
106144
ProcrastinateSystemIntegration(app=app, auto_track_tasks=True)
107145

taskbadger/_heartbeat.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Background heartbeat support for the system integrations (Celery,
2+
Procrastinate). Not part of the public API.
3+
4+
A task that sets ``stale_timeout`` is marked ``stale`` by the API if it goes
5+
too long without an update. Long-running queue tasks that don't report progress
6+
would trip that timeout while perfectly healthy, so the integrations can ping
7+
the task periodically for the duration of the run.
8+
9+
All in-flight tasks are pinged from a single daemon thread rather than one
10+
thread per task. The thread is started lazily on first use and sleeps
11+
indefinitely while nothing is registered.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import dataclasses
17+
import logging
18+
import threading
19+
import time
20+
21+
from ._integrations import TERMINAL_STATES, is_valid_interval
22+
from .mug import Badger, Settings
23+
from .safe_sdk import update_task_safe
24+
25+
log = logging.getLogger("taskbadger")
26+
27+
28+
@dataclasses.dataclass
29+
class _Entry:
30+
interval: float
31+
settings: Settings
32+
due: float
33+
34+
35+
class Heartbeat:
36+
"""Periodically pings registered tasks to keep them from going stale."""
37+
38+
def __init__(self):
39+
self._lock = threading.Lock()
40+
self._entries: dict[str, _Entry] = {}
41+
self._wake = threading.Event()
42+
self._thread = None
43+
44+
def start(self, task_id: str, interval: float | None) -> None:
45+
"""Begin pinging ``task_id`` every ``interval`` seconds.
46+
47+
No-op if there is no interval or Task Badger isn't configured in the
48+
calling thread. Registering the same task again resets its schedule.
49+
"""
50+
if not task_id or not is_valid_interval(interval):
51+
return
52+
53+
settings = Badger.current.settings
54+
if settings is None:
55+
return
56+
57+
with self._lock:
58+
self._entries[task_id] = _Entry(interval, settings, time.monotonic() + interval)
59+
self._ensure_thread()
60+
self._wake.set()
61+
62+
def stop(self, task_id: str) -> None:
63+
"""Stop pinging ``task_id``. No-op if it isn't registered."""
64+
with self._lock:
65+
self._entries.pop(task_id, None)
66+
67+
def stop_all(self) -> None:
68+
with self._lock:
69+
self._entries.clear()
70+
71+
def _ensure_thread(self) -> None:
72+
"""Start the beat thread if it isn't running. Called with the lock held."""
73+
if self._thread is not None and self._thread.is_alive():
74+
return
75+
76+
# `is_alive()` is also False for a thread inherited from a parent
77+
# process, so a forked worker (e.g. Celery's prefork pool) starts its
78+
# own thread the first time it runs a tracked task.
79+
self._thread = threading.Thread(target=self._run, name="taskbadger-heartbeat", daemon=True)
80+
self._thread.start()
81+
82+
def _run(self) -> None:
83+
while True:
84+
try:
85+
timeout = self._beat()
86+
except Exception:
87+
# Never let the thread die: every registered task would then go
88+
# stale with nothing to restart the pings.
89+
log.exception("heartbeat beat failed")
90+
timeout = 1.0
91+
self._wake.wait(timeout)
92+
self._wake.clear()
93+
94+
def _beat(self) -> float | None:
95+
"""Ping every task that is due and return the seconds to sleep for.
96+
97+
``None`` means "sleep until a task is registered".
98+
"""
99+
now = time.monotonic()
100+
with self._lock:
101+
due = [(task_id, entry) for task_id, entry in self._entries.items() if entry.due <= now]
102+
for _, entry in due:
103+
entry.due = now + entry.interval
104+
105+
for task_id, entry in due:
106+
self._ping(task_id, entry)
107+
108+
with self._lock:
109+
if not self._entries:
110+
return None
111+
next_due = min(entry.due for entry in self._entries.values())
112+
return max(next_due - time.monotonic(), 0)
113+
114+
def _ping(self, task_id: str, entry: _Entry) -> None:
115+
# The beat thread has its own context, so bind the settings captured
116+
# when the task registered rather than relying on inheritance.
117+
if Badger.current.settings is not entry.settings:
118+
Badger.current.bind(entry.settings)
119+
120+
log.debug("heartbeat ping '%s'", task_id)
121+
task = update_task_safe(task_id)
122+
if task is not None and task.status in TERMINAL_STATES:
123+
# The task finished (or was cancelled) elsewhere.
124+
self.stop(task_id)
125+
126+
127+
heartbeat = Heartbeat()

taskbadger/_integrations.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212

1313
import collections
1414
import logging
15+
import math
1516
import re
1617

1718
from . import sdk
19+
from .exceptions import ConfigurationError
1820
from .internal.models import StatusEnum
1921
from .systems import System
2022

@@ -27,6 +29,10 @@
2729
StatusEnum.STALE,
2830
}
2931

32+
# When a heartbeat is configured but no `stale_timeout` is given, the timeout
33+
# is derived from the interval using this factor (as the CLI's `run` does).
34+
STALE_TIMEOUT_FACTOR = 2
35+
3036

3137
class TaskCache:
3238
"""Bounded LRU-ish cache for TaskBadger Task objects.
@@ -91,18 +97,71 @@ def match_task_name(task_name: str, includes, excludes) -> bool:
9197
return True
9298

9399

100+
def is_valid_interval(interval) -> bool:
101+
"""Return True if ``interval`` is usable as a heartbeat interval."""
102+
return isinstance(interval, int | float) and interval > 0
103+
104+
105+
def validate_interval(interval) -> None:
106+
"""Raise if ``interval`` is set but isn't usable as a heartbeat interval."""
107+
if interval is not None and not is_valid_interval(interval):
108+
raise ConfigurationError(f"heartbeat_interval must be a positive number of seconds: {interval!r}")
109+
110+
111+
def resolve_heartbeat_options(heartbeat_interval, stale_timeout, system):
112+
"""Resolve the heartbeat interval and stale timeout for a single task.
113+
114+
Values set on the task win over those set on the system integration. If a
115+
heartbeat is configured without a stale timeout, one is derived from the
116+
interval.
117+
118+
Returns:
119+
A tuple of ``(heartbeat_interval, stale_timeout)``, either of which may
120+
be ``None``.
121+
"""
122+
if system is not None:
123+
if heartbeat_interval is None:
124+
heartbeat_interval = system.heartbeat_interval
125+
if stale_timeout is None:
126+
stale_timeout = system.stale_timeout
127+
128+
if heartbeat_interval is not None and not is_valid_interval(heartbeat_interval):
129+
# Per-task values don't go through `validate_interval`, and a bad one
130+
# shouldn't stop the task from being tracked.
131+
log.warning("Ignoring invalid heartbeat_interval: %r", heartbeat_interval)
132+
heartbeat_interval = None
133+
134+
if stale_timeout is None and heartbeat_interval:
135+
# `stale_timeout` is whole seconds, so round up: a sub-second interval
136+
# must not produce a timeout of 0.
137+
stale_timeout = max(1, math.ceil(heartbeat_interval * STALE_TIMEOUT_FACTOR))
138+
139+
return heartbeat_interval, stale_timeout
140+
141+
94142
class BaseSystemIntegration(System):
95143
"""Common ctor + ``track_task`` body for system integrations.
96144
97145
Subclasses set ``identifier`` and may override ``track_task`` to add
98146
additional filtering (e.g. skipping built-in tasks).
99147
"""
100148

101-
def __init__(self, auto_track_tasks=True, includes=None, excludes=None, record_task_args=False):
149+
def __init__(
150+
self,
151+
auto_track_tasks=True,
152+
includes=None,
153+
excludes=None,
154+
record_task_args=False,
155+
heartbeat_interval=None,
156+
stale_timeout=None,
157+
):
158+
validate_interval(heartbeat_interval)
102159
self.auto_track_tasks = auto_track_tasks
103160
self.includes = includes
104161
self.excludes = excludes
105162
self.record_task_args = record_task_args
163+
self.heartbeat_interval = heartbeat_interval
164+
self.stale_timeout = stale_timeout
106165

107166
def track_task(self, task_name: str) -> bool:
108167
if not self.auto_track_tasks:

0 commit comments

Comments
 (0)