mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
fix(sync): 卡死判定改进度停滞 + 协作式取消 + 执行槽所有权
- reap 判定从总时长一刀切改为进度停滞(心跳 last_progress_at), 慢带宽冷启动不再被误杀; 12h 总时长硬上限兜底 - 协作式取消: JobCancelledError(BaseException) 经分块进度回调穿透, 僵尸线程在下一分块自行退出, UI 状态与执行体对齐 - 执行槽带所有权 token, 僵尸 finally 不得误释放新任务的槽 - 手动取消端点同样走协作式终止 - 前端超时卡片文案对齐停滞语义, 失败提示引导调整阈值 - 新增 10 个回归测试(972 passed)
This commit is contained in:
+24
-12
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user