Files
tick-stock-panel/backend/tests/test_job_record_persistence.py
T
shy3130 3b09fac15e feat(realtime): 自选实时分流与数据落盘可靠性
- quote_service 实时通道重构与自选分流, 旧 watchlist 批量测试相应移除
- repository.replace_with_retry 原子写重试; pipeline_jobs 任务记录落盘
- stock-sdk provider 适配增强; 分时图组件与预览入口调整
2026-08-30 19:05:27 +08:00

126 lines
4.5 KiB
Python

"""回归测试: job 记录跨进程死亡持久化(「数据在、记录丢」补丁)。
背景(用户反馈): 全市场同步 12:11~12:42 成功结束后 0.7s, uvicorn --reload
检测到代码变更杀死 worker, 恰好落在管道完成与 job_store.succeed() 落盘之间
—— 数据已写盘但同步历史无任何记录。旧实现 pending/running 仅存内存、终态才
落盘, 存在整段丢失窗口。
修复后契约:
- create()/start() 即落盘 pending/running 快照;
- 下次进程启动(= 新 JobStore 实例, 同目录)把遗留的 pending/running
孤儿记录补标为 failed(中断), finished_at 取文件 mtime;
- 终态记录不受补录影响; 终态写入覆盖 running 快照(同一文件)。
均为纯逻辑, 不触网。
"""
from __future__ import annotations
import json
from app.services.pipeline_jobs import JobStore
def _read_disk(d, jid: str) -> dict:
return json.loads((d / f"{jid}.json").read_text("utf-8"))
# ── 创建/启动即落盘 ──────────────────────────────────────────────────────
def test_create_writes_pending_snapshot_to_disk(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
disk = _read_disk(d, jid)
assert disk["status"] == "pending"
assert disk["stage"] == "init"
def test_start_updates_disk_snapshot_to_running(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
disk = _read_disk(d, jid)
assert disk["status"] == "running"
assert disk["started_at"] is not None
# ── 进程死亡 → 下次启动补录 ──────────────────────────────────────────────
def test_orphan_running_record_is_reaped_on_next_boot(tmp_path):
"""核心场景: 进程死在 running(甚至工作已做完但未终态), 记录必须可见。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
jid, _ = dead.create(timeout_s=60)
dead.start(jid)
dead.progress(jid, "sync", 50, "halfway") # 进度只更新内存
# 新进程 = 同目录新实例(内存为空, 只有磁盘)
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j is not None
assert j["status"] == "failed"
assert "中断" in j["error"]
assert j["finished_at"] is not None
# finished_at 基于文件 mtime(≈ start 时刻), 时长不得虚增为负或巨大
assert j["duration_s"] is not None
assert 0 <= j["duration_s"] <= 60
# 同步历史列表可见
assert any(x["id"] == jid for x in revived.list_recent())
def test_orphan_pending_record_is_reaped(tmp_path):
"""进程死在 create() 与 start() 之间: 记录同样可见, 时长为 None。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
jid, _ = dead.create(timeout_s=60)
# 未 start 即死亡
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j["status"] == "failed"
assert j["duration_s"] is None
def test_reap_does_not_touch_terminal_records(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
store.succeed(jid, {"daily_rows": 100})
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j["status"] == "succeeded"
assert j["result"] == {"daily_rows": 100}
def test_reap_allows_new_job_after_dead_orphan(tmp_path):
"""补录后旧 job 已 failed: 新进程 create() 不被死孤儿阻塞(单飞只看内存)。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
old_jid, _ = dead.create(timeout_s=60)
dead.start(old_jid)
revived = JobStore(store_dir=d)
new_jid, is_new = revived.create(timeout_s=60)
assert is_new is True
assert new_jid != old_jid
# ── 终态覆盖快照 ─────────────────────────────────────────────────────────
def test_terminal_write_replaces_running_snapshot(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
store.fail(jid, "boom")
files = list(d.glob("*.json"))
assert len(files) == 1
disk = _read_disk(d, jid)
assert disk["status"] == "failed"
assert disk["error"] == "boom"