|
| 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() |
0 commit comments