"""Fire-and-forget background-task helper. ``asyncio`` only keeps a *weak* reference to tasks created with ``asyncio.create_task`` — a task with no other strong reference can be garbage-collected mid-flight, silently dropping the work it was meant to do (see the "Important" note in the CPython ``asyncio.create_task`` docs). Across Helix the JML lifecycle fires several genuinely fire-and-forget pushes (post-commit identity-sync events on hire / offboard). Each call site previously did a bare ``create_task(...)`` and discarded the return value, so a freshly-hired employee's SCIM/directory push could be dropped under GC pressure. This helper centralises the correct pattern: retain a strong reference until the task finishes, then drop it and surface any exception through the logger so a failed background push is never swallowed. """ from __future__ import annotations import asyncio import logging from collections.abc import Coroutine from typing import Any logger = logging.getLogger(__name__) # Module-global so references survive the spawning frame. Tasks remove # themselves on completion via the done-callback below. _background_tasks: set[asyncio.Task[Any]] = set() def spawn( coro: Coroutine[Any, Any, Any], *, name: str | None = None, ) -> asyncio.Task[Any]: """Schedule ``coro`` as a retained fire-and-forget task. Unlike a bare ``asyncio.create_task``, the returned task is held in a module-level set until it completes, so it cannot be garbage-collected before it runs. Any exception it raises is logged (not swallowed). """ task = asyncio.create_task(coro, name=name) _background_tasks.add(task) def _on_done(t: asyncio.Task[Any]) -> None: _background_tasks.discard(t) if t.cancelled(): return exc = t.exception() if exc is not None: logger.error( "background task %s failed", name or t.get_name(), exc_info=exc, ) task.add_done_callback(_on_done) return task