165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from app.core.errors import ConflictError
|
|
from app.core.time import to_iso
|
|
from app.rotation.repository import RotationRepository
|
|
from app.rotation.service import RotationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SchedulerLoop:
|
|
def __init__(
|
|
self,
|
|
repository: RotationRepository,
|
|
rotation_service: RotationService,
|
|
) -> None:
|
|
self.repository = repository
|
|
self.rotation_service = rotation_service
|
|
self._task: asyncio.Task[None] | None = None
|
|
self._stopping = asyncio.Event()
|
|
self._tick_seconds: float | None = None
|
|
self._started_at: str | None = None
|
|
self._last_tick_at: str | None = None
|
|
self._last_success_at: str | None = None
|
|
self._last_dispatch_at: str | None = None
|
|
self._last_error_at: str | None = None
|
|
self._consecutive_errors = 0
|
|
self._started_monotonic: float | None = None
|
|
self._last_tick_monotonic: float | None = None
|
|
|
|
def start(self, tick_seconds: float) -> None:
|
|
if self._task and not self._task.done():
|
|
return
|
|
self._stopping = asyncio.Event()
|
|
self._tick_seconds = tick_seconds
|
|
self._started_at = to_iso()
|
|
self._started_monotonic = time.monotonic()
|
|
self._last_tick_monotonic = None
|
|
self._task = asyncio.create_task(self._run(tick_seconds), name="fleet-schedule-loop")
|
|
self._task.add_done_callback(self._task_done)
|
|
logger.info("scheduler_started", extra={"tick_seconds": tick_seconds})
|
|
|
|
async def stop(self) -> None:
|
|
self._stopping.set()
|
|
if self._task:
|
|
self._task.cancel()
|
|
await asyncio.gather(self._task, return_exceptions=True)
|
|
|
|
def status(self, *, enabled: bool) -> dict[str, Any]:
|
|
running = bool(self._task and not self._task.done())
|
|
state = "disabled" if not enabled else "running" if running else "stopped"
|
|
healthy = not enabled or (
|
|
running and self._consecutive_errors < 3 and self._tick_is_fresh()
|
|
)
|
|
return {
|
|
"enabled": enabled,
|
|
"running": running,
|
|
"healthy": healthy,
|
|
"state": state,
|
|
"tick_seconds": self._tick_seconds,
|
|
"started_at": self._started_at,
|
|
"last_tick_at": self._last_tick_at,
|
|
"last_success_at": self._last_success_at,
|
|
"last_dispatch_at": self._last_dispatch_at,
|
|
"last_error_at": self._last_error_at,
|
|
"consecutive_errors": self._consecutive_errors,
|
|
}
|
|
|
|
async def _run(self, tick_seconds: float) -> None:
|
|
while not self._stopping.is_set():
|
|
self._last_tick_at = to_iso()
|
|
self._last_tick_monotonic = time.monotonic()
|
|
try:
|
|
tick_succeeded = self._dispatch_due_group()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
self._record_error()
|
|
self._consecutive_errors += 1
|
|
logger.exception("scheduler_tick_failed")
|
|
else:
|
|
if tick_succeeded:
|
|
self._last_success_at = to_iso()
|
|
self._consecutive_errors = 0
|
|
else:
|
|
self._consecutive_errors += 1
|
|
try:
|
|
await asyncio.wait_for(self._stopping.wait(), timeout=tick_seconds)
|
|
except TimeoutError:
|
|
continue
|
|
|
|
def _dispatch_due_group(self) -> bool:
|
|
succeeded = True
|
|
due_group_ids = self.repository.due_group_ids()
|
|
if due_group_ids:
|
|
logger.info(
|
|
"scheduler_due_groups_found",
|
|
extra={"due_group_count": len(due_group_ids)},
|
|
)
|
|
for group_id in due_group_ids:
|
|
try:
|
|
run = self.rotation_service.start_group(group_id, trigger="scheduled")
|
|
except ConflictError as exc:
|
|
logger.warning(
|
|
"scheduler_dispatch_conflict",
|
|
extra={"group_id": group_id, "code": exc.code},
|
|
)
|
|
if exc.code == "ROTATION_IN_PROGRESS":
|
|
break
|
|
except Exception:
|
|
self._record_error()
|
|
succeeded = False
|
|
logger.exception("scheduler_dispatch_failed", extra={"group_id": group_id})
|
|
try:
|
|
self.repository.defer_group(group_id)
|
|
except Exception:
|
|
self._record_error()
|
|
logger.exception("scheduler_defer_failed", extra={"group_id": group_id})
|
|
else:
|
|
self._last_dispatch_at = to_iso()
|
|
logger.info(
|
|
"scheduler_group_dispatched",
|
|
extra={"group_id": group_id, "run_id": run.id},
|
|
)
|
|
break
|
|
return succeeded
|
|
|
|
def _record_error(self) -> None:
|
|
self._last_error_at = to_iso()
|
|
|
|
def _tick_is_fresh(self) -> bool:
|
|
reference = self._last_tick_monotonic or self._started_monotonic
|
|
if reference is None:
|
|
return False
|
|
allowed_silence = max((self._tick_seconds or 10) * 3, 10)
|
|
return time.monotonic() - reference <= allowed_silence
|
|
|
|
def _task_done(self, task: asyncio.Task[None]) -> None:
|
|
if task.cancelled():
|
|
if self._stopping.is_set():
|
|
logger.info("scheduler_stopped")
|
|
else:
|
|
self._record_error()
|
|
self._consecutive_errors += 1
|
|
logger.error("scheduler_cancelled_unexpectedly")
|
|
return
|
|
try:
|
|
task.result()
|
|
except Exception:
|
|
self._record_error()
|
|
self._consecutive_errors += 1
|
|
logger.exception("scheduler_exited_unexpectedly")
|
|
return
|
|
if not self._stopping.is_set():
|
|
self._record_error()
|
|
self._consecutive_errors += 1
|
|
logger.error("scheduler_exited_unexpectedly")
|
|
else:
|
|
logger.info("scheduler_stopped")
|