mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-24 13:40:02 +08:00
♻️ refactor(scheduler): 重构定时任务系统并增强功能 (#2009)
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
* ♻️ refactor(scheduler): 重构定时任务系统并增强功能 - 【模型重命名】将 `ScheduleInfo` 模型及其数据库表重命名为 `ScheduledJob`,以提高语义清晰度。 - 【触发器抽象】引入 `Trigger` 工厂类,提供类型安全的 Cron、Interval 和 Date 触发器配置。 - 【执行策略】新增 `ExecutionPolicy` 模型,允许为定时任务定义重试策略、延迟、异常类型以及成功/失败回调。 - 【任务执行】重构任务执行逻辑,支持 NoneBot 的依赖注入,并根据 `ExecutionPolicy` 处理任务的重试和回调。 - 【临时任务】增加声明式和编程式的临时任务调度能力,支持非持久化任务在运行时动态创建和执行。 - 【管理命令】更新定时任务管理命令 (`schedule_admin`),使其适配新的 `ScheduledJob` 模型和参数验证逻辑。 - 【展示优化】改进定时任务列表和状态展示,使用新的触发器格式化逻辑和参数模型信息。 - 【重试装饰器】为 `Retry.api` 装饰器添加 `on_success` 回调,允许在任务成功执行后触发额外操作。 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
67a2560de4
commit
be86e0bb7f
+179
-132
@@ -6,25 +6,148 @@
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import inspect
|
||||
from functools import partial
|
||||
import random
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.dependencies import Dependent
|
||||
from nonebot.exception import FinishedException, PausedException, SkippedException
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.typing import T_State
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.models.schedule_info import ScheduleInfo
|
||||
from zhenxun.models.scheduled_job import ScheduledJob
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.common_utils import CommonUtils
|
||||
from zhenxun.utils.decorator.retry import Retry
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
from zhenxun.utils.pydantic_compat import parse_as
|
||||
|
||||
SCHEDULE_CONCURRENCY_KEY = "all_groups_concurrency_limit"
|
||||
|
||||
|
||||
class ScheduleContext(BaseModel):
|
||||
"""
|
||||
定时任务执行上下文,可通过依赖注入获取。
|
||||
"""
|
||||
|
||||
schedule_id: int = Field(..., description="数据库中的任务ID")
|
||||
plugin_name: str = Field(..., description="任务所属的插件名称")
|
||||
bot_id: str | None = Field(None, description="执行任务的Bot ID")
|
||||
group_id: str | None = Field(None, description="任务目标群组ID")
|
||||
job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数")
|
||||
|
||||
|
||||
async def _execute_single_job_instance(schedule: ScheduledJob, bot):
|
||||
"""
|
||||
负责执行一个具体目标的任务实例。
|
||||
"""
|
||||
plugin_name = schedule.plugin_name
|
||||
group_id = schedule.group_id
|
||||
|
||||
from .service import ExecutionPolicy, scheduler_manager
|
||||
|
||||
task_meta = scheduler_manager._registered_tasks.get(plugin_name)
|
||||
|
||||
if not task_meta:
|
||||
logger.error(f"无法执行任务:插件 '{plugin_name}' 在执行期间变得不可用。")
|
||||
return
|
||||
|
||||
is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id)
|
||||
if is_blocked:
|
||||
target_desc = f"群 {group_id}" if group_id else "全局"
|
||||
logger.info(
|
||||
f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}] "
|
||||
f"因功能被禁用而跳过执行。"
|
||||
)
|
||||
return
|
||||
|
||||
context = ScheduleContext(
|
||||
schedule_id=schedule.id,
|
||||
plugin_name=schedule.plugin_name,
|
||||
bot_id=bot.self_id,
|
||||
group_id=schedule.group_id,
|
||||
job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {},
|
||||
)
|
||||
state: T_State = {ScheduleContext: context}
|
||||
|
||||
policy_data = context.job_kwargs.pop("execution_policy", {})
|
||||
policy = ExecutionPolicy(**policy_data)
|
||||
|
||||
async def task_execution_coro():
|
||||
injected_params = {"context": context}
|
||||
|
||||
params_model = task_meta.get("model")
|
||||
if params_model and isinstance(context.job_kwargs, dict):
|
||||
try:
|
||||
if isinstance(params_model, type) and issubclass(
|
||||
params_model, BaseModel
|
||||
):
|
||||
params_instance = parse_as(params_model, context.job_kwargs)
|
||||
injected_params["params"] = params_instance # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"任务 {schedule.id} (目标: {group_id}) 参数验证失败: {e}", e=e
|
||||
)
|
||||
raise
|
||||
|
||||
async def wrapper(bot: Bot):
|
||||
return await task_meta["func"](bot=bot, **injected_params) # type: ignore
|
||||
|
||||
dependent = Dependent.parse(
|
||||
call=wrapper,
|
||||
allow_types=Matcher.HANDLER_PARAM_TYPES,
|
||||
)
|
||||
return await dependent(bot=bot, state=state)
|
||||
|
||||
try:
|
||||
if policy.retries > 0:
|
||||
on_success_handler = None
|
||||
if policy.on_success_callback:
|
||||
on_success_handler = partial(policy.on_success_callback, context)
|
||||
|
||||
on_failure_handler = None
|
||||
if policy.on_failure_callback:
|
||||
on_failure_handler = partial(policy.on_failure_callback, context)
|
||||
|
||||
retry_exceptions = tuple(policy.retry_on_exceptions or [])
|
||||
|
||||
retry_decorator = Retry.api(
|
||||
stop_max_attempt=policy.retries + 1,
|
||||
strategy="exponential" if policy.retry_backoff else "fixed",
|
||||
wait_fixed_seconds=policy.retry_delay_seconds,
|
||||
exception=retry_exceptions,
|
||||
on_success=on_success_handler,
|
||||
on_failure=on_failure_handler,
|
||||
log_name=f"ScheduledJob-{schedule.id}-{schedule.group_id or 'global'}",
|
||||
)
|
||||
|
||||
decorated_executor = retry_decorator(task_execution_coro)
|
||||
await decorated_executor()
|
||||
else:
|
||||
logger.info(
|
||||
f"插件 '{plugin_name}' 开始为目标 [{group_id or '全局'}] "
|
||||
f"执行定时任务 (ID: {schedule.id})。"
|
||||
)
|
||||
await task_execution_coro()
|
||||
|
||||
except (PausedException, FinishedException, SkippedException) as e:
|
||||
logger.warning(
|
||||
f"定时任务 {schedule.id} (目标: {group_id}) 被中断: {type(e).__name__}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"执行定时任务 {schedule.id} (目标: {group_id}) "
|
||||
f"时发生未被策略处理的最终错误",
|
||||
e=e,
|
||||
)
|
||||
|
||||
|
||||
async def _execute_job(schedule_id: int):
|
||||
"""
|
||||
APScheduler 调度的入口函数。
|
||||
根据 schedule_id 处理特定任务、所有群组任务或全局任务。
|
||||
APScheduler 调度的入口函数,现在作为分发器。
|
||||
"""
|
||||
from .repository import ScheduleRepository
|
||||
from .service import scheduler_manager
|
||||
@@ -36,12 +159,10 @@ async def _execute_job(schedule_id: int):
|
||||
logger.warning(f"定时任务 {schedule_id} 不存在或已禁用,跳过执行。")
|
||||
return
|
||||
|
||||
plugin_name = schedule.plugin_name
|
||||
|
||||
task_meta = scheduler_manager._registered_tasks.get(plugin_name)
|
||||
if not task_meta:
|
||||
if schedule.plugin_name not in scheduler_manager._registered_tasks:
|
||||
logger.error(
|
||||
f"无法执行定时任务:插件 '{plugin_name}' 未注册或已卸载。将禁用该任务。"
|
||||
f"无法执行定时任务:插件 '{schedule.plugin_name}' "
|
||||
f"未注册或已卸载。将禁用该任务。"
|
||||
)
|
||||
schedule.is_enabled = False
|
||||
await ScheduleRepository.save(schedule, update_fields=["is_enabled"])
|
||||
@@ -51,142 +172,68 @@ async def _execute_job(schedule_id: int):
|
||||
return
|
||||
|
||||
try:
|
||||
if schedule.bot_id:
|
||||
bot = nonebot.get_bot(schedule.bot_id)
|
||||
else:
|
||||
bot = nonebot.get_bot()
|
||||
logger.debug(
|
||||
f"任务 {schedule_id} 未关联特定Bot,使用默认Bot {bot.self_id}"
|
||||
)
|
||||
except KeyError:
|
||||
bot = (
|
||||
nonebot.get_bot(schedule.bot_id)
|
||||
if schedule.bot_id
|
||||
else nonebot.get_bot()
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
logger.warning(
|
||||
f"定时任务 {schedule_id} 需要的 Bot {schedule.bot_id} "
|
||||
f"不在线,本次执行跳过。"
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
logger.warning(f"当前没有Bot在线,定时任务 {schedule_id} 跳过。")
|
||||
return
|
||||
|
||||
if schedule.group_id == scheduler_manager.ALL_GROUPS:
|
||||
await _execute_for_all_groups(schedule, task_meta, bot)
|
||||
else:
|
||||
await _execute_for_single_target(schedule, task_meta, bot)
|
||||
finally:
|
||||
scheduler_manager._running_tasks.discard(schedule_id)
|
||||
|
||||
|
||||
async def _execute_for_all_groups(schedule: ScheduleInfo, task_meta: dict, bot):
|
||||
"""为所有群组执行任务,并处理优先级覆盖。"""
|
||||
plugin_name = schedule.plugin_name
|
||||
|
||||
concurrency_limit = Config.get_config(
|
||||
"SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5
|
||||
)
|
||||
if not isinstance(concurrency_limit, int) or concurrency_limit <= 0:
|
||||
logger.warning(
|
||||
f"无效的定时任务并发限制配置 '{concurrency_limit}',将使用默认值 5。"
|
||||
)
|
||||
concurrency_limit = 5
|
||||
|
||||
logger.info(
|
||||
f"开始执行针对 [所有群组] 的任务 "
|
||||
f"(ID: {schedule.id}, 插件: {plugin_name}, Bot: {bot.self_id}),"
|
||||
f"并发限制: {concurrency_limit}"
|
||||
)
|
||||
|
||||
all_gids = set()
|
||||
try:
|
||||
group_list, _ = await PlatformUtils.get_group_list(bot)
|
||||
all_gids.update(
|
||||
g.group_id for g in group_list if g.group_id and not g.channel_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"为 'all' 任务获取 Bot {bot.self_id} 的群列表失败", e=e)
|
||||
return
|
||||
|
||||
specific_tasks_gids = set(
|
||||
await ScheduleInfo.filter(
|
||||
plugin_name=plugin_name, group_id__in=list(all_gids)
|
||||
).values_list("group_id", flat=True)
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(concurrency_limit)
|
||||
|
||||
async def worker(gid: str):
|
||||
"""使用 Semaphore 包装单个群组的任务执行"""
|
||||
await asyncio.sleep(random.uniform(0, 59))
|
||||
async with semaphore:
|
||||
temp_schedule = copy.deepcopy(schedule)
|
||||
temp_schedule.group_id = gid
|
||||
await _execute_for_single_target(temp_schedule, task_meta, bot)
|
||||
await asyncio.sleep(random.uniform(0.1, 0.5))
|
||||
|
||||
tasks_to_run = []
|
||||
for gid in all_gids:
|
||||
if gid in specific_tasks_gids:
|
||||
logger.debug(f"群组 {gid} 已有特定任务,跳过 'all' 任务的执行。")
|
||||
continue
|
||||
tasks_to_run.append(worker(gid))
|
||||
|
||||
if tasks_to_run:
|
||||
await asyncio.gather(*tasks_to_run)
|
||||
|
||||
|
||||
async def _execute_for_single_target(schedule: ScheduleInfo, task_meta: dict, bot):
|
||||
"""为单个目标(具体群组或全局)执行任务。"""
|
||||
|
||||
plugin_name = schedule.plugin_name
|
||||
group_id = schedule.group_id
|
||||
|
||||
try:
|
||||
is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id)
|
||||
if is_blocked:
|
||||
target_desc = f"群 {group_id}" if group_id else "全局"
|
||||
logger.info(
|
||||
f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}]"
|
||||
"因功能被禁用而跳过执行。"
|
||||
concurrency_limit = Config.get_config(
|
||||
"SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5
|
||||
)
|
||||
return
|
||||
if not isinstance(concurrency_limit, int) or concurrency_limit <= 0:
|
||||
concurrency_limit = 5
|
||||
|
||||
max_retries = Config.get_config("SchedulerManager", "JOB_MAX_RETRIES", 2)
|
||||
retry_delay = Config.get_config("SchedulerManager", "JOB_RETRY_DELAY", 10)
|
||||
logger.info(
|
||||
f"开始执行针对 [所有群组] 的任务 (ID: {schedule.id}, "
|
||||
f"插件: {schedule.plugin_name}, Bot: {bot.self_id}),"
|
||||
f"并发限制: {concurrency_limit}"
|
||||
)
|
||||
|
||||
@Retry.simple(
|
||||
stop_max_attempt=max_retries + 1,
|
||||
wait_fixed_seconds=retry_delay,
|
||||
log_name=f"定时任务执行:{schedule.plugin_name}",
|
||||
)
|
||||
async def _execute_task_with_retry():
|
||||
task_func = task_meta["func"]
|
||||
job_kwargs = schedule.job_kwargs
|
||||
if not isinstance(job_kwargs, dict):
|
||||
logger.error(
|
||||
f"任务 {schedule.id} 的 job_kwargs 不是字典类型: {type(job_kwargs)}"
|
||||
)
|
||||
try:
|
||||
group_list, _ = await PlatformUtils.get_group_list(bot)
|
||||
all_gids = {
|
||||
g.group_id for g in group_list if g.group_id and not g.channel_id
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"为 'all' 任务获取 Bot {bot.self_id} 的群列表失败", e=e)
|
||||
return
|
||||
|
||||
sig = inspect.signature(task_func)
|
||||
if "bot" in sig.parameters:
|
||||
job_kwargs["bot"] = bot
|
||||
specific_tasks_gids = set(
|
||||
await ScheduledJob.filter(
|
||||
plugin_name=schedule.plugin_name, group_id__in=list(all_gids)
|
||||
).values_list("group_id", flat=True)
|
||||
)
|
||||
|
||||
await task_func(group_id, **job_kwargs)
|
||||
semaphore = asyncio.Semaphore(concurrency_limit)
|
||||
|
||||
try:
|
||||
async def worker(gid: str):
|
||||
await asyncio.sleep(random.uniform(0.1, 1.0))
|
||||
async with semaphore:
|
||||
temp_schedule = copy.deepcopy(schedule)
|
||||
temp_schedule.group_id = gid
|
||||
await _execute_single_job_instance(temp_schedule, bot)
|
||||
|
||||
tasks_to_run = [
|
||||
worker(gid) for gid in all_gids if gid not in specific_tasks_gids
|
||||
]
|
||||
|
||||
if tasks_to_run:
|
||||
await asyncio.gather(*tasks_to_run)
|
||||
logger.info(
|
||||
f"插件 '{schedule.plugin_name}' 开始为目标 "
|
||||
f"[{schedule.group_id or '全局'}] 执行定时任务 (ID: {schedule.id})。"
|
||||
f"针对 [所有群组] 的任务 (ID: {schedule.id}) 执行完毕,"
|
||||
f"共处理 {len(tasks_to_run)} 个群组。"
|
||||
)
|
||||
await _execute_task_with_retry()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"执行定时任务 (ID: {schedule.id}, 插件: {schedule.plugin_name}, "
|
||||
f"目标: {schedule.group_id or '全局'}) 在所有重试后最终失败",
|
||||
e=e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"执行定时任务 (ID: {schedule.id}, 插件: {plugin_name}, "
|
||||
f"目标: {group_id or '全局'}) 时发生异常",
|
||||
e=e,
|
||||
)
|
||||
|
||||
else:
|
||||
await _execute_single_job_instance(schedule, bot)
|
||||
|
||||
finally:
|
||||
scheduler_manager._running_tasks.discard(schedule_id)
|
||||
|
||||
Reference in New Issue
Block a user