"""Fire-and-forget background tasks that actually run. The event loop holds only a WEAK reference to a task, so a bare ``create_task`` with no other holder can be garbage-collected mid-flight — a write that never errors and never lands (the #2663 GC footgun). This module is the one place that gets the pattern right: strong references in ``_pending``, discarded on completion, with failures logged at WARNING instead of vanishing. ``note_usage`` and ``retrieval_telemetry`` predate this module and carry their own copies with bespoke canary semantics; new fire-and-forget callers use this instead of writing a fourth copy. """ from __future__ import annotations import asyncio import logging from collections.abc import Coroutine logger = logging.getLogger(__name__) _pending: set[asyncio.Task] = set() def spawn(coro: Coroutine, *, site: str) -> None: """Schedule ``coro`` fire-and-forget; ``site`` names it in failure logs. No running loop (sync context outside the app) closes the coroutine and skips — every app path runs on the loop, and blocking would be worse. """ try: task = asyncio.get_running_loop().create_task(coro) except RuntimeError: coro.close() logger.debug("background task %s skipped — no running event loop", site) return _pending.add(task) def _done(t: asyncio.Task) -> None: _pending.discard(t) if not t.cancelled() and t.exception() is not None: logger.warning( "background task %s failed", site, exc_info=t.exception() ) task.add_done_callback(_done) def start_periodic(interval_s: float, work, *, label: str) -> asyncio.Task: """A forever loop that sleeps ``interval_s`` then awaits ``work()``, logging (never raising) when a tick fails — the one shape the hourly/daily retention sweeps share (log retention, notification sweep, auth-token purge). Sleeps FIRST so startup isn't a sweep; holds a strong reference like spawn() so the loop cannot be garbage-collected mid-flight.""" async def _loop() -> None: while True: await asyncio.sleep(interval_s) try: await work() except Exception: logger.exception("periodic task %s failed", label) task = asyncio.get_running_loop().create_task(_loop(), name=f"periodic-{label}") _pending.add(task) task.add_done_callback(_pending.discard) return task async def drain() -> None: """Await everything in flight — for tests that need the writes landed.""" while _pending: await asyncio.gather(*list(_pending), return_exceptions=True)