import asyncio import logging from datetime import datetime, timedelta, timezone from typing import Callable from app.db.repositories import SchedulerSettingsRepository from app.sync.manager import SyncAlreadyRunning logger = logging.getLogger(__name__) def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool: """True if `hour` falls within [day_start_hour, night_start_hour), handling a day period that wraps past midnight (e.g. day_start=20, night_start=6). Equal start hours are treated as "always day".""" if day_start_hour == night_start_hour: return True if day_start_hour < night_start_hour: return day_start_hour <= hour < night_start_hour return hour >= day_start_hour or hour < night_start_hour class DayNightIntervalProvider: """Reads the admin-configurable day/night interval from the DB on every call, so a change made via the UI takes effect on the scheduler's very next tick without an app restart.""" def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None: self.session_factory = session_factory self.default_minutes = default_minutes self._now = now or datetime.now def __call__(self) -> float: with self.session_factory() as session: row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes) day_start_hour = row.day_start_hour night_start_hour = row.night_start_hour day_interval_minutes = row.day_interval_minutes night_interval_minutes = row.night_interval_minutes hour = self._now().hour minutes = ( day_interval_minutes if is_daytime(hour, day_start_hour, night_start_hour) else night_interval_minutes ) return minutes * 60 class SyncScheduler: def __init__( self, manager, *, interval_seconds: float, interval_provider: Callable[[], float] | None = None, ) -> None: self.manager = manager self.interval_seconds = interval_seconds self.interval_provider = interval_provider self._task: asyncio.Task | None = None self._stop = asyncio.Event() self.last_tick = None self.next_tick = None def _current_interval(self) -> float: if self.interval_provider is None: return self.interval_seconds try: return float(self.interval_provider()) except Exception: logger.warning("interval_provider failed, falling back to default interval", exc_info=True) return self.interval_seconds async def run_once(self, interval: float | None = None) -> None: if interval is None: interval = self._current_interval() 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=interval) async def _run(self) -> None: while not self._stop.is_set(): interval = self._current_interval() await self.run_once(interval) try: await asyncio.wait_for(self._stop.wait(), timeout=interval) 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