提交 bdac1140 authored 作者: 陈泽健's avatar 陈泽健

fix(scheduler): 定时任务立即执行未生成执行记录 + 取消即停中断后台线程

5.44 前端「定时任务」页点击执行后,执行中心始终不生成新执行记录。
根因:自动调度触发后 task_id 卡住 _running_tasks,所有手动点击被静默吞掉
(return None),返回 200 但无新 execution。取消执行也不中断 Playwright 线程,
run_execution 阻塞在 worker_thread.join() 上,全局锁数小时不释放。

1. **scheduler_service.py** — run_task_once(task_id, manual=False) 新增
   manual 参数,手动触发绕过任务级 _running_tasks 守卫(仍受全局锁约束)

2. **scheduled_tasks.py** — POST /{task_id}/run 改为同步预检:
   全局锁占用 → 返回 status:skipped + running_execution_id;
   空闲 → manual=True 触发,返回 status:triggered

3. **execution_service.py** — 取消信号机制:
   request_cancel / is_cancel_requested / clear_cancel_requested 线程安全函数;
   cancel_execution 置 cancelled 后调用 request_cancel;
   run_all_cases_sync 用例循环开头检查并中断剩余用例;
   实时进度回调 _persist_case_result 按用例 commit

4. **playwright_executor.py** — 步骤边界检查取消请求:
   execute_step 重试循环开头查 is_cancel_requested → 置 _cancel_requested
   → break 退出重试;最终状态设为 skipped

