import asyncio import logging from datetime import datetime, timedelta, timezone from app.sync.manager import SyncAlreadyRunning logger = logging.getLogger(__name__) class SyncScheduler: def __init__(self, manager, *, interval_seconds: float) -> None: self.manager = manager self.interval_seconds = interval_seconds self._task: asyncio.Task | None = None self._stop = asyncio.Event() self.last_tick = None self.next_tick = None async def run_once(self) -> None: self.last_tick = datetime.now(timezone.utc) try: results = await self.manager.sync_all_enabled() for result in results: if not isinstance(result, Exception): continue # SyncAlreadyRunning is an expected, benign outcome when a # manual sync and a scheduler tick overlap for the same user # -- not an error worth logging. if isinstance(result, SyncAlreadyRunning): continue logger.warning( "sync_all_enabled: user sync failed during scheduled tick: %s: %s", type(result).__name__, str(result)[:200], ) except Exception: logger.exception("sync_all_enabled failed during scheduled tick") finally: self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds) async def _run(self) -> None: while not self._stop.is_set(): await self.run_once() try: await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) except TimeoutError: pass async def start(self) -> None: self._stop.clear() self._task = asyncio.create_task(self._run()) async def stop(self) -> None: self._stop.set() if self._task is not None: await self._task self._task = None