feat: 新增回收站任务清理handler,完善任务统计与批量操作能力

本次提交包含多维度功能增强:
1. 新增TaskRecycleCleanupHandler,实现过期软删除任务物理清理能力
2. 扩展任务统计模型,新增死信任务计数并完善统计逻辑
3. 新增异常任务聚合查询接口,支持按死信/连续失败/长期未执行分类返回
4. 实现任务批量暂停/恢复/软删除操作
5. 扩展运行日志与任务查询过滤条件,新增handler_name维度
6. 优化健康检查逻辑,支持动态健康窗口并返回配置快照
7. 完善数据映射与DTO定义,补充缺失字段与类型支持
This commit is contained in:
Kris 2026-07-11 21:53:56 +08:00
parent 371c554aa7
commit 36d0add930
11 changed files with 516 additions and 79 deletions

View File

@ -88,6 +88,7 @@ def orm_to_run_log(orm: ScheduledTaskRunLogORM | None) -> ScheduledTaskRunLogDC
return ScheduledTaskRunLogDC(
id=orm.id,
task_id=orm.task_id,
handler_name=orm.handler_name,
run_id=orm.run_id,
triggered_by=orm.triggered_by,
status=orm.status,
@ -124,12 +125,13 @@ def handler_summary_row_to_dataclass(row: Any) -> HandlerSummaryDC:
"""仓储层 ``HandlerSummary`` → core ``HandlerSummary`` dataclass按字段名映射。
仓储层 ``HandlerSummary`` dataclassrow 可能是 NamedTuple dataclass 实例
统一按字段名读取并转换为 core dataclass``active_task_count`` 在仓储层聚合
时提供缺失时默认 0
统一按字段名读取并转换为 core dataclass``active_task_count`` /
``dead_letter_count`` 在仓储层聚合时提供缺失时默认 0
"""
return HandlerSummaryDC(
name=row.name,
task_count=int(row.task_count),
active_task_count=int(row.active_task_count) if hasattr(row, "active_task_count") else 0,
dead_letter_count=int(row.dead_letter_count) if hasattr(row, "dead_letter_count") else 0,
last_active_at=row.last_active_at,
)

View File

@ -48,6 +48,7 @@ class SqlAlchemyRunLogDailyRepository:
handler_name: str,
result: str,
*,
increment: int = 1,
commit: bool = True,
) -> None:
"""委托给 ScheduledTaskRunLogDailyRepository.upsert_daily_stat。"""
@ -55,6 +56,7 @@ class SqlAlchemyRunLogDailyRepository:
stat_date,
handler_name,
result,
increment=increment,
commit=commit,
)

View File

