117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
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
|