"""Per-account sync runner (refactor §4). An ``AccountRunner`` owns the long-lived sync loops for a single account and exposes a small ``start`` / ``stop`` lifecycle. This is a pure extraction of the loops that previously lived inline in the supervisor — behavior is unchanged (INBOX IDLE + secondary CONDSTORE poll, each self-healing with backoff). """ from __future__ import annotations import asyncio import contextlib import logging import uuid from collections.abc import Awaitable, Callable from app.sync.engine import live_loop, secondary_poll_loop logger = logging.getLogger(__name__) _RECONNECT_BACKOFF = 30.0 class AccountRunner: """Owns the sync loops for one account until stopped.""" def __init__(self, account_id: uuid.UUID) -> None: self.account_id = account_id self._stop = asyncio.Event() self._tasks: list[asyncio.Task[None]] = [] def start(self) -> None: """Launch the account's sync loops (idempotent-ish: call once).""" self._stop.clear() self._tasks = [ asyncio.create_task(self._supervise(self._run_live, "live")), asyncio.create_task(self._supervise(self._run_secondary, "secondary")), ] def running(self) -> bool: return any(not t.done() for t in self._tasks) async def stop(self) -> None: """Signal and cancel the loops, waiting for teardown.""" self._stop.set() for task in self._tasks: task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) self._tasks = [] async def _run_live(self) -> None: await live_loop(self.account_id, "INBOX", self._stop) async def _run_secondary(self) -> None: await secondary_poll_loop(self.account_id, self._stop) async def _supervise(self, run: Callable[[], Awaitable[None]], label: str) -> None: """Run a loop, restarting it (with backoff) until stopped.""" while not self._stop.is_set(): try: await run() except asyncio.CancelledError: raise except Exception: logger.exception("%s loop for account %s crashed; retrying", label, self.account_id) with contextlib.suppress(TimeoutError): await asyncio.wait_for(self._stop.wait(), timeout=_RECONNECT_BACKOFF)