5.44+5.202 已部署(deploy_scheduled_fix.py),容器 healthy
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 687deef2
此差异已折叠。
...@@ -70,6 +70,26 @@ ...@@ -70,6 +70,26 @@
"fingerprint": [ "fingerprint": [
{"selector": ".meeting_list", "min_count": 1} {"selector": ".meeting_list", "min_count": 1}
] ]
},
{
"id": "statistics",
"name": "数据统计",
"url": "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FStatisticsModule",
"url_patterns": [
"StatisticsModule"
],
"fingerprint": [
{"selector": "text=预定会议数据", "min_count": 1}
],
"match_rules": [
{"type": "step_name_contains", "value": "预定数据"},
{"type": "selector_contains", "value": "预定数据"},
{"type": "step_name_contains", "value": "数据统计"}
],
"skip_steps": {
"step_name_contains": ["预定数据", "数据统计"],
"action_is": ["click"]
}
} }
] ]
} }
...@@ -202,6 +202,11 @@ class PlaywrightExecutor: ...@@ -202,6 +202,11 @@ class PlaywrightExecutor:
# 语义化执行:当前页面 scope(页面注册表 id,登录/导航后同步,仅记录) # 语义化执行:当前页面 scope(页面注册表 id,登录/导航后同步,仅记录)
self._current_scope: Optional[str] = None self._current_scope: Optional[str] = None
# 取消请求标记:由 run_all_cases_sync 在创建执行器后设置执行 ID,
# execute_step 在每次重试前检查 is_cancel_requested 并提前退出
self._execution_id: Optional[str] = None
self._cancel_requested: bool = False
# 确保截图目录存在 # 确保截图目录存在
os.makedirs(self.screenshot_dir, exist_ok=True) os.makedirs(self.screenshot_dir, exist_ok=True)
...@@ -1265,12 +1270,29 @@ class PlaywrightExecutor: ...@@ -1265,12 +1270,29 @@ class PlaywrightExecutor:
attempt = 0 attempt = 0
max_attempts = self.step_retry_count + 1 # 至少执行一次 max_attempts = self.step_retry_count + 1 # 至少执行一次
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
# 取消请求标志(由 cancel_execution → request_cancel 在 execute_step 之间置位)
self._cancel_requested = False
while attempt < max_attempts: while attempt < max_attempts:
attempt += 1 attempt += 1
step_result.status = "running" step_result.status = "running"
step_result.error = None step_result.error = None
# ★ 取消请求检查(步骤边界):置位后立即中断,避免重试放大器
try:
from app.services.execution_service import is_cancel_requested
if is_cancel_requested(self._execution_id or ""):
self._cancel_requested = True
except Exception:
pass
if self._cancel_requested:
logger.info(
f"步骤 {step_result.order} 检测到取消请求,中断执行 "
f"(case={self._ctx.case_name if self._ctx else ''})"
)
break
try: try:
logger.debug(f"⏳ 执行步骤 {step_result.order}: {step_result.name} (action={action}, attempt={attempt}/{max_attempts})") logger.debug(f"⏳ 执行步骤 {step_result.order}: {step_result.name} (action={action}, attempt={attempt}/{max_attempts})")
...@@ -1402,6 +1424,12 @@ class PlaywrightExecutor: ...@@ -1402,6 +1424,12 @@ class PlaywrightExecutor:
elif step_result.status != "passed": elif step_result.status != "passed":
step_result.status = "failed" step_result.status = "failed"
# 取消请求中断:标记步骤为 skipped(不走失败重试/截图),
# 由 execute_case 在回填主结果时识别并保持 cancelled 语境
if self._cancel_requested:
step_result.status = "skipped"
step_result.log = "⏹ 检测到取消请求,中断执行"
# 结束计时 # 结束计时
step_result.end_time = datetime.now() step_result.end_time = datetime.now()
step_result.duration = (step_result.end_time - start_time).total_seconds() step_result.duration = (step_result.end_time - start_time).total_seconds()
......
...@@ -249,12 +249,15 @@ async def run_task_now( ...@@ -249,12 +249,15 @@ async def run_task_now(
后台异步创建执行记录并运行,执行完成后自动生成报告。 后台异步创建执行记录并运行,执行完成后自动生成报告。
若当前已有 UI 执行任务在运行,返回跳过状态(不创建执行记录),
前端可据此提示用户"请等待当前执行完成"。
Args: Args:
task_id (str): 任务ID task_id (str): 任务ID
db (AsyncSession): 数据库会话 db (AsyncSession): 数据库会话
Returns: Returns:
dict: 触发结果 dict: 触发结果,含 status 字段(triggered / skipped)
""" """
try: try:
result = await db.execute( result = await db.execute(
...@@ -264,11 +267,27 @@ async def run_task_now( ...@@ -264,11 +267,27 @@ async def run_task_now(
if not task: if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}") raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
# 后台异步执行(不阻塞请求) # 预检全局执行锁:已有 UI 执行在运行 → 返回明确跳过状态
asyncio.create_task(run_task_once(task_id)) from app.services.execution_service import is_execution_running
is_running, running_id = is_execution_running()
if is_running:
logger.info(
f"[定时任务] 手动触发跳过:已有执行 {running_id} 正在运行"
)
return {
"message": f"已有执行 {running_id} 正在运行,请等待完成后再执行",
"task_id": task_id,
"status": "skipped",
"running_execution_id": running_id,
}
# 后台异步执行(manual=True 绕过任务级去重守卫)
asyncio.create_task(run_task_once(task_id, manual=True))
return { return {
"message": f"任务「{task.name}」已触发执行", "message": f"任务「{task.name}」已触发执行",
"task_id": task_id, "task_id": task_id,
"status": "triggered",
} }
except HTTPException: except HTTPException:
raise raise
......
...@@ -235,7 +235,7 @@ async def _run_security_task_once( ...@@ -235,7 +235,7 @@ async def _run_security_task_once(
return execution.id return execution.id
async def run_task_once(task_id: str) -> Optional[str]: async def run_task_once(task_id: str, manual: bool = False) -> Optional[str]:
""" """
执行一次定时任务(创建执行记录 → 运行 → 自动生成报告 → 推进下次调度) 执行一次定时任务(创建执行记录 → 运行 → 自动生成报告 → 推进下次调度)
...@@ -244,11 +244,15 @@ async def run_task_once(task_id: str) -> Optional[str]: ...@@ -244,11 +244,15 @@ async def run_task_once(task_id: str) -> Optional[str]:
Args: Args:
task_id (str): 定时任务 ID task_id (str): 定时任务 ID
manual (bool): 是否为手动触发。手动触发时绕过任务级去重守卫
(`_running_tasks` 检查),但仍受全局执行锁约束。
同一任务自动调度与手动触发互不阻塞。
Returns: Returns:
Optional[str]: 本次创建的执行记录 ID;跳过/失败时返回 None Optional[str]: 本次创建的执行记录 ID;跳过/失败时返回 None
""" """
if task_id in _running_tasks: # 仅自动调度走任务级去重;手动触发允许跳过(用户意图就是"再跑一次")
if not manual and task_id in _running_tasks:
logger.info(f"[定时任务] 任务 {task_id} 正在执行中,忽略重复触发") logger.info(f"[定时任务] 任务 {task_id} 正在执行中,忽略重复触发")
return None return None
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论