mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
复现的原始问题: 旧实例挂死在 shutdown (热重载撞上挖掘运行) 或 worker
成为孤儿后, `.mining_process.lock` 被无限期占用, 后续所有后端实例
在 lifespan 抢锁时直接启动失败, 用户只能对着笼统报错手动找进程。
- 锁文件记录持有者 {pid, started_at, role, host} (flock 仍是唯一
事实, 内容只是诊断线索; 旧格式 "0" 兼容, 视为持有者未知);
新增非阻塞探测, 无需抢锁即可判断是否被持有。
- 撞锁降级启动: lifespan 抢锁失败不再上抛, 面板照常可用; 挖掘
启动入口 (手动/自动/周度调度) 返回 409 并指引到挖掘页; 桌面版
预检从「秒退失败页」改为留日志继续启动; 守护复验不受影响。
- 挖掘页锁占用横幅: 显示持有者 pid/角色/挖掘存储静默时长; 满足
陈旧判定时可一键「强制停止并接管」(SIGTERM→SIGKILL→当前后端
获取锁, 挖掘入口立即恢复)。
- 陈旧判定全部 fail-closed: 持有者信息可读、进程存活且 pid 未被
复用 (POSIX 比对进程实际启动时间)、无进行中的挖掘运行、挖掘
存储 10 分钟内无写入 — 任一不满足即拒绝并给出原因。不做启动
时自动清理, 终止进程必须人工确认。
测试: 新增 test_mining_lock_admin (含真实外部持锁进程的强停回路与
五类拒绝路径), 重写 lifespan 降级契约与桌面预检继续启动契约;
后端全量 3239 通过, 前端 308 通过, ruff 零新增; 沙盒 E2E 完整
复刻「持锁→降级→强停→接管→恢复」事故场景通过。
Signed-off-by: shy3130 <shy3130@qq.com>
305 lines
9.8 KiB
Python
305 lines
9.8 KiB
Python
"""挖掘锁持有者记录 / 陈旧判定 / 强制释放 + 撞锁降级启动测试。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.mining import router as mining_router
|
|
from app.services.mining_lock_admin import (
|
|
MiningLockAdminError,
|
|
_classify,
|
|
_count_active_runs,
|
|
_store_idle_seconds,
|
|
force_release,
|
|
lock_status,
|
|
)
|
|
from app.services.mining_process_lock import (
|
|
MiningProcessLock,
|
|
MiningProcessLockError,
|
|
probe_lock_held,
|
|
read_lock_holder,
|
|
)
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
_HOLDER_SCRIPT = """
|
|
import sys, time
|
|
from app.services.mining_process_lock import MiningProcessLock
|
|
lock = MiningProcessLock(sys.argv[1])
|
|
lock.acquire(role="worker")
|
|
print("HELD", flush=True)
|
|
time.sleep(600)
|
|
"""
|
|
|
|
_POSIX = pytest.mark.skipif(os.name == "nt", reason="外部持锁进程仅在 POSIX 验证")
|
|
|
|
|
|
def _spawn_holder(data_dir: Path) -> subprocess.Popen:
|
|
env = {**os.environ, "PYTHONPATH": str(_BACKEND_ROOT)}
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-c", _HOLDER_SCRIPT, str(data_dir)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(_BACKEND_ROOT),
|
|
)
|
|
line = proc.stdout.readline()
|
|
assert line.strip() == "HELD", f"外部持锁进程未就绪: {line!r} {proc.stderr.read()}"
|
|
return proc
|
|
|
|
|
|
def _kill(proc: subprocess.Popen) -> None:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
|
|
|
|
# ── 锁内容与探测 ───────────────────────────────────────────────
|
|
|
|
|
|
def test_lock_records_holder_and_release_marker(tmp_path):
|
|
lock = MiningProcessLock(tmp_path)
|
|
lock.acquire()
|
|
holder = read_lock_holder(tmp_path)
|
|
assert holder is not None
|
|
assert holder["pid"] == os.getpid()
|
|
assert holder["role"] == "app"
|
|
assert probe_lock_held(tmp_path) is True
|
|
|
|
lock.release()
|
|
assert probe_lock_held(tmp_path) is False
|
|
# 释放标记后不再把内容当作有效持有者
|
|
assert read_lock_holder(tmp_path) is None
|
|
|
|
|
|
def test_second_acquire_conflicts_in_same_process(tmp_path):
|
|
first = MiningProcessLock(tmp_path)
|
|
first.acquire()
|
|
second = MiningProcessLock(tmp_path)
|
|
with pytest.raises(MiningProcessLockError):
|
|
second.acquire()
|
|
first.release()
|
|
|
|
|
|
def test_legacy_lock_content_is_unknown_holder(tmp_path):
|
|
path = tmp_path / ".mining_process.lock"
|
|
path.write_bytes(b"0")
|
|
assert read_lock_holder(tmp_path) is None
|
|
assert probe_lock_held(tmp_path) is False
|
|
|
|
|
|
def test_corrupt_lock_content_is_unknown_holder(tmp_path):
|
|
(tmp_path / ".mining_process.lock").write_text("{not json")
|
|
assert read_lock_holder(tmp_path) is None
|
|
|
|
|
|
def test_status_reports_mine(tmp_path):
|
|
lock = MiningProcessLock(tmp_path)
|
|
lock.acquire()
|
|
try:
|
|
status = lock_status(tmp_path)
|
|
assert status["held"] is True
|
|
assert status["mine"] is True
|
|
assert status["force_releasable"] is False
|
|
assert "当前后端" in status["reason"]
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
# ── 陈旧判定单元矩阵 ──────────────────────────────────────────
|
|
|
|
|
|
def test_classify_matrix():
|
|
base = dict(
|
|
held=True, mine=False, holder_known=True, holder_alive=True,
|
|
pid_consistent=True, active_runs=0, store_idle=None, stale_after=600.0,
|
|
)
|
|
ok, reason = _classify(**base)
|
|
assert ok and reason is None
|
|
|
|
not_held = {**base, "held": False}
|
|
assert _classify(**not_held) == (False, "锁未被持有")
|
|
|
|
legacy = {**base, "holder_known": False}
|
|
ok, reason = _classify(**legacy)
|
|
assert not ok and "旧格式" in reason
|
|
|
|
reused = {**base, "pid_consistent": False}
|
|
ok, reason = _classify(**reused)
|
|
assert not ok and "复用" in reason
|
|
|
|
active = {**base, "active_runs": 2}
|
|
ok, reason = _classify(**active)
|
|
assert not ok and "2 个进行中" in reason
|
|
|
|
fresh = {**base, "store_idle": 12.0}
|
|
ok, reason = _classify(**fresh)
|
|
assert not ok and "陈旧阈值" in reason
|
|
|
|
dead = {**base, "holder_alive": False}
|
|
ok, reason = _classify(**dead)
|
|
assert not ok and "已不存在" in reason
|
|
|
|
|
|
def test_count_active_runs_and_store_idle(tmp_path):
|
|
runs = tmp_path / "research" / "mining" / "runs"
|
|
assert _count_active_runs(tmp_path) == 0
|
|
assert _store_idle_seconds(tmp_path, now=time.time()) is None
|
|
|
|
done = runs / "run-done"
|
|
done.mkdir(parents=True)
|
|
(done / "manifest.json").write_text(json.dumps({"status": "succeeded"}))
|
|
active = runs / "run-live"
|
|
active.mkdir(parents=True)
|
|
(active / "manifest.json").write_text(json.dumps({"status": "running"}))
|
|
(active / "events.jsonl").write_text("{}\n")
|
|
|
|
assert _count_active_runs(tmp_path) == 1
|
|
idle = _store_idle_seconds(tmp_path, now=time.time())
|
|
assert idle is not None and idle < 60
|
|
|
|
|
|
def test_count_active_runs_tolerates_corrupt_manifest(tmp_path):
|
|
runs = tmp_path / "research" / "mining" / "runs" / "bad"
|
|
runs.mkdir(parents=True)
|
|
(runs / "manifest.json").write_text("{broken")
|
|
assert _count_active_runs(tmp_path) == 0
|
|
|
|
|
|
# ── 强制释放 (真实外部持有进程, POSIX) ─────────────────────────
|
|
|
|
|
|
@_POSIX
|
|
def test_force_release_kills_external_holder_and_takes_over(tmp_path):
|
|
proc = _spawn_holder(tmp_path)
|
|
try:
|
|
status = lock_status(tmp_path)
|
|
assert status["held"] is True
|
|
assert status["mine"] is False
|
|
assert status["holder"]["role"] == "worker"
|
|
assert status["force_releasable"] is True, status["reason"]
|
|
|
|
lock = MiningProcessLock(tmp_path)
|
|
report = force_release(tmp_path, lock)
|
|
assert report["released"] is True
|
|
assert report["terminated_pid"] == proc.pid
|
|
assert report["reacquired"] is True
|
|
assert lock.acquired is True
|
|
proc.wait(timeout=10)
|
|
assert lock_status(tmp_path)["mine"] is True
|
|
lock.release()
|
|
finally:
|
|
_kill(proc)
|
|
|
|
|
|
@_POSIX
|
|
def test_force_release_refuses_fresh_store(tmp_path):
|
|
proc = _spawn_holder(tmp_path)
|
|
try:
|
|
runs = tmp_path / "research" / "mining" / "runs" / "r1"
|
|
runs.mkdir(parents=True)
|
|
(runs / "manifest.json").write_text(json.dumps({"status": "succeeded"}))
|
|
status = lock_status(tmp_path)
|
|
assert status["force_releasable"] is False
|
|
assert "陈旧阈值" in status["reason"]
|
|
with pytest.raises(MiningLockAdminError, match="陈旧阈值"):
|
|
force_release(tmp_path, MiningProcessLock(tmp_path))
|
|
assert proc.poll() is None, "被拒绝时不得误杀持有者"
|
|
finally:
|
|
_kill(proc)
|
|
|
|
|
|
@_POSIX
|
|
def test_force_release_refuses_active_runs(tmp_path):
|
|
proc = _spawn_holder(tmp_path)
|
|
try:
|
|
runs = tmp_path / "research" / "mining" / "runs" / "r2"
|
|
runs.mkdir(parents=True)
|
|
(runs / "manifest.json").write_text(json.dumps({"status": "running"}))
|
|
status = lock_status(tmp_path)
|
|
assert status["force_releasable"] is False
|
|
assert "进行中" in status["reason"]
|
|
assert proc.poll() is None
|
|
finally:
|
|
_kill(proc)
|
|
|
|
|
|
def test_force_release_refuses_when_lock_free(tmp_path):
|
|
with pytest.raises(MiningLockAdminError, match="未被持有"):
|
|
force_release(tmp_path, MiningProcessLock(tmp_path))
|
|
|
|
|
|
# ── API 接线: 撞锁降级 + 锁端点 ────────────────────────────────
|
|
|
|
|
|
def _api_client(tmp_path, *, acquired: bool):
|
|
app = FastAPI()
|
|
app.include_router(mining_router)
|
|
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
|
app.state.mining_lock_acquired = acquired
|
|
app.state.mining_process_lock = MiningProcessLock(tmp_path)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_degraded_mode_rejects_mining_start(tmp_path):
|
|
client = _api_client(tmp_path, acquired=False)
|
|
resp = client.post(
|
|
"/api/backtest/mining/runs",
|
|
json={"factor_names": ["momentum_5d"], "budget_profile": "exploratory"},
|
|
)
|
|
assert resp.status_code == 409
|
|
assert "降级" in resp.json()["detail"]
|
|
|
|
|
|
def test_lock_endpoints_free_lock(tmp_path):
|
|
client = _api_client(tmp_path, acquired=True)
|
|
body = client.get("/api/backtest/mining/lock").json()
|
|
assert body["held"] is False
|
|
assert body["force_releasable"] is False
|
|
resp = client.post("/api/backtest/mining/lock/force-release")
|
|
assert resp.status_code == 409
|
|
assert "未被持有" in resp.json()["detail"]
|
|
|
|
|
|
@_POSIX
|
|
def test_lock_endpoints_full_recovery_loop(tmp_path):
|
|
proc = _spawn_holder(tmp_path)
|
|
try:
|
|
client = _api_client(tmp_path, acquired=False)
|
|
body = client.get("/api/backtest/mining/lock").json()
|
|
assert body["held"] is True
|
|
assert body["mine"] is False
|
|
assert body["force_releasable"] is True, body["reason"]
|
|
|
|
resp = client.post("/api/backtest/mining/lock/force-release")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {
|
|
"released": True,
|
|
"terminated_pid": proc.pid,
|
|
"reacquired": True,
|
|
}
|
|
proc.wait(timeout=10)
|
|
after = client.get("/api/backtest/mining/lock").json()
|
|
assert after["mine"] is True
|
|
|
|
# 接管后挖掘入口必须立即恢复 — 不再是 409 降级
|
|
# (stub app 无 manager, 返回 503; 真实环境此处应为 200/400)
|
|
resp = client.post(
|
|
"/api/backtest/mining/runs",
|
|
json={"factor_names": ["momentum_5d"], "budget_profile": "exploratory"},
|
|
)
|
|
assert resp.status_code != 409
|
|
assert "降级" not in resp.json().get("detail", "")
|
|
finally:
|
|
_kill(proc)
|