Files
tick-stock-panel/backend/tests/test_job_stall_and_cancel.py
T
shy3130 a6a4bcde43 fix(sync): 卡死判定改进度停滞 + 协作式取消 + 执行槽所有权
- reap 判定从总时长一刀切改为进度停滞(心跳 last_progress_at),
  慢带宽冷启动不再被误杀; 12h 总时长硬上限兜底
- 协作式取消: JobCancelledError(BaseException) 经分块进度回调穿透,
  僵尸线程在下一分块自行退出, UI 状态与执行体对齐
- 执行槽带所有权 token, 僵尸 finally 不得误释放新任务的槽
- 手动取消端点同样走协作式终止
- 前端超时卡片文案对齐停滞语义, 失败提示引导调整阈值
- 新增 10 个回归测试(972 passed)
2026-08-23 14:57:12 +08:00

168 lines
6.8 KiB
Python

"""回归测试: 卡死判定从「总时长一刀切」改为「进度停滞」+ 协作式取消 + 执行槽所有权。
背景(用户反馈): 慢带宽环境冷启动全市场拉取超过 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")