mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-21 00:57:42 +02:00
200 lines
6.7 KiB
Python
200 lines
6.7 KiB
Python
"""In-process cron-style scheduler.
|
|
|
|
One long-lived asyncio task wakes on the next-due workflow boundary, fires
|
|
matching workflows, then re-computes. We deliberately avoid one-task-per-
|
|
workflow (turns rescheduling into a thundering re-spawn problem). On
|
|
startup we walk persisted workflows once, decide what to do about missed
|
|
fires via on_missed, and queue each.
|
|
|
|
Schedule semantics:
|
|
unit=day: fires every repeat_every days at hour:minute
|
|
unit=week: fires on the listed weekday(s) every repeat_every weeks
|
|
unit=month: fires on the original day-of-month every repeat_every months
|
|
|
|
Local clock only. We avoid timezone math here; users see all calendars in
|
|
their machine local time, which matches the in-app calendar in the images.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from backend.apps.workflows.models import Workflow, ScheduleConfig
|
|
from backend.apps.workflows import storage, executor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_loop_task: Optional[asyncio.Task] = None
|
|
_wake = asyncio.Event()
|
|
|
|
|
|
def _next_fire_after(sched: ScheduleConfig, ref: datetime) -> Optional[datetime]:
|
|
if not sched.enabled:
|
|
return None
|
|
base = ref.replace(second=0, microsecond=0)
|
|
candidate = base.replace(hour=sched.hour, minute=sched.minute)
|
|
if candidate <= ref:
|
|
candidate = candidate + timedelta(days=1)
|
|
|
|
if sched.repeat_unit == "day":
|
|
step = max(1, sched.repeat_every)
|
|
# Walk forward in step-day increments until we find a day strictly
|
|
# after `ref`. Cheap because step is small.
|
|
while candidate <= ref:
|
|
candidate = candidate + timedelta(days=step)
|
|
return candidate
|
|
|
|
if sched.repeat_unit == "week":
|
|
# Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's
|
|
# datetime.weekday() is Mon=0..Sun=6, so we translate before
|
|
# matching. Keep the wire format JS-style so the UI math stays
|
|
# trivial and the cron picker stays self-explanatory.
|
|
def _js_weekday(d: datetime) -> int:
|
|
return (d.weekday() + 1) % 7
|
|
allowed = sched.on_days or [_js_weekday(ref)]
|
|
for _ in range(0, 14):
|
|
if _js_weekday(candidate) in allowed and candidate > ref:
|
|
return candidate
|
|
candidate = candidate + timedelta(days=1)
|
|
return candidate
|
|
|
|
if sched.repeat_unit == "month":
|
|
target_day = ref.day
|
|
step = max(1, sched.repeat_every)
|
|
# Walk month-by-month preserving the original day-of-month when it
|
|
# exists (Feb 30 falls back to the month's last day).
|
|
c = candidate.replace(day=min(target_day, 28))
|
|
while c <= ref:
|
|
month = c.month + step
|
|
year = c.year + (month - 1) // 12
|
|
month = ((month - 1) % 12) + 1
|
|
c = c.replace(year=year, month=month)
|
|
return c
|
|
|
|
return None
|
|
|
|
|
|
def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[datetime]:
|
|
return _next_fire_after(wf.schedule, ref or datetime.now())
|
|
|
|
|
|
def kick() -> None:
|
|
_wake.set()
|
|
|
|
|
|
async def _tick() -> None:
|
|
now = datetime.now()
|
|
due: list[Workflow] = []
|
|
for wf in storage.list_workflows():
|
|
if not wf.schedule.enabled:
|
|
continue
|
|
if wf.next_run_at and wf.next_run_at <= now:
|
|
due.append(wf)
|
|
|
|
for wf in due:
|
|
scheduled_for = wf.next_run_at
|
|
nxt = compute_next_fire(wf, now)
|
|
wf.next_run_at = nxt
|
|
storage.save_workflow(wf)
|
|
asyncio.create_task(_fire(wf, scheduled_for=scheduled_for))
|
|
|
|
|
|
async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None:
|
|
try:
|
|
await executor.execute(wf, triggered_by="schedule", scheduled_for=scheduled_for)
|
|
except Exception:
|
|
logger.exception("scheduler fire failed for workflow=%s", wf.id)
|
|
|
|
|
|
def _seconds_until_next() -> float:
|
|
now = datetime.now()
|
|
soonest: Optional[datetime] = None
|
|
for wf in storage.list_workflows():
|
|
if not wf.schedule.enabled or not wf.next_run_at:
|
|
continue
|
|
if soonest is None or wf.next_run_at < soonest:
|
|
soonest = wf.next_run_at
|
|
if soonest is None:
|
|
return 60.0
|
|
delta = (soonest - now).total_seconds()
|
|
return max(1.0, min(delta, 60.0))
|
|
|
|
|
|
async def _loop() -> None:
|
|
logger.info("workflow scheduler loop started")
|
|
while True:
|
|
try:
|
|
await _tick()
|
|
except Exception:
|
|
logger.exception("scheduler tick error")
|
|
try:
|
|
await asyncio.wait_for(_wake.wait(), timeout=_seconds_until_next())
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
_wake.clear()
|
|
|
|
|
|
def _mark_stuck_runs_failed() -> None:
|
|
"""Any run marked 'running' that survives a backend restart is dead.
|
|
|
|
The owning event loop is gone, so there's no way to resume. Mark it
|
|
failed once at startup instead of letting the History tab show a
|
|
forever-spinning row that misleads the user.
|
|
"""
|
|
now = datetime.now()
|
|
for wf in storage.list_workflows():
|
|
for r in storage.list_runs(wf.id, limit=200):
|
|
if r.status == "running":
|
|
storage.update_run(r.id, status="failure", error="Killed by restart", finished_at=now)
|
|
|
|
|
|
def reconcile_on_startup() -> None:
|
|
"""Walk persisted workflows once and resolve missed fires per policy.
|
|
|
|
Missed-run policies:
|
|
skip -> roll forward to next future fire, ignore missed
|
|
run_once -> if any fires were missed, schedule a single catch-up at now
|
|
run_all -> not actually run_all in v1 (would burn tokens); same as run_once
|
|
but we mark the run.status as ran_late so the UI surfaces it
|
|
"""
|
|
now = datetime.now()
|
|
for wf in storage.list_workflows():
|
|
if not wf.schedule.enabled:
|
|
wf.next_run_at = None
|
|
storage.save_workflow(wf)
|
|
continue
|
|
|
|
missed = bool(wf.next_run_at and wf.next_run_at <= now)
|
|
if missed and wf.schedule.on_missed in ("run_once", "run_all"):
|
|
# Leave next_run_at <= now so the very next tick fires it. The
|
|
# executor records the run with started_at=now; the UI badges
|
|
# it ran_late if scheduled_for is more than a few minutes
|
|
# behind started_at.
|
|
pass
|
|
else:
|
|
wf.next_run_at = compute_next_fire(wf, now)
|
|
storage.save_workflow(wf)
|
|
|
|
|
|
async def start() -> None:
|
|
global _loop_task
|
|
if _loop_task is not None:
|
|
return
|
|
_mark_stuck_runs_failed()
|
|
reconcile_on_startup()
|
|
_loop_task = asyncio.create_task(_loop())
|
|
|
|
|
|
async def stop() -> None:
|
|
global _loop_task
|
|
if _loop_task is None:
|
|
return
|
|
_loop_task.cancel()
|
|
try:
|
|
await _loop_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
_loop_task = None
|