feat(cron): 实现完整的定时任务调度模块
新增了从调度引擎、任务注册、状态追踪到结果投递的全套cron任务系统,支持cron表达式、一次性任务和周期任务,包含失败告警、任务持久化和运行日志功能
This commit is contained in:
parent
4962d1f6f8
commit
0dcc370819
47
backend/package/yuxi/channel/cron/__init__.py
Normal file
47
backend/package/yuxi/channel/cron/__init__.py
Normal file
@ -0,0 +1,47 @@
|
||||
from yuxi.channel.cron.engine import CronEngine, get_cron_engine
|
||||
from yuxi.channel.cron.handler_registry import (
|
||||
list_registered,
|
||||
register_handler,
|
||||
resolve_handler,
|
||||
)
|
||||
from yuxi.channel.cron.normalize import normalize_job
|
||||
from yuxi.channel.cron.phase_tracker import PhaseTracker, create_run_id
|
||||
from yuxi.channel.cron.types import (
|
||||
CronAgentExecutionPhase,
|
||||
CronDeliveryReceipt,
|
||||
CronDeliveryTarget,
|
||||
CronFailureAlert,
|
||||
CronJob,
|
||||
CronJobState,
|
||||
CronJobStatus,
|
||||
CronPhaseRecord,
|
||||
CronRunDiagnostic,
|
||||
CronRunOutcome,
|
||||
CronRunStatus,
|
||||
DeliveryHandler,
|
||||
ScheduleKind,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CronAgentExecutionPhase",
|
||||
"CronDeliveryReceipt",
|
||||
"CronDeliveryTarget",
|
||||
"CronEngine",
|
||||
"CronFailureAlert",
|
||||
"CronJob",
|
||||
"CronJobState",
|
||||
"CronJobStatus",
|
||||
"CronPhaseRecord",
|
||||
"CronRunDiagnostic",
|
||||
"CronRunOutcome",
|
||||
"CronRunStatus",
|
||||
"DeliveryHandler",
|
||||
"PhaseTracker",
|
||||
"ScheduleKind",
|
||||
"create_run_id",
|
||||
"get_cron_engine",
|
||||
"list_registered",
|
||||
"normalize_job",
|
||||
"register_handler",
|
||||
"resolve_handler",
|
||||
]
|
||||
122
backend/package/yuxi/channel/cron/alert.py
Normal file
122
backend/package/yuxi/channel/cron/alert.py
Normal file
@ -0,0 +1,122 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from yuxi.channel.cron.types import CronFailureAlert, CronJob, DeliveryHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALERT_COOLDOWN_MS = 600_000
|
||||
RECOVERY_COOLDOWN_MS = 300_000
|
||||
_GLOBAL_ALERT_MAX_PER_MINUTE = 10
|
||||
_global_alert_timestamps: list[int] = []
|
||||
|
||||
|
||||
def _global_rate_check(now_ms: int) -> bool:
|
||||
cutoff = now_ms - 60_000
|
||||
global _global_alert_timestamps
|
||||
_global_alert_timestamps = [t for t in _global_alert_timestamps if t > cutoff]
|
||||
if len(_global_alert_timestamps) >= _GLOBAL_ALERT_MAX_PER_MINUTE:
|
||||
return False
|
||||
_global_alert_timestamps.append(now_ms)
|
||||
return True
|
||||
|
||||
|
||||
def _format_cooldown_ms(ms: int) -> str:
|
||||
if ms < 60_000:
|
||||
return f"{ms // 1000}s"
|
||||
if ms < 3_600_000:
|
||||
return f"{ms // 60_000}min"
|
||||
return f"{ms // 3_600_000}h"
|
||||
|
||||
|
||||
def format_alert_message(job: CronJob, consecutive_errors: int, last_error: str | None) -> str:
|
||||
job_label = job.name or job.id
|
||||
return (
|
||||
f"🚨 Cron 任务连续失败告警\n\n"
|
||||
f"任务:{job_label}\n"
|
||||
f"连续失败次数:{consecutive_errors}\n"
|
||||
f"告警触发阈值:{job.failure_alert.threshold}\n"
|
||||
f"最近错误:{last_error or '未知'}\n\n"
|
||||
f"任务已自动标记为 FAILED,冷却期 {_format_cooldown_ms(job.failure_alert.cooldown_ms)} 内不再告警"
|
||||
)
|
||||
|
||||
|
||||
def format_recovery_message(job: CronJob) -> str:
|
||||
job_label = job.name or job.id
|
||||
return f"✅ Cron 任务已恢复正常\n\n任务:{job_label}\n任务状态已恢复为 ACTIVE"
|
||||
|
||||
|
||||
async def send_alert(
|
||||
handler: DeliveryHandler | None,
|
||||
delivery_targets: list,
|
||||
job: CronJob,
|
||||
content: str,
|
||||
) -> None:
|
||||
if handler is None:
|
||||
logger.warning("Cron alert for '%s' cannot be delivered: no delivery handler configured", job.id)
|
||||
return
|
||||
|
||||
if not delivery_targets:
|
||||
logger.warning("Cron alert for '%s': no delivery targets configured", job.id)
|
||||
return
|
||||
|
||||
for target in delivery_targets:
|
||||
if not target.enabled or not target.channel or not target.target_id:
|
||||
continue
|
||||
try:
|
||||
await handler(target.channel, target.target_id, content)
|
||||
except Exception:
|
||||
logger.exception("Cron alert delivery to %s:%s failed", target.channel, target.target_id)
|
||||
|
||||
|
||||
async def check_and_alert(
|
||||
handler: DeliveryHandler | None,
|
||||
job: CronJob,
|
||||
) -> bool:
|
||||
alert = job.failure_alert
|
||||
now_ms = int(time.time() * 1000)
|
||||
delivery_targets = job.delivery
|
||||
|
||||
if alert.should_alert(job.state.consecutive_errors, now_ms):
|
||||
if not _global_rate_check(now_ms):
|
||||
logger.warning("Cron alert for '%s' suppressed by global rate limit", job.id)
|
||||
return False
|
||||
content = format_alert_message(job, job.state.consecutive_errors, job.state.last_error)
|
||||
await send_alert(handler, delivery_targets, job, content)
|
||||
alert.mark_alerted(now_ms)
|
||||
logger.warning(
|
||||
"Cron failure alert sent for '%s' (consecutive errors: %d)",
|
||||
job.id,
|
||||
job.state.consecutive_errors,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def check_and_recover(
|
||||
handler: DeliveryHandler | None,
|
||||
job: CronJob,
|
||||
) -> bool:
|
||||
alert = job.failure_alert
|
||||
now_ms = int(time.time() * 1000)
|
||||
delivery_targets = job.delivery
|
||||
|
||||
if alert.should_recover(job.state.consecutive_errors, now_ms):
|
||||
content = format_recovery_message(job)
|
||||
await send_alert(handler, delivery_targets, job, content)
|
||||
alert.mark_recovered(now_ms)
|
||||
logger.info("Cron recovery notification sent for '%s'", job.id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def evaluate_alerts(
|
||||
handler: DeliveryHandler | None,
|
||||
job: CronJob,
|
||||
) -> dict:
|
||||
alert_sent = await check_and_alert(handler, job)
|
||||
recovery_sent = await check_and_recover(handler, job)
|
||||
return {"alert_sent": alert_sent, "recovery_sent": recovery_sent}
|
||||
203
backend/package/yuxi/channel/cron/delivery.py
Normal file
203
backend/package/yuxi/channel/cron/delivery.py
Normal file
@ -0,0 +1,203 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from yuxi.channel.cron.types import (
|
||||
CronAgentExecutionPhase,
|
||||
CronDeliveryReceipt,
|
||||
CronDeliveryTarget,
|
||||
CronJob,
|
||||
CronRunDiagnostic,
|
||||
DeliveryHandler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
DEFAULT_RETRY_BASE_MS = 500
|
||||
DEFAULT_RETRY_MAX_MS = 10_000
|
||||
DEFAULT_RETRY_BACKOFF = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryConfig:
|
||||
max_retries: int = DEFAULT_MAX_RETRIES
|
||||
retry_base_ms: int = DEFAULT_RETRY_BASE_MS
|
||||
retry_max_ms: int = DEFAULT_RETRY_MAX_MS
|
||||
retry_backoff: float = DEFAULT_RETRY_BACKOFF
|
||||
concurrency: int = 5
|
||||
request_timeout_ms: int = 30_000
|
||||
total_timeout_ms: int = 120_000
|
||||
|
||||
|
||||
def resolve_delivery_targets(job: CronJob) -> list[CronDeliveryTarget]:
|
||||
return [t for t in job.delivery if t.enabled and t.channel and t.target_id]
|
||||
|
||||
|
||||
def format_delivery_content(
|
||||
job: CronJob,
|
||||
result: dict | None,
|
||||
phase: CronAgentExecutionPhase | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
job_label = job.name or job.id
|
||||
|
||||
if error:
|
||||
return f"⛔ Cron 任务执行失败\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n错误:{error}"
|
||||
|
||||
if result is None:
|
||||
return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:无返回值"
|
||||
|
||||
result_text = _format_result_value(result)
|
||||
return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:\n{result_text}"
|
||||
|
||||
|
||||
def _format_result_value(result: dict) -> str:
|
||||
if not result:
|
||||
return "(空)"
|
||||
lines = []
|
||||
for k, v in result.items():
|
||||
if isinstance(v, (list, dict)):
|
||||
v = str(v)[:500]
|
||||
elif isinstance(v, str) and len(v) > 500:
|
||||
v = v[:500] + "..."
|
||||
lines.append(f" {k}: {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _backoff_delay(attempt: int, config: DeliveryConfig) -> int:
|
||||
base = config.retry_base_ms * (config.retry_backoff ** (attempt - 1))
|
||||
return min(int(base), config.retry_max_ms)
|
||||
|
||||
|
||||
async def deliver_to_target(
|
||||
handler: DeliveryHandler,
|
||||
target: CronDeliveryTarget,
|
||||
content: str,
|
||||
config: DeliveryConfig,
|
||||
) -> CronDeliveryReceipt:
|
||||
receipt = CronDeliveryReceipt(target=target)
|
||||
started_mono = time.monotonic()
|
||||
receipt.sent_at_ms = int(time.time() * 1000)
|
||||
total_started_mono = time.monotonic()
|
||||
|
||||
for attempt in range(1, config.max_retries + 1):
|
||||
try:
|
||||
success = await asyncio.wait_for(
|
||||
handler(target.channel, target.target_id, content),
|
||||
timeout=config.request_timeout_ms / 1000.0,
|
||||
)
|
||||
if success:
|
||||
receipt.success = True
|
||||
receipt.retries = attempt - 1
|
||||
receipt.sent_at_ms = int(time.time() * 1000)
|
||||
receipt.duration_ms = int((time.monotonic() - started_mono) * 1000)
|
||||
return receipt
|
||||
receipt.error = "handler returned False"
|
||||
except asyncio.TimeoutError:
|
||||
receipt.error = f"delivery timeout ({config.request_timeout_ms}ms)"
|
||||
logger.warning(
|
||||
"Cron delivery attempt %d/%d to %s:%s timed out after %dms",
|
||||
attempt,
|
||||
config.max_retries,
|
||||
target.channel,
|
||||
target.target_id,
|
||||
config.request_timeout_ms,
|
||||
)
|
||||
except Exception as e:
|
||||
receipt.error = str(e)
|
||||
logger.warning(
|
||||
"Cron delivery attempt %d/%d to %s:%s failed: %s",
|
||||
attempt,
|
||||
config.max_retries,
|
||||
target.channel,
|
||||
target.target_id,
|
||||
e,
|
||||
)
|
||||
|
||||
total_elapsed_ms = int((time.monotonic() - total_started_mono) * 1000)
|
||||
if total_elapsed_ms >= config.total_timeout_ms:
|
||||
receipt.error = f"total delivery timeout ({config.total_timeout_ms}ms)"
|
||||
logger.warning(
|
||||
"Cron delivery to %s:%s exceeded total timeout %dms",
|
||||
target.channel,
|
||||
target.target_id,
|
||||
config.total_timeout_ms,
|
||||
)
|
||||
break
|
||||
|
||||
if attempt < config.max_retries:
|
||||
delay = _backoff_delay(attempt, config)
|
||||
await asyncio.sleep(delay / 1000.0)
|
||||
|
||||
receipt.success = False
|
||||
receipt.retries = config.max_retries
|
||||
receipt.duration_ms = int((time.monotonic() - started_mono) * 1000)
|
||||
return receipt
|
||||
|
||||
|
||||
async def batch_deliver(
|
||||
handler: DeliveryHandler,
|
||||
job: CronJob,
|
||||
result: dict | None,
|
||||
error: str | None,
|
||||
diagnostic: CronRunDiagnostic | None,
|
||||
config: DeliveryConfig | None = None,
|
||||
) -> list[CronDeliveryReceipt]:
|
||||
targets = resolve_delivery_targets(job)
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
config = config or DeliveryConfig()
|
||||
content = format_delivery_content(job, result, CronAgentExecutionPhase.DELIVERING, error)
|
||||
|
||||
semaphore = asyncio.Semaphore(config.concurrency)
|
||||
|
||||
async def _deliver_one(target: CronDeliveryTarget) -> CronDeliveryReceipt:
|
||||
async with semaphore:
|
||||
return await deliver_to_target(handler, target, content, config)
|
||||
|
||||
receipts = await asyncio.gather(*[_deliver_one(t) for t in targets], return_exceptions=True)
|
||||
|
||||
results: list[CronDeliveryReceipt] = []
|
||||
for i, r in enumerate(receipts):
|
||||
if isinstance(r, CronDeliveryReceipt):
|
||||
results.append(r)
|
||||
elif isinstance(r, Exception):
|
||||
logger.exception("Cron delivery task crashed for target [%d]", i)
|
||||
target = targets[i] if i < len(targets) else CronDeliveryTarget(channel="unknown", target_id="unknown")
|
||||
results.append(
|
||||
CronDeliveryReceipt(
|
||||
target=target,
|
||||
success=False,
|
||||
error=str(r),
|
||||
)
|
||||
)
|
||||
|
||||
if diagnostic is not None:
|
||||
diagnostic.delivery_receipts = results
|
||||
|
||||
succeeded = sum(1 for r in results if r.success)
|
||||
failed = len(results) - succeeded
|
||||
if failed > 0:
|
||||
logger.warning(
|
||||
"Cron batch delivery for '%s': %d/%d succeeded, %d failed",
|
||||
job.id,
|
||||
succeeded,
|
||||
len(results),
|
||||
failed,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def deliver_result(
|
||||
handler: DeliveryHandler,
|
||||
job: CronJob,
|
||||
result: dict | None,
|
||||
error: str | None,
|
||||
diagnostic: CronRunDiagnostic | None,
|
||||
config: DeliveryConfig | None = None,
|
||||
) -> list[CronDeliveryReceipt]:
|
||||
return await batch_deliver(handler, job, result, error, diagnostic, config)
|
||||
755
backend/package/yuxi/channel/cron/engine.py
Normal file
755
backend/package/yuxi/channel/cron/engine.py
Normal file
@ -0,0 +1,755 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from yuxi.channel.cron.alert import evaluate_alerts
|
||||
from yuxi.channel.config.defaults import TIMEOUT
|
||||
from yuxi.channel.cron.delivery import DeliveryConfig, deliver_result
|
||||
from yuxi.channel.cron.handler_registry import resolve_handler
|
||||
from yuxi.channel.cron.normalize import normalize_job
|
||||
from yuxi.channel.cron.phase_tracker import PhaseTracker
|
||||
from yuxi.channel.cron.schedule import (
|
||||
compute_next_run_at_ms,
|
||||
compute_next_with_error_tracking,
|
||||
)
|
||||
from yuxi.channel.cron.types import (
|
||||
CronAgentExecutionPhase,
|
||||
CronDeliveryReceipt,
|
||||
CronDeliveryTarget,
|
||||
CronFailureAlert,
|
||||
CronJob,
|
||||
CronJobState,
|
||||
CronJobStatus,
|
||||
CronPhaseRecord,
|
||||
CronRunDiagnostic,
|
||||
CronRunOutcome,
|
||||
CronRunStatus,
|
||||
DeliveryHandler,
|
||||
ScheduleKind,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CONCURRENT = 3
|
||||
DEFAULT_TICK_INTERVAL = 1.0
|
||||
DEFAULT_STORE_DIR = "cron"
|
||||
DEFAULT_STORE_FILE = "jobs.json"
|
||||
DEFAULT_STATE_FILE = "jobs-state.json"
|
||||
DEFAULT_RUN_LOG_MAX_BYTES = 2_000_000
|
||||
DEFAULT_RUN_LOG_KEEP_LINES = 2_000
|
||||
DEFAULT_DIAGNOSTIC_KEEP = 200
|
||||
DEFAULT_DIAGNOSTIC_LOG_MAX_BYTES = 5_000_000
|
||||
DEFAULT_DIAGNOSTIC_LOG_KEEP_LINES = 5000
|
||||
TOKEN_USAGE_KEYS = {"prompt_tokens", "completion_tokens", "total_tokens"}
|
||||
TOKEN_USAGE_NESTED_KEYS = {"usage", "token_usage", "tokens"}
|
||||
|
||||
|
||||
def _cron_dir() -> Path:
|
||||
from yuxi.channel.config.discovery import resolve_config_dir
|
||||
|
||||
return Path(resolve_config_dir()) / DEFAULT_STORE_DIR
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return _cron_dir() / DEFAULT_STORE_FILE
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
return _cron_dir() / DEFAULT_STATE_FILE
|
||||
|
||||
|
||||
def _diagnostic_log_path() -> Path:
|
||||
return _cron_dir() / "diagnostics.jsonl"
|
||||
|
||||
|
||||
def _run_log_path(job_id: str) -> Path:
|
||||
runs_dir = _cron_dir() / "runs"
|
||||
safe_id = job_id.strip().replace("\\", "_").replace("/", "_")
|
||||
return runs_dir / f"{safe_id}.jsonl"
|
||||
|
||||
|
||||
class CronEngine:
|
||||
def __init__(
|
||||
self,
|
||||
max_concurrent: int = DEFAULT_MAX_CONCURRENT,
|
||||
tick_interval: float = DEFAULT_TICK_INTERVAL,
|
||||
store_path: str | None = None,
|
||||
state_path: str | None = None,
|
||||
):
|
||||
self._jobs: dict[str, CronJob] = {}
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._tick_interval = tick_interval
|
||||
self._stop_event = asyncio.Event()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._store_path = Path(store_path) if store_path else _store_path()
|
||||
self._state_path = Path(state_path) if state_path else _state_path()
|
||||
self._cron_dir = self._store_path.parent
|
||||
self._loaded = False
|
||||
self._delivery_handler: DeliveryHandler | None = None
|
||||
self._delivery_config = DeliveryConfig()
|
||||
self._diagnostics: list[CronRunDiagnostic] = []
|
||||
self._running_tasks: set[asyncio.Task] = set()
|
||||
|
||||
def _cron_dir_path(self) -> Path:
|
||||
return self._cron_dir
|
||||
|
||||
def _diagnostic_log_path(self) -> Path:
|
||||
return self._cron_dir / "diagnostics.jsonl"
|
||||
|
||||
def _run_log_path(self, job_id: str) -> Path:
|
||||
runs_dir = self._cron_dir / "runs"
|
||||
safe_id = job_id.strip().replace("\\", "_").replace("/", "_")
|
||||
return runs_dir / f"{safe_id}.jsonl"
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
def set_delivery_handler(self, handler: DeliveryHandler) -> None:
|
||||
self._delivery_handler = handler
|
||||
|
||||
def set_delivery_config(self, config: DeliveryConfig) -> None:
|
||||
self._delivery_config = config
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if not self._loaded:
|
||||
self._load_store()
|
||||
|
||||
def _load_store(self):
|
||||
self._loaded = True
|
||||
try:
|
||||
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self._load_diagnostics()
|
||||
|
||||
state_data: dict[str, dict] = {}
|
||||
if self._state_path.exists():
|
||||
try:
|
||||
raw = self._state_path.read_text(encoding="utf-8")
|
||||
state_raw = json.loads(raw)
|
||||
if isinstance(state_raw, dict):
|
||||
state_data = state_raw.get("jobs", {})
|
||||
if isinstance(state_data, list):
|
||||
state_data = {item.get("id", ""): item for item in state_data if isinstance(item, dict)}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("CronEngine: failed to load state, starting fresh")
|
||||
|
||||
if not self._store_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = self._store_path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
jobs_data = data.get("jobs", []) if isinstance(data, dict) else []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("CronEngine: failed to load store, starting fresh")
|
||||
return
|
||||
|
||||
for jd in jobs_data:
|
||||
if not isinstance(jd, dict):
|
||||
continue
|
||||
try:
|
||||
job = self._deserialize_job(jd, state_data.get(jd.get("id", ""), {}))
|
||||
normalize_job(job)
|
||||
self._jobs[job.id] = job
|
||||
except Exception:
|
||||
logger.exception("CronEngine: failed to deserialize job '%s'", jd.get("id", "?"))
|
||||
|
||||
logger.info("CronEngine: loaded %d jobs from %s", len(self._jobs), self._store_path)
|
||||
|
||||
def _load_diagnostics(self):
|
||||
diag_path = self._diagnostic_log_path()
|
||||
if not diag_path.exists():
|
||||
return
|
||||
try:
|
||||
self._diagnostics = []
|
||||
with open(diag_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
self._diagnostics.append(
|
||||
CronRunDiagnostic(
|
||||
run_id=data.get("run_id", ""),
|
||||
job_id=data.get("job_id", ""),
|
||||
outcome=CronRunOutcome(data.get("outcome", "success")),
|
||||
phases=[
|
||||
CronPhaseRecord(
|
||||
phase=CronAgentExecutionPhase(p["phase"]),
|
||||
started_at_ms=p["started_at_ms"],
|
||||
finished_at_ms=p.get("finished_at_ms"),
|
||||
error=p.get("error"),
|
||||
)
|
||||
for p in data.get("phases", [])
|
||||
],
|
||||
total_duration_ms=data.get("total_duration_ms", 0),
|
||||
error=data.get("error"),
|
||||
token_usage=data.get("token_usage", {}),
|
||||
delivery_receipts=[
|
||||
CronDeliveryReceipt(
|
||||
target=CronDeliveryTarget.from_dict(r["target"]),
|
||||
success=r.get("success", False),
|
||||
message_id=r.get("message_id"),
|
||||
error=r.get("error"),
|
||||
retries=r.get("retries", 0),
|
||||
sent_at_ms=r.get("sent_at_ms", 0),
|
||||
duration_ms=r.get("duration_ms", 0),
|
||||
)
|
||||
for r in data.get("delivery_receipts", [])
|
||||
],
|
||||
started_at_ms=data.get("started_at_ms", 0),
|
||||
finished_at_ms=data.get("finished_at_ms", 0),
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
logger.warning("CronEngine: failed to parse diagnostic line")
|
||||
self._diagnostics = self._diagnostics[-DEFAULT_DIAGNOSTIC_KEEP:]
|
||||
logger.info("CronEngine: loaded %d diagnostics from %s", len(self._diagnostics), diag_path)
|
||||
except OSError:
|
||||
logger.warning("CronEngine: failed to load diagnostics, starting fresh")
|
||||
|
||||
def _save_store(self):
|
||||
try:
|
||||
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": 1,
|
||||
"jobs": [self._serialize_job(j) for j in self._jobs.values()],
|
||||
}
|
||||
tmp_path = self._store_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp_path, self._store_path)
|
||||
except OSError:
|
||||
logger.exception("CronEngine: failed to save store")
|
||||
|
||||
def _save_state(self):
|
||||
try:
|
||||
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_data = {
|
||||
"version": 1,
|
||||
"jobs": {j.id: j.state.to_dict() for j in self._jobs.values()},
|
||||
}
|
||||
tmp_path = self._state_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(state_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp_path, self._state_path)
|
||||
except OSError:
|
||||
logger.exception("CronEngine: failed to save state")
|
||||
|
||||
def _save_all(self):
|
||||
self._save_store()
|
||||
self._save_state()
|
||||
|
||||
def _serialize_job(self, job: CronJob) -> dict:
|
||||
d = job.to_dict()
|
||||
d.pop("handler", None)
|
||||
d.pop("handler_args", None)
|
||||
return d
|
||||
|
||||
def _deserialize_job(self, jd: dict, state_data: dict | None = None) -> CronJob:
|
||||
state_dict = jd.get("state", {})
|
||||
if state_data:
|
||||
state_dict = {**state_dict, **state_data}
|
||||
|
||||
job = CronJob(
|
||||
id=jd["id"],
|
||||
name=jd.get("name", ""),
|
||||
handler=resolve_handler(jd.get("handler_name", "")),
|
||||
handler_name=jd.get("handler_name", ""),
|
||||
handler_args={},
|
||||
schedule_kind=ScheduleKind(jd.get("schedule_kind", "cron")),
|
||||
schedule_value=jd.get("schedule_value", jd.get("cron_expr", "* * * * *")),
|
||||
schedule_tz=jd.get("schedule_tz"),
|
||||
stagger_ms=jd.get("stagger_ms", 0),
|
||||
every_ms=jd.get("every_ms"),
|
||||
anchor_ms=jd.get("anchor_ms"),
|
||||
status=CronJobStatus(jd.get("status", "active")),
|
||||
enabled=jd.get("enabled", True),
|
||||
max_runs=jd.get("max_runs", 0),
|
||||
delete_after_run=jd.get("delete_after_run", False),
|
||||
error_threshold=jd.get("error_threshold", 5),
|
||||
delivery=[CronDeliveryTarget.from_dict(t) for t in jd.get("delivery", []) if isinstance(t, dict)],
|
||||
failure_alert=CronFailureAlert.from_dict(jd.get("failure_alert")),
|
||||
state=CronJobState.from_dict(state_dict),
|
||||
created_at=jd.get("created_at", time.time()),
|
||||
updated_at=jd.get("updated_at", time.time()),
|
||||
)
|
||||
return job
|
||||
|
||||
def add_job(self, job: CronJob) -> None:
|
||||
self._ensure_loaded()
|
||||
if job.id in self._jobs:
|
||||
raise ValueError(f"Job '{job.id}' already registered")
|
||||
normalize_job(job)
|
||||
job.state.next_run_at_ms = compute_next_run_at_ms(job)
|
||||
self._jobs[job.id] = job
|
||||
self._save_all()
|
||||
|
||||
def remove_job(self, job_id: str) -> None:
|
||||
self._ensure_loaded()
|
||||
self._jobs.pop(job_id, None)
|
||||
self._save_all()
|
||||
|
||||
def get_job(self, job_id: str) -> CronJob | None:
|
||||
self._ensure_loaded()
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def list_jobs(self) -> list[CronJob]:
|
||||
self._ensure_loaded()
|
||||
return list(self._jobs.values())
|
||||
|
||||
def update_job(self, job_id: str, **kwargs) -> CronJob | None:
|
||||
self._ensure_loaded()
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
allowed = frozenset({
|
||||
"name", "enabled", "schedule_kind", "schedule_value", "schedule_tz",
|
||||
"stagger_ms", "every_ms", "anchor_ms", "max_runs", "error_threshold",
|
||||
"delivery", "failure_alert", "delete_after_run",
|
||||
})
|
||||
for k, v in kwargs.items():
|
||||
if k in allowed and hasattr(job, k):
|
||||
setattr(job, k, v)
|
||||
job.updated_at = time.time()
|
||||
normalize_job(job)
|
||||
self._save_all()
|
||||
return job
|
||||
|
||||
def pause_job(self, job_id: str) -> None:
|
||||
self._ensure_loaded()
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.status = CronJobStatus.PAUSED
|
||||
job.updated_at = time.time()
|
||||
self._save_all()
|
||||
|
||||
def resume_job(self, job_id: str) -> None:
|
||||
self._ensure_loaded()
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.status = CronJobStatus.ACTIVE
|
||||
job.state.next_run_at_ms = compute_next_run_at_ms(job)
|
||||
job.updated_at = time.time()
|
||||
self._save_all()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._ensure_loaded()
|
||||
if self.is_running:
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._task = asyncio.create_task(self._loop(), name="cron-engine")
|
||||
logger.info("CronEngine started with %d jobs", len(self._jobs))
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self.is_running:
|
||||
return
|
||||
self._stop_event.set()
|
||||
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
|
||||
running = [t for t in self._running_tasks if not t.done()]
|
||||
if running:
|
||||
for task in running:
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*running, return_exceptions=True), timeout=TIMEOUT.cron.gather)
|
||||
except TimeoutError:
|
||||
logger.warning("CronEngine: some running jobs did not cancel within 15s")
|
||||
logger.info("CronEngine cancelled %d running jobs", len(running))
|
||||
|
||||
if self._task:
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=TIMEOUT.cron.task)
|
||||
except (asyncio.CancelledError, TimeoutError):
|
||||
pass
|
||||
finally:
|
||||
if not self._task.done():
|
||||
logger.warning("CronEngine: main task did not terminate gracefully")
|
||||
self._task = None
|
||||
self._running_tasks.clear()
|
||||
self._save_all()
|
||||
logger.info("CronEngine stopped")
|
||||
|
||||
async def force_run(self, job_id: str) -> dict:
|
||||
self._ensure_loaded()
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return {"success": False, "error": f"Job '{job_id}' not found"}
|
||||
if job.state.running_at_ms is not None:
|
||||
return {"success": False, "error": f"Job '{job_id}' is already running"}
|
||||
return await self._run_job(job)
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self._tick()
|
||||
except Exception:
|
||||
logger.exception("CronEngine tick error")
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=self._tick_interval,
|
||||
)
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
self._save_all()
|
||||
|
||||
async def _tick(self) -> None:
|
||||
now_ms = int(time.time() * 1000)
|
||||
due_jobs = [
|
||||
j
|
||||
for j in self._jobs.values()
|
||||
if j.status == CronJobStatus.ACTIVE
|
||||
and j.enabled
|
||||
and j.state.next_run_at_ms is not None
|
||||
and now_ms >= j.state.next_run_at_ms
|
||||
and j.state.running_at_ms is None
|
||||
]
|
||||
if not due_jobs:
|
||||
return
|
||||
|
||||
tasks = [asyncio.create_task(self._run_job(j), name=f"cron-job-{j.id}") for j in due_jobs]
|
||||
self._running_tasks.update(tasks)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*tasks, return_exceptions=True),
|
||||
timeout=TIMEOUT.cron.delivery,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("CronEngine tick timeout: %d jobs did not complete in 300s", len(due_jobs))
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._running_tasks.difference_update(tasks)
|
||||
|
||||
async def _run_job(self, job: CronJob) -> dict:
|
||||
async with self._semaphore:
|
||||
if job.state.running_at_ms is not None:
|
||||
return {"success": False, "error": f"Job '{job.id}' is already running"}
|
||||
|
||||
if job.max_runs > 0 and job.state.run_count >= job.max_runs:
|
||||
job.status = CronJobStatus.EXPIRED
|
||||
self._save_all()
|
||||
return {"success": False, "error": "max_runs reached"}
|
||||
|
||||
tracker = PhaseTracker(job)
|
||||
diagnostic = tracker.diagnostic
|
||||
exec_phase = tracker.start_executing()
|
||||
|
||||
run_start_mono = time.monotonic()
|
||||
job.state.running_at_ms = diagnostic.started_at_ms
|
||||
|
||||
try:
|
||||
result = await job.handler(**(job.handler_args or {}))
|
||||
tracker.finish_executing(exec_phase)
|
||||
error = None
|
||||
outcome = CronRunOutcome.SUCCESS
|
||||
|
||||
job.state.last_run_status = CronRunStatus.OK
|
||||
job.state.last_error = None
|
||||
job.state.run_count += 1
|
||||
job.state.consecutive_errors = 0
|
||||
job.state.consecutive_skipped = 0
|
||||
job.state.next_run_at_ms = compute_next_with_error_tracking(job)
|
||||
job.status = CronJobStatus.ACTIVE
|
||||
|
||||
summ_phase = tracker.start_summarizing()
|
||||
tracker.finish_summarizing(summ_phase)
|
||||
|
||||
deliver_phase = tracker.start_delivering()
|
||||
delivery_receipts: list = []
|
||||
if job.delivery and self._delivery_handler:
|
||||
delivery_receipts = await deliver_result(
|
||||
self._delivery_handler,
|
||||
job,
|
||||
result if isinstance(result, dict) else {"result": str(result)},
|
||||
None,
|
||||
diagnostic,
|
||||
self._delivery_config,
|
||||
)
|
||||
tracker.finish_delivering(deliver_phase)
|
||||
|
||||
receipt_dicts = [r.to_dict() for r in delivery_receipts]
|
||||
except Exception as e:
|
||||
try:
|
||||
tracker.finish_executing(exec_phase, str(e))
|
||||
except Exception:
|
||||
logger.exception("CronEngine: failed to finish executing tracker for job '%s'", job.id)
|
||||
error = str(e)
|
||||
outcome = CronRunOutcome.FAILED
|
||||
|
||||
job.state.error_count += 1
|
||||
job.state.consecutive_errors += 1
|
||||
job.state.last_run_status = CronRunStatus.ERROR
|
||||
job.state.last_error = error
|
||||
job.state.next_run_at_ms = compute_next_with_error_tracking(job)
|
||||
|
||||
if job.state.consecutive_errors >= job.error_threshold:
|
||||
job.status = CronJobStatus.FAILED
|
||||
|
||||
deliver_phase = tracker.start_delivering()
|
||||
delivery_receipts: list = []
|
||||
if job.delivery and self._delivery_handler:
|
||||
delivery_receipts = await deliver_result(
|
||||
self._delivery_handler,
|
||||
job,
|
||||
None,
|
||||
error,
|
||||
diagnostic,
|
||||
self._delivery_config,
|
||||
)
|
||||
tracker.finish_delivering(deliver_phase)
|
||||
receipt_dicts = [r.to_dict() for r in delivery_receipts]
|
||||
result = None
|
||||
|
||||
logger.exception(
|
||||
"CronEngine job '%s' failed (consecutive: %d)",
|
||||
job.id,
|
||||
job.state.consecutive_errors,
|
||||
)
|
||||
|
||||
duration_ms = int((time.monotonic() - run_start_mono) * 1000)
|
||||
token_usage = _extract_token_usage(result) if not error else {}
|
||||
diagnostic = tracker.close(outcome, error, token_usage)
|
||||
self._append_diagnostic(diagnostic)
|
||||
|
||||
job.state.last_run_at_ms = diagnostic.started_at_ms
|
||||
job.state.last_duration_ms = duration_ms
|
||||
job.state.running_at_ms = None
|
||||
job.updated_at = time.time()
|
||||
|
||||
self._append_run_log(
|
||||
job,
|
||||
{
|
||||
"ts": diagnostic.started_at_ms,
|
||||
"job_id": job.id,
|
||||
"action": "finished",
|
||||
"status": "ok" if outcome == CronRunOutcome.SUCCESS else "error",
|
||||
"run_id": diagnostic.run_id,
|
||||
"run_at_ms": diagnostic.started_at_ms,
|
||||
"duration_ms": duration_ms,
|
||||
"next_run_at_ms": job.state.next_run_at_ms,
|
||||
"consecutive_errors": job.state.consecutive_errors,
|
||||
**({"error": error} if error else {}),
|
||||
**(
|
||||
{"result": result if isinstance(result, dict) else {"result": str(result)}}
|
||||
if result is not None
|
||||
else {}
|
||||
),
|
||||
"phases": [p.to_dict() for p in diagnostic.phases],
|
||||
"delivery": receipt_dicts,
|
||||
},
|
||||
)
|
||||
|
||||
if job.delete_after_run and job.schedule_kind == ScheduleKind.AT:
|
||||
self._jobs.pop(job.id, None)
|
||||
logger.info("CronEngine: deleted one-shot job '%s'", job.id)
|
||||
|
||||
await evaluate_alerts(self._delivery_handler, job)
|
||||
self._save_all()
|
||||
|
||||
if outcome == CronRunOutcome.SUCCESS:
|
||||
return {
|
||||
"success": True,
|
||||
"job_id": job.id,
|
||||
"run_id": diagnostic.run_id,
|
||||
"diagnostic": diagnostic.to_dict(),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": error,
|
||||
"run_id": diagnostic.run_id,
|
||||
"diagnostic": diagnostic.to_dict(),
|
||||
}
|
||||
|
||||
def _append_run_log(self, job: CronJob, entry: dict):
|
||||
try:
|
||||
log_path = self._run_log_path(job.id)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
self._prune_run_log(log_path)
|
||||
except OSError:
|
||||
logger.exception("CronEngine: failed to write run log for '%s'", job.id)
|
||||
|
||||
def _prune_run_log(self, log_path: Path):
|
||||
stat = log_path.stat()
|
||||
if stat.st_size <= DEFAULT_RUN_LOG_MAX_BYTES:
|
||||
return
|
||||
try:
|
||||
lines = log_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
kept = lines[-DEFAULT_RUN_LOG_KEEP_LINES:]
|
||||
log_path.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def read_run_log(self, job_id: str, limit: int = 50, offset: int = 0) -> dict:
|
||||
log_path = self._run_log_path(job_id)
|
||||
if not log_path.exists():
|
||||
return {"entries": [], "total": 0, "offset": offset, "limit": limit, "has_more": False}
|
||||
try:
|
||||
raw = log_path.read_text(encoding="utf-8")
|
||||
lines = [json.loads(line) for line in raw.strip().split("\n") if line.strip()]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"entries": [], "total": 0, "offset": offset, "limit": limit, "has_more": False}
|
||||
|
||||
total = len(lines)
|
||||
start = max(0, min(total, offset))
|
||||
end = min(total, start + limit)
|
||||
entries = lines[start:end]
|
||||
return {
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": end < total,
|
||||
}
|
||||
|
||||
def _append_diagnostic(self, diagnostic: CronRunDiagnostic) -> None:
|
||||
self._diagnostics.append(diagnostic)
|
||||
if len(self._diagnostics) > DEFAULT_DIAGNOSTIC_KEEP:
|
||||
self._diagnostics = self._diagnostics[-DEFAULT_DIAGNOSTIC_KEEP:]
|
||||
self._write_diagnostic_log(diagnostic)
|
||||
|
||||
def _write_diagnostic_log(self, diagnostic: CronRunDiagnostic) -> None:
|
||||
try:
|
||||
diag_path = self._diagnostic_log_path()
|
||||
diag_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(diagnostic.to_dict(), ensure_ascii=False) + "\n"
|
||||
with open(diag_path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
self._prune_diagnostic_log(diag_path)
|
||||
except OSError:
|
||||
logger.exception("CronEngine: failed to write diagnostic log")
|
||||
|
||||
def _prune_diagnostic_log(self, diag_path: Path) -> None:
|
||||
try:
|
||||
stat = diag_path.stat()
|
||||
except OSError:
|
||||
return
|
||||
if stat.st_size <= DEFAULT_DIAGNOSTIC_LOG_MAX_BYTES:
|
||||
return
|
||||
try:
|
||||
lines = diag_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
kept = lines[-DEFAULT_DIAGNOSTIC_LOG_KEEP_LINES:]
|
||||
diag_path.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def read_diagnostics(self, job_id: str | None = None, limit: int = 50, offset: int = 0) -> dict:
|
||||
all_diags = self._read_all_diagnostics()
|
||||
filtered = all_diags
|
||||
if job_id:
|
||||
filtered = [d for d in filtered if d.job_id == job_id]
|
||||
total = len(filtered)
|
||||
start = max(0, min(total, offset))
|
||||
end = min(total, start + limit)
|
||||
entries = [d.to_dict() for d in filtered[start:end]]
|
||||
return {
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": end < total,
|
||||
}
|
||||
|
||||
def _read_all_diagnostics(self) -> list[CronRunDiagnostic]:
|
||||
diag_path = self._diagnostic_log_path()
|
||||
if not diag_path.exists():
|
||||
return list(self._diagnostics)
|
||||
memory_ids = {d.run_id for d in self._diagnostics if d.run_id}
|
||||
result = list(self._diagnostics)
|
||||
try:
|
||||
raw = diag_path.read_text(encoding="utf-8")
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("run_id") in memory_ids:
|
||||
continue
|
||||
result.append(
|
||||
CronRunDiagnostic(
|
||||
run_id=data.get("run_id", ""),
|
||||
job_id=data.get("job_id", ""),
|
||||
outcome=CronRunOutcome(data.get("outcome", "success")),
|
||||
phases=[
|
||||
CronPhaseRecord(
|
||||
phase=CronAgentExecutionPhase(p["phase"]),
|
||||
started_at_ms=p["started_at_ms"],
|
||||
finished_at_ms=p.get("finished_at_ms"),
|
||||
error=p.get("error"),
|
||||
)
|
||||
for p in data.get("phases", [])
|
||||
],
|
||||
total_duration_ms=data.get("total_duration_ms", 0),
|
||||
error=data.get("error"),
|
||||
token_usage=data.get("token_usage", {}),
|
||||
delivery_receipts=[
|
||||
CronDeliveryReceipt(
|
||||
target=CronDeliveryTarget.from_dict(r["target"]),
|
||||
success=r.get("success", False),
|
||||
message_id=r.get("message_id"),
|
||||
error=r.get("error"),
|
||||
retries=r.get("retries", 0),
|
||||
sent_at_ms=r.get("sent_at_ms", 0),
|
||||
duration_ms=r.get("duration_ms", 0),
|
||||
)
|
||||
for r in data.get("delivery_receipts", [])
|
||||
],
|
||||
started_at_ms=data.get("started_at_ms", 0),
|
||||
finished_at_ms=data.get("finished_at_ms", 0),
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(result, key=lambda d: d.started_at_ms)
|
||||
|
||||
def list_diagnostics_for_job(self, job_id: str, limit: int = 10) -> list[dict]:
|
||||
all_diags = self._read_all_diagnostics()
|
||||
matching = [d for d in all_diags if d.job_id == job_id]
|
||||
return [d.to_dict() for d in matching[-limit:]]
|
||||
|
||||
|
||||
def _extract_token_usage(result: dict | None) -> dict:
|
||||
if not result or not isinstance(result, dict):
|
||||
return {}
|
||||
usage = {}
|
||||
for key in TOKEN_USAGE_KEYS:
|
||||
if key in result and isinstance(result[key], (int, float)):
|
||||
usage[key] = result[key]
|
||||
for nested_key in TOKEN_USAGE_NESTED_KEYS:
|
||||
if nested_key in result and isinstance(result[nested_key], dict):
|
||||
for k, v in result[nested_key].items():
|
||||
if isinstance(v, (int, float)):
|
||||
usage[k] = v
|
||||
return usage
|
||||
|
||||
|
||||
_engine: CronEngine | None = None
|
||||
|
||||
|
||||
def get_cron_engine() -> CronEngine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = CronEngine()
|
||||
return _engine
|
||||
26
backend/package/yuxi/channel/cron/handler_registry.py
Normal file
26
backend/package/yuxi/channel/cron/handler_registry.py
Normal file
@ -0,0 +1,26 @@
|
||||
import logging
|
||||
from collections.abc import Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
type CronHandler = Callable[..., Awaitable[dict]]
|
||||
|
||||
_handler_registry: dict[str, CronHandler] = {}
|
||||
|
||||
|
||||
def register_handler(name: str, handler: CronHandler) -> None:
|
||||
if name in _handler_registry:
|
||||
logger.warning("CronHandler '%s' already registered, overwriting", name)
|
||||
_handler_registry[name] = handler
|
||||
|
||||
|
||||
def resolve_handler(name: str) -> CronHandler:
|
||||
return _handler_registry.get(name, _noop_handler)
|
||||
|
||||
|
||||
def list_registered() -> list[str]:
|
||||
return list(_handler_registry.keys())
|
||||
|
||||
|
||||
async def _noop_handler(**kwargs) -> dict:
|
||||
return {"message": "noop handler, job needs reassignment after reload"}
|
||||
169
backend/package/yuxi/channel/cron/normalize.py
Normal file
169
backend/package/yuxi/channel/cron/normalize.py
Normal file
@ -0,0 +1,169 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from yuxi.channel.cron.schedule import (
|
||||
compute_next_run_at_ms,
|
||||
is_recurring_top_of_hour_cron_expr,
|
||||
resolve_default_cron_stagger_ms,
|
||||
)
|
||||
from yuxi.channel.cron.types import (
|
||||
CronDeliveryTarget,
|
||||
CronFailureAlert,
|
||||
CronJob,
|
||||
ScheduleKind,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CRON_FIELD_COUNT = 5
|
||||
CRON_FIELD_RANGES = [
|
||||
(0, 59),
|
||||
(0, 23),
|
||||
(1, 31),
|
||||
(1, 12),
|
||||
(0, 7),
|
||||
]
|
||||
|
||||
|
||||
def _validate_cron_expr_format(expr: str) -> str | None:
|
||||
parts = expr.strip().split()
|
||||
if len(parts) != CRON_FIELD_COUNT:
|
||||
return f"cron 表达式应包含 {CRON_FIELD_COUNT} 个字段,实际 {len(parts)} 个: {expr}"
|
||||
|
||||
for i, (field, (lo, hi)) in enumerate(zip(parts, CRON_FIELD_RANGES)):
|
||||
for segment in field.split(","):
|
||||
step = 1
|
||||
if "/" in segment:
|
||||
segment, step_str = segment.split("/", 1)
|
||||
try:
|
||||
step = int(step_str)
|
||||
except ValueError:
|
||||
return f"cron 表达式字段 {i} 步长无效: {step_str}"
|
||||
if step < 1:
|
||||
return f"cron 表达式字段 {i} 步长必须 >= 1: {step}"
|
||||
if segment == "*":
|
||||
continue
|
||||
if "-" in segment:
|
||||
try:
|
||||
low_str, high_str = segment.split("-", 1)
|
||||
low = int(low_str)
|
||||
high = int(high_str)
|
||||
except ValueError:
|
||||
return f"cron 表达式字段 {i} 范围格式无效: {segment}"
|
||||
if low < lo or high > hi or low > high:
|
||||
return f"cron 表达式字段 {i} 范围 {low}-{high} 超出 [{lo}, {hi}]"
|
||||
else:
|
||||
try:
|
||||
val = int(segment)
|
||||
except ValueError:
|
||||
return f"cron 表达式字段 {i} 值无效: {segment}"
|
||||
if val < lo or val > hi:
|
||||
return f"cron 表达式字段 {i} 值 {val} 超出 [{lo}, {hi}]"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_at_value(value: str) -> str | None:
|
||||
try:
|
||||
at_ms = int(value)
|
||||
if at_ms <= 0:
|
||||
return f"AT 调度值必须为正整数 (毫秒时间戳): {value}"
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return f"AT 调度值格式无效 (需为毫秒时间戳或 ISO 8601): {value}"
|
||||
|
||||
|
||||
def _validate_every_value(every_ms: int | None, anchor_ms: int | None) -> str | None:
|
||||
if every_ms is None:
|
||||
return "EVERY 调度必须设置 every_ms"
|
||||
if every_ms <= 0:
|
||||
return f"EVERY 调度的 every_ms 必须 > 0: {every_ms}"
|
||||
if anchor_ms is not None and anchor_ms < 0:
|
||||
return f"EVERY 调度的 anchor_ms 不能为负数: {anchor_ms}"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_delivery_target(target: CronDeliveryTarget) -> str | None:
|
||||
if not target.channel or not target.channel.strip():
|
||||
return f"投递目标缺少 channel: {target.to_dict()}"
|
||||
if not target.target_id or not target.target_id.strip():
|
||||
return f"投递目标缺少 target_id: {target.to_dict()}"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_job(job: CronJob) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
|
||||
if not job.id or not job.id.strip():
|
||||
warnings.append("CronJob.id 不能为空")
|
||||
job.id = job.id or f"unnamed_{int(time.time() * 1000)}"
|
||||
|
||||
if not job.name:
|
||||
job.name = job.id
|
||||
warnings.append(f"未设置 name,已使用 id 作为名称: {job.name}")
|
||||
|
||||
if job.error_threshold < 1:
|
||||
job.error_threshold = 5
|
||||
warnings.append(f"error_threshold 必须 >= 1,已重置为 {job.error_threshold}")
|
||||
|
||||
if job.max_runs < 0:
|
||||
job.max_runs = 0
|
||||
warnings.append(f"max_runs 不能为负数,已重置为 0 (无限制)")
|
||||
|
||||
if job.stagger_ms < 0:
|
||||
job.stagger_ms = 0
|
||||
warnings.append(f"stagger_ms 不能为负数,已重置为 0")
|
||||
|
||||
if job.schedule_kind == ScheduleKind.CRON:
|
||||
err = _validate_cron_expr_format(job.schedule_value)
|
||||
if err:
|
||||
warnings.append(err)
|
||||
if job.stagger_ms == 0 and job.schedule_value:
|
||||
default_stagger = resolve_default_cron_stagger_ms(job.schedule_value)
|
||||
if default_stagger > 0:
|
||||
job.stagger_ms = default_stagger
|
||||
warnings.append(f"整点 CRON 表达式未设置 stagger,已自动设置 {default_stagger}ms 错峰")
|
||||
|
||||
elif job.schedule_kind == ScheduleKind.AT:
|
||||
err = _validate_at_value(job.schedule_value)
|
||||
if err:
|
||||
warnings.append(err)
|
||||
if job.stagger_ms > 0:
|
||||
warnings.append("AT 调度不支持 stagger,已忽略")
|
||||
job.stagger_ms = 0
|
||||
|
||||
elif job.schedule_kind == ScheduleKind.EVERY:
|
||||
err = _validate_every_value(job.every_ms, job.anchor_ms)
|
||||
if err:
|
||||
warnings.append(err)
|
||||
|
||||
for i, target in enumerate(job.delivery):
|
||||
err = _validate_delivery_target(target)
|
||||
if err:
|
||||
warnings.append(f"投递目标 [{i}]: {err}")
|
||||
|
||||
if job.failure_alert.threshold < 1:
|
||||
job.failure_alert.threshold = 5
|
||||
warnings.append(f"告警阈值必须 >= 1,已重置为 {job.failure_alert.threshold}")
|
||||
|
||||
if job.failure_alert.cooldown_ms < 0:
|
||||
job.failure_alert.cooldown_ms = 600_000
|
||||
warnings.append(f"告警冷却时间不能为负数,已重置为 {job.failure_alert.cooldown_ms}ms")
|
||||
|
||||
try:
|
||||
next_at = compute_next_run_at_ms(job)
|
||||
if next_at is not None:
|
||||
job.state.next_run_at_ms = next_at
|
||||
except Exception as e:
|
||||
warnings.append(f"无法计算下次执行时间: {e}")
|
||||
|
||||
if warnings:
|
||||
logger.warning("CronJob '%s' 规范化产生 %d 条警告: %s", job.id, len(warnings), warnings)
|
||||
|
||||
return warnings
|
||||
104
backend/package/yuxi/channel/cron/phase_tracker.py
Normal file
104
backend/package/yuxi/channel/cron/phase_tracker.py
Normal file
@ -0,0 +1,104 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from yuxi.channel.cron.types import (
|
||||
CronAgentExecutionPhase,
|
||||
CronJob,
|
||||
CronPhaseRecord,
|
||||
CronRunDiagnostic,
|
||||
CronRunOutcome,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_run_id(job_id: str, started_at_ms: int) -> str:
|
||||
return f"cron:{job_id}:{started_at_ms}:{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def create_diagnostic(job: CronJob, started_at_ms: int) -> CronRunDiagnostic:
|
||||
return CronRunDiagnostic(
|
||||
run_id=create_run_id(job.id, started_at_ms),
|
||||
job_id=job.id,
|
||||
started_at_ms=started_at_ms,
|
||||
)
|
||||
|
||||
|
||||
def record_phase(
|
||||
diagnostic: CronRunDiagnostic,
|
||||
phase: CronAgentExecutionPhase,
|
||||
) -> CronPhaseRecord:
|
||||
now_ms = int(time.time() * 1000)
|
||||
record = CronPhaseRecord(
|
||||
phase=phase,
|
||||
started_at_ms=now_ms,
|
||||
)
|
||||
diagnostic.phases.append(record)
|
||||
return record
|
||||
|
||||
|
||||
def finish_phase(record: CronPhaseRecord, error: str | None = None) -> None:
|
||||
record.finished_at_ms = int(time.time() * 1000)
|
||||
if error:
|
||||
record.error = error
|
||||
|
||||
|
||||
def finalize_diagnostic(
|
||||
diagnostic: CronRunDiagnostic,
|
||||
outcome: CronRunOutcome,
|
||||
error: str | None = None,
|
||||
token_usage: dict | None = None,
|
||||
started_mono: float = 0.0,
|
||||
) -> None:
|
||||
diagnostic.finished_at_ms = int(time.time() * 1000)
|
||||
diagnostic.total_duration_ms = _mono_duration_ms(started_mono) if started_mono > 0 else diagnostic.finished_at_ms - diagnostic.started_at_ms
|
||||
diagnostic.outcome = outcome
|
||||
if error:
|
||||
diagnostic.error = error
|
||||
if token_usage:
|
||||
diagnostic.token_usage = token_usage
|
||||
|
||||
|
||||
def _mono_duration_ms(started_mono: float) -> int:
|
||||
return int((time.monotonic() - started_mono) * 1000)
|
||||
|
||||
|
||||
class PhaseTracker:
|
||||
def __init__(self, job: CronJob):
|
||||
self.job = job
|
||||
started_at = int(time.time() * 1000)
|
||||
self._started_mono = time.monotonic()
|
||||
self.diagnostic = create_diagnostic(job, started_at)
|
||||
self._init_phase = record_phase(self.diagnostic, CronAgentExecutionPhase.INITIALIZING)
|
||||
finish_phase(self._init_phase)
|
||||
|
||||
def start_planning(self) -> CronPhaseRecord:
|
||||
return record_phase(self.diagnostic, CronAgentExecutionPhase.PLANNING)
|
||||
|
||||
def finish_planning(self, record: CronPhaseRecord, error: str | None = None) -> None:
|
||||
finish_phase(record, error)
|
||||
|
||||
def start_executing(self) -> CronPhaseRecord:
|
||||
return record_phase(self.diagnostic, CronAgentExecutionPhase.EXECUTING)
|
||||
|
||||
def finish_executing(self, record: CronPhaseRecord, error: str | None = None) -> None:
|
||||
finish_phase(record, error)
|
||||
|
||||
def start_summarizing(self) -> CronPhaseRecord:
|
||||
return record_phase(self.diagnostic, CronAgentExecutionPhase.SUMMARIZING)
|
||||
|
||||
def finish_summarizing(self, record: CronPhaseRecord, error: str | None = None) -> None:
|
||||
finish_phase(record, error)
|
||||
|
||||
def start_delivering(self) -> CronPhaseRecord:
|
||||
return record_phase(self.diagnostic, CronAgentExecutionPhase.DELIVERING)
|
||||
|
||||
def finish_delivering(self, record: CronPhaseRecord, error: str | None = None) -> None:
|
||||
finish_phase(record, error)
|
||||
|
||||
def close(
|
||||
self, outcome: CronRunOutcome, error: str | None = None, token_usage: dict | None = None
|
||||
) -> CronRunDiagnostic:
|
||||
finalize_diagnostic(self.diagnostic, outcome, error, token_usage, self._started_mono)
|
||||
return self.diagnostic
|
||||
318
backend/package/yuxi/channel/cron/schedule.py
Normal file
318
backend/package/yuxi/channel/cron/schedule.py
Normal file
@ -0,0 +1,318 @@
|
||||
import calendar
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from yuxi.channel.cron.types import CronJob, CronJobStatus, ScheduleKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CRON_EVAL_CACHE_MAX = 512
|
||||
_cron_eval_cache: OrderedDict[str, tuple[set[int] | None, ...]] = OrderedDict()
|
||||
|
||||
DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000
|
||||
|
||||
|
||||
def _clear_cron_cache_for_test():
|
||||
_cron_eval_cache.clear()
|
||||
|
||||
|
||||
def _get_cron_cache_size_for_test():
|
||||
return len(_cron_eval_cache)
|
||||
|
||||
|
||||
def _resolve_field(field: str, lo: int, hi: int) -> set[int] | None:
|
||||
if field == "*":
|
||||
return None
|
||||
result: set[int] = set()
|
||||
for part in field.split(","):
|
||||
step = 1
|
||||
if "/" in part:
|
||||
part, step_str = part.split("/", 1)
|
||||
step = int(step_str)
|
||||
low = lo
|
||||
high = hi
|
||||
if "-" in part:
|
||||
low_str, high_str = part.split("-", 1)
|
||||
low = int(low_str)
|
||||
high = int(high_str)
|
||||
elif part != "*":
|
||||
low = high = int(part)
|
||||
for v in range(low, high + 1, step):
|
||||
if lo <= v <= hi:
|
||||
result.add(v)
|
||||
return result or None
|
||||
|
||||
|
||||
def _parse_cron_expr(cron_expr: str) -> tuple[set[int] | None, ...]:
|
||||
cached = _cron_eval_cache.get(cron_expr)
|
||||
if cached is not None:
|
||||
_cron_eval_cache.move_to_end(cron_expr)
|
||||
return cached
|
||||
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(f"Invalid cron expression: {cron_expr}")
|
||||
|
||||
parsed = (
|
||||
_resolve_field(parts[0], 0, 59),
|
||||
_resolve_field(parts[1], 0, 23),
|
||||
_resolve_field(parts[2], 1, 31),
|
||||
_resolve_field(parts[3], 1, 12),
|
||||
_resolve_field(parts[4], 0, 7),
|
||||
)
|
||||
|
||||
if len(_cron_eval_cache) >= CRON_EVAL_CACHE_MAX:
|
||||
_cron_eval_cache.popitem(last=False)
|
||||
|
||||
_cron_eval_cache[cron_expr] = parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_tz(tz_name: str | None) -> ZoneInfo:
|
||||
if tz_name:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning("Invalid timezone '%s', using system local time", tz_name)
|
||||
local_tz = datetime.now().astimezone().tzinfo
|
||||
if local_tz is not None:
|
||||
return local_tz
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _compute_cron_next(expr: str, tz_name: str | None, now_ms: int) -> int | None:
|
||||
parsed = _parse_cron_expr(expr)
|
||||
tz = _resolve_tz(tz_name)
|
||||
|
||||
dt = datetime.fromtimestamp(now_ms / 1000.0, tz=tz)
|
||||
current_min = dt.minute
|
||||
current_hour = dt.hour
|
||||
current_day = dt.day
|
||||
current_month = dt.month
|
||||
current_year = dt.year
|
||||
current_wday = (dt.weekday() + 1) % 7
|
||||
|
||||
minutes_set, hours_set, days_set, months_set, weekdays_set = parsed
|
||||
|
||||
max_iterations = 525600
|
||||
|
||||
for _ in range(max_iterations):
|
||||
if months_set is not None and current_month not in months_set:
|
||||
current_month += 1
|
||||
if current_month > 12:
|
||||
current_month = 1
|
||||
current_year += 1
|
||||
current_day = 1
|
||||
current_hour = 0
|
||||
current_min = -1
|
||||
continue
|
||||
|
||||
month_days = calendar.monthrange(current_year, current_month)[1]
|
||||
if days_set is not None and current_day not in days_set:
|
||||
current_day += 1
|
||||
if current_day > month_days:
|
||||
current_day = 1
|
||||
current_month += 1
|
||||
if current_month > 12:
|
||||
current_month = 1
|
||||
current_year += 1
|
||||
current_hour = 0
|
||||
current_min = -1
|
||||
continue
|
||||
|
||||
if hours_set is not None and current_hour not in hours_set:
|
||||
current_hour += 1
|
||||
if current_hour >= 24:
|
||||
current_hour = 0
|
||||
current_day += 1
|
||||
current_wday = (current_wday + 1) % 7
|
||||
if current_day > month_days:
|
||||
current_day = 1
|
||||
current_month += 1
|
||||
if current_month > 12:
|
||||
current_month = 1
|
||||
current_year += 1
|
||||
current_min = -1
|
||||
continue
|
||||
|
||||
current_min += 1
|
||||
if current_min >= 60:
|
||||
current_min = 0
|
||||
current_hour += 1
|
||||
if current_hour >= 24:
|
||||
current_hour = 0
|
||||
current_day += 1
|
||||
current_wday = (current_wday + 1) % 7
|
||||
month_days = calendar.monthrange(current_year, current_month)[1]
|
||||
if current_day > month_days:
|
||||
current_day = 1
|
||||
current_month += 1
|
||||
if current_month > 12:
|
||||
current_month = 1
|
||||
current_year += 1
|
||||
|
||||
if (
|
||||
minutes_set is not None
|
||||
and current_min not in minutes_set
|
||||
or hours_set is not None
|
||||
and current_hour not in hours_set
|
||||
or days_set is not None
|
||||
and current_day not in days_set
|
||||
or months_set is not None
|
||||
and current_month not in months_set
|
||||
or weekdays_set is not None
|
||||
and current_wday not in weekdays_set
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
candidate = datetime(
|
||||
current_year,
|
||||
current_month,
|
||||
current_day,
|
||||
current_hour,
|
||||
current_min,
|
||||
0,
|
||||
tzinfo=tz,
|
||||
)
|
||||
candidate_ms = int(candidate.timestamp() * 1000)
|
||||
except (OverflowError, ValueError):
|
||||
continue
|
||||
|
||||
if candidate_ms > now_ms:
|
||||
return candidate_ms
|
||||
|
||||
logger.warning("Cron next-run computation exceeded %d iterations for expr='%s'", max_iterations, expr)
|
||||
return now_ms + 60000
|
||||
|
||||
|
||||
def _compute_cron_previous(expr: str, tz_name: str | None, now_ms: int) -> int | None:
|
||||
parsed = _parse_cron_expr(expr)
|
||||
tz = _resolve_tz(tz_name)
|
||||
|
||||
minutes_set, hours_set, days_set, months_set, weekdays_set = parsed
|
||||
|
||||
search_dt = datetime.fromtimestamp(now_ms / 1000.0, tz=tz)
|
||||
max_iterations = 525600
|
||||
|
||||
for _ in range(max_iterations):
|
||||
search_dt -= timedelta(minutes=1)
|
||||
|
||||
sm = search_dt.minute
|
||||
sh = search_dt.hour
|
||||
sd = search_dt.day
|
||||
sM = search_dt.month
|
||||
sw = (search_dt.weekday() + 1) % 7
|
||||
|
||||
if (
|
||||
minutes_set is not None
|
||||
and sm not in minutes_set
|
||||
or hours_set is not None
|
||||
and sh not in hours_set
|
||||
or days_set is not None
|
||||
and sd not in days_set
|
||||
or months_set is not None
|
||||
and sM not in months_set
|
||||
or weekdays_set is not None
|
||||
and sw not in weekdays_set
|
||||
):
|
||||
continue
|
||||
|
||||
candidate_ms = int(search_dt.timestamp() * 1000)
|
||||
if candidate_ms < now_ms:
|
||||
return candidate_ms
|
||||
|
||||
logger.warning("Cron previous-run computation exceeded %d iterations for expr='%s'", max_iterations, expr)
|
||||
return None
|
||||
|
||||
|
||||
def _compute_every_next(every_ms: int, anchor_ms: int, now_ms: int) -> int:
|
||||
every = max(1, every_ms)
|
||||
anchor = max(0, anchor_ms)
|
||||
if now_ms < anchor:
|
||||
return anchor
|
||||
elapsed = now_ms - anchor
|
||||
steps = max(1, (elapsed + every - 1) // every)
|
||||
return anchor + steps * every
|
||||
|
||||
|
||||
def is_recurring_top_of_hour_cron_expr(expr: str) -> bool:
|
||||
fields = expr.strip().split()
|
||||
if len(fields) == 5:
|
||||
minute_field, hour_field = fields[0], fields[1]
|
||||
return minute_field == "0" and "*" in hour_field
|
||||
return False
|
||||
|
||||
|
||||
def resolve_default_cron_stagger_ms(expr: str) -> int:
|
||||
return DEFAULT_TOP_OF_HOUR_STAGGER_MS if is_recurring_top_of_hour_cron_expr(expr) else 0
|
||||
|
||||
|
||||
def resolve_cron_stagger_ms(job: CronJob) -> int:
|
||||
if job.stagger_ms > 0:
|
||||
return job.stagger_ms
|
||||
if job.schedule_kind == ScheduleKind.CRON:
|
||||
return resolve_default_cron_stagger_ms(job.schedule_value)
|
||||
return 0
|
||||
|
||||
|
||||
def compute_next_run_at_ms(job: CronJob) -> int | None:
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
if job.schedule_kind == ScheduleKind.AT:
|
||||
try:
|
||||
at_ms = int(job.schedule_value)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(job.schedule_value)
|
||||
at_ms = int(parsed.timestamp() * 1000)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return at_ms if at_ms > now_ms else None
|
||||
|
||||
if job.schedule_kind == ScheduleKind.EVERY:
|
||||
if job.every_ms is None or job.every_ms <= 0:
|
||||
return None
|
||||
raw = _compute_every_next(job.every_ms, job.anchor_ms or now_ms, now_ms)
|
||||
stagger = job.stagger_ms
|
||||
if stagger > 0:
|
||||
raw += random.randint(0, stagger)
|
||||
return raw
|
||||
|
||||
if job.schedule_kind == ScheduleKind.CRON:
|
||||
raw = _compute_cron_next(job.schedule_value, job.schedule_tz, now_ms)
|
||||
if raw is None:
|
||||
return None
|
||||
stagger = resolve_cron_stagger_ms(job)
|
||||
if stagger > 0:
|
||||
raw += random.randint(0, stagger)
|
||||
return raw
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compute_previous_run_at_ms(job: CronJob) -> int | None:
|
||||
now_ms = int(time.time() * 1000)
|
||||
if job.schedule_kind == ScheduleKind.CRON:
|
||||
return _compute_cron_previous(job.schedule_value, job.schedule_tz, now_ms)
|
||||
return None
|
||||
|
||||
|
||||
def compute_next_with_error_tracking(job: CronJob) -> int | None:
|
||||
try:
|
||||
return compute_next_run_at_ms(job)
|
||||
except Exception:
|
||||
job.state.schedule_error_count += 1
|
||||
logger.exception("CronEngine schedule computation error for '%s'", job.id)
|
||||
if job.state.schedule_error_count >= 10:
|
||||
job.status = CronJobStatus.FAILED
|
||||
logger.error(
|
||||
"CronEngine disabling job '%s' after %d schedule errors",
|
||||
job.id,
|
||||
job.state.schedule_error_count,
|
||||
)
|
||||
return None
|
||||
302
backend/package/yuxi/channel/cron/types.py
Normal file
302
backend/package/yuxi/channel/cron/types.py
Normal file
@ -0,0 +1,302 @@
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
type CronHandler = Callable[..., Awaitable[dict]]
|
||||
type DeliveryHandler = Callable[[str, str, str], Awaitable[bool]]
|
||||
|
||||
|
||||
class ScheduleKind(StrEnum):
|
||||
AT = "at"
|
||||
EVERY = "every"
|
||||
CRON = "cron"
|
||||
|
||||
|
||||
class CronJobStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
EXPIRED = "expired"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class CronRunStatus(StrEnum):
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class CronAgentExecutionPhase(StrEnum):
|
||||
INITIALIZING = "initializing"
|
||||
PLANNING = "planning"
|
||||
EXECUTING = "executing"
|
||||
SUMMARIZING = "summarizing"
|
||||
DELIVERING = "delivering"
|
||||
|
||||
|
||||
class CronRunOutcome(StrEnum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
TIMEOUT = "timeout"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronPhaseRecord:
|
||||
phase: CronAgentExecutionPhase
|
||||
started_at_ms: int
|
||||
finished_at_ms: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int | None:
|
||||
if self.finished_at_ms is not None:
|
||||
return self.finished_at_ms - self.started_at_ms
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"phase": self.phase.value,
|
||||
"started_at_ms": self.started_at_ms,
|
||||
"finished_at_ms": self.finished_at_ms,
|
||||
"error": self.error,
|
||||
"duration_ms": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronDeliveryTarget:
|
||||
channel: str
|
||||
target_id: str
|
||||
thread_id: str | None = None
|
||||
reply_to_id: str | None = None
|
||||
account_id: str | None = None
|
||||
enabled: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"target_id": self.target_id,
|
||||
"thread_id": self.thread_id,
|
||||
"reply_to_id": self.reply_to_id,
|
||||
"account_id": self.account_id,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CronDeliveryTarget":
|
||||
return cls(
|
||||
channel=data.get("channel", ""),
|
||||
target_id=data.get("target_id", ""),
|
||||
thread_id=data.get("thread_id"),
|
||||
reply_to_id=data.get("reply_to_id"),
|
||||
account_id=data.get("account_id"),
|
||||
enabled=data.get("enabled", True),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronDeliveryReceipt:
|
||||
target: CronDeliveryTarget
|
||||
success: bool = False
|
||||
message_id: str | None = None
|
||||
error: str | None = None
|
||||
retries: int = 0
|
||||
sent_at_ms: int = 0
|
||||
duration_ms: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"target": self.target.to_dict(),
|
||||
"success": self.success,
|
||||
"message_id": self.message_id,
|
||||
"error": self.error,
|
||||
"retries": self.retries,
|
||||
"sent_at_ms": self.sent_at_ms,
|
||||
"duration_ms": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronFailureAlert:
|
||||
threshold: int = 5
|
||||
cooldown_ms: int = 600_000
|
||||
last_alert_at_ms: int = 0
|
||||
last_recovery_at_ms: int = 0
|
||||
enabled: bool = True
|
||||
|
||||
def should_alert(self, consecutive_errors: int, now_ms: int) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
if consecutive_errors < self.threshold:
|
||||
return False
|
||||
if self.last_alert_at_ms > 0 and (now_ms - self.last_alert_at_ms) < self.cooldown_ms:
|
||||
return False
|
||||
return True
|
||||
|
||||
def should_recover(self, consecutive_errors: int, now_ms: int) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
if self.last_alert_at_ms <= 0:
|
||||
return False
|
||||
if self.last_recovery_at_ms > 0 and (now_ms - self.last_recovery_at_ms) < self.cooldown_ms:
|
||||
return False
|
||||
return consecutive_errors == 0
|
||||
|
||||
def mark_alerted(self, now_ms: int) -> None:
|
||||
self.last_alert_at_ms = now_ms
|
||||
|
||||
def mark_recovered(self, now_ms: int) -> None:
|
||||
self.last_recovery_at_ms = now_ms
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"threshold": self.threshold,
|
||||
"cooldown_ms": self.cooldown_ms,
|
||||
"last_alert_at_ms": self.last_alert_at_ms,
|
||||
"last_recovery_at_ms": self.last_recovery_at_ms,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "CronFailureAlert":
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
threshold=data.get("threshold", 5),
|
||||
cooldown_ms=data.get("cooldown_ms", 600_000),
|
||||
last_alert_at_ms=data.get("last_alert_at_ms", 0),
|
||||
last_recovery_at_ms=data.get("last_recovery_at_ms", 0),
|
||||
enabled=data.get("enabled", True),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronRunDiagnostic:
|
||||
run_id: str = ""
|
||||
job_id: str = ""
|
||||
outcome: CronRunOutcome = CronRunOutcome.SUCCESS
|
||||
phases: list[CronPhaseRecord] = field(default_factory=list)
|
||||
total_duration_ms: int = 0
|
||||
error: str | None = None
|
||||
token_usage: dict = field(default_factory=dict)
|
||||
delivery_receipts: list[CronDeliveryReceipt] = field(default_factory=list)
|
||||
started_at_ms: int = 0
|
||||
finished_at_ms: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"job_id": self.job_id,
|
||||
"outcome": self.outcome.value,
|
||||
"phases": [p.to_dict() for p in self.phases],
|
||||
"total_duration_ms": self.total_duration_ms,
|
||||
"error": self.error,
|
||||
"token_usage": self.token_usage,
|
||||
"delivery_receipts": [r.to_dict() for r in self.delivery_receipts],
|
||||
"started_at_ms": self.started_at_ms,
|
||||
"finished_at_ms": self.finished_at_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJobState:
|
||||
next_run_at_ms: int | None = None
|
||||
running_at_ms: int | None = None
|
||||
last_run_at_ms: int | None = None
|
||||
last_run_status: CronRunStatus | None = None
|
||||
last_error: str | None = None
|
||||
last_duration_ms: int | None = None
|
||||
consecutive_errors: int = 0
|
||||
consecutive_skipped: int = 0
|
||||
schedule_error_count: int = 0
|
||||
run_count: int = 0
|
||||
error_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"next_run_at_ms": self.next_run_at_ms,
|
||||
"running_at_ms": self.running_at_ms,
|
||||
"last_run_at_ms": self.last_run_at_ms,
|
||||
"last_run_status": self.last_run_status.value if self.last_run_status else None,
|
||||
"last_error": self.last_error,
|
||||
"last_duration_ms": self.last_duration_ms,
|
||||
"consecutive_errors": self.consecutive_errors,
|
||||
"consecutive_skipped": self.consecutive_skipped,
|
||||
"schedule_error_count": self.schedule_error_count,
|
||||
"run_count": self.run_count,
|
||||
"error_count": self.error_count,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "CronJobState":
|
||||
if not data:
|
||||
return cls()
|
||||
state = cls()
|
||||
state.next_run_at_ms = data.get("next_run_at_ms")
|
||||
state.running_at_ms = data.get("running_at_ms")
|
||||
state.last_run_at_ms = data.get("last_run_at_ms")
|
||||
status = data.get("last_run_status")
|
||||
if status and status in {s.value for s in CronRunStatus}:
|
||||
state.last_run_status = CronRunStatus(status)
|
||||
state.last_error = data.get("last_error")
|
||||
state.last_duration_ms = data.get("last_duration_ms")
|
||||
state.consecutive_errors = data.get("consecutive_errors", 0)
|
||||
state.consecutive_skipped = data.get("consecutive_skipped", 0)
|
||||
state.schedule_error_count = data.get("schedule_error_count", 0)
|
||||
state.run_count = data.get("run_count", 0)
|
||||
state.error_count = data.get("error_count", 0)
|
||||
return state
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
name: str
|
||||
handler: CronHandler
|
||||
handler_name: str = ""
|
||||
handler_args: dict = field(default_factory=dict)
|
||||
|
||||
schedule_kind: ScheduleKind = ScheduleKind.CRON
|
||||
schedule_value: str = "* * * * *"
|
||||
schedule_tz: str | None = None
|
||||
stagger_ms: int = 0
|
||||
every_ms: int | None = None
|
||||
anchor_ms: int | None = None
|
||||
|
||||
status: CronJobStatus = CronJobStatus.ACTIVE
|
||||
enabled: bool = True
|
||||
max_runs: int = 0
|
||||
delete_after_run: bool = False
|
||||
error_threshold: int = 5
|
||||
|
||||
delivery: list[CronDeliveryTarget] = field(default_factory=list)
|
||||
failure_alert: CronFailureAlert = field(default_factory=CronFailureAlert)
|
||||
|
||||
state: CronJobState = field(default_factory=CronJobState)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"handler_name": self.handler_name,
|
||||
"schedule_kind": self.schedule_kind.value,
|
||||
"schedule_value": self.schedule_value,
|
||||
"schedule_tz": self.schedule_tz,
|
||||
"stagger_ms": self.stagger_ms,
|
||||
"every_ms": self.every_ms,
|
||||
"anchor_ms": self.anchor_ms,
|
||||
"status": self.status.value,
|
||||
"enabled": self.enabled,
|
||||
"max_runs": self.max_runs,
|
||||
"delete_after_run": self.delete_after_run,
|
||||
"error_threshold": self.error_threshold,
|
||||
"delivery": [t.to_dict() for t in self.delivery],
|
||||
"failure_alert": self.failure_alert.to_dict(),
|
||||
"state": self.state.to_dict(),
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user