@ -95,7 +95,8 @@ class SqlAlchemyRunLogRepository:
async def list_by_status(
self,
*,
status: str,
status: str | None = None,
handler_name: str | None = None,
started_after: datetime | None = None,
started_before: datetime | None = None,
page: int = 1,
@ -104,6 +105,7 @@ class SqlAlchemyRunLogRepository:
"""委托给 ScheduledTaskRunLogRepository.list_by_status。"""
orms, total = await self._repo.list_by_status(
status=status,
handler_name=handler_name,
started_after=started_after,
started_before=started_before,
page=page,
@ -120,11 +122,15 @@ class SqlAlchemyRunLogRepository:
"""委托给 ScheduledTaskRunLogRepository.count_running。"""
return await self._repo.count_running()
async def get_latest_started_at(self, *, exclude_skipped: bool = True) -> datetime | None:
"""委托给 ScheduledTaskRunLogRepository.get_latest_started_at。"""
return await self._repo.get_latest_started_at(exclude_skipped=exclude_skipped)
async def reclaim_stale_runs(
self,
*,
stale_before: datetime,
error_message: str = "reclaimed by scheduler restart",
error_message: str = "reclaimed as stale run",
commit: bool = True,
) -> list[str]:
"""委托给 ScheduledTaskRunLogRepository.reclaim_stale_runs。

View File

@ -85,10 +85,13 @@ class SqlAlchemyTaskRepository:
owner_scope: str | None = None,
owner_id: str | None = None,
handler_name: str | None = None,
keyword: str | None = None,
enabled: bool | None = None,
status: str | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
sort_by: str | None = None,
sort_order: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[ScheduledTaskDC], int]:
@ -97,10 +100,13 @@ class SqlAlchemyTaskRepository:
owner_scope=owner_scope,
owner_id=owner_id,
handler_name=handler_name,
keyword=keyword,
enabled=enabled,
status=status,
created_after=created_after,
created_before=created_before,
sort_by=sort_by,
sort_order=sort_order,
page=page,
page_size=page_size,
)
@ -121,6 +127,26 @@ class SqlAlchemyTaskRepository:
"""委托给 ScheduledTaskRepository.delete。"""
return await self._repo.delete(task_id, commit=commit)
async def batch_pause(
self,
task_ids: list[str],
*,
updated_by: str | None = None,
commit: bool = True,
) -> int:
"""委托给 ScheduledTaskRepository.batch_pause。"""
return await self._repo.batch_pause(task_ids, updated_by=updated_by, commit=commit)
async def batch_soft_delete(
self,
task_ids: list[str],
*,
updated_by: str | None = None,
commit: bool = True,
) -> int:
"""委托给 ScheduledTaskRepository.batch_soft_delete。"""
return await self._repo.batch_soft_delete(task_ids, updated_by=updated_by, commit=commit)
# ------------------------------------------------------------------
# 业务查询
# ------------------------------------------------------------------
@ -206,9 +232,7 @@ class SqlAlchemyTaskRepository:
commit: bool = True,
) -> ScheduledTaskDC | None:
"""委托给 ScheduledTaskRepository.resume。"""
orm = await self._repo.resume(
task_id, next_run_at=next_run_at, updated_by=updated_by, commit=commit
)
orm = await self._repo.resume(task_id, next_run_at=next_run_at, updated_by=updated_by, commit=commit)
return orm_to_task(orm)
async def mark_dead_letter(
@ -267,9 +291,7 @@ class SqlAlchemyTaskRepository:
commit: bool = True,
) -> ScheduledTaskDC | None:
"""委托给 ScheduledTaskRepository.finalize_one_shot_task。"""
orm = await self._repo.finalize_one_shot_task(
task_id, updated_by=updated_by, commit=commit
)
orm = await self._repo.finalize_one_shot_task(task_id, updated_by=updated_by, commit=commit)
return orm_to_task(orm)
# ------------------------------------------------------------------

View File

@ -63,6 +63,7 @@ class ScheduledTaskRunLog:
task_id: str
run_id: str
handler_name: str | None = None
triggered_by: str = "auto"
status: str = "running" # running / success / failure / timeout / skipped
error_message: str | None = None
@ -82,12 +83,13 @@ class ScheduledTaskRunLog:
class HandlerSummary:
"""handler 聚合摘要(与仓储层 dataclass 同名同字段core 层独立声明)。
用于 Admin API 展示各 handler 的任务数活跃任务数与最近执行时间
用于 Admin API 展示各 handler 的任务数活跃任务数死信任务数与最近执行时间
"""
name: str
task_count: int
active_task_count: int = 0
dead_letter_count: int = 0
last_active_at: Any = None

View File

@ -177,6 +177,14 @@ class ScheduledTaskRepositoryPort(Protocol):
handler_name: str | None = None,
) -> dict[str, int]: ...
async def list_anomalies(
self,
*,
consecutive_error_threshold: int = 3,
stale_days: int = 7,
limit: int = 5,
) -> dict[str, list[ScheduledTask]]: ...
# 并发控制
async def acquire_for_run(
@ -280,7 +288,8 @@ class ScheduledTaskRunLogRepositoryPort(Protocol):
async def list_by_status(
self,
*,
status: str,
status: str | None = None,
handler_name: str | None = None,
started_after: datetime | None = None,
started_before: datetime | None = None,
page: int = 1,
@ -291,11 +300,13 @@ class ScheduledTaskRunLogRepositoryPort(Protocol):
async def count_running(self) -> int: ...
async def get_latest_started_at(self, *, exclude_skipped: bool = True) -> datetime | None: ...
async def reclaim_stale_runs(
self,
*,
stale_before: datetime,
error_message: str = "reclaimed by scheduler restart",
error_message: str = "reclaimed as stale run",
commit: bool = True,
) -> list[str]: ...
@ -316,6 +327,7 @@ class ScheduledTaskRunLogDailyRepositoryPort(Protocol):
handler_name: str,
result: str,
*,
increment: int = 1,
commit: bool = True,
) -> None: ...

View File

@ -13,6 +13,7 @@ handler``external_systems`` 等)的区别:
- ``RunLogCleanupHandler``清理过期的执行明细日志PRD §FR-ST-11
- ``IdempotencyCleanupHandler``清理过期的幂等记录
- ``TaskRecycleCleanupHandler``清理过期的回收站任务超期自动硬删除
依赖方向仅依赖 ``core/contracts````TaskHandler`` 协议+ ``adapters/persistence``
``create_repositories``+ ``utils``不依赖 ``use_cases`` / ``repositories`` /
@ -26,8 +27,12 @@ from yuxi.scheduler.framework.handlers.idempotency_cleanup_handler import (
from yuxi.scheduler.framework.handlers.run_log_cleanup_handler import (
RunLogCleanupHandler,
)
from yuxi.scheduler.framework.handlers.task_recycle_cleanup_handler import (
TaskRecycleCleanupHandler,
)
__all__ = [
"IdempotencyCleanupHandler",
"RunLogCleanupHandler",
"TaskRecycleCleanupHandler",
]

View File

@ -0,0 +1,94 @@
"""清理过期回收站任务的维护型 handler。
scheduler 自身在 worker 启动时注册到 ``HandlerRegistry``通过
``scheduled_tasks`` 表配置触发周期按保留天数物理删除已软删除且超过
保留期的任务记录回收存储空间
设计要点见设计方案 §14.1
- ** ``RunLogCleanupHandler`` 结构对齐**差异仅在清理对象
- **会话隔离**通过构造函数注入 ``session_factory``每次执行创建独立 db
会话避免 worker 进程长生命周期会话导致的连接失效问题
- **保留策略可配置**保留天数从 ``payload.retention_days`` 读取未传入时
使用构造函数默认值对齐 ``config.scheduler_task_recycle_retention_days``
- **物理删除**通过仓储的 ``hard_delete_before`` 方法执行物理删除
``deleted_at`` 早于 cutoff 的软删除记录
- **异常隔离**handler 内部捕获所有异常并转换为 ``TaskResult(success=False)``
不向调度域传播业务异常对齐 INV-CROSS-4
"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.scheduler.adapters.persistence import create_repositories
from yuxi.scheduler.core.contracts import TaskContext, TaskResult
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
# 默认保留天数(与 config.scheduler_task_recycle_retention_days 默认值对齐)
_DEFAULT_RETENTION_DAYS = 7
class TaskRecycleCleanupHandler:
"""清理过期回收站任务的维护型 handler。
通过 ``scheduled_tasks`` 表配置触发按保留天数物理删除
``scheduled_tasks`` 表中已软删除且 ``deleted_at`` 早于 cutoff 的记录
payload 参数
retention_days: 保留天数未传入时使用构造函数默认值
依赖注入
session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂
通常为 ``pg_manager.get_async_session_context``每次执行创建独立
会话避免长生命周期会话问题
default_retention_days: 默认保留天数未在 payload 指定时使用
"""
name = "task_recycle_cleanup"
description = "清理过期的回收站任务(超期自动硬删除)"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_retention_days: int = _DEFAULT_RETENTION_DAYS,
) -> None:
self._session_factory = session_factory
self._default_retention_days = default_retention_days
async def execute(self, ctx: TaskContext) -> TaskResult:
retention_days = int(ctx.payload.get("retention_days", self._default_retention_days))
cutoff = utc_now_naive() - timedelta(days=retention_days)
try:
async with self._session_factory() as db:
repos = create_repositories(db)
deleted_count = await repos.task.hard_delete_before(cutoff)
except Exception as e:
logger.exception(
"scheduler_task_recycle_cleanup_failed",
extra={"retention_days": retention_days, "cutoff": cutoff},
)
return TaskResult(success=False, error=str(e))
logger.info(
"scheduler_task_recycle_cleanup",
extra={
"deleted_count": deleted_count,
"retention_days": retention_days,
"cutoff": cutoff,
},
)
return TaskResult(
success=True,
output={
"deleted_count": deleted_count,
"retention_days": retention_days,
},
)

View File

@ -301,12 +301,41 @@ class ListTasksInput(BaseModel):
owner_scope: str | None = Field(default=None, max_length=64)
owner_id: str | None = Field(default=None, max_length=128)
handler_name: str | None = Field(default=None, max_length=128)
keyword: str | None = Field(
default=None,
max_length=128,
description="关键词模糊搜索,匹配 task_id 或 handler_name",
)
enabled: bool | None = None
status: str | None = Field(
default=None,
pattern=r"^(active|paused|dead_letter)$",
description="任务状态过滤",
)
sort_by: str | None = Field(
default=None,
pattern=r"^(created_at|next_run_at|last_run_at|consecutive_errors|updated_at)$",
description="排序字段",
)
sort_order: str | None = Field(
default=None,
pattern=r"^(asc|desc)$",
description="排序方向,默认 desc",
)
class BatchTaskOperationOutput(BaseModel):
"""批量任务操作输出 DTO。
``success_count`` 为实际受影响行数``skipped_count`` 为未匹配或状态
不允许的任务数``task_ids`` 总数减去 ``success_count``
"""
model_config = ConfigDict(frozen=True)
success_count: int = Field(..., description="实际操作成功的任务数")
skipped_count: int = Field(..., description="未匹配或状态不允许的任务数")
failed: list[str] = Field(default_factory=list, description="未成功的 task_id 列表")
class ListTasksOutput(BaseModel):
@ -378,6 +407,7 @@ class RunLogOutput(BaseModel):
model_config = ConfigDict(frozen=True)
task_id: str
handler_name: str | None = None
run_id: str
triggered_by: str = "auto"
status: str = "running"
@ -413,7 +443,8 @@ class ListRunLogsOutput(BaseModel):
class ListAllRunLogsInput(BaseModel):
"""跨任务执行日志查询输入 DTO。
``task_id`` ``status`` 均可选但不可同时为空由用例层校验
``task_id`` / ``status`` / ``handler_name`` / 时间范围均可选
但至少需指定 ``task_id````status`` 或时间范围之一由用例层校验
避免无过滤的全表扫描``start_date`` / ``end_date`` ISO 格式字符串
由用例层解析为 ``datetime`` 后传入仓储层
"""
@ -427,6 +458,12 @@ class ListAllRunLogsInput(BaseModel):
pattern=r"^(running|success|failure|timeout|skipped)$",
description="执行状态过滤",
)
handler_name: str | None = Field(
default=None,
min_length=1,
max_length=128,
description="handler 名称过滤(跨任务按 handler 维度查询)",
)
start_date: str | None = Field(
default=None,
description="起始时间ISO 格式字符串),按 started_at 过滤",
@ -453,9 +490,9 @@ class HandlerSummaryOutput(BaseModel):
model_config = ConfigDict(frozen=True)
handler_name: str
description: str = ""
task_count: int = 0
active_task_count: int = 0
dead_letter_count: int = 0
last_active_at: str | None = None
@ -520,12 +557,51 @@ class ListUpcomingOutput(BaseModel):
items: list[TaskOutput] = Field(default_factory=list)
class ListAnomaliesOutput(BaseModel):
"""工作台异常任务聚合输出 DTO。
聚合三类异常任务供工作台一次性拉取避免前端依赖活跃任务列表前 N
做前端过滤导致的漏报问题
- ``dead_letter``死信任务status=dead_letter updated_at 降序
- ``consecutive_failure``连续失败任务consecutive_errors >= 3
dead_letter consecutive_errors 降序
- ``stale``长期未执行任务active 状态last_run_at 早于 7 天前
last_run_at 升序
"""
model_config = ConfigDict(frozen=True)
dead_letter: list[TaskOutput] = Field(default_factory=list)
consecutive_failure: list[TaskOutput] = Field(default_factory=list)
stale: list[TaskOutput] = Field(default_factory=list)
class SchedulerConfigOutput(BaseModel):
"""调度器配置快照(只读),用于运维中心展示当前生效配置。
字段值由 ``scheduler_service.get_health`` ``app_config`` 读取并填充
确保前端展示与后端实际配置一致避免硬编码默认值导致的信息不同步
"""
model_config = ConfigDict(frozen=True)
enabled: bool = Field(description="定时任务总开关")
tick_interval_seconds: int = Field(description="tick 频率(秒)")
task_timeout_seconds: int = Field(description="单任务超时(秒)")
max_consecutive_errors: int = Field(description="死信阈值(连续失败次数)")
backoff_schedule: list[int] = Field(description="指数退避序列(秒)")
stagger_max_seconds: int = Field(description="整点抖动上限(秒)")
run_log_retention_days: int = Field(description="run_logs 保留天数")
idempotency_retention_hours: int = Field(description="幂等记录保留小时数")
min_cron_interval_minutes: int = Field(description="cron 最小间隔(分钟)")
class GetHealthOutput(BaseModel):
"""调度器健康检查输出 DTO。
对齐 PRD §FR-ST-07 健康检查端点响应``status`` 表示 worker 是否在最近
5 分钟内执行 tick``last_tick_at`` 为最近 tick 时间``active_task_count``
/ ``dead_letter_count`` / ``running_count`` 为关键状态计数
对齐 PRD §FR-ST-07 健康检查端点响应``status`` 表示 worker 活跃度健康
判断结果``last_tick_at`` 为最近一次任务活动时间不限状态``config``
为当前生效的调度器配置快照
"""
model_config = ConfigDict(frozen=True)
@ -533,12 +609,13 @@ class GetHealthOutput(BaseModel):
status: str = Field(
default="healthy",
pattern=r"^(healthy|unhealthy)$",
description="健康状态healthy(最近 5 分钟内有 tick/ unhealthy",
description="健康状态healthy / unhealthy",
)
last_tick_at: str | None = Field(default=None, description="最近 tick 时间ISO 格式字符串)")
last_tick_at: str | None = Field(default=None, description="最近一次任务活动时间ISO 格式字符串)")
active_task_count: int = 0
dead_letter_count: int = 0
running_count: int = 0
config: SchedulerConfigOutput = Field(description="当前生效的调度器配置快照")
# ---------------------------------------------------------------------------
@ -564,7 +641,8 @@ class DailyStatOutput(BaseModel):
"""日聚合统计输出 DTO。字段对齐 ``DailyStat`` dataclass补充派生字段。
``total_count`` / ``success_rate`` 为派生字段 mapper 计算
``total_count = success_count + failure_count + timeout_count``
``total_count = success_count + failure_count + timeout_count + dead_letter_count``
dead_letter 视为失败终态纳入分母
``success_rate = success_count / total_count if total_count > 0 else 0.0``
"""
@ -596,9 +674,9 @@ class ListDailyStatsOutput(BaseModel):
class ReclaimStaleRunsInput(BaseModel):
"""回收僵尸执行输入 DTO。
用于 scheduler 重启后回收 ``status=running`` 但实际已超时的执行记录
将其标记为 ``failure`` 并写入回收错误信息``timeout_seconds`` 为可选参数
未传入时由用例层从配置取默认值
用于回收 ``status=running`` 但实际已超时的执行记录将其标记为 ``timeout``
并写入回收错误信息``timeout_seconds`` 为可选参数未传入时由用例层从配置
取默认值``scheduler_task_timeout_seconds``
"""
model_config = ConfigDict(frozen=True)

View File

@ -26,10 +26,12 @@ from yuxi.scheduler.use_cases.dto.scheduler import (
DailyStatOutput,
GetHealthOutput,
HandlerSummaryOutput,
ListAnomaliesOutput,
ListDailyStatsOutput,
ListHandlerSummaryOutput,
ListUpcomingOutput,
RunLogOutput,
SchedulerConfigOutput,
TaskOutput,
TriggerTaskOutput,
)
@ -41,6 +43,7 @@ __all__ = [
"to_daily_stat_output",
"to_get_health_output",
"to_handler_summary_output",
"to_list_anomalies_output",
"to_list_daily_stats_output",
"to_run_log_output",
"to_task_output",
@ -99,6 +102,7 @@ def to_run_log_output(log: ScheduledTaskRunLog) -> RunLogOutput:
duration_seconds = round(delta.total_seconds(), 3)
return RunLogOutput(
task_id=log.task_id,
handler_name=log.handler_name,
run_id=log.run_id,
triggered_by=log.triggered_by,
status=log.status,
@ -120,14 +124,13 @@ def to_run_log_output(log: ScheduledTaskRunLog) -> RunLogOutput:
def to_handler_summary_output(summary: HandlerSummary) -> HandlerSummaryOutput:
"""``HandlerSummary`` dataclass → ``HandlerSummaryOutput`` DTO。
``description`` 字段仓储层不聚合DB 无此列由调用方 worker 进程
持有 ``HandlerRegistry`` 按需补充API 进程无注册表时保持空字符串
``active_task_count`` 已由仓储层聚合此处直接透传
``active_task_count`` / ``dead_letter_count`` 已由仓储层聚合此处直接透传
"""
return HandlerSummaryOutput(
handler_name=summary.name,
task_count=summary.task_count,
active_task_count=summary.active_task_count,
dead_letter_count=summary.dead_letter_count,
last_active_at=format_utc_datetime(summary.last_active_at),
)
@ -152,10 +155,12 @@ def to_get_health_output(
dead_letter_count: int,
running_count: int,
healthy: bool,
config: SchedulerConfigOutput,
) -> GetHealthOutput:
"""构造 ``GetHealthOutput`` DTO。
``healthy`` 由调用方按"最近 tick 是否在 5 分钟内"判断后传入
``healthy`` 由调用方按 worker 活跃度判断后传入``config`` 为从
``app_config`` 读取的当前生效配置快照
"""
return GetHealthOutput(
status="healthy" if healthy else "unhealthy",
@ -163,6 +168,7 @@ def to_get_health_output(
active_task_count=active_task_count,
dead_letter_count=dead_letter_count,
running_count=running_count,
config=config,
)
@ -186,9 +192,10 @@ def to_daily_stat_output(stat: DailyStat) -> DailyStatOutput:
"""``DailyStat`` dataclass → ``DailyStatOutput`` DTO。
计算派生字段 ``total_count`` / ``success_rate````stat_date`` 格式化为
ISO 日期字符串``YYYY-MM-DD``
ISO 日期字符串``YYYY-MM-DD````dead_letter_count`` 视为失败终态
纳入 ``total_count`` 分母以反映真实成功率
"""
total_count = stat.success_count + stat.failure_count + stat.timeout_count
total_count = stat.success_count + stat.failure_count + stat.timeout_count + stat.dead_letter_count
success_rate = stat.success_count / total_count if total_count > 0 else 0.0
return DailyStatOutput(
stat_date=stat.stat_date.isoformat(),
@ -212,6 +219,15 @@ def to_list_upcoming_output(tasks: list[ScheduledTask]) -> ListUpcomingOutput:
return ListUpcomingOutput(items=[to_task_output(t) for t in tasks])
def to_list_anomalies_output(groups: dict[str, list[ScheduledTask]]) -> ListAnomaliesOutput:
"""异常任务分组映射 → ``ListAnomaliesOutput`` DTO。"""
return ListAnomaliesOutput(
dead_letter=[to_task_output(t) for t in groups.get("dead_letter", [])],
consecutive_failure=[to_task_output(t) for t in groups.get("consecutive_failure", [])],
stale=[to_task_output(t) for t in groups.get("stale", [])],
)
def to_list_handler_summary_output(
summaries: list[HandlerSummary],
) -> ListHandlerSummaryOutput:

View File

@ -47,6 +47,7 @@ from yuxi.scheduler.exceptions import (
TaskStatusTransitionError,
)
from yuxi.scheduler.use_cases.dto.scheduler import (
BatchTaskOperationOutput,
CountByStatusInput,
CountByStatusOutput,
CreateTaskInput,
@ -56,6 +57,7 @@ from yuxi.scheduler.use_cases.dto.scheduler import (
GetTaskInput,
HardDeleteTaskInput,
ListAllRunLogsInput,
ListAnomaliesOutput,
ListDailyStatsInput,
ListDailyStatsOutput,
ListDeletedTasksInput,
@ -72,6 +74,7 @@ from yuxi.scheduler.use_cases.dto.scheduler import (
RestoreTaskInput,
ResumeTaskInput,
RunLogOutput,
SchedulerConfigOutput,
TaskOutput,
TriggerTaskInput,
TriggerTaskOutput,
@ -80,6 +83,7 @@ from yuxi.scheduler.use_cases.dto.scheduler import (
from yuxi.scheduler.use_cases.mappers import (
to_count_by_status_output,
to_get_health_output,
to_list_anomalies_output,
to_list_daily_stats_output,
to_list_handler_summary_output,
to_list_upcoming_output,
@ -93,6 +97,7 @@ from yuxi.scheduler.use_cases.utils.cron_calc import calc_next_run_at
from yuxi.scheduler.use_cases.utils.stagger import calc_stagger_seconds
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from yuxi.utils.trace_context import get_trace_id
if TYPE_CHECKING:
from arq import ArqRedis
@ -101,8 +106,8 @@ if TYPE_CHECKING:
from yuxi.scheduler.framework.runtime import RuntimeServices
# tick 健康检查的"最近活跃"窗口(分钟)
_HEALTH_RECENT_WINDOW_MINUTES = 5
# 健康检查窗口下限(秒):有活跃任务时,最近活动超过此窗口则判定不健康
_HEALTH_WINDOW_MIN_SECONDS = 1800
# 日聚合统计的自然日时区(与 ORM stat_date 注释约定一致)
_DAILY_STAT_TZ = ZoneInfo("Asia/Shanghai")
@ -457,8 +462,11 @@ class SchedulerService(SchedulerServicePort):
owner_scope=input_dto.owner_scope,
owner_id=input_dto.owner_id,
handler_name=input_dto.handler_name,
keyword=input_dto.keyword,
enabled=input_dto.enabled,
status=input_dto.status,
sort_by=input_dto.sort_by,
sort_order=input_dto.sort_order,
page=input_dto.page,
page_size=input_dto.page_size,
)
@ -496,9 +504,7 @@ class SchedulerService(SchedulerServicePort):
details={"task_id": input_dto.task_id},
)
update_data: dict[str, Any] = input_dto.model_dump(
exclude_unset=True, exclude={"task_id", "idempotency_key"}
)
update_data: dict[str, Any] = input_dto.model_dump(exclude_unset=True, exclude={"task_id", "idempotency_key"})
if not update_data:
return to_task_output(task)
@ -545,15 +551,11 @@ class SchedulerService(SchedulerServicePort):
min_interval_minutes=self._config.scheduler_min_cron_interval_minutes,
)
effective_tz = update_data.get("tz", task.tz)
update_data["next_run_at"] = calc_next_run_at(
update_data["cron_expression"], tz=effective_tz
)
update_data["next_run_at"] = calc_next_run_at(update_data["cron_expression"], tz=effective_tz)
# run_at 变更时重算 next_run_atat 类型),校验未来时间
if "run_at" in update_data and task.schedule_kind == "at":
run_at_dt = self._parse_iso_to_utc_naive(
update_data["run_at"], field_name="run_at"
)
run_at_dt = self._parse_iso_to_utc_naive(update_data["run_at"], field_name="run_at")
if run_at_dt <= utc_now_naive():
raise SchedulerValidationError("run_at 必须为未来时间")
update_data["next_run_at"] = run_at_dt
@ -583,9 +585,7 @@ class SchedulerService(SchedulerServicePort):
operation="update",
operator=input_dto.updated_by,
)
updated = await self._repos.task.update(
input_dto.task_id, update_data, commit=False
)
updated = await self._repos.task.update(input_dto.task_id, update_data, commit=False)
if updated is not None:
await self._store_idempotency_response(
input_dto.idempotency_key,
@ -641,6 +641,158 @@ class SchedulerService(SchedulerServicePort):
extra={"task_id": input_dto.task_id, "operator": input_dto.updated_by},
)
# ==================================================================
# 批量操作
# ==================================================================
async def batch_pause_tasks(
self,
task_ids: list[str],
*,
operator: str | None = None,
) -> BatchTaskOperationOutput:
"""批量暂停任务(仅 ``active`` 状态被转换)。
单事务单 SQL 完成``paused`` / ``dead_letter`` 状态的任务跳过
不支持幂等键批量操作的幂等由调用方按业务场景处理
"""
if not task_ids:
return BatchTaskOperationOutput(success_count=0, skipped_count=0, failed=[])
async with self._uow:
affected = await self._repos.task.batch_pause(
task_ids,
updated_by=operator,
commit=False,
)
failed = list(set(task_ids) - set(await self._resolve_affected_task_ids(task_ids, affected)))
logger.info(
"scheduler_batch_paused",
extra={
"total": len(task_ids),
"affected": affected,
"operator": operator,
"trace_id": get_trace_id(),
},
)
return BatchTaskOperationOutput(
success_count=affected,
skipped_count=len(task_ids) - affected,
failed=failed,
)
async def batch_resume_tasks(
self,
task_ids: list[str],
*,
operator: str | None = None,
) -> BatchTaskOperationOutput:
"""批量恢复任务(``paused`` → ``active````dead_letter`` → ``active``)。
由于每个任务需按其 ``cron_expression`` / ``run_at`` 重算 ``next_run_at``
在单事务内逐个调用 ``resume`` / ``reset_from_dead_letter``
非法状态的任务跳过并记录到 ``failed``
"""
if not task_ids:
return BatchTaskOperationOutput(success_count=0, skipped_count=0, failed=[])
success_count = 0
failed: list[str] = []
async with self._uow:
for task_id in task_ids:
existing = await self._repos.task.get_by_task_id(task_id, for_update=True)
if existing is None:
failed.append(task_id)
continue
next_run_at = self._calc_next_run_at(existing)
if existing.status == "paused":
task = await self._repos.task.resume(
task_id,
next_run_at=next_run_at,
updated_by=operator,
commit=False,
)
elif existing.status == "dead_letter":
task = await self._repos.task.reset_from_dead_letter(
task_id,
next_run_at=next_run_at,
updated_by=operator,
commit=False,
)
else:
failed.append(task_id)
continue
if task is None:
failed.append(task_id)
else:
success_count += 1
logger.info(
"scheduler_batch_resumed",
extra={
"total": len(task_ids),
"affected": success_count,
"operator": operator,
"trace_id": get_trace_id(),
},
)
return BatchTaskOperationOutput(
success_count=success_count,
skipped_count=len(task_ids) - success_count,
failed=failed,
)
async def batch_delete_tasks(
self,
task_ids: list[str],
*,
operator: str | None = None,
) -> BatchTaskOperationOutput:
"""批量软删除任务。
单事务单 SQL 完成已软删除的任务跳过
"""
if not task_ids:
return BatchTaskOperationOutput(success_count=0, skipped_count=0, failed=[])
async with self._uow:
affected = await self._repos.task.batch_soft_delete(
task_ids,
updated_by=operator,
commit=False,
)
logger.info(
"scheduler_batch_deleted",
extra={
"total": len(task_ids),
"affected": affected,
"operator": operator,
"trace_id": get_trace_id(),
},
)
return BatchTaskOperationOutput(
success_count=affected,
skipped_count=len(task_ids) - affected,
failed=[],
)
async def _resolve_affected_task_ids(
self,
task_ids: list[str],
expected_count: int,
) -> list[str]:
"""查询实际被批量暂停影响的 task_id 列表(用于 failed 推断)。
简化实现 affected == len(task_ids) 时直接返回全部否则查询当前
``paused`` 状态的 task_id 交集
"""
if expected_count >= len(task_ids):
return list(task_ids)
# 查询当前已暂停的任务(刚被批量暂停的目标)
affected_tasks, _ = await self._repos.task.list(
status="paused",
page=1,
page_size=100,
)
affected_ids = {t.task_id for t in affected_tasks}
return [tid for tid in task_ids if tid in affected_ids]
# ==================================================================
# 状态机
# ==================================================================
@ -879,8 +1031,9 @@ class SchedulerService(SchedulerServicePort):
async def list_all_run_logs(self, input_dto: ListAllRunLogsInput) -> ListRunLogsOutput:
"""跨任务列出执行日志FR-ST-09
``task_id`` 非空时按任务维度查询``task_id`` 为空时按 ``status`` 跨任务
查询两者不可同时为空否则抛 ``SchedulerValidationError``
``task_id`` 非空时按任务维度查询否则按 ``status`` / ``handler_name`` /
时间范围跨任务查询至少需指定 ``task_id````status`` 或时间范围之一
避免无过滤的全表扫描``handler_name`` 为可选过滤维度
"""
started_after: datetime | None = None
started_before: datetime | None = None
@ -898,9 +1051,11 @@ class SchedulerService(SchedulerServicePort):
page=input_dto.page,
page_size=input_dto.page_size,
)
elif input_dto.status:
elif input_dto.status or started_after is not None or started_before is not None:
# status 可为 None仅靠时间范围 / handler_name 过滤的"浏览全部"场景)
logs, total = await self._repos.run_log.list_by_status(
status=input_dto.status,
handler_name=input_dto.handler_name,
started_after=started_after,
started_before=started_before,
page=input_dto.page,
@ -908,7 +1063,7 @@ class SchedulerService(SchedulerServicePort):
)
else:
raise SchedulerValidationError(
"task_id 与 status 不可同时为空",
"至少需要指定 task_id、status 或时间范围之一",
details={"reason": "至少需要一个过滤条件避免全表扫描"},
)
@ -950,6 +1105,15 @@ class SchedulerService(SchedulerServicePort):
)
return to_list_upcoming_output(tasks)
async def list_anomalies(self) -> ListAnomaliesOutput:
"""列出工作台异常任务聚合(死信 / 连续失败 / 长期未执行)。
委托 ``task_repo.list_anomalies`` 在数据库层精准过滤避免前端依赖
活跃任务列表前 N 条做前端过滤导致的漏报问题
"""
groups = await self._repos.task.list_anomalies()
return to_list_anomalies_output(groups)
async def list_daily_stats(self, input_dto: ListDailyStatsInput) -> ListDailyStatsOutput:
"""列出日聚合统计FR-ST-09
@ -1029,6 +1193,7 @@ class SchedulerService(SchedulerServicePort):
await self._repos.run_log.create(
{
"task_id": task.task_id,
"handler_name": task.handler_name,
"run_id": run_id,
"triggered_by": "auto",
"status": "skipped",
@ -1153,6 +1318,7 @@ class SchedulerService(SchedulerServicePort):
await self._repos.run_log.create(
{
"task_id": task_id,
"handler_name": task.handler_name,
"run_id": run_id,
"triggered_by": triggered_by,
"status": "running",
@ -1397,44 +1563,70 @@ class SchedulerService(SchedulerServicePort):
async def get_health(self) -> GetHealthOutput:
"""获取调度器健康状态FR-ST-07
检测 worker 是否在最近活跃窗口内有执行记录v1 通过查询最近 run_log
``started_at`` 近似判断v2 评估独立 tick 心跳表
健康判断逻辑基于 worker 活跃度非固定 5 分钟窗口
- running 执行 健康worker 正在执行任务
- 无活跃任务active=0 健康worker 无事可做不报警
- 有活跃任务时检查最近一次任务活动是否在健康窗口内
窗口取 ``max(task_timeout_seconds * 2, _HEALTH_WINDOW_MIN_SECONDS)``
覆盖 tick 周期与任务超时避免 cron 间隔较大时的误报
``last_tick_at`` 取最近一条非 skipped 执行日志的 ``started_at``不限
窗口表示"最近一次任务活动时间"``config`` 返回当前生效配置快照
供前端展示避免硬编码默认值导致的信息不同步
"""
running_count = await self._repos.run_log.count_running()
status_dist = await self._repos.task.count_by_status()
# 查询最近活跃窗口内的执行记录(近似判断 worker 是否在运行)
now = utc_now_naive()
window_start = now - timedelta(minutes=_HEALTH_RECENT_WINDOW_MINUTES)
recent_logs, recent_total = await self._repos.run_log.list_by_status(
status="success",
started_after=window_start,
page=1,
page_size=1,
running_count, status_dist, last_activity_at = await asyncio.gather(
self._repos.run_log.count_running(),
self._repos.task.count_by_status(),
self._repos.run_log.get_latest_started_at(),
)
last_tick_at = recent_logs[0].started_at if recent_logs else None
active_task_count = status_dist.get("active", 0)
dead_letter_count = status_dist.get("dead_letter", 0)
# 若最近窗口内有成功记录,或当前有运行中任务,则视为健康
healthy = recent_total > 0 or running_count > 0
health_window = timedelta(
seconds=max(self._config.scheduler_task_timeout_seconds * 2, _HEALTH_WINDOW_MIN_SECONDS)
)
if running_count > 0 or active_task_count == 0:
healthy = True
elif last_activity_at is not None and (now - last_activity_at) <= health_window:
healthy = True
else:
healthy = False
config = SchedulerConfigOutput(
enabled=self._config.scheduler_enabled,
tick_interval_seconds=self._config.scheduler_tick_interval_seconds,
task_timeout_seconds=self._config.scheduler_task_timeout_seconds,
max_consecutive_errors=self._config.scheduler_max_consecutive_errors,
backoff_schedule=list(self._config.scheduler_backoff_schedule),
stagger_max_seconds=self._config.scheduler_stagger_max_seconds,
run_log_retention_days=self._config.scheduler_run_log_retention_days,
idempotency_retention_hours=self._config.scheduler_idempotency_retention_hours,
min_cron_interval_minutes=self._config.scheduler_min_cron_interval_minutes,
)
return to_get_health_output(
last_tick_at=last_tick_at,
active_task_count=status_dist.get("active", 0),
dead_letter_count=status_dist.get("dead_letter", 0),
last_tick_at=last_activity_at,
active_task_count=active_task_count,
dead_letter_count=dead_letter_count,
running_count=running_count,
healthy=healthy,
config=config,
)
async def reclaim_stale_runs(self, input_dto: ReclaimStaleRunsInput) -> ReclaimStaleRunsOutput:
"""回收僵尸执行(运维恢复)。
scheduler 重启后回收 ``status=running`` 但实际已超时的执行记录
将其标记为 ``timeout`` 并写入回收错误信息同时按 handler_name 分组
补录 ``timeout_count`` 到日聚合统计表保证统计准确此前
``timeout_count`` 字段无写入路径永远为 0
回收 ``status=running`` 但实际已超时的执行记录将其标记为 ``timeout``
并写入回收错误信息同时按 handler_name 分组补录 ``timeout_count``
日聚合统计表保证统计准确此前 ``timeout_count`` 字段无写入路径
永远为 0
reclaim 与补录在同一事务内完成保证原子性补录失败时 reclaim
也回滚避免出现"已标记 timeout 但未计入聚合"的中间状态
reclaim 与补录在同一事务内完成保证原子性补录失败时 reclaim
回滚避免出现"已标记 timeout 但未计入聚合"的中间状态补录使用
``increment`` 参数按 handler 分组单次 upsert ``+N``避免逐条 upsert
产生 N SQL
"""
timeout_seconds = (
input_dto.timeout_seconds
@ -1445,19 +1637,20 @@ class SchedulerService(SchedulerServicePort):
async with self._uow:
handler_names = await self._repos.run_log.reclaim_stale_runs(
stale_before=stale_before, commit=False
stale_before=stale_before,
error_message="reclaimed by admin operation",
commit=False,
)
if handler_names:
today = self._daily_stat_date(utc_now_naive())
# 按 handler_name 分组计数,逐条 upsert 补录 timeout_count
# 按 handler_name 分组计数,单次 upsert 增量补录 timeout_count
timeout_counts: dict[str, int] = {}
for name in handler_names:
timeout_counts[name] = timeout_counts.get(name, 0) + 1
for name, count in timeout_counts.items():
for _ in range(count):
await self._repos.run_log_daily.upsert_daily_stat(
today, name, "timeout", commit=False
)
await self._repos.run_log_daily.upsert_daily_stat(
today, name, "timeout", increment=count, commit=False
)
reclaimed_count = len(handler_names)
if reclaimed_count > 0:
@ -1535,11 +1728,16 @@ class SchedulerService(SchedulerServicePort):
operation="restore",
operator=input_dto.updated_by,
)
await self._repos.task.restore_by_id(
restored_rows = await self._repos.task.restore_by_id(
deleted_task.id,
updated_by=input_dto.updated_by,
commit=False,
)
if restored_rows == 0:
raise SchedulerConflictError(
f"任务 {input_dto.task_id!r} 恢复失败:可能已被其他请求恢复或 task_id 冲突",
details={"task_id": input_dto.task_id},
)
if was_active:
await self._repos.task.pause(
input_dto.task_id,