mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
清库半删除等异常会留下 owner 已死但状态仍为 publishing 的 generation 标记, 后续读取直接抛 EnrichedGenerationUnavailableError, 步进优化连锁失败。 - 读取侧仅在「owner 是其他进程且该进程已死」时判孤儿并在独占锁内 自愈 (换新 uuid 重建 ready 标记); 同 pid 无活跃对象时保守不判, 避免暴露清库半删除的数据 (fail-closed) - 回测引擎改为 data_generation_await 轮询等待 (300s 上限), 遇短暂 publishing 自动重试而非直接失败; worker 错误文案同步为可重试提示
347 lines
12 KiB
Python
347 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import threading
|
|
from datetime import date
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.backtest.engine import BacktestEngine, PanelCache
|
|
from app.enriched_generation import (
|
|
EnrichedGenerationUnavailableError,
|
|
EnrichedPublication,
|
|
get_enriched_generation,
|
|
)
|
|
from app.tickflow.repository import DataStore, KlineRepository
|
|
|
|
|
|
def _frame(value: float = 10.0) -> pl.DataFrame:
|
|
return pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"date": [date(2026, 8, 14)],
|
|
"open": [value],
|
|
"high": [value],
|
|
"low": [value],
|
|
"close": [value],
|
|
"volume": [1_000.0],
|
|
})
|
|
|
|
|
|
def test_repository_enriched_noop_does_not_bump_generation(tmp_path) -> None:
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
frame = _frame()
|
|
|
|
repo.append_enriched(frame)
|
|
first = repo.get_matrix_data_generation("stock")
|
|
repo.append_enriched(frame)
|
|
|
|
assert repo.get_matrix_data_generation("stock") == first
|
|
|
|
|
|
def test_failed_multi_partition_publication_remains_fail_closed(
|
|
tmp_path,
|
|
monkeypatch,
|
|
) -> None:
|
|
publication = EnrichedPublication(tmp_path, recover=True)
|
|
first = tmp_path / "kline_daily_enriched" / "date=2026-08-13" / "part.parquet"
|
|
second = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
publication.write_parquet(_frame(10.0), first)
|
|
|
|
original_write = pl.DataFrame.write_parquet
|
|
|
|
def fail_second(self, path, *args, **kwargs):
|
|
if "2026-08-14" in str(path):
|
|
raise OSError("injected write failure")
|
|
return original_write(self, path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_second)
|
|
with pytest.raises(OSError, match="injected"):
|
|
publication.write_parquet(_frame(11.0), second)
|
|
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "publishing"
|
|
assert first.is_file()
|
|
assert not second.is_file()
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
|
|
get_enriched_generation(tmp_path, "stock")
|
|
|
|
|
|
def test_recovery_replaces_stale_publication_but_not_active_owner(tmp_path) -> None:
|
|
first = EnrichedPublication(tmp_path, recover=True)
|
|
out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
first.write_parquet(_frame(10.0), out)
|
|
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="active"):
|
|
EnrichedPublication(tmp_path, recover=True).write_parquet(_frame(11.0), out)
|
|
|
|
del first
|
|
recovered = EnrichedPublication(tmp_path, recover=True)
|
|
recovered.write_parquet(_frame(12.0), out)
|
|
recovered_generation = recovered.commit()
|
|
|
|
assert recovered_generation == get_enriched_generation(tmp_path, "stock")
|
|
assert pl.read_parquet(out)["close"].item() == pytest.approx(12.0)
|
|
|
|
|
|
def test_recovery_takes_over_when_owner_pid_is_dead_on_windows(tmp_path, monkeypatch) -> None:
|
|
# 跨进程孤儿锁: 属主进程已死, 但 Windows 的 os.kill(pid, 0) 对不存在的 pid
|
|
# 抛 WinError 87 (ERROR_INVALID_PARAMETER) 而非 ProcessLookupError,
|
|
# 存活探测若把它当"存活", recover 将永远报 another publication is active。
|
|
stale = {
|
|
"state": "publishing",
|
|
"generation": "stale-generation",
|
|
"publication_id": "stale-publication",
|
|
"owner_pid": 12345,
|
|
"updated_at_ns": 0,
|
|
}
|
|
(tmp_path / ".matrix_generation_stock.json").write_text(
|
|
json.dumps(stale), encoding="utf-8"
|
|
)
|
|
|
|
def probe(_pid: int, _sig: int) -> None:
|
|
error = OSError()
|
|
error.winerror = 87
|
|
raise error
|
|
|
|
monkeypatch.setattr("app.enriched_generation.os.kill", probe)
|
|
|
|
out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
recovered = EnrichedPublication(tmp_path, recover=True)
|
|
recovered.write_parquet(_frame(10.0), out)
|
|
recovered.commit()
|
|
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
assert get_enriched_generation(tmp_path, "stock") == marker["generation"]
|
|
assert pl.read_parquet(out)["close"].item() == pytest.approx(10.0)
|
|
|
|
|
|
def test_panel_cache_generation_change_forces_recompute() -> None:
|
|
cache = PanelCache()
|
|
calls: list[int] = []
|
|
args = (["000001.SZ"], date(2026, 8, 13), date(2026, 8, 14), None)
|
|
|
|
def compute(*_args):
|
|
calls.append(len(calls) + 1)
|
|
return pl.DataFrame({"value": [calls[-1]]})
|
|
|
|
first = cache.get_or_compute(*args, compute, "stock", "generation-a")
|
|
second = cache.get_or_compute(*args, compute, "stock", "generation-b")
|
|
|
|
assert first["value"].item() == 1
|
|
assert second["value"].item() == 2
|
|
assert cache.stats()["compute_count"] == 2
|
|
|
|
|
|
def test_panel_reader_retries_when_generation_changes_during_scan(tmp_path) -> None:
|
|
generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"])
|
|
repo = SimpleNamespace(
|
|
store=SimpleNamespace(data_dir=tmp_path),
|
|
get_matrix_data_generation=lambda _asset_type: next(generations),
|
|
)
|
|
engine = BacktestEngine(repo)
|
|
calls: list[str] = []
|
|
|
|
def load(*_args):
|
|
calls.append("scan")
|
|
return _frame(float(len(calls)))
|
|
|
|
engine._load_panel_inner = load
|
|
panel = engine.load_panel(
|
|
None,
|
|
date(2026, 8, 14),
|
|
date(2026, 8, 14),
|
|
columns=["symbol", "date", "close"],
|
|
)
|
|
|
|
assert calls == ["scan", "scan"]
|
|
assert panel["close"].item() == pytest.approx(2.0)
|
|
|
|
|
|
def test_matrix_reader_retries_when_generation_changes_during_build(
|
|
tmp_path,
|
|
monkeypatch,
|
|
) -> None:
|
|
generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"])
|
|
repo = SimpleNamespace(
|
|
store=SimpleNamespace(data_dir=tmp_path),
|
|
get_matrix_data_generation=lambda _asset_type: next(generations),
|
|
get_instruments_asset=lambda _asset_type: pl.DataFrame(),
|
|
)
|
|
engine = BacktestEngine(repo)
|
|
calls: list[str | None] = []
|
|
expected = SimpleNamespace(
|
|
execution_backend="matrix_native",
|
|
base_columns={"open", "high", "low", "close", "volume"},
|
|
instrument_columns=set(),
|
|
matrix_columns=set(),
|
|
)
|
|
market = SimpleNamespace()
|
|
|
|
def load_matrix(*_args, source_generation=None, **_kwargs):
|
|
calls.append(source_generation)
|
|
return market
|
|
|
|
monkeypatch.setattr("app.backtest.engine.load_market_data_matrix_from_parquet", load_matrix)
|
|
|
|
result = engine.load_market_data_matrix_for_backtest(
|
|
None,
|
|
date(2026, 8, 14),
|
|
date(2026, 8, 14),
|
|
expected,
|
|
)
|
|
|
|
assert result is market
|
|
assert calls == ["generation-a", "generation-b"]
|
|
|
|
|
|
def test_live_flush_write_recovers_stale_marker_from_dead_process(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
"""实时 enriched 落盘(repository 路径)遇到僵死 publishing 标记应接管自愈,
|
|
而非持续抛错直到下一次盘后管道。"""
|
|
from app.tickflow.repository import DataStore, KlineRepository
|
|
|
|
(tmp_path / ".matrix_generation_stock.json").write_text(
|
|
json.dumps({
|
|
"state": "publishing",
|
|
"generation": "stale-generation",
|
|
"publication_id": "stale-publication",
|
|
"owner_pid": 999999999,
|
|
"updated_at_ns": 0,
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
repo.append_enriched(_frame(10.0))
|
|
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
|
|
|
|
def _stale_publishing_marker(tmp_path, owner_pid: int = 999999999) -> None:
|
|
(tmp_path / ".matrix_generation_stock.json").write_text(
|
|
json.dumps({
|
|
"state": "publishing",
|
|
"generation": "stale-generation",
|
|
"publication_id": "stale-publication",
|
|
"owner_pid": owner_pid,
|
|
"updated_at_ns": 0,
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_reader_self_heals_stale_publishing_marker_from_dead_owner(tmp_path) -> None:
|
|
"""读取方遇到属主已死的 publishing 标记应就地恢复 ready, 而非持续失败
|
|
直到某个写入方碰巧接管 (dev 热重载杀掉发布进程即产生这种孤儿)。"""
|
|
_stale_publishing_marker(tmp_path)
|
|
|
|
generation = get_enriched_generation(tmp_path, "stock")
|
|
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
# 恢复时换新 generation: 磁盘可能残留部分替换的文件, 按代缓存需要失效。
|
|
assert generation not in ("", "stale-generation")
|
|
assert generation == marker["generation"]
|
|
assert get_enriched_generation(tmp_path, "stock") == generation
|
|
|
|
|
|
def test_reader_still_fails_closed_while_owner_is_alive(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setattr("app.enriched_generation._process_is_alive", lambda pid: True)
|
|
_stale_publishing_marker(tmp_path, owner_pid=os.getpid() + 1)
|
|
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
|
|
get_enriched_generation(tmp_path, "stock")
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "publishing" # 标记未被读取方改动
|
|
|
|
|
|
def test_reader_respects_active_in_process_publication(tmp_path) -> None:
|
|
publication = EnrichedPublication(tmp_path, recover=True)
|
|
publication.begin()
|
|
try:
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
|
|
get_enriched_generation(tmp_path, "stock")
|
|
finally:
|
|
publication.abandon()
|
|
|
|
assert _is_ready_marker(tmp_path)
|
|
|
|
|
|
def _is_ready_marker(tmp_path) -> bool:
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
return marker["state"] == "ready"
|
|
|
|
|
|
def test_data_generation_await_retries_during_publication(tmp_path, monkeypatch) -> None:
|
|
engine = BacktestEngine(KlineRepository(DataStore(tmp_path)))
|
|
calls = {"n": 0}
|
|
|
|
def flaky(asset_type: str = "stock"):
|
|
calls["n"] += 1
|
|
if calls["n"] < 3:
|
|
raise EnrichedGenerationUnavailableError(
|
|
"enriched data is being published; retry after the update finishes"
|
|
)
|
|
return "gen-final"
|
|
|
|
monkeypatch.setattr(engine, "data_generation", flaky)
|
|
monkeypatch.setattr("app.backtest.engine._GENERATION_POLL_S", 0.001)
|
|
|
|
assert engine.data_generation_await("stock") == "gen-final"
|
|
assert calls["n"] == 3
|
|
|
|
|
|
def test_data_generation_await_times_out_and_respects_cancel(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
engine = BacktestEngine(KlineRepository(DataStore(tmp_path)))
|
|
monkeypatch.setattr("app.backtest.engine._GENERATION_POLL_S", 0.001)
|
|
|
|
def always_publishing(asset_type: str = "stock"):
|
|
raise EnrichedGenerationUnavailableError(
|
|
"enriched data is being published; retry after the update finishes"
|
|
)
|
|
|
|
monkeypatch.setattr(engine, "data_generation", always_publishing)
|
|
|
|
with pytest.raises(EnrichedGenerationUnavailableError):
|
|
engine.data_generation_await("stock", timeout_s=0.01)
|
|
|
|
cancel = threading.Event()
|
|
cancel.set()
|
|
with pytest.raises(EnrichedGenerationUnavailableError):
|
|
engine.data_generation_await("stock", cancel_event=cancel, timeout_s=30.0)
|
|
|
|
|
|
def test_worker_error_message_translates_publishing_error() -> None:
|
|
from app.backtest.worker import _error_message
|
|
from app.enriched_generation import EnrichedGenerationUnavailableError
|
|
|
|
translated = _error_message(
|
|
EnrichedGenerationUnavailableError(
|
|
"enriched data is being published; retry after the update finishes"
|
|
)
|
|
)
|
|
assert translated == "指标数据正在发布更新,请稍后重试"
|
|
assert _error_message(ValueError("boom")) == "boom"
|