Files
gyxx-flow/src/gyxx_flow/scheduler_service.py
T

398 lines
15 KiB
Python

"""Cross-platform resident scheduler for project-owned workflows."""
from __future__ import annotations
import calendar
import json
import os
import subprocess
import sys
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Protocol
from zoneinfo import ZoneInfo
from gyxx_flow.catalog import ScheduleEntry, WorkflowCatalog
from gyxx_flow.core.artifacts import atomic_write_json
_TMALL_BAIBU_WORKFLOW_ID = "product.tmall_baibu_apply"
_TMALL_BAIBU_SCHEDULE_IMPORT_MODE = "without_hyperlinks"
def latest_due_slot(schedule: ScheduleEntry, now: datetime) -> datetime:
"""Return the latest scheduled wall-clock slot at or before ``now``."""
if now.tzinfo is None:
raise ValueError("scheduler time must be timezone-aware")
candidates = [
_latest_due_slot_at(schedule, now, wall_clock)
for wall_clock in schedule.effective_times
]
eligible = [candidate for candidate in candidates if candidate <= now]
if eligible:
return max(eligible)
return min(candidates)
def _latest_due_slot_at(
schedule: ScheduleEntry,
now: datetime,
wall_clock: str,
) -> datetime:
hour, minute = (int(part) for part in wall_clock.split(":"))
def at(day: date) -> datetime:
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=now.tzinfo)
if schedule.kind == "daily":
candidate = at(now.date())
return candidate if candidate <= now else at(now.date() - timedelta(days=1))
if schedule.kind == "weekly":
for offset in range(8):
candidate = at(now.date() - timedelta(days=offset))
if candidate.strftime("%A") in schedule.days and candidate <= now:
return candidate
raise ValueError(f"weekly schedule has no due slot: {schedule.workflow_id}")
if schedule.kind == "monthly":
if schedule.day_of_month is None:
raise ValueError(f"monthly schedule has no day: {schedule.workflow_id}")
year, month = now.year, now.month
for _ in range(24):
if schedule.day_of_month <= calendar.monthrange(year, month)[1]:
candidate = datetime(year, month, schedule.day_of_month, hour, minute, tzinfo=now.tzinfo)
if candidate <= now:
return candidate
month -= 1
if month == 0:
year -= 1
month = 12
raise ValueError(f"monthly schedule has no due slot: {schedule.workflow_id}")
if schedule.kind == "interval_days":
if not schedule.anchor_date or not schedule.every_days:
raise ValueError(f"interval schedule is incomplete: {schedule.workflow_id}")
anchor = at(date.fromisoformat(schedule.anchor_date))
if now < anchor:
return anchor
elapsed_days = (now.date() - anchor.date()).days
periods = elapsed_days // schedule.every_days
candidate = anchor + timedelta(days=periods * schedule.every_days)
if candidate > now:
candidate -= timedelta(days=schedule.every_days)
return candidate
raise ValueError(f"unsupported schedule kind: {schedule.kind}")
class SchedulerProcess(Protocol):
pid: int
def poll(self) -> int | None: ...
class WorkflowLauncher(Protocol):
def launch(
self,
workflow_id: str,
slot: datetime,
business_date: date,
) -> SchedulerProcess: ...
def close(self, process: SchedulerProcess) -> None: ...
class SubprocessWorkflowLauncher:
"""Launch workflows through the installed gyxx CLI and retain per-run logs."""
def __init__(self, project_root: Path, data_root: Path, python_executable: Path | None = None) -> None:
self.project_root = Path(project_root).resolve()
self.data_root = Path(data_root).resolve()
self.python_executable = Path(python_executable or sys.executable).resolve()
self.log_root = self.data_root / "logs" / "scheduler"
self._streams: dict[int, object] = {}
def launch(
self,
workflow_id: str,
slot: datetime,
business_date: date,
) -> subprocess.Popen:
self.log_root.mkdir(parents=True, exist_ok=True)
stamp = slot.strftime("%Y%m%dT%H%M%S%z")
log_path = self.log_root / f"{workflow_id}-{stamp}.log"
stream = log_path.open("a", encoding="utf-8", newline="\n")
environment = {
**os.environ,
"GYXX_PROJECT_ROOT": str(self.project_root),
"GYXX_DATA_ROOT": str(self.data_root),
# Keep the concrete scheduled slot available to the workflow.
# A workflow may have more than one daily slot; the business date
# alone cannot distinguish the initial collection from a later
# same-day retry.
"GYXX_SCHEDULE_SLOT": slot.isoformat(),
"GYXX_SCHEDULE_TIME": slot.strftime("%H:%M"),
}
kwargs: dict[str, object] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
command = [
str(self.python_executable),
"-m",
"gyxx_flow",
"run",
workflow_id,
"--date",
business_date.isoformat(),
]
if workflow_id == _TMALL_BAIBU_WORKFLOW_ID:
command.extend(
[
"--tmall-baibu-import-mode",
_TMALL_BAIBU_SCHEDULE_IMPORT_MODE,
]
)
command.append("--execute")
try:
process = subprocess.Popen(
command,
cwd=self.project_root,
env=environment,
stdin=subprocess.DEVNULL,
stdout=stream,
stderr=subprocess.STDOUT,
shell=False,
**kwargs,
)
except Exception:
stream.close()
raise
self._streams[process.pid] = stream
return process
def close(self, process: SchedulerProcess) -> None:
stream = self._streams.pop(process.pid, None)
if stream is not None:
stream.close() # type: ignore[attr-defined]
@dataclass(slots=True)
class _ActiveRun:
process: SchedulerProcess
slot: datetime
class PythonScheduler:
"""Evaluate catalog schedules with persistent at-most-once slot tracking."""
def __init__(
self,
catalog: WorkflowCatalog,
data_root: Path,
*,
launcher: WorkflowLauncher,
misfire_grace_seconds: int = 6 * 60 * 60,
dry_run: bool = False,
catalog_loader: Callable[[], WorkflowCatalog] | None = None,
maintenance: Callable[[], object] | None = None,
) -> None:
if misfire_grace_seconds < 0:
raise ValueError("misfire grace must be non-negative")
self.catalog = catalog
self.timezone = ZoneInfo(catalog.timezone)
self.data_root = Path(data_root).resolve()
self.state_path = self.data_root / "state" / "scheduler" / "state.json"
self.launcher = launcher
self.misfire_grace_seconds = misfire_grace_seconds
self.dry_run = dry_run
self._catalog_loader = catalog_loader
self._maintenance = maintenance
self._state = self._load_state()
self._active: dict[str, _ActiveRun] = {}
def _reload_catalog(self) -> None:
if self._catalog_loader is None:
return
catalog = self._catalog_loader()
self.catalog = catalog
self.timezone = ZoneInfo(catalog.timezone)
def _load_state(self) -> dict:
if not self.state_path.exists():
return {"schema_version": 1, "updated_at": None, "jobs": {}}
try:
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"cannot load scheduler state: {exc}") from exc
if payload.get("schema_version") != 1 or not isinstance(payload.get("jobs"), dict):
raise ValueError("invalid scheduler state")
return payload
def _save_state(self, now: datetime) -> None:
self._state["updated_at"] = now.astimezone(timezone.utc).isoformat(timespec="seconds")
atomic_write_json(self.state_path, self._state)
def _reap(self, now: datetime) -> None:
changed = False
for workflow_id, active in list(self._active.items()):
return_code = active.process.poll()
if return_code is None:
continue
self.launcher.close(active.process)
job = self._state["jobs"][workflow_id]
job.update({
"status": "success" if return_code == 0 else "failed",
"exit_code": return_code,
"finished_at": now.astimezone(timezone.utc).isoformat(timespec="seconds"),
})
del self._active[workflow_id]
changed = True
if changed:
self._save_state(now)
def tick(self, now: datetime | None = None) -> list[str]:
self._reload_catalog()
current = (now or datetime.now(self.timezone)).astimezone(self.timezone)
self._reap(current)
if not self.dry_run and self._maintenance is not None:
try:
self._maintenance()
except Exception:
# Maintenance must never prevent scheduled business workflows
# from being evaluated. The maintenance owner records its own
# redacted status where appropriate.
pass
due: list[tuple[str, datetime, date]] = []
state_changed = False
for workflow in self.catalog.scheduled_workflows():
schedule = self.catalog.schedule_for(workflow.workflow_id)
if not schedule.enabled:
continue
slot = latest_due_slot(schedule, current)
age = (current - slot).total_seconds()
if age < 0 or age > self.misfire_grace_seconds:
continue
previous = self._state["jobs"].get(workflow.workflow_id, {})
if previous.get("status") == "running":
pid = int(previous.get("pid") or 0)
if pid and _pid_exists(pid):
continue
previous.update({
"status": "interrupted",
"finished_at": current.astimezone(timezone.utc).isoformat(timespec="seconds"),
})
state_changed = True
if previous.get("slot") == slot.isoformat() or workflow.workflow_id in self._active:
continue
business_date = slot.date() + timedelta(
days=schedule.business_date_offset_days
)
due.append((workflow.workflow_id, slot, business_date))
if state_changed and not self.dry_run:
self._save_state(current)
if self.dry_run:
return [workflow_id for workflow_id, _slot, _date in due]
started: list[str] = []
for workflow_id, slot, business_date in due:
started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
try:
process = self.launcher.launch(workflow_id, slot, business_date)
except Exception as exc:
self._state["jobs"][workflow_id] = {
"slot": slot.isoformat(), "status": "launch_failed",
"business_date": business_date.isoformat(),
"started_at": started_at, "error": type(exc).__name__,
}
self._save_state(current)
continue
self._active[workflow_id] = _ActiveRun(process, slot)
self._state["jobs"][workflow_id] = {
"slot": slot.isoformat(), "status": "running", "pid": process.pid,
"business_date": business_date.isoformat(),
"started_at": started_at,
}
self._save_state(current)
started.append(workflow_id)
return started
def wait_for_active(self, timeout_seconds: float | None = None, poll_seconds: float = 0.2) -> bool:
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
while self._active:
self._reap(datetime.now(self.timezone))
if not self._active:
return True
if deadline is not None and time.monotonic() >= deadline:
return False
time.sleep(poll_seconds)
return True
def status(self) -> dict:
self._reap(datetime.now(self.timezone))
return json.loads(json.dumps(self._state))
class SchedulerInstanceLock:
"""Single-instance PID lock with stale-owner recovery."""
def __init__(self, path: Path) -> None:
self.path = Path(path).resolve()
self._owned = False
def __enter__(self) -> "SchedulerInstanceLock":
self.path.parent.mkdir(parents=True, exist_ok=True)
for _ in range(2):
try:
descriptor = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
pid = int(json.loads(self.path.read_text(encoding="utf-8")).get("pid", 0))
except Exception:
pid = 0
if pid and _pid_exists(pid):
raise RuntimeError(f"scheduler is already running (pid={pid})")
self.path.unlink(missing_ok=True)
continue
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
json.dump({"pid": os.getpid(), "started_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}, stream)
self._owned = True
return self
raise RuntimeError("cannot acquire scheduler instance lock")
def __exit__(self, exc_type, exc, traceback) -> None: # type: ignore[no-untyped-def]
if self._owned:
self.path.unlink(missing_ok=True)
self._owned = False
def _pid_exists(pid: int) -> bool:
if pid <= 0:
return False
if os.name == "nt":
# Windows 的 os.kill(pid, 0) 并非可靠的只读探测;使用查询句柄,
# 避免向被检查进程发送控制信号。
import ctypes
process_query_limited_information = 0x1000
handle = ctypes.windll.kernel32.OpenProcess( # type: ignore[attr-defined]
process_query_limited_information, False, pid
)
if not handle:
return False
ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined]
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True