Files
tracer-cloud--opensre/platform/scheduler/runner.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

195 lines
6.4 KiB
Python

"""APScheduler-backed blocking runner for scheduled tasks.
Loads all enabled tasks from the store, creates CronTrigger jobs, and
blocks until SIGINT/SIGTERM. Fire times for dedup come from
``JobSubmissionEvent.scheduled_run_times[0]`` (UTC, minute precision),
not wall-clock time inside the callback.
"""
from __future__ import annotations
import logging
import signal
import threading
from datetime import UTC, datetime
from typing import Any
from platform.scheduler.executor import execute_task
from platform.scheduler.store import get_task, list_tasks, update_task
from platform.scheduler.types import ScheduledTask
logger = logging.getLogger(__name__)
# Populated by EVENT_JOB_SUBMITTED before each job runs (job_id -> fire_time).
_pending_fire_times: dict[str, str] = {}
_pending_fire_times_lock = threading.Lock()
def _make_trigger(task: ScheduledTask) -> Any:
"""Build an APScheduler CronTrigger from a task's cron expression and timezone.
Raises ValueError if the cron expression or timezone is invalid.
"""
from apscheduler.triggers.cron import CronTrigger
parts = task.cron.split()
if len(parts) != 5:
raise ValueError(f"Invalid cron expression (need 5 fields): {task.cron!r}")
try:
trigger = CronTrigger.from_crontab(task.cron, timezone=task.timezone)
except (ValueError, TypeError, KeyError) as exc:
raise ValueError(f"Invalid cron/timezone for task {task.id}: {exc}") from exc
return trigger
def _compute_fire_time(scheduled_run_time: Any) -> str:
"""Compute a stable, UTC-normalized fire_time string.
Always converts to UTC so DST transitions don't produce ambiguous keys.
"""
if scheduled_run_time is not None:
utc_time: datetime = scheduled_run_time.astimezone(UTC)
return utc_time.strftime("%Y-%m-%dT%H:%MZ")
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%MZ")
def _on_job_submitted(event: Any) -> None:
"""Capture the intended fire time for this tick before the job callback runs."""
run_times = getattr(event, "scheduled_run_times", None)
if run_times:
fire_time = _compute_fire_time(run_times[0])
else:
fire_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%MZ")
with _pending_fire_times_lock:
_pending_fire_times[event.job_id] = fire_time
def _scheduled_job(task_id: str) -> None:
"""Job callback invoked by APScheduler on each cron tick."""
with _pending_fire_times_lock:
fire_time = _pending_fire_times.pop(task_id, None)
if fire_time is None:
logger.warning(
"No scheduled fire_time for task %s; using UTC now (listener may have missed)",
task_id,
)
fire_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%MZ")
task = get_task(task_id)
if task is None:
logger.warning("Task %s not found in store, skipping", task_id)
return
if not task.enabled:
logger.info("Task %s is disabled, skipping", task_id)
return
result = execute_task(task, fire_time)
if result:
task.last_run = datetime.now(UTC).isoformat()
update_task(task)
def _register_jobs(scheduler: Any) -> int:
"""Register all enabled tasks on *scheduler*; invalid tasks are logged and skipped."""
from apscheduler.events import EVENT_JOB_SUBMITTED
scheduler.add_listener(_on_job_submitted, EVENT_JOB_SUBMITTED)
enabled_count = 0
for task in list_tasks():
if not task.enabled:
continue
try:
trigger = _make_trigger(task)
except ValueError as exc:
logger.error("Skipping task %s: %s", task.id, exc)
continue
scheduler.add_job(
_scheduled_job,
trigger=trigger,
args=[task.id],
id=task.id,
name=f"{task.kind.value}:{task.id}",
replace_existing=True,
misfire_grace_time=60,
)
enabled_count += 1
logger.info(
"Registered task %s (%s) with cron=%s tz=%s",
task.id,
task.kind,
task.cron,
task.timezone,
)
return enabled_count
def start_background_scheduler() -> tuple[Any, int]:
"""Start a non-blocking scheduler for embedding in a host process.
Installs no signal handlers and never exits the process. Returns
``(scheduler, task_count)``; the scheduler is ``None`` when there are no
enabled tasks. The caller owns shutdown via ``scheduler.shutdown()``.
"""
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
enabled_count = _register_jobs(scheduler)
if enabled_count == 0:
return None, 0
scheduler.start()
logger.info("Scheduler started with %d task(s). Waiting for triggers...", enabled_count)
return scheduler, enabled_count
def start_scheduler() -> None:
"""Load all enabled tasks and start the blocking scheduler.
Blocks until SIGINT or SIGTERM. Invalid tasks (bad cron, bad timezone)
are logged and skipped rather than crashing the entire daemon.
"""
from apscheduler.schedulers.blocking import BlockingScheduler
scheduler = BlockingScheduler()
enabled_count = _register_jobs(scheduler)
if enabled_count == 0:
logger.warning("No enabled tasks found. Scheduler has nothing to run.")
raise SystemExit("No enabled tasks found. Add tasks with `opensre cron add` first.")
stop_event = threading.Event()
def _shutdown_handler(_signum: int, _frame: Any) -> None:
stop_event.set()
scheduler.shutdown(wait=False)
signal.signal(signal.SIGINT, _shutdown_handler)
sigterm = getattr(signal, "SIGTERM", None)
if sigterm is not None:
signal.signal(sigterm, _shutdown_handler)
logger.info("Scheduler started with %d task(s). Waiting for triggers...", enabled_count)
try:
scheduler.start()
except (KeyboardInterrupt, SystemExit):
logger.info("Scheduler stopped.")
def run_task_now(task_id: str) -> bool:
"""Execute a task immediately (ad-hoc one-shot for debugging).
Uses the current time with seconds precision as fire_time so it does
not conflict with scheduled runs (which use minute precision).
"""
task = get_task(task_id)
if task is None:
return False
fire_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
return execute_task(task, fire_time)
__all__ = ["run_task_now", "start_background_scheduler", "start_scheduler"]