ForcePilot/backend/package/yuxi/services/scheduler_tick.py
Kris 6498af03e3 feat(scheduler): 完整实现定时任务调度限界上下文基础架构
本次提交完成了定时任务调度限界上下文的全量基础架构搭建,包括:
1.  基于六边形架构的完整分层(core/use_cases/framework/adapters/infrastructure)
2.  任务调度核心领域模型、端口契约与校验工具
3.  持久化适配器层与SQLAlchemy仓储实现
4.  调度运行时核心组件(handler注册表、执行引擎)
5.  内置维护型任务handler(日志清理、幂等记录清理)
6.  全局异常处理器与PostgreSQL表结构适配
7.  ARQ worker调度任务集成与启动装配逻辑
2026-06-22 20:07:20 +08:00

174 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""scheduler 的 ARQ 桥接层。
本模块是 ARQ 队列协议(模块级函数 + ``ctx``)与 ``SchedulerService``(类实例方法)
之间的适配器。ARQ 只认模块级函数,无法直接调用持有依赖的 ``SchedulerService`` 实例;
本模块在 ARQ 函数内部从 ``ctx`` 取出 worker 进程级 ``HandlerRegistry`` 与 ``arq_pool``
按请求创建 ``SchedulerService`` 实例,转调其 ``tick`` / ``execute_task`` 方法。
对外暴露:
- ``run_scheduler_tick(ctx)``ARQ cron 入口,周期性扫描到期任务并入队执行。
由 ``WorkerSettings.cron_jobs`` 注册,默认每分钟触发(对齐
``config.scheduler_tick_interval_seconds`` 默认 60s
- ``execute_scheduled_task(ctx, task_id, run_id, triggered_by, scheduled_at=None)``
ARQ worker 入口,执行单个到期 / 手动触发的任务。函数名与
``SchedulerService._EXECUTE_TASK_FUNCTION`` 对齐,由 ``tick`` / ``trigger_task``
通过 ``pool.enqueue_job`` 入队。
- ``register_builtin_handlers(registry)``:注册 scheduler 自带的维护型 handler
``RunLogCleanupHandler`` / ``IdempotencyCleanupHandler``),由 worker 启动钩子调用。
依赖方向:依赖 ``scheduler/infrastructure/container``factory+
``scheduler/framework/handlers``(维护型 handler+ ``storage/postgres/manager``
db 会话)。不依赖 ``use_cases`` 的具体实现(仅通过 factory 间接装配)。
进程级生命周期:
- ``HandlerRegistry``worker 启动时创建并填充,塞入 ``ctx['scheduler_handler_registry']``
- ``arq_pool``ARQ worker 自动注入到 ``ctx['pool']``
- ``SchedulerService``:请求级,每次 ARQ 函数调用新建(含独立 db session
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from yuxi.config.app import config as app_config
from yuxi.scheduler.framework.handlers import (
IdempotencyCleanupHandler,
RunLogCleanupHandler,
)
from yuxi.scheduler.framework.runtime import HandlerRegistry
from yuxi.scheduler.infrastructure.container import create_worker_scheduler_service
from yuxi.storage.postgres.manager import pg_manager
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from arq import ArqRedis
from arq.worker import WorkerContext
__all__ = [
"execute_scheduled_task",
"register_builtin_handlers",
"run_scheduler_tick",
]
# ctx 中存储 HandlerRegistry 的键名(与 _worker_startup 约定)
_HANDLER_REGISTRY_CTX_KEY = "scheduler_handler_registry"
def register_builtin_handlers(registry: HandlerRegistry) -> None:
"""注册 scheduler 自带的维护型 handler 到 ``HandlerRegistry``。
由 worker 启动钩子(``_worker_startup``)在创建 registry 后调用。
注册的 handler 通过 ``scheduled_tasks`` 表配置触发周期(如每天凌晨清理)。
Args:
registry: worker 进程级 handler 注册表。
"""
session_factory = pg_manager.get_async_session_context
registry.register(
RunLogCleanupHandler(
session_factory=session_factory,
default_retention_days=app_config.scheduler_run_log_retention_days,
)
)
registry.register(
IdempotencyCleanupHandler(
session_factory=session_factory,
default_retention_hours=app_config.scheduler_idempotency_retention_hours,
)
)
logger.info(
"scheduler_builtin_handlers_registered",
extra={"handlers": ["run_log_cleanup", "idempotency_cleanup"]},
)
async def run_scheduler_tick(ctx: WorkerContext) -> None:
"""ARQ cron 入口:周期性扫描到期任务并入队执行。
由 ``WorkerSettings.cron_jobs`` 注册,默认每分钟触发。内部转调
``SchedulerService.tick()``,扫描 ``next_run_at <= now`` 的任务,应用
stagger 抖动与 block_strategy 后入队 ``execute_scheduled_task``。
Args:
ctx: ARQ worker 上下文,含进程级 ``HandlerRegistry`` 与 ``arq_pool``。
"""
if not app_config.scheduler_enabled:
return
registry: HandlerRegistry | None = ctx.get(_HANDLER_REGISTRY_CTX_KEY)
if registry is None:
logger.error("scheduler_tick_handler_registry_missing")
return
arq_pool: ArqRedis | None = ctx.get("pool")
if arq_pool is None:
logger.error("scheduler_tick_arq_pool_missing")
return
async with pg_manager.get_async_session_context() as db:
service = create_worker_scheduler_service(
db, handler_registry=registry, arq_pool=arq_pool
)
await service.tick()
async def execute_scheduled_task(
ctx: WorkerContext,
task_id: str,
run_id: str,
triggered_by: str,
scheduled_at: str | None = None,
) -> None:
"""ARQ worker 入口:执行单个到期 / 手动触发的任务。
由 ``tick``auto 触发)或 ``trigger_task``manual 触发)通过
``pool.enqueue_job("execute_scheduled_task", ...)`` 入队。函数名与
``SchedulerService._EXECUTE_TASK_FUNCTION`` 对齐。
Args:
ctx: ARQ worker 上下文,含进程级 ``HandlerRegistry`` 与 ``arq_pool``。
task_id: 任务标识。
run_id: 本次执行标识(由 tick 或 trigger_task 生成)。
triggered_by: 触发来源(``auto`` / ``manual``)。
scheduled_at: 本次计划执行时间的 ISO 字符串tick 触发时为 acquire 前的
原 ``next_run_at``;手动触发时为 None
"""
registry: HandlerRegistry | None = ctx.get(_HANDLER_REGISTRY_CTX_KEY)
if registry is None:
logger.error(
"scheduler_execute_handler_registry_missing",
extra={"task_id": task_id, "run_id": run_id},
)
return
arq_pool: ArqRedis | None = ctx.get("pool")
if arq_pool is None:
logger.error(
"scheduler_execute_arq_pool_missing",
extra={"task_id": task_id, "run_id": run_id},
)
return
scheduled_at_dt: datetime | None = None
if scheduled_at is not None:
try:
scheduled_at_dt = datetime.fromisoformat(scheduled_at)
except ValueError:
logger.warning(
"scheduler_execute_invalid_scheduled_at",
extra={"task_id": task_id, "run_id": run_id, "scheduled_at": scheduled_at},
)
async with pg_manager.get_async_session_context() as db:
service = create_worker_scheduler_service(
db, handler_registry=registry, arq_pool=arq_pool
)
await service.execute_task(
task_id=task_id,
run_id=run_id,
triggered_by=triggered_by,
scheduled_at=scheduled_at_dt,
)