feat(mining): 挖掘锁撞锁降级启动 + 持有者诊断 + 一键强制释放

复现的原始问题: 旧实例挂死在 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>
This commit is contained in:
shy3130
2026-10-26 00:01:20 +08:00
parent e01fe96241
commit b573ca43eb
14 changed files with 905 additions and 52 deletions
+40
View File
@@ -154,6 +154,44 @@ class MiningAutoStartRequest(BaseModel):
return self
def _require_mining_lock(request: Request) -> None:
"""降级模式 (撞锁启动) 下拒绝一切挖掘启动入口, 指引到挖掘页锁状态卡。"""
if not getattr(request.app.state, "mining_lock_acquired", True):
raise HTTPException(
status_code=409,
detail="挖掘锁被其他进程占用, 当前后端以降级模式运行 — "
"请在挖掘页查看锁状态并强制释放后重试",
)
@router.get("/lock")
def get_lock_status(request: Request) -> dict[str, Any]:
"""挖掘锁诊断: 持有者线索 + 是否允许强制释放 (fail-closed 判定)。"""
from app.services.mining_lock_admin import lock_status
return lock_status(request.app.state.repo.store.data_dir)
@router.post("/lock/force-release")
def force_release_lock(request: Request) -> dict[str, Any]:
"""终止陈旧锁持有者并由当前后端接管; 不满足陈旧判定时返回拒绝原因。"""
from app.services.mining_lock_admin import (
MiningLockAdminError,
force_release,
)
lock = getattr(request.app.state, "mining_process_lock", None)
if lock is None:
raise HTTPException(status_code=409, detail="挖掘锁状态不可用")
try:
result = force_release(request.app.state.repo.store.data_dir, lock)
except MiningLockAdminError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# 接管成功后同步降级标志, 挖掘入口立即恢复可用
request.app.state.mining_lock_acquired = True
return result
@router.get("/availability")
def get_availability(
request: Request,
@@ -200,6 +238,7 @@ def list_runs(
@router.post("/runs")
def start_run(payload: MiningStartRequest, request: Request) -> dict[str, Any]:
_require_mining_lock(request)
manager = _manager(request)
worker_request = payload.model_dump(mode="json", exclude={"force"})
try:
@@ -279,6 +318,7 @@ def start_auto_run(payload: MiningAutoStartRequest, request: Request) -> dict[st
筛选结果随请求持久化 (request.auto_screening), 供结果页展示达标因子清单与
失败原因分布; 无达标因子时返回 started=false 而不是报错。
"""
_require_mining_lock(request)
from app.services.auto_mining import screen_all_factors
manager = _manager(request)
+9 -18
View File
@@ -79,13 +79,12 @@ def _ensure_data_dir_writable() -> None:
def _precheck_mining_lock() -> str | None:
"""启动前预检挖掘锁。返回 None = 可启动; 返回 str = 冲突原因 (给失败页)。
"""启动前预检挖掘锁。返回 None = 无冲突; 返回 str = 冲突原因 (仅日志)。
app.main 的 lifespan 第一件事就是抢 data_dir/.mining_process_lock,
被别的后端实例 (panel 模式、隧道后端或残留进程) 占住时 lifespan 直接
抛 MiningProcessLockError, uvicorn 吞掉后主线程只能等满超时报笼统的
"启动失败"。这里用同一把锁试抢一下, 提前把冲突翻译成用户可操作的指引。
试抢成功立即释放, 真正的持有仍由 lifespan 负责。
app.main 的 lifespan 会抢 data_dir/.mining_process_lock; 被别的后端
实例 (panel 模式、隧道后端或残留进程) 占住时, 现在 lifespan 只降级
(挖掘入口 409, 面板可用, 挖掘页提供强制释放), 这里提前留一条日志
方便排查。试抢成功立即释放, 真正的持有仍由 lifespan 负责。
"""
from app.config import settings
from app.services.mining_process_lock import (
@@ -399,20 +398,12 @@ def main() -> int:
if not _acquire_single_instance():
return 0
# 挖掘锁预检: 被其他后端实例占住时 lifespan 必败, 提前秒显失败页并给出
# 操作指引, 而不是让用户对着超时后的笼统"启动失败"猜 (见 _precheck_mining_lock)
# 挖掘锁预检: 撞锁只降级不再拦截启动 — lifespan 会以降级模式起面板
# (挖掘入口 409), 用户可在挖掘页查看持有者并强制释放陈旧持有者;
# 这里只提前留一条日志方便排查。
conflict = _precheck_mining_lock()
if conflict is not None:
logger.error("启动预检: %s", conflict.replace("\n", " "))
try:
_open_static_window(_failed_html(conflict))
except Exception:
# 失败页自身也打不开 (如缺 webview 依赖) 时不能让异常裸穿 main():
# console=False 下进程闪退无反馈, 且本路径在 try/finally 之外,
# 单实例锁会跳过释放
logger.exception("启动失败页窗口打开失败")
_release_single_instance()
return 1
logger.warning("启动预检: %s", conflict.replace("\n", " "))
try:
port = _find_free_port(_BASE_PORT)
+14 -5
View File
@@ -500,18 +500,27 @@ async def _application_lifespan(app: FastAPI):
@asynccontextmanager
async def lifespan(app: FastAPI):
mining_process_lock = MiningProcessLock(settings.data_dir)
lock_acquired = True
try:
mining_process_lock.acquire()
except MiningProcessLockError:
# uvicorn 吞掉 lifespan 异常只留一行 startup failed; 桌面版失败页指向
# data/desktop.log, 先把真因 (含 traceback) 落盘, 不依赖 uvicorn 的日志行为
logger.error("挖掘锁被其他后端实例占用, lifespan 启动失败", exc_info=True)
raise
# 撞锁不再让整个后端起不来 (典型: 旧实例挂死在 shutdown 持锁不放):
# 面板以降级模式启动 — 挖掘启动入口返回 409, 用户可在挖掘页查看
# 持有者并 (满足陈旧判定时) 强制释放, 之后自动恢复。
lock_acquired = False
logger.warning(
"挖掘锁被其他进程占用, 面板以降级模式启动 (挖掘功能暂不可用; "
"可在挖掘页查看锁状态并强制释放)"
)
app.state.mining_process_lock = mining_process_lock
app.state.mining_lock_acquired = lock_acquired
try:
async with _application_lifespan(app):
yield
finally:
mining_process_lock.release()
# 用 lock.acquired 而非启动时的 flag: 强制释放接管后同样要在退出时释放
if mining_process_lock.acquired:
mining_process_lock.release()
app = FastAPI(
+7 -1
View File
@@ -18,7 +18,13 @@ def run_scheduled_lab(app_state: Any) -> dict[str, Any]:
results: dict[str, Any] = {"mining": None, "guardian": None}
try:
results["mining"] = run_weekly_mining(app_state)
if not getattr(app_state, "mining_lock_acquired", True):
# 降级模式 (挖掘锁被其他进程占用): 跳过周度挖掘而不是报错,
# 守护复验不受影响
logger.warning("scheduled lab mining skipped: mining lock unavailable")
results["mining"] = {"skipped": "mining lock unavailable"}
else:
results["mining"] = run_weekly_mining(app_state)
except Exception:
logger.exception("scheduled lab mining enqueue failed")
try:
+263
View File
@@ -0,0 +1,263 @@
"""挖掘锁状态诊断与 fail-closed 强制释放。
背景: 应用进程在 lifespan 抢 `.mining_process.lock`; 当持有者挂死 (典型:
热重载撞上挖掘运行, 旧进程卡在 shutdown 等待收尾) 或 worker 成为孤儿时,
锁会被无限期占用, 后续实例全部无法获得挖掘能力。
设计纪律 (设计稿讨论结论):
- 只提供「人工确认的强制停止按钮」, 不做启动时自动清理 — 终止进程是
破坏性操作, 必须人来确认。
- 强制释放的判定全部 fail-closed: 持有者不明 (旧格式锁)、pid 可能被
复用、存在进行中的挖掘运行、挖掘存储近期仍有写入 — 任一命中即拒绝,
宁可让用户手动处理也不误杀健康实例。
"""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from app.services.mining_jobs import ACTIVE_RUN_STATUSES
from app.services.mining_process_lock import (
MiningProcessLock,
MiningProcessLockError,
probe_lock_held,
read_lock_holder,
)
logger = logging.getLogger(__name__)
# 挖掘存储静默多久才允许判定持有者陈旧 (秒)。健康实例空闲时也会持锁,
# 该阈值用于排除「正在收尾/写 artifact」的正常窗口。
STALE_AFTER_SECONDS = 600.0
_TERMINATE_GRACE_SECONDS = 6.0
_RELEASE_WAIT_SECONDS = 5.0
class MiningLockAdminError(ValueError):
"""强制释放被拒绝 (fail-closed), message 即拒绝原因。"""
def lock_status(
data_dir: Path | str,
*,
self_pid: int | None = None,
now: float | None = None,
stale_after: float = STALE_AFTER_SECONDS,
) -> dict[str, Any]:
"""汇总挖掘锁状态: 是否被持有、持有者线索、是否允许强制释放。"""
pid = os.getpid() if self_pid is None else self_pid
held = probe_lock_held(data_dir)
holder = read_lock_holder(data_dir)
mine = held and bool(holder) and holder.get("pid") == pid
holder_pid = holder.get("pid") if holder else None
holder_alive = _pid_alive(holder_pid) if holder_pid else False
pid_consistent = (
_pid_matches_start_time(holder_pid, holder.get("started_at"))
if holder_alive
else True
)
active_runs = _count_active_runs(data_dir)
store_idle = _store_idle_seconds(data_dir, now=time.time() if now is None else now)
releasable, reason = _classify(
held=held,
mine=mine,
holder_known=holder is not None,
holder_alive=holder_alive,
pid_consistent=pid_consistent,
active_runs=active_runs,
store_idle=store_idle,
stale_after=stale_after,
)
return {
"held": held,
"mine": mine,
"holder": holder,
"holder_alive": holder_alive,
"pid_consistent": pid_consistent,
"active_runs": active_runs,
"store_idle_seconds": (
round(store_idle, 1) if store_idle is not None else None
),
"stale_after_seconds": stale_after,
"force_releasable": releasable,
"reason": reason,
}
def force_release(
data_dir: Path | str,
lock: MiningProcessLock,
*,
stale_after: float = STALE_AFTER_SECONDS,
) -> dict[str, Any]:
"""终止陈旧持有者并让本进程接管锁; 任何不满足条件的情况直接拒绝。"""
status = lock_status(data_dir, stale_after=stale_after)
if not status["force_releasable"]:
raise MiningLockAdminError(status["reason"] or "不可强制释放")
if lock.acquired:
raise MiningLockAdminError("当前进程已持有挖掘锁")
target_pid = int(status["holder"]["pid"])
logger.warning(
"强制释放挖掘锁: 终止持有者 pid=%s (持有于 %s, 存储静默 %ss)",
target_pid,
status["holder"].get("started_at"),
status["store_idle_seconds"],
)
_terminate_pid(target_pid)
if not _wait_condition(
lambda: (not _pid_alive(target_pid)) or (not probe_lock_held(data_dir)),
_TERMINATE_GRACE_SECONDS + _RELEASE_WAIT_SECONDS,
):
raise MiningLockAdminError(
f"持有者 pid={target_pid} 未能终止或锁未释放, 请手动处理"
)
try:
lock.acquire()
except MiningProcessLockError as exc:
raise MiningLockAdminError(
"持有者已终止, 但锁仍被其他进程抢先获取; 请重试"
) from exc
return {
"released": True,
"terminated_pid": target_pid,
"reacquired": True,
}
def _classify(
*,
held: bool,
mine: bool,
holder_known: bool,
holder_alive: bool,
pid_consistent: bool,
active_runs: int,
store_idle: float | None,
stale_after: float,
) -> tuple[bool, str | None]:
if not held:
return False, "锁未被持有"
if mine:
return False, "锁由当前后端持有"
if not holder_known:
return False, "锁文件为旧格式或不可读, 无法定位持有进程 — 请手动查找并结束它"
if not holder_alive:
# 内核会在进程退出时释放 flock, 理论上到不了这里; 防御性兜底
return False, "持有者进程已不存在, 锁应已释放 — 请刷新状态"
if not pid_consistent:
return False, "持有者 pid 已被其他进程复用, 拒绝自动终止 — 请手动确认"
if active_runs:
return False, f"仍有 {active_runs} 个进行中的挖掘运行, 不能终止持有者"
if store_idle is not None and store_idle < stale_after:
return False, (
f"挖掘存储 {store_idle:.0f} 秒内仍有写入 (陈旧阈值 {stale_after:.0f}s), "
"持有者可能在正常收尾 — 请稍后再试"
)
return True, None
def _pid_alive(pid: int | None) -> bool:
if not pid or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _pid_matches_start_time(pid: int, started_at: Any) -> bool:
"""POSIX 下比对进程实际启动时间与锁内记录, 排除 pid 复用误杀。
无法获取 (Windows / ps 不可用 / 记录缺失) 时返回 True —
该信号只是附加保险, 主判定靠「无活跃 run + 存储静默」。
"""
if os.name != "posix" or not isinstance(started_at, str):
return True
try:
recorded = datetime.fromisoformat(started_at)
except ValueError:
return True
try:
output = subprocess.run(
["ps", "-p", str(pid), "-o", "lstart="],
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout.strip()
actual = datetime.strptime(output, "%a %b %d %H:%M:%S %Y")
except (OSError, subprocess.SubprocessError, ValueError):
return True
if recorded.tzinfo is not None:
# ps lstart 是本地时间墙钟; 统一成同口径再比
recorded = recorded.astimezone().replace(tzinfo=None)
# ps 的 lstart 无亚秒精度, 放宽到 120s
return abs((actual - recorded).total_seconds()) <= 120
def _runs_dir(data_dir: Path | str) -> Path:
return Path(data_dir) / "research" / "mining" / "runs"
def _count_active_runs(data_dir: Path | str) -> int:
count = 0
for manifest in _runs_dir(data_dir).glob("*/manifest.json"):
try:
value = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if isinstance(value, dict) and value.get("status") in ACTIVE_RUN_STATUSES:
count += 1
return count
def _store_idle_seconds(data_dir: Path | str, *, now: float) -> float | None:
"""挖掘 run 存储最近一次写入距今的秒数; 无任何文件返回 None (视为足够陈旧)。"""
runs_dir = _runs_dir(data_dir)
if not runs_dir.is_dir():
return None
latest: float | None = None
for path in runs_dir.rglob("*"):
if not path.is_file():
continue
try:
mtime = path.stat().st_mtime
except OSError:
continue
if latest is None or mtime > latest:
latest = mtime
if latest is None:
return None
return max(0.0, now - latest)
def _terminate_pid(pid: int) -> None:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
except PermissionError as exc:
raise MiningLockAdminError(f"无权限终止持有者 pid={pid}") from exc
def _wait_condition(condition, timeout: float) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(0.2)
return condition()
+97 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import json
import os
import socket
from datetime import UTC, datetime
from pathlib import Path
from typing import BinaryIO
@@ -9,12 +12,19 @@ class MiningProcessLockError(RuntimeError):
"""Another application process owns mining for this data directory."""
_LOCK_SCHEMA_VERSION = 1
class MiningProcessLock:
def __init__(self, data_dir: Path) -> None:
self._path = Path(data_dir) / ".mining_process.lock"
self._stream: BinaryIO | None = None
def acquire(self) -> None:
@property
def path(self) -> Path:
return self._path
def acquire(self, *, role: str = "app") -> None:
if self._stream is not None:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
@@ -30,6 +40,9 @@ class MiningProcessLock:
stream.close()
raise
self._stream = stream
# flock 是唯一事实; 内容只是给诊断/强制释放用的持有者线索。
# 持锁期间本进程是该文件唯一写入者, 无并发竞争。
self._rewrite_locked(stream, holder_payload(role))
def release(self) -> None:
stream = self._stream
@@ -37,10 +50,93 @@ class MiningProcessLock:
return
self._stream = None
try:
self._rewrite_locked(
stream,
{
"schema_version": _LOCK_SCHEMA_VERSION,
"pid": os.getpid(),
"released_at": datetime.now(UTC).isoformat(),
},
)
_unlock_file(stream)
finally:
stream.close()
@property
def acquired(self) -> bool:
return self._stream is not None
@staticmethod
def _rewrite_locked(stream: BinaryIO, payload: dict) -> None:
try:
stream.seek(0)
stream.truncate()
stream.write(
(json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
)
stream.flush()
os.fsync(stream.fileno())
except OSError:
# 诊断信息写失败不影响锁语义 (flock 已生效/将生效)
pass
def holder_payload(role: str) -> dict:
return {
"schema_version": _LOCK_SCHEMA_VERSION,
"pid": os.getpid(),
"started_at": datetime.now(UTC).isoformat(),
"role": role,
"host": socket.gethostname(),
}
def read_lock_holder(data_dir: Path | str) -> dict | None:
"""读取锁文件中的持有者线索; 旧格式 ("0") / 损坏内容返回 None。
返回值仅供参考: flock 才是持锁事实, 内容可能滞后 (例如持有者崩溃后
内核自动释放锁但内容未更新)。
"""
path = Path(data_dir) / ".mining_process.lock"
try:
raw = path.read_bytes()
except OSError:
return None
try:
value = json.loads(raw.decode("utf-8", errors="replace"))
except (UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict):
return None
pid = value.get("pid")
if not isinstance(pid, int) or pid <= 0 or "released_at" in value:
return None
return value
def probe_lock_held(data_dir: Path | str) -> bool:
"""非阻塞探测锁是否被任何进程持有 (含本进程自己)。"""
path = Path(data_dir) / ".mining_process.lock"
try:
stream = path.open("a+b")
except OSError:
return False
try:
os.set_inheritable(stream.fileno(), False)
_try_lock_file(stream)
except MiningProcessLockError:
return True
except OSError:
# 无法探测时按未持锁处理, 让真正的 acquire 路径给出准确错误
return False
try:
_unlock_file(stream)
except OSError:
pass
finally:
stream.close()
return False
def _try_lock_file(stream: BinaryIO) -> None:
if os.name == "nt":
+23 -13
View File
@@ -99,28 +99,38 @@ def test_precheck_mining_lock_detects_conflict(data_dir):
# ---------- main() 预检冲突路径 ----------
def test_main_releases_lock_when_failure_window_crashes(
def test_main_proceeds_when_mining_lock_precheck_conflicts(
tmp_path, monkeypatch, caplog
):
"""预检冲突 + 失败页窗口自身崩溃 (如缺 webview 依赖): 异常不得裸穿 main()。
"""预检撞锁只降级: 留 WARNING 日志并继续启动, 不再提前退出失败页。
该路径在 main() 的 try/finally 之外, 不兜底会导致 console=False 闪退无反馈、
.desktop.lock 跳过释放。断言: 记录异常日志、单实例锁仍释放、退出码 1。
lifespan 会以降级模式起面板 (挖掘入口 409, 挖掘页提供强制释放),
桌面版必须照常开窗, 否则用户没有入口处理锁冲突。
"""
import logging
monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False)
monkeypatch.setattr(desktop, "_guard_streams", lambda: None)
monkeypatch.setattr(desktop, "_setup_logging", lambda: None)
monkeypatch.setattr(desktop, "_precheck_mining_lock", lambda: "冲突原因")
monkeypatch.setattr(
desktop, "_precheck_mining_lock", lambda: "另一个后端实例正在占用数据目录"
)
monkeypatch.setattr(desktop, "_find_free_port", lambda base: 59998)
monkeypatch.setattr(
desktop, "_wait_for_server",
lambda port, timeout, done_event: True,
)
opened = []
monkeypatch.setattr(desktop, "_open_window", lambda url: opened.append(url))
def _boom(_html_text):
raise RuntimeError("webview unavailable")
def _fake_uvmicorn(port, ready):
ready.set()
monkeypatch.setattr(desktop, "_open_static_window", _boom)
monkeypatch.setattr(desktop, "_run_uvmicorn", _fake_uvmicorn)
import logging
with caplog.at_level(logging.WARNING, logger="app.desktop"):
assert desktop.main() == 0
with caplog.at_level(logging.ERROR, logger="app.desktop"):
assert desktop.main() == 1
assert "启动失败页窗口打开失败" in caplog.text
assert opened and opened[0].startswith("http://127.0.0.1:")
assert "启动预检" in caplog.text
assert not (tmp_path / ".desktop.lock").exists()
+41 -13
View File
@@ -9,6 +9,10 @@ import pytest
import app.main as main_module
def _app_stub() -> SimpleNamespace:
return SimpleNamespace(state=SimpleNamespace())
def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> None:
events: list[str] = []
@@ -16,11 +20,14 @@ def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> N
def __init__(self, data_dir) -> None:
del data_dir
events.append("lock_created")
self.acquired = False
def acquire(self) -> None:
self.acquired = True
events.append("lock_acquired")
def release(self) -> None:
self.acquired = False
events.append("lock_released")
@asynccontextmanager
@@ -34,8 +41,10 @@ def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> N
monkeypatch.setattr(main_module, "MiningProcessLock", LockStub)
monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan)
app = _app_stub()
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
async with main_module.lifespan(app):
events.append("request_serving")
asyncio.run(exercise())
@@ -48,6 +57,8 @@ def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> N
"application_stopped",
"lock_released",
]
assert app.state.mining_lock_acquired is True
assert app.state.mining_process_lock is not None
def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) -> None:
@@ -56,11 +67,14 @@ def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) ->
class LockStub:
def __init__(self, data_dir) -> None:
del data_dir
self.acquired = False
def acquire(self) -> None:
self.acquired = True
events.append("acquired")
def release(self) -> None:
self.acquired = False
events.append("released")
@asynccontextmanager
@@ -74,7 +88,7 @@ def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) ->
monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan)
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
async with main_module.lifespan(_app_stub()):
pass
with pytest.raises(RuntimeError, match="shutdown failed"):
@@ -83,17 +97,22 @@ def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) ->
assert events == ["acquired", "released"]
def test_lifespan_logs_true_cause_when_lock_held(monkeypatch, caplog) -> None:
"""抢锁失败: 先落 ERROR 日志 (桌面版失败页指向 desktop.log 的排查依据) 再上抛。
def test_lifespan_degrades_instead_of_failing_when_lock_held(
monkeypatch, caplog
) -> None:
"""抢锁失败: 不再让 lifespan 上抛 (整个后端起不来), 改为降级启动。
uvicorn 会吞掉 lifespan 异常只留一行笼统的 startup failed, 真因必须由
app 自己先写日志, 否则用户按失败页指引打开 desktop.log 查无对证。
面板照常可用, 挖掘启动入口返回 409; WARNING 日志留排查线索;
未获锁不得在 shutdown 时误调 release。
"""
from app.services.mining_process_lock import MiningProcessLockError
events: list[str] = []
class LockStub:
def __init__(self, data_dir) -> None:
del data_dir
self.acquired = False
def acquire(self) -> None:
raise MiningProcessLockError("another process owns mining")
@@ -101,16 +120,25 @@ def test_lifespan_logs_true_cause_when_lock_held(monkeypatch, caplog) -> None:
def release(self) -> None:
raise AssertionError("acquire 失败后不应调用 release")
@asynccontextmanager
async def application_lifespan(_app):
events.append("application_started")
yield
events.append("application_stopped")
monkeypatch.setattr(main_module, "MiningProcessLock", LockStub)
monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan)
app = _app_stub()
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
pass
async with main_module.lifespan(app):
events.append("request_serving")
with (
caplog.at_level("ERROR", logger="app.main"),
pytest.raises(MiningProcessLockError),
):
asyncio.run(exercise())
with caplog.at_level("WARNING", logger="app.main"):
asyncio.run(exercise()) # 不再抛出
assert events == ["application_started", "request_serving", "application_stopped"]
assert "挖掘锁" in caplog.text
assert "降级" in caplog.text
assert app.state.mining_lock_acquired is False
+304
View File
@@ -0,0 +1,304 @@
"""挖掘锁持有者记录 / 陈旧判定 / 强制释放 + 撞锁降级启动测试。"""
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)
+1 -1
View File
@@ -66,7 +66,7 @@
**策略守护(验证 tab)**:挖掘发布的策略在发布时自动建立守护基线(晋升门槛指标,天然 OOS);此后每天收盘数据更新后自动在新数据上切 K 段做真实回测复验,按稳健分对比基线判定三态——健康(退化≤阈值)/观察(20%-50% 或击穿软底线,连续 2 次告警)/退役(退化>50% 或交易数枯竭)。退役只标记与置灰,不自动删策略文件;已退役策略不再自动复验(重新守护=移除后重新入护)。任意策略也可手动入护(尾随参考基线,非 OOS,判定只针对入护后新数据)。数据纪律:守护层结果绝不反馈进取参加权(逐层 OOS);段落回测失败时只降级到观察不直接退役;新数据不足 30 个交易日跳过本轮。守护页还提供**实验室战报**(守护/挖掘/名额聚合总览,`/api/backtest/lab/report`)与**组合建议**:扫描健康守护冠军间近 126 个交易日的日收益 Pearson 相关性,≤0.7 的对列出,人工确认后生成等权 composite 策略并自动以尾随参考基线入护(幂等,不在最新建议中的对拒绝生成);**挖掘策略名额上限**(偏好项,默认 10,0=不限)在发布门槛通过后校验,名额满时新冠军停在 pending 候选等位。设计见 [自动策略实验室设计](./auto-strategy-lab-plan.md)。
**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。冠军排序按稳健分(0.6×中位+0.4×最差外层折),strict 档要求最差折 Sharpe>0;对冠军组合再做权重精调({0.5,1.0,1.5,2.0} 网格,消耗 run 剩余预算,退化>20% 放弃),精调结果作为独立候选行追加。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布,周度挖掘与每日守护由调度单入口(`lab_schedule`)编排、失败互不阻断。完整口径见 [因子与策略挖掘](./mining.md)。
**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。冠军排序按稳健分(0.6×中位+0.4×最差外层折),strict 档要求最差折 Sharpe>0;对冠军组合再做权重精调({0.5,1.0,1.5,2.0} 网格,消耗 run 剩余预算,退化>20% 放弃),精调结果作为独立候选行追加。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布,周度挖掘与每日守护由调度单入口(`lab_schedule`)编排、失败互不阻断。挖掘锁被其他进程占用时后端**降级启动**(挖掘入口 409、其余功能不受影响),挖掘页显示持有者横幅,满足陈旧判定(无进行中运行 + 存储静默 + pid 未复用,fail-closed)可一键强制停止并接管。完整口径见 [因子与策略挖掘](./mining.md)。
**策略设置统一**:回测页「高级策略设置」抽屉与策略页设置弹窗采用同一信息架构(策略条件 / 策略权重 / 风控触发 / 回测范围)。风控与持仓纪律(止损 · 止盈 · 移动止损 · 回撤止盈 · 最长持仓)以策略设置保存的值为默认,回测抽屉内可临时覆盖,也可「应用到策略」持久化;策略页保存的叠加条件随回测请求继承,与扫描 / 监控同一口径;回测范围(资产类型 / 股票池)为回测专属概念。
+2
View File
@@ -108,6 +108,8 @@ run 级的“有效折”统计只描述因子赛道:它按每折被选中的
当前 run store 和重任务 limiter 使用进程内锁。生产环境必须只启动一个应用进程负责挖掘;多 worker Uvicorn 部署不能保证跨进程单飞、事件追加和容量限制。要支持多应用进程,必须先增加操作系统级文件锁或外部任务协调器。
**挖掘锁冲突的降级与强制释放**:数据目录的 `.mining_process.lock`(flock)保证同一时刻只有一个应用进程负责挖掘;持锁者会把 `{pid, started_at, role}` 写进锁文件(flock 仍是唯一事实,内容只是诊断线索,旧格式 `"0"` 视为持有者未知)。撞锁不再导致后端启动失败——面板以降级模式运行:挖掘启动入口(手动/自动/周度调度)返回 409,守护复验等其他功能不受影响;挖掘页顶部显示持有者横幅(pid、角色、挖掘存储静默时长)。当且仅当满足陈旧判定(持有者信息可读、进程存活且 pid 未被复用、无进行中的挖掘运行、挖掘存储 10 分钟内无写入——全部 fail-closed,任一不满足即拒绝并给出原因)时,可一键「强制停止并接管」:终止持有者进程并由当前后端获取锁,挖掘入口立即恢复。不做启动时自动清理——终止进程是破坏性操作,必须人工确认。
每个运行目录包含 manifest、compact summary、最近 256 条事件和四个 Parquet artifact。大面板、完整矩阵对象和逐折宽结果不会通过 worker IPC 返回。
## 自动运行
+28
View File
@@ -1575,6 +1575,26 @@ export interface MiningAvailability {
suggested_start: string | null
}
/** 挖掘锁诊断 (撞锁降级模式的持有者线索 + 强制释放判定)。 */
export interface MiningLockStatus {
held: boolean
mine: boolean
holder: {
pid: number
started_at?: string
role?: string
host?: string
schema_version?: number
} | null
holder_alive: boolean
pid_consistent: boolean
active_runs: number
store_idle_seconds: number | null
stale_after_seconds: number
force_releasable: boolean
reason: string | null
}
export interface MiningRequestV1 {
factor_names: string[]
strategy_ids?: string[]
@@ -3462,6 +3482,14 @@ export const api = {
...(signal ? { signal } : {}),
}),
miningLockStatus: () =>
request<MiningLockStatus>('/api/backtest/mining/lock', { quiet: true }),
miningForceReleaseLock: () =>
request<{ released: boolean; terminated_pid: number; reacquired: boolean }>(
'/api/backtest/mining/lock/force-release', { method: 'POST' },
),
miningRuns: () =>
request<{ items: MiningRun[] }>('/api/backtest/mining/runs'),
+1
View File
@@ -68,6 +68,7 @@ export const QK = {
miningRun: (id: string) => ['backtest-mining-run', id] as const,
miningResult: (id: string) => ['backtest-mining-result', id] as const,
miningConfig: ['backtest-mining-config'] as const,
miningLock: ['backtest-mining-lock'] as const,
guardianOverview: ['backtest-guardian-overview'] as const,
guardianRuns: ['backtest-guardian-runs'] as const,
labReport: ['backtest-lab-report'] as const,
@@ -343,6 +343,78 @@ function FactorTable({ result }: { result: MiningResult }) {
)
}
/** 挖掘锁占用横幅: 撞锁降级时展示持有者线索, 满足陈旧判定可强制释放。 */
function MiningLockBanner() {
const queryClient = useQueryClient()
const lockQuery = useQuery({
queryKey: QK.miningLock,
queryFn: api.miningLockStatus,
staleTime: 10_000,
// 被他人持有时轮询等待恢复; 空闲/自己持有则不轮询
refetchInterval: query =>
query.state.data?.held && !query.state.data.mine ? 15_000 : false,
})
const release = useMutation({
mutationFn: api.miningForceReleaseLock,
onSuccess: () => {
toast('已终止持有者并由当前后端接管挖掘锁', 'success')
void queryClient.invalidateQueries({ queryKey: QK.miningLock })
},
onError: (e: Error) => toast(e.message, 'error'),
})
const data = lockQuery.data
if (!data?.held || data.mine) return null
const holder = data.holder
const idleMin = data.store_idle_seconds != null
? Math.max(1, Math.round(data.store_idle_seconds / 60))
: null
return (
<div className="mb-3 rounded-card border border-warning/40 bg-warning/10 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-start gap-2 text-xs">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div>
<div className="font-medium text-foreground">挖掘锁被其他进程占用 — 挖掘暂不可用</div>
<div className="mt-0.5 text-[11px] leading-4 text-secondary">
{holder ? (
<>
持有者 pid {holder.pid}
{holder.role ? ` · ${holder.role === 'worker' ? '挖掘 worker 进程' : '后端实例'}` : ''}
{idleMin != null ? ` · 挖掘存储已静默约 ${idleMin} 分钟` : ''}
{data.force_releasable
? ' — 满足陈旧判定, 可强制释放'
: ` — 不可强制释放: ${data.reason}`}
</>
) : (
'锁文件为旧格式, 无法定位持有进程 — 请手动结束后端残留进程后重启面板'
)}
</div>
</div>
</div>
{data.force_releasable && (
<button
type="button"
disabled={release.isPending}
onClick={() => {
if (window.confirm(
`确定终止持有挖掘锁的进程 (pid ${holder?.pid}) 并由当前后端接管?\n\n` +
'仅当该进程没有进行中的挖掘、且挖掘存储长期无写入时才会允许此操作。\n' +
'若它是你正在使用的另一个面板实例, 请改到那边正常退出。',
)) release.mutate()
}}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn border border-warning/60 bg-warning/20 px-3 text-xs font-medium text-foreground hover:bg-warning/30 disabled:opacity-50"
>
{release.isPending
? <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
: <Square className="h-3.5 w-3.5" />}
强制停止并接管
</button>
)}
</div>
</div>
)
}
export function MiningWorkbench() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
@@ -668,6 +740,8 @@ export function MiningWorkbench() {
}
return (
<>
<MiningLockBanner />
<div className="grid min-h-[calc(100vh-9rem)] grid-cols-1 overflow-hidden rounded-card border border-border bg-surface xl:grid-cols-[20rem_minmax(0,1fr)]">
<aside className="border-b border-border bg-base/25 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto xl:border-b-0 xl:border-r">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
@@ -893,5 +967,6 @@ export function MiningWorkbench() {
</div>
)}
</div>
</>
)
}