fix(sync): 卡死判定改进度停滞 + 协作式取消 + 执行槽所有权

- reap 判定从总时长一刀切改为进度停滞(心跳 last_progress_at),
  慢带宽冷启动不再被误杀; 12h 总时长硬上限兜底
- 协作式取消: JobCancelledError(BaseException) 经分块进度回调穿透,
  僵尸线程在下一分块自行退出, UI 状态与执行体对齐
- 执行槽带所有权 token, 僵尸 finally 不得误释放新任务的槽
- 手动取消端点同样走协作式终止
- 前端超时卡片文案对齐停滞语义, 失败提示引导调整阈值
- 新增 10 个回归测试(972 passed)
This commit is contained in:
shy3130
2026-08-23 14:57:12 +08:00
parent 45bdbcbe65
commit a6a4bcde43
7 changed files with 389 additions and 69 deletions
+24 -12
View File
@@ -918,7 +918,7 @@ async def sync_minute(request: Request):
"""
import asyncio
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import JobCancelledError, job_store, release_run_slot, try_acquire_run_slot
from app.api.data import invalidate_storage_cache
from app.services.preferences import get_minute_sync_days
from app.tickflow.capabilities import Cap
@@ -946,7 +946,7 @@ async def sync_minute(request: Request):
return {"status": "reused", "job_id": job_id}
async def task() -> None:
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
loop = asyncio.get_event_loop()
@@ -997,11 +997,14 @@ async def sync_minute(request: Request):
progress("done", 100, f"分钟 K 同步完成,{written}")
job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)})
invalidate_storage_cache()
except JobCancelledError:
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
invalidate_storage_cache()
except Exception as e: # noqa: BLE001
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot()
release_run_slot(job_id)
asyncio.create_task(task())
return {"status": "started", "job_id": job_id}
@@ -1119,7 +1122,7 @@ async def extend_history(request: Request):
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
from app.services.extend_history import run_extend_history
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import JobCancelledError, job_store, release_run_slot, try_acquire_run_slot
from app.api.data import invalidate_storage_cache
job_id, is_new = job_store.create()
@@ -1127,7 +1130,7 @@ async def extend_history(request: Request):
return {"status": "reused", "job_id": job_id}
async def task() -> None:
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
loop = asyncio.get_event_loop()
@@ -1148,12 +1151,15 @@ async def extend_history(request: Request):
else:
job_store.succeed(job_id, result)
invalidate_storage_cache()
except JobCancelledError:
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
invalidate_storage_cache()
except Exception as e:
logger.exception("extend_history failed: job_id=%s", job_id)
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot()
release_run_slot(job_id)
asyncio.create_task(task())
return {"status": "started", "job_id": job_id}
@@ -1198,7 +1204,7 @@ async def repair_daily(request: Request):
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
from app.services.repair_daily import run_repair_daily
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import JobCancelledError, job_store, release_run_slot, try_acquire_run_slot
from app.api.data import invalidate_storage_cache
job_id, is_new = job_store.create()
@@ -1206,7 +1212,7 @@ async def repair_daily(request: Request):
return {"status": "reused", "job_id": job_id}
async def task() -> None:
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
loop = asyncio.get_event_loop()
@@ -1232,12 +1238,15 @@ async def repair_daily(request: Request):
else:
job_store.succeed(job_id, result)
invalidate_storage_cache()
except JobCancelledError:
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
invalidate_storage_cache()
except Exception as e:
logger.exception("repair_daily failed: job_id=%s", job_id)
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot()
release_run_slot(job_id)
asyncio.create_task(task())
return {"status": "started", "job_id": job_id}
@@ -1258,7 +1267,7 @@ async def rebuild_enriched(request: Request):
try:
repo = request.app.state.repo
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import JobCancelledError, job_store, release_run_slot, try_acquire_run_slot
from app.api.data import invalidate_storage_cache
job_id, is_new = job_store.create()
@@ -1266,7 +1275,7 @@ async def rebuild_enriched(request: Request):
return {"status": "reused", "job_id": job_id}
async def task() -> None:
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
loop = asyncio.get_event_loop()
@@ -1314,12 +1323,15 @@ async def rebuild_enriched(request: Request):
"enriched_rows": written,
})
invalidate_storage_cache()
except JobCancelledError:
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
invalidate_storage_cache()
except Exception as e:
logger.exception("rebuild_enriched failed: job_id=%s", job_id)
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot()
release_run_slot(job_id)
asyncio.create_task(task())
return {"status": "started", "job_id": job_id}
+16 -7
View File
@@ -8,7 +8,12 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from app.jobs import daily_pipeline
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import (
JobCancelledError,
job_store,
release_run_slot,
try_acquire_run_slot,
)
from app.api.data import invalidate_storage_cache
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
@@ -24,7 +29,7 @@ async def run_now(request: Request) -> dict:
"""异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。
若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。
但如果该任务已运行超过 10 分钟 (可能因 reload 卡死), 强制标记为失败后重新创建
卡死判定按「进度停滞」而非总时长(慢带宽下长任务不会被误杀), 见 reap_stale
"""
repo = request.app.state.repo
capset = request.app.state.capabilities
@@ -41,7 +46,7 @@ async def run_now(request: Request) -> dict:
# 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU)
async def task() -> None:
# 重任务执行槽: 防僵尸并发(reap 后线程仍活时新任务不得并行写 parquet)
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
return
# 管道运行期间暂停实时行情取数, 防止覆写同一批 parquet 竞态
@@ -64,12 +69,16 @@ async def run_now(request: Request) -> dict:
job_store.succeed(job_id, result)
invalidate_storage_cache()
repo.refresh_cache() # 刷新 Polars 缓存
except JobCancelledError:
# 已被 reap/手动取消终止: job 状态已由 terminate() 写为 failed,
# 拉取线程在分块回调处自行退出, 这里无需(也无法)再写状态。
logger.warning("pipeline job %s cancelled", job_id)
except Exception as e: # noqa: BLE001
logger.exception("pipeline failed")
job_store.fail(job_id, str(e))
invalidate_storage_cache()
finally:
release_run_slot()
release_run_slot(job_id)
asyncio.create_task(task())
return {"job_id": job_id, "reused": False}
@@ -77,7 +86,7 @@ async def run_now(request: Request) -> dict:
@router.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
# 每次轮询都检查卡死 job — 前端每秒轮询,STALE_JOB_TIMEOUT_S(10min)后必定自愈,
# 每次轮询都检查卡死 job — 前端持续轮询, 进度停滞超阈值后必定自愈,
# 无需用户再次手动点「同步」。
job_store.reap_stale()
j = job_store.get(job_id)
@@ -88,13 +97,13 @@ def get_job(job_id: str) -> dict:
@router.post("/jobs/{job_id}/cancel")
def cancel_job(job_id: str) -> dict:
"""手动取消一个 running 的 job。"""
"""手动取消一个 running 的 job(协作式: 拉取线程在当前分块完成后自行退出)"""
j = job_store.get(job_id)
if not j:
raise HTTPException(status_code=404, detail="job not found")
if j["status"] not in ("running", "pending"):
raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel")
job_store.fail(job_id, "用户手动取消")
job_store.terminate(job_id, "用户手动取消")
return {"cancelled": job_id}
+6 -3
View File
@@ -690,13 +690,13 @@ def _run_tracked(fn, job_label: str) -> bool:
重任务执行槽: 再挡一层僵尸并发(reap 后线程仍活时不得并行写 parquet)。
返回 True 仅表示任务已成功并且执行槽已释放。
"""
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
from app.services.pipeline_jobs import JobCancelledError, job_store, release_run_slot, try_acquire_run_slot
job_id, is_new = job_store.create()
if not is_new:
logger.info("scheduled %s 跳过: 已有活跃任务在运行 (job_id=%s)", job_label, job_id)
return False
if not try_acquire_run_slot():
if not try_acquire_run_slot(job_id):
logger.warning("scheduled %s 跳过: 重任务执行槽被占用(疑似上次任务卡死)", job_label)
job_store.fail(job_id, f"scheduled {job_label} skipped: 已有数据任务在运行")
return False
@@ -712,11 +712,14 @@ def _run_tracked(fn, job_label: str) -> bool:
job_store.succeed(job_id, result)
succeeded = True
logger.info("scheduled %s completed: job_id=%s", job_label, job_id)
except JobCancelledError:
# 已由 terminate() 标记失败(卡死/手动取消), 拉取线程在分块回调处自行退出
logger.warning("scheduled %s cancelled: job_id=%s", job_label, job_id)
except Exception:
logger.exception("scheduled %s failed: job_id=%s", job_label, job_id)
job_store.fail(job_id, f"scheduled {job_label} failed")
finally:
release_run_slot()
release_run_slot(job_id)
return succeeded
+165 -42
View File
@@ -23,21 +23,72 @@ logger = logging.getLogger(__name__)
JobStatus = Literal["pending", "running", "succeeded", "failed"]
# 运行超过此秒数视为卡死(reload 后孤儿 task / 网络读无限阻塞等)。
# 由 reap_stale() 在 /run 和 /jobs/{id} 轮询端点检查 — 保证卡死后能自愈,
# 无需用户再次点击「同步」。
# 卡死判定阈值(秒)。语义是「进度停滞」而非「总时长」:
# running 期间只要 progress() 还在上报(每个分块都会回调), 就永远不算卡死 ——
# 慢带宽/高延迟环境下的冷启动全市场拉取可能远超 20 分钟, 但只要分块在推进,
# 不应被误杀(reap 旧实现按总时长一刀切, 导致 UI 标记失败而拉取线程仍在写盘)。
#
# 默认超时阈值按任务类型区分,可在 Web 数据源设置中调整:
# - 普通任务(日K管道/扩展/修正/重算): 1200s (20 分钟)
# - 长任务(分钟K全市场同步,数据量是日K的 ~240 倍): 1800s (30 分钟)
# 分钟K即使流式落盘后仍可能跑十几到数十分钟(限速 sleep 是主因),
# 用 600s 会误杀正常任务并留下写盘僵尸线程。
# 阈值按任务类型区分,可在 Web 数据源设置中调整:
# - 普通任务(日K管道/扩展/修正/重算): 1200s (20 分钟无进度)
# - 长任务(分钟K全市场同步,数据量是日K的 ~240 倍): 1800s (30 分钟无进度)
DEFAULT_JOB_TIMEOUT_S = 1200
LONG_JOB_TIMEOUT_S = 1800
# 总时长硬上限(兜底): 进度回调持续上报但永不结束的病态循环无法靠停滞判定捕获,
# 超过该值无条件终止。取 12h(最长合法任务分钟K补齐的历史量级远小于此)。
HARD_JOB_TIMEOUT_S = 12 * 3600
# 向后兼容: 旧调用方引用 STALE_JOB_TIMEOUT_S
STALE_JOB_TIMEOUT_S = DEFAULT_JOB_TIMEOUT_S
class JobCancelledError(BaseException):
"""任务已被取消(reap 判定卡死后自动取消,或未来的手动取消)。
继承 BaseException 而非 Exception(对齐 asyncio.CancelledError 的设计):
同步循环内部的分块异常隔离(``except Exception: continue``)不得吞掉取消信号,
它必须从 executor 线程一路传播回 API 边界的 task()。API 层应有独立
``except JobCancelledError`` 分支(job 此时已被 reap 标记 failed,无需再写状态)。
"""
def __init__(self, job_id: str) -> None:
super().__init__(f"job {job_id} 已取消")
self.job_id = job_id
# ── 取消标志注册表 ──────────────────────────────────────────────────────
# reap 终止 job 时置位; 僵尸线程随后每次 progress() 回调检查到即抛
# JobCancelledError 自行退出。flag 独立于 job 记录存活 —— fail() 会把记录从
# _active_jobs 弹出, 但僵尸线程仍需通过 flag 感知取消。
# 有界(最多 _CANCEL_FLAG_MAX 条, 淘汰最老): 真卡死的僵尸永远不会回来清 flag。
_CANCEL_FLAG_MAX = 32
_CANCEL_FLAGS: dict[str, threading.Event] = {}
_CANCEL_FLAGS_LOCK = threading.Lock()
def request_cancel(job_id: str) -> bool:
"""请求取消指定 job。返回是否存在该 job 的 flag。"""
with _CANCEL_FLAGS_LOCK:
ev = _CANCEL_FLAGS.get(job_id)
if ev is None:
return False
ev.set()
return True
def is_cancelled(job_id: str) -> bool:
ev = _CANCEL_FLAGS.get(job_id)
return ev is not None and ev.is_set()
def _register_cancel_flag(job_id: str) -> None:
with _CANCEL_FLAGS_LOCK:
if job_id not in _CANCEL_FLAGS:
_CANCEL_FLAGS[job_id] = threading.Event()
# 有界淘汰最老(当前活跃 job 总是最新注册, 不会被误淘汰)
while len(_CANCEL_FLAGS) > _CANCEL_FLAG_MAX:
oldest = next(iter(_CANCEL_FLAGS))
_CANCEL_FLAGS.pop(oldest)
def _default_store_dir() -> Path:
from app.config import settings
return settings.data_dir / "job_store"
@@ -120,7 +171,7 @@ class JobStore:
is_new=False 表示复用了已有活跃任务,调用方**不得**再调度新的后台任务。
timeout_s: reap_stale 判定卡死的阈值。None 时读取用户配置。
timeout_s: reap_stale 判定「进度停滞卡死的阈值。None 时读取用户配置。
long_running: timeout_s 为 None 时,是否读取长任务配置;普通任务默认
1200s,分钟K全市场同步等长任务默认 1800s。
"""
@@ -146,6 +197,7 @@ class JobStore:
"stage_pct": 0,
"log": [],
"started_at": None,
"last_progress_at": None,
"finished_at": None,
"duration_s": None,
"result": None,
@@ -153,7 +205,8 @@ class JobStore:
"timeout_s": timeout_s,
}
self._active_id = job_id
return job_id, True
_register_cancel_flag(job_id)
return job_id, True
def start(self, job_id: str) -> None:
with self._lock:
@@ -162,6 +215,9 @@ class JobStore:
return
j["status"] = "running"
j["started_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
# 心跳基准初始化为启动时刻: start() 到首次 progress() 之间的
# 初始化阶段(解析标的池等)同样计入停滞计时。
j["last_progress_at"] = j["started_at"]
def succeed(self, job_id: str, result: Any) -> None:
with self._lock:
@@ -199,9 +255,15 @@ class JobStore:
with self._lock:
j = self._active_jobs.get(job_id)
if not j:
# 记录已不在(通常是被 reap 终止后 fail() 弹出)。
# 僵尸线程仍需感知取消 —— flag 检查不能依赖记录存在。
cancelled = _CANCEL_FLAGS.get(job_id)
if cancelled is not None and cancelled.is_set():
raise JobCancelledError(job_id)
return
j["stage"] = stage
j["progress"] = max(0, min(100, int(pct)))
j["last_progress_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
if stage_pct is not None:
j["stage_pct"] = max(0, min(100, int(stage_pct)))
elif j["stage"] != stage:
@@ -219,6 +281,11 @@ class JobStore:
j["log"].append(entry)
if len(j["log"]) > 200:
j["log"] = j["log"][-200:]
# 锁外检查取消: reap 可能在本次更新刚结束后置位,下一次回调必然命中;
# 在这里立即检查可以把终止延迟压缩到当次回调。
ev = _CANCEL_FLAGS.get(job_id)
if ev is not None and ev.is_set():
raise JobCancelledError(job_id)
# ===== query =====
@@ -251,13 +318,22 @@ class JobStore:
return self._active_id
def reap_stale(self, timeout_s: int | None = None) -> None:
"""回收运行超过阈值(卡死)的 running job(标记为 failed)
"""回收卡死的 running job。两种判定:
1. 进度停滞(主判定): 距上次 progress() 上报超过阈值秒数。
慢带宽环境下任务只要仍在分块推进就不会被误杀 —— 这正是旧的
总时长判定的问题(冷启动全市场拉取 >20min 就被标死,线程却还在写盘)。
2. 总时长硬上限(兜底): 进度回调持续但永不结束的病态循环。
在 /run 和 /jobs/{id} 轮询端点都会调用 — 保证卡死后任意轮询都能自愈,
无需用户再次手动触发同步。reload 后的孤儿 task(内存里已无 job 记录)
不在此处理:它们没有 active_id,只能靠 executor 线程自然结束或进程重启。
timeout_s: 显式覆盖。None 时用 job 自身 create() 时存的 timeout_s,
终止是**协作式**的: 置 cancel flag → 僵尸线程在下一个分块进度回调处
抛 JobCancelledError 自行退出(BaseException,不会被分块异常隔离吞掉)。
线程真正退出前,由所有权 token 保证它误释放不了新任务的执行槽。
timeout_s: 显式覆盖停滞阈值。None 时用 job 自身 create() 时存的 timeout_s,
缺失则回退 DEFAULT_JOB_TIMEOUT_S。分钟K长任务在 create 时存了更大阈值,
不被普通任务的 1200s 误杀。
"""
@@ -269,31 +345,52 @@ class JobStore:
if not j or j.get("status") != "running":
return
started = j.get("started_at")
last_alive = j.get("last_progress_at") or started
if not started:
return
# 优先用显式传入, 其次 job 自身阈值, 最后默认值
effective_timeout = timeout_s if timeout_s is not None else j.get("timeout_s", DEFAULT_JOB_TIMEOUT_S)
timeout_s = effective_timeout
started_at = started
last_alive_at = last_alive
# 时间计算放到锁外(避免 datetime 解析持锁)。
# started_at 形如 "2026-07-04T12:00:00Z"(start() 用 datetime.utcnow 存)。
# 两端都用 timezone-aware UTC 比较,避免 naive/aware 混用导致 TypeError。
try:
start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
elapsed = (datetime.now(start_dt.tzinfo) - start_dt).total_seconds()
start_dt = _parse_utc(started_at)
alive_dt = _parse_utc(last_alive_at)
now = datetime.now(start_dt.tzinfo)
stalled_s = (now - alive_dt).total_seconds()
total_s = (now - start_dt).total_seconds()
except Exception: # noqa: BLE001
return
if elapsed > effective_timeout:
logger.warning("reap_stale: 强制取消卡死 job %s (已运行 %.0fs, 阈值 %ss)",
jid, elapsed, effective_timeout)
self.fail(jid, f"超时自动取消 (运行 {int(elapsed)}s, 疑似卡死)")
# 强制释放重任务锁: 卡死的线程无法被中断, 锁永远不会自然释放。
# job 已标记 failed, 即使僵尸线程后续写入 parquet, 下次拉取会覆盖, 安全。
try:
_heavy_run_lock.release()
except RuntimeError:
pass
if stalled_s > timeout_s:
logger.warning(
"reap_stale: 强制取消卡死 job %s (进度停滞 %.0fs > 阈值 %ss, 总运行 %.0fs)",
jid, stalled_s, timeout_s, total_s)
self.terminate(jid, f"超时自动取消: 进度停滞 {int(stalled_s)}s 超过阈值 {timeout_s}s,已请求终止")
elif total_s > HARD_JOB_TIMEOUT_S:
logger.warning(
"reap_stale: 强制取消 job %s (总运行 %.0fs 超过硬上限 %ss)",
jid, total_s, HARD_JOB_TIMEOUT_S)
self.terminate(jid, f"超时自动取消: 总运行 {int(total_s)}s 超过硬上限,已请求终止")
def terminate(self, job_id: str, message: str) -> None:
"""标记失败 + 请求协作式终止 + 强制释放执行槽(带所有权)。
reap_stale(判定卡死)与手动取消端点共用。
"""
# 先置 cancel flag 再标失败: fail() 弹出记录后,僵尸线程的 progress()
# 依赖 flag(而非记录)感知取消。
request_cancel(job_id)
self.fail(job_id, message)
# 强制释放重任务槽(按所有权): 卡死线程可能永远回不来释放。
# job 已标记 failed 且已请求终止; 僵尸线程即使后续短暂写盘,
# 也会在下一个分块回调处自行退出, 下次拉取会覆盖, 安全。
release_run_slot(job_id)
def clear(self) -> None:
"""清空所有任务(内存 + 磁盘文件)。"""
"""清空所有任务(内存 + 磁盘文件 + 取消标志)。"""
with self._lock:
self._active_jobs.clear()
self._active_id = None
@@ -302,6 +399,8 @@ class JobStore:
f.unlink()
except Exception:
pass
with _CANCEL_FLAGS_LOCK:
_CANCEL_FLAGS.clear()
def _summary(j: dict[str, Any]) -> dict[str, Any]:
@@ -335,29 +434,53 @@ job_store = JobStore()
# ================================================================
# 重任务互斥 — 防「僵尸并发」
# 重任务互斥执行槽 — 防「僵尸并发」, 带所有权 token
# ================================================================
# create() 的单飞去重能挡住 pending/running 窗口内的重复点击, 但挡不住
# reap_stale 把卡死 job 标记 failed、清掉 _active_id 之后 —— 此时 executor
# 线程仍在跑(线程无法被中断), 下一次 /run 会视作无活跃任务而另起一条,
# 线程可能仍在跑(线程无法被中断), 下一次 /run 会视作无活跃任务而另起一条,
# 与僵尸线程并发读改写同一 parquet。
#
# 该绑定「实际执行体(协程/线程)」的生命周期而非 job 状态: 每个重任务在真正
# 开跑前 try_acquire_run_slot(), 结束(含异常)在 finally 里 release_run_slot()。
# 僵尸任务因卡在 executor await 中始终未 release, 新任务 try_acquire 失败 → 快速
# 失败而非并发执行。代价: 真卡死时需重启进程才能再次跑重任务(优先保证数据不损坏)。
_heavy_run_lock = threading.Lock()
# 该绑定「实际执行体」的生命周期而非 job 状态: 每个重任务在真正开跑前
# try_acquire_run_slot(job_id), 结束(含异常)在 finally 里 release_run_slot(job_id)。
#
# 所有权 token 修复的竞态: 旧实现用裸 threading.Lock + 无参 release ——
# 僵尸线程最终结束时会在 finally 里误释放**新任务**正持有的锁(Lock 允许
# 任意线程 release), 第三次点击又能插入并发。现在 release 必须携带持有者
# job_id, 非持有者的释放一律忽略; reap 的强制释放也走同一入口。
# 代价: 真卡死且协作式终止不生效(如卡在单个无限阻塞的网络读里)时,
# 需重启进程才能再次跑重任务(优先保证数据不损坏)。
_run_slot_lock = threading.Lock()
_run_slot_owner: str | None = None
def try_acquire_run_slot() -> bool:
"""尝试占用重任务执行槽(非阻塞)。成功返回 True"""
return _heavy_run_lock.acquire(blocking=False)
def try_acquire_run_slot(owner: str = "") -> bool:
"""尝试占用重任务执行槽(非阻塞)。成功返回 True 并记录持有者。
owner: 持有者标识(调用方传 job_id), 供 release_run_slot 校验所有权。
"""
global _run_slot_owner
with _run_slot_lock:
if _run_slot_owner is not None:
return False
_run_slot_owner = owner
return True
def release_run_slot() -> None:
"""释放重任务执行槽(允许跨线程释放)。"""
try:
_heavy_run_lock.release()
except RuntimeError:
# 未持有(重复释放)—— 幂等忽略
pass
def release_run_slot(owner: str | None = None) -> None:
"""释放重任务执行槽
owner=None 时无条件释放(兼容旧调用/测试);
owner 非 None 时仅当它是当前持有者才释放 —— 僵尸线程 finally 里的
误释放(持有者已换成新 job 或槽已被 reap 释放)会被忽略, 幂等不抛。
"""
global _run_slot_owner
with _run_slot_lock:
if owner is not None and _run_slot_owner is not None and _run_slot_owner != owner:
return
_run_slot_owner = None
def _parse_utc(ts: str) -> datetime:
"""解析 start()/progress() 存的 "2026-07-04T12:00:00Z" 形式时间戳。"""
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
+167
View File
@@ -0,0 +1,167 @@
"""回归测试: 卡死判定从「总时长一刀切」改为「进度停滞」+ 协作式取消 + 执行槽所有权。
背景(用户反馈): 慢带宽环境冷启动全市场拉取超过 20 分钟被误标失败,
拉取线程(僵尸)仍在写盘, UI 状态与实际不对齐; 重复点击还可能撞执行锁。
均为纯逻辑, 不触网。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.services import pipeline_jobs, preferences
from app.services.pipeline_jobs import JobCancelledError, JobStore
def _iso(dt: datetime) -> str:
return dt.isoformat(timespec="seconds")
def _now() -> datetime:
return datetime.now(timezone.utc)
@pytest.fixture(autouse=True)
def _reset_module_globals():
"""取消标志与执行槽是模块级单例, 每个用例前后复位, 避免相互污染。"""
pipeline_jobs._CANCEL_FLAGS.clear()
pipeline_jobs._run_slot_owner = None
yield
pipeline_jobs._CANCEL_FLAGS.clear()
pipeline_jobs._run_slot_owner = None
def _make_running_job(store: JobStore, timeout_s: int) -> str:
jid, _ = store.create(timeout_s=timeout_s)
store.start(jid)
return jid
# ── 进度停滞判定 ────────────────────────────────────────────────────────
def test_stalled_job_is_reaped(monkeypatch, tmp_path):
"""无进度上报超过阈值 → 标记失败 + 置取消标志 + 释放执行槽。"""
monkeypatch.setattr(preferences, "load", lambda: {})
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
# 启动后 5 分钟无任何进度 → 停滞 300s > 60s
stale = _iso(_now() - timedelta(minutes=5))
store._active_jobs[jid]["started_at"] = stale
store._active_jobs[jid]["last_progress_at"] = stale
assert pipeline_jobs.try_acquire_run_slot(jid) is True
store.reap_stale()
j = store.get(jid)
assert j["status"] == "failed"
assert "进度停滞" in j["error"]
# 协作式取消: 僵尸线程通过 flag 感知(记录已被 fail 弹出, flag 仍在)
assert pipeline_jobs.is_cancelled(jid)
# 执行槽已按所有权释放
assert pipeline_jobs.try_acquire_run_slot("next") is True
def test_progressing_job_is_not_reaped(monkeypatch, tmp_path):
"""慢但在推进: 总时长远超阈值, 但进度心跳新鲜 → 不得误杀(核心回归)。"""
monkeypatch.setattr(preferences, "load", lambda: {})
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
# 总时长 2 小时(远超 60s 阈值), 但 10 秒前刚上报过进度
store._active_jobs[jid]["started_at"] = _iso(_now() - timedelta(hours=2))
store._active_jobs[jid]["last_progress_at"] = _iso(_now() - timedelta(seconds=10))
store.reap_stale()
assert store.get(jid)["status"] == "running"
def test_hard_cap_terminates_endless_progress(tmp_path):
"""进度回调持续但总时长超硬上限 → 兜底终止。"""
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
beyond = timedelta(seconds=pipeline_jobs.HARD_JOB_TIMEOUT_S + 3600)
store._active_jobs[jid]["started_at"] = _iso(_now() - beyond)
store._active_jobs[jid]["last_progress_at"] = _iso(_now())
store.reap_stale()
j = store.get(jid)
assert j["status"] == "failed"
assert "硬上限" in j["error"]
def test_progress_updates_heartbeat(tmp_path):
"""progress() 刷新 last_progress_at(停滞计时的基准)。"""
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
store.progress(jid, "sync", 10, "chunk 1/10")
assert store.get(jid)["last_progress_at"] is not None
# ── 协作式取消 ──────────────────────────────────────────────────────────
def test_progress_raises_after_cancel(tmp_path):
"""取消后, 僵尸线程下一次 progress() 回调抛 JobCancelledError 自行退出。"""
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
pipeline_jobs.request_cancel(jid)
with pytest.raises(JobCancelledError):
store.progress(jid, "sync", 20, "chunk 2/10")
def test_progress_raises_after_record_popped(tmp_path):
"""terminate() 已把记录弹出后, flag 仍需生效(僵尸靠 flag 而非记录感知)。"""
store = JobStore(store_dir=tmp_path / "jobs")
jid = _make_running_job(store, timeout_s=60)
store.terminate(jid, "超时自动取消")
# 记录已从内存弹出
assert store.get(jid)["status"] == "failed"
with pytest.raises(JobCancelledError):
store.progress(jid, "sync", 20, "zombie chunk")
def test_cancelled_error_survives_chunk_isolation():
"""JobCancelledError 继承 BaseException: 同步循环的分块异常隔离不得吞掉它。"""
def chunk_loop(cancel_at: int) -> str:
for i in range(5):
try:
if i == cancel_at:
raise JobCancelledError("j1")
except Exception: # noqa: BLE001 — 分块隔离的典型写法
continue
return "completed"
with pytest.raises(JobCancelledError):
chunk_loop(2)
# ── 执行槽所有权 ────────────────────────────────────────────────────────
def test_run_slot_ownership_guard():
"""非持有者的释放一律忽略 —— 僵尸线程 finally 不得误释放新任务的槽。"""
assert pipeline_jobs.try_acquire_run_slot("jobA") is True
assert pipeline_jobs.try_acquire_run_slot("jobB") is False
# 旧 job(僵尸)的 finally 释放: 槽属于 jobA, 忽略
pipeline_jobs.release_run_slot("jobB")
assert pipeline_jobs.try_acquire_run_slot("jobC") is False
# 持有者自己释放后才可用
pipeline_jobs.release_run_slot("jobA")
assert pipeline_jobs.try_acquire_run_slot("jobC") is True
pipeline_jobs.release_run_slot("jobC")
def test_run_slot_reap_release_prevents_zombie_release():
"""reap 强制释放后, 僵尸晚到的同 owner 释放是幂等 no-op, 不影响新持有者。"""
assert pipeline_jobs.try_acquire_run_slot("jobA") is True
pipeline_jobs.release_run_slot("jobA") # terminate 的强制释放
assert pipeline_jobs.try_acquire_run_slot("jobB") is True # 新任务立即入槽
pipeline_jobs.release_run_slot("jobA") # 僵尸 finally: owner 不匹配 → 忽略
assert pipeline_jobs.try_acquire_run_slot("jobC") is False # jobB 仍持有
pipeline_jobs.release_run_slot("jobB")
assert pipeline_jobs.try_acquire_run_slot("jobC") is True
pipeline_jobs.release_run_slot("jobC")
@@ -128,6 +128,12 @@ export function ActiveJobCard({ job }: { job: PipelineJob }) {
{job.status === 'failed' && job.error && (
<div className="mt-3 rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
{job.error}
{job.error.includes('超时自动取消') && (
<div className="mt-1 text-[10px] text-muted">
, ;
</div>
)}
</div>
)}
</div>
@@ -74,7 +74,7 @@ export function JobTimeoutCard() {
<div>
<h2 className="text-sm font-medium text-foreground"></h2>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
<b></b>
</p>
</div>
</div>
@@ -89,7 +89,7 @@ export function JobTimeoutCard() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
<span className="block text-xs font-medium text-foreground mb-1"></span>
<span className="block text-xs font-medium text-foreground mb-1"></span>
<span className="block text-[10px] text-muted mb-2"> K </span>
<div className="flex items-center gap-2">
<input
@@ -117,11 +117,11 @@ export function JobTimeoutCard() {
<option value="hour"></option>
</select>
</div>
<span className="block text-[10px] text-muted/60 mt-1.5"> 20 1 </span>
<span className="block text-[10px] text-muted/60 mt-1.5"> 20 1 </span>
</label>
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
<span className="block text-xs font-medium text-foreground mb-1"></span>
<span className="block text-xs font-medium text-foreground mb-1"></span>
<span className="block text-[10px] text-muted mb-2"> K </span>
<div className="flex items-center gap-2">
<input
@@ -149,7 +149,7 @@ export function JobTimeoutCard() {
<option value="hour"></option>
</select>
</div>
<span className="block text-[10px] text-muted/60 mt-1.5"> 30 1 </span>
<span className="block text-[10px] text-muted/60 mt-1.5"> 30 1 </span>
</label>
</div>
</section>