ForcePilot/backend/package/yuxi/channel/cron/delivery.py
Kris 0dcc370819 feat(cron): 实现完整的定时任务调度模块
新增了从调度引擎、任务注册、状态追踪到结果投递的全套cron任务系统,支持cron表达式、一次性任务和周期任务,包含失败告警、任务持久化和运行日志功能
2026-05-21 10:24:54 +08:00

204 lines
6.5 KiB
Python

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)