fix(minute): 全量分钟服务落盘持仓库写锁 + 原子写临时名唯一化 + 基准状态暴露

- minute_refresh 落盘改持 repo._write_lock: 与 minute-batch 补拉/盘后同步
  同一纪律。此前服务是唯一不持锁的写者, 并发替换同一分区时后写者以过期
  基底覆盖丢整批行, 且共用固定名 tmp 会交错损坏且无自愈。实测增量轮落盘
  ~10ms, 锁占用可忽略
- _atomic_write_parquet 临时名加 uuid 后缀: 消除两写者共用同一 tmp 的
  交错损坏窗口; 崩溃孤儿 tmp 不匹配 *.parquet glob
- 服务每轮记录本地库 raw 基准状态 (status 新增 minute_basis_raw 字段),
  未迁移库一次性警告不刷屏; 插件契约文档补"full_minute 必须返回原始价"条款
- 修正头注释 clamp 漂移 ([3,300]→[3,120])

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-25 17:31:05 +08:00
parent d43a3021da
commit ddaab1fbf1
5 changed files with 266 additions and 6 deletions
+3 -1
View File
@@ -96,7 +96,9 @@ def _atomic_write_parquet(df: pl.DataFrame, out) -> None:
*.parquet glob, 不会被扫描误读。Windows 下目标正被并发读取时由
replace_with_retry 短退避穿过。
"""
tmp = out.with_name(out.name + ".tmp")
# 唯一后缀: 两个写入者共用固定名 tmp 并发写同一文件会交错损坏 (被 rename
# 定版后分区报废)。崩溃残留的孤儿 tmp 不匹配 *.parquet glob, 读侧无影响。
tmp = out.with_name(f"{out.name}.{uuid.uuid4().hex[:8]}.tmp")
df.write_parquet(tmp)
replace_with_retry(tmp, out)
+23 -5
View File
@@ -25,7 +25,7 @@
由 provider 自管批量与限速 (rpm 配置/内部并发), 服务不代限。
- 稳态轮单请求: 无脉冲并发, 间隔可低至 3s; 实际节奏 = max(间隔, 单轮完成),
服务端响应 ~5s 时自动退化为响应节奏, 不会重叠请求。
- 固定节奏: 默认 6s 一轮 (clamp [3, 300]), 不补跑 (missed 轮次直接跳过);
- 固定节奏: 默认 6s 一轮 (clamp [3, 120]), 不补跑 (missed 轮次直接跳过);
仅修复轮的自定义源下限抬到 60s (全天批量打不住 6s 节奏)。
- 仅连续竞价时段运行 (9:30-11:30 / 13:00-15:00), 午休/收盘自动暂停与恢复;
午休后恢复因覆盖滞后会多跑一次修复轮, 幂等无害。
@@ -50,7 +50,7 @@ from typing import Any
import polars as pl
from app.market_time import cn_now, cn_today, in_continuous_session
from app.services import preferences
from app.services import minute_adjust, preferences
logger = logging.getLogger(__name__)
@@ -89,6 +89,8 @@ class _RefreshState:
last_error: str | None = None
next_round_at: float | None = None # epoch 秒
extra: dict[str, Any] = field(default_factory=dict)
minute_basis_raw: bool | None = None # 上轮落盘时本地库是否 raw 基准
basis_warned: bool = False # 混基准风险只警告一次
class MinuteRefreshService:
@@ -338,10 +340,25 @@ class MinuteRefreshService:
)
return
self._empty_rounds = 0
# 基准可见性: intraday 端点的价格基准未签约 (SDK 无 adjust 参数),
# 本地库未迁移 raw (.raw_basis 缺失) 时服务写入与端点补拉 (forward)
# 可能混基准 —— 只暴露状态与一次性警告, 不阻断 (阻断属功能变更, 另行决策)。
basis_raw = minute_adjust.minute_basis_is_raw(self._repo.store.data_dir)
if not basis_raw and not self._state.basis_warned:
logger.warning(
"全量分钟: 本地分钟库未迁移 raw 基准 (.raw_basis 缺失), intraday "
"价格基准未签约, 服务写入可能与端点补拉混基准; 建议先运行分钟基准迁移"
)
self._state.basis_warned = True
self._state.minute_basis_raw = basis_raw
write_started = time.perf_counter()
written = kline_sync._write_minute_partition(
df, self._repo.store.data_dir / "kline_minute",
)
# 读-改-写必须持仓库写锁 (与 minute-batch 补拉 / 盘后同步同一纪律):
# 无互斥的两个写者并发替换同一分区时, 后写者以过期基底覆盖丢整批行,
# 且会共用同一 tmp 文件交错损坏。实测增量轮落盘 ~10ms, 锁占用可忽略。
with self._repo._write_lock:
written = kline_sync._write_minute_partition(
df, self._repo.store.data_dir / "kline_minute",
)
write_ms = (time.perf_counter() - write_started) * 1000
self._state.rounds += 1
@@ -413,6 +430,7 @@ class MinuteRefreshService:
"last_symbols": self._state.last_symbols,
"last_requests": self._state.last_requests,
"last_mode": self._state.last_mode,
"minute_basis_raw": self._state.minute_basis_raw,
"next_round_at": self._state.next_round_at,
"last_error": self._state.last_error,
}
@@ -0,0 +1,233 @@
"""分钟数据完整性修复的回归锁定。
四项: ① 全量分钟服务落盘持仓库写锁 + 原子写临时名唯一化; ② 服务暴露 raw 基准
状态; ③ minute-batch 门控在全量分钟服务健康时放行本地读; ④ 补拉限速与盘后
同步统一走 resolve_limit (×0.8 安全系数, 同 rpm 桶)。
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.services import kline_sync
from app.services.minute_refresh import MinuteRefreshService
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
_BJ = date(2026, 10, 23)
_SYM = "600519.SH"
# ─────────────────────────── ① 服务落盘持写锁 ───────────────────────────
class _RecordingLock:
"""记录获取次数的锁包装; spy 在写分区时检查它是否被持有。"""
def __init__(self) -> None:
self._lock = threading.Lock()
def __enter__(self):
self._lock.acquire()
return self
def __exit__(self, *exc):
self._lock.release()
return None
def locked(self) -> bool:
return self._lock.locked()
def _round_df() -> pl.DataFrame:
start = datetime(_BJ.year, _BJ.month, _BJ.day, 10, 0)
return pl.DataFrame({
"symbol": [_SYM] * 3,
"datetime": [start + timedelta(minutes=i) for i in range(3)],
"close": [10.0, 10.1, 10.2],
})
def test_service_write_holds_repo_write_lock(tmp_path, monkeypatch):
"""服务增量轮落盘必须持 repo._write_lock (与端点补拉/盘后同步同一纪律)。"""
lock = _RecordingLock()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path), _write_lock=lock)
svc = MinuteRefreshService(repo)
monkeypatch.setattr(svc, "_resolve_custom", lambda: (None, "tickflow"))
monkeypatch.setattr(svc, "_select_mode", lambda: "increment")
df = _round_df()
monkeypatch.setattr(
kline_sync, "fetch_intraday_universe_increment", lambda: (df, 1)
)
seen: dict[str, object] = {}
def spy_write(frame, minute_dir):
seen["locked"] = lock.locked()
return frame.height
monkeypatch.setattr(kline_sync, "_write_minute_partition", spy_write)
svc._run_round()
assert seen.get("locked") is True, "落盘时必须持有仓库写锁"
def test_service_records_minute_basis_state(tmp_path, monkeypatch):
"""② 每轮记录本地库 raw 基准状态; 未迁移 (无 .raw_basis) 时只警告一次。"""
lock = _RecordingLock()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path), _write_lock=lock)
svc = MinuteRefreshService(repo)
monkeypatch.setattr(svc, "_resolve_custom", lambda: (None, "tickflow"))
monkeypatch.setattr(svc, "_select_mode", lambda: "increment")
monkeypatch.setattr(
kline_sync, "fetch_intraday_universe_increment", lambda: (_round_df(), 1)
)
monkeypatch.setattr(kline_sync, "_write_minute_partition", lambda f, d: f.height)
warnings: list[str] = []
from app.services import minute_refresh as mr
monkeypatch.setattr(mr.logger, "warning", lambda msg, *a: warnings.append(msg))
svc._run_round()
svc._run_round()
assert svc._state.minute_basis_raw is False # tmp_path 无 .raw_basis 标记
assert len(warnings) == 1 # 只警告一次
assert "raw 基准" in warnings[0]
def test_atomic_write_tmp_names_unique(tmp_path, monkeypatch):
"""① 原子写的临时文件名必须每次唯一 (防两写者共用同一 tmp 交错损坏)。"""
df = pl.DataFrame({"a": [1, 2, 3]})
out = tmp_path / "part.parquet"
captured: list[Path] = []
monkeypatch.setattr(kline_sync, "replace_with_retry",
lambda tmp, dst: captured.append(tmp))
kline_sync._atomic_write_parquet(df, out)
kline_sync._atomic_write_parquet(df, out)
assert len(captured) == 2
assert captured[0] != captured[1]
assert all(p.name.endswith(".tmp") for p in captured) # 不匹配 *.parquet glob
assert not list(tmp_path.glob("*.parquet")) # 正常路径无残留
# ─────────────────────── ③ 门控: 服务健康放行本地读 ───────────────────────
def _bars(sym: str, n: int) -> pl.DataFrame:
start = datetime(_BJ.year, _BJ.month, _BJ.day, 9, 31)
return pl.DataFrame({
"symbol": [sym] * n,
"datetime": [start + timedelta(minutes=i) for i in range(n)],
"open": [10.0] * n, "high": [10.1] * n, "low": [9.9] * n, "close": [10.0] * n,
"volume": [100.0] * n, "amount": [1000.0] * n,
})
def _gate_env(monkeypatch, *, healthy: bool | None):
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
monkeypatch.setattr(kline_api, "cn_now", lambda: datetime(_BJ.year, _BJ.month, _BJ.day, 15, 0))
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: True)
repo = MagicMock()
repo.get_etf_symbol_set.return_value = set()
repo.get_minute_batch.return_value = _bars(_SYM, 240) # 240 根 = 盘后完整, 不触发补拉
capset = MagicMock()
capset.has.return_value = False # 无 Pro+ BATCH 能力
capset.limits.return_value = None
svc = None
if healthy is not None:
svc = MagicMock()
svc.is_healthy.return_value = healthy
request = MagicMock()
request.headers = {}
request.app.state.repo = repo
request.app.state.capabilities = capset
# MagicMock 的属性访问恒返回 truthy mock, 服务缺席场景必须显式置 None
request.app.state.minute_refresh = svc
return kline_api, request
def test_gate_allows_local_read_when_service_healthy(monkeypatch):
"""③ 无 Pro+ 但全量分钟服务健康 (正写本地分区) → 本地读放行。"""
kline_api, request = _gate_env(monkeypatch, healthy=True)
resp = kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
payload = json.loads(resp.body)
assert len(payload["data"][_SYM]) == 240
def test_gate_rejects_when_service_absent_or_unhealthy(monkeypatch):
"""③ 服务缺席或不健康且无能力 → 维持 403 (回落现状, 拉取仍需 Pro+)。"""
kline_api, request = _gate_env(monkeypatch, healthy=None)
with pytest.raises(HTTPException) as exc:
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert exc.value.status_code == 403
kline_api, request = _gate_env(monkeypatch, healthy=False)
with pytest.raises(HTTPException) as exc:
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert exc.value.status_code == 403
# ─────────────── ④ 补拉限速与盘后同步统一 (×0.8, 同 rpm 桶) ───────────────
def test_pull_uses_resolve_limit_safety_rpm(monkeypatch):
"""④ 补拉 rpm 必须经 resolve_limit (60 → 48), 与盘后同步落同一共享桶。"""
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
monkeypatch.setattr(kline_api, "cn_now", lambda: datetime(_BJ.year, _BJ.month, _BJ.day, 10, 30))
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: True)
# 中间有洞 (间距 2min) → 全天补拉, 捕获传给 sync_minute_batch 的限速参数
hole = pl.DataFrame({
"symbol": [_SYM] * 2,
"datetime": [datetime(_BJ.year, _BJ.month, _BJ.day, 9, 31),
datetime(_BJ.year, _BJ.month, _BJ.day, 9, 33)],
"open": [10.0] * 2, "high": [10.1] * 2, "low": [9.9] * 2, "close": [10.0] * 2,
"volume": [100.0] * 2, "amount": [1000.0] * 2,
})
repo = MagicMock()
repo.get_etf_symbol_set.return_value = set()
repo.get_minute_batch.return_value = hole
repo._write_lock = threading.Lock()
capset = CapabilitySet({Cap.KLINE_MINUTE_BATCH: CapabilityLimits(batch=200, rpm=60)})
captured: dict[str, object] = {}
def spy_sync(symbols, *, start_time, end_time, batch_size=None, rpm=None,
asset_type="stock", **kw):
captured["batch"] = batch_size
captured["rpm"] = rpm
return pl.DataFrame()
monkeypatch.setattr(kline_sync, "sync_minute_batch", spy_sync)
request = MagicMock()
request.headers = {}
request.app.state.repo = repo
request.app.state.capabilities = capset
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert captured["batch"] == 200 # batch 原样透传
assert captured["rpm"] == 48 # 60 × 0.8 安全系数, 与盘后同步同桶
+3
View File
@@ -47,9 +47,12 @@ class _FakeAppState:
class _FakeRepo:
def __init__(self, symbols: list[str]):
import threading
from pathlib import Path
self._inst = pl.DataFrame({"symbol": symbols})
self.store = type("S", (), {"data_dir": Path(".")})()
# 与真实仓库同契约: 分区读-改-写必须持仓库写锁
self._write_lock = threading.Lock()
def get_instruments(self) -> pl.DataFrame:
return self._inst
+4
View File
@@ -234,6 +234,10 @@ provider 不应自行切换或回退到其他数据源。
> - `get_intraday_latest(symbols=None, count=3) -> pl.DataFrame` — **可选**,
> 稳态增量轮专用:尽量单请求返回全市场每只标的最新 `count` 根。未实现时
> 服务自动降级为仅修复轮,节奏下限抬到 60s(全天批量打不住 6s 节奏)。
> - **价格基准契约**:两个方法都必须返回**原始价(不复权)**。本地分钟库以 raw
> 基准落盘 (`.raw_basis` 标记),读取侧统一做前复权投影;返回已复权价格会与
> 本地存储混基准,产生静默的错误价格。服务状态面板的 `minute_basis_raw`
> 字段会暴露本地库的基准迁移状态。
>
> 两个方法的返回帧都过 `_enforce_minute_beijing_wallclock` 时区守卫(与
> `get_minute` 同纪律);失败抛异常或返回空 df 均按空轮处理,连续空轮触发