2026-07-18 19:56:06 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-07-19 06:49:48 +02:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
from app.db import session_scope
|
|
|
|
|
from app.models import Account
|
2026-07-18 19:56:06 +02:00
|
|
|
from app.sync.engine import live_loop, secondary_poll_loop
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_RECONNECT_BACKOFF = 30.0
|
2026-07-19 06:49:48 +02:00
|
|
|
_VERIFY_INTERVAL = 60.0
|
2026-07-18 19:56:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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]] = []
|
2026-07-19 06:49:48 +02:00
|
|
|
# Set when the account is found no longer live (§3 defense in depth); the
|
|
|
|
|
# coordinator reaps such runners and releases their lease.
|
|
|
|
|
self.dead = asyncio.Event()
|
|
|
|
|
self.verify_interval = _VERIFY_INTERVAL
|
2026-07-18 19:56:06 +02:00
|
|
|
|
|
|
|
|
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")),
|
2026-07-19 06:49:48 +02:00
|
|
|
asyncio.create_task(self._verify_loop()),
|
2026-07-18 19:56:06 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-19 06:49:48 +02:00
|
|
|
async def _verify_loop(self) -> None:
|
|
|
|
|
"""Periodically confirm the account is still live; flag ``dead`` if not.
|
|
|
|
|
|
|
|
|
|
This makes zombie loops structurally impossible even if a lifecycle event
|
|
|
|
|
is lost: an account that was disabled/deleted stops being synced within a
|
|
|
|
|
verify cycle regardless of the lease/event paths.
|
|
|
|
|
"""
|
|
|
|
|
while not self._stop.is_set():
|
|
|
|
|
with contextlib.suppress(TimeoutError):
|
|
|
|
|
await asyncio.wait_for(self._stop.wait(), timeout=self.verify_interval)
|
|
|
|
|
if self._stop.is_set():
|
|
|
|
|
return
|
|
|
|
|
if not await self._account_is_live():
|
|
|
|
|
logger.info("account %s no longer live; flagging runner dead", self.account_id)
|
|
|
|
|
self.dead.set()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
async def _account_is_live(self) -> bool:
|
|
|
|
|
async with session_scope() as session:
|
|
|
|
|
state = await session.scalar(select(Account.state).where(Account.id == self.account_id))
|
|
|
|
|
return state == "live"
|
|
|
|
|
|
2026-07-18 19:56:06 +02:00
|
|
|
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)
|