mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user