Files
FabledSteward/steward/core/scheduler.py
T
bvandeusenandClaude Opus 5.5 401bfb5251
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m57s
CI / unit (push) Successful in 2m8s
CI / publish (push) Successful in 1m33s
fix(scheduler): a task marked not to run on startup waits an interval after boot (#5431)
_DueTracker counted a task that had never run from 0 on the loop clock,
which is host uptime, so every run_on_startup=False task (hourly data
cleanup with the Docker log prune, the weekly report check, alert
reconcile, agent age, Ansible schedules) fired on the first tick after
each deploy, while agents were flushing their backlog. Agent ingest was
slow or dropped for a few minutes after every deploy.

start_scheduler now calls tracker.begin(): such a task is first due one
interval after the loop starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:52:08 -04:00

92 lines
3.3 KiB
Python

from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Callable, Coroutine
logger = logging.getLogger(__name__)
@dataclass
class ScheduledTask:
name: str
coro_factory: Callable[[], Coroutine]
interval_seconds: int
run_on_startup: bool = False
@dataclass
class _DueTracker:
"""Decides which scheduled tasks are due, with a self-overlap guard.
Pure (no asyncio, no clock of its own — `now` is passed in) so the
scheduling policy is unit-testable without timing races. A task whose prior
run is still in flight is NOT re-fired: overlapping poll runs stack up open
connections/subprocesses and amplify any per-poll resource use — the same
failure mode behind the fd-leak lockups. The skipped task is retried on the
next tick once it completes (its last_run isn't advanced while skipped).
"""
last_run: dict[str, float] = field(default_factory=dict)
in_flight: set[str] = field(default_factory=set)
def due(self, tasks: list[ScheduledTask], now: float) -> list[ScheduledTask]:
ready: list[ScheduledTask] = []
for task in tasks:
if now - self.last_run.get(task.name, 0) < task.interval_seconds:
continue
if task.name in self.in_flight:
logger.warning(
"Scheduled task %r still running — skipping this tick",
task.name)
continue
ready.append(task)
return ready
def begin(self, tasks: list[ScheduledTask], now: float) -> None:
"""Start the clock: a task that doesn't run on startup is first due
one interval from now. Without this a task that had never run counted
from 0 on the loop's clock (host uptime), so every one of them fired
on the first tick after a deploy, the hourly cleanup with its Docker
log prune among them, holding up agent ingest for minutes (#5431)."""
for task in tasks:
if not task.run_on_startup:
self.last_run.setdefault(task.name, now)
def mark_started(self, task: ScheduledTask, now: float) -> None:
self.in_flight.add(task.name)
self.last_run[task.name] = now
def mark_done(self, name: str) -> None:
self.in_flight.discard(name)
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
tracker = _DueTracker()
def _spawn(task: ScheduledTask, now: float) -> None:
tracker.mark_started(task, now)
asyncio.create_task(_run_task(task, tracker))
started = asyncio.get_event_loop().time()
tracker.begin(tasks, started)
for task in tasks:
if task.run_on_startup:
logger.info(f"Startup task: {task.name}")
_spawn(task, started)
while True:
now = asyncio.get_event_loop().time()
for task in tracker.due(tasks, now):
_spawn(task, now)
await asyncio.sleep(1)
async def _run_task(task: ScheduledTask, tracker: _DueTracker) -> None:
try:
await task.coro_factory()
except Exception:
logger.exception(f"Scheduled task {task.name!r} raised an exception")
finally:
tracker.mark_done(task.name)