mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
单连接全局互斥下, 同步期任一长查询(全视图扫描)把所有交互读堵到前端 30s 超时, 不同端点轮流失害 (/status 事故即此形态)。cursor() 本就是同一内存库实例的独立连接 (MVCC 快照隔离; duckdb 1.5.3 实测 6 读线程+2 换视图线程共 1800 次零异常), execute_all/execute_one 改为信号量限并发 (TICK_DUCKDB_READ_CONCURRENCY, 默认 3, 1=完全串行旧行为), 根连接运行期仅作连接工厂。 并发 CREATE OR REPLACE VIEW 同名视图会抛 Catalog write-write conflict (实测), 运行期视图重建一律收口 repo.refresh_view (_ddl_lock 串行, 与读不互斥), 同时消除 kline_sync/daily_pipeline/extend_history/api-kline 共 7 处绕锁直连 db.execute 的线程安全隐患 — 两同步路径同时刷同一视图的静默失败一并修复。 验证: 新增 test_duckdb_read_concurrency (并发重叠/串行回退/视图重建/DDL 零冲突/ 坏名字拒绝/env 钳制) 6 条; 全量 pytest 3479 通过; ruff 码级零新增(消 3 条存量); 基准 376ms 长查询持锁时短读 271ms → 0.1ms。 Signed-off-by: shy3130 <shy3130@qq.com>
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
"""DuckDB 读并发与视图重建串行 — repository 连接模型的回归锁。
|
|
|
|
背景: 原实现单连接全局互斥, 同步期任一长查询(全视图扫描)把所有交互读
|
|
堵在门外直到前端 30s 超时(/status 事故)。改为:
|
|
- execute_all/execute_one 走独立 cursor(=同一内存库实例的独立连接,
|
|
MVCC 快照隔离), 用信号量限并发数而非全局互斥;
|
|
- 运行期视图重建一律走 refresh_view(_ddl_lock 串行)。
|
|
|
|
duckdb 1.5.3 实测依据: 并发读 + 换视图零异常(两种视图版本都被观察到);
|
|
并发 CREATE OR REPLACE VIEW 同名视图抛 Catalog write-write conflict,
|
|
因此 DDL 必须串行 —— 旧代码两处同步路径同时刷视图会静默失败。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import polars as pl
|
|
|
|
from app.tickflow.repository import (
|
|
DataStore,
|
|
KlineRepository,
|
|
_read_concurrency_env,
|
|
)
|
|
|
|
|
|
def _write_partition(root: Path, name: str, rows: int) -> Path:
|
|
d = root / name
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "part.parquet").unlink(missing_ok=True)
|
|
pl.DataFrame({"x": list(range(rows))}).write_parquet(d / "part.parquet")
|
|
return d
|
|
|
|
|
|
class _SlowCursor:
|
|
"""记录并发数并模拟耗时查询的假 cursor (避免依赖真实查询时长, 不抖动)。"""
|
|
|
|
def __init__(self, tracker: dict) -> None:
|
|
self._tracker = tracker
|
|
|
|
def execute(self, sql: str, params: list | None = None) -> _SlowCursor:
|
|
with self._tracker["lock"]:
|
|
self._tracker["cur"] += 1
|
|
self._tracker["max"] = max(self._tracker["max"], self._tracker["cur"])
|
|
time.sleep(0.12)
|
|
with self._tracker["lock"]:
|
|
self._tracker["cur"] -= 1
|
|
return self
|
|
|
|
def fetchall(self) -> list[tuple]:
|
|
return [(1,)]
|
|
|
|
def fetchone(self) -> tuple:
|
|
return (1,)
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def _read_overlap(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
env_value: str,
|
|
n_threads: int,
|
|
) -> int:
|
|
monkeypatch.setenv("TICK_DUCKDB_READ_CONCURRENCY", env_value)
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
tracker: dict = {"lock": threading.Lock(), "cur": 0, "max": 0}
|
|
monkeypatch.setattr(repo, "_new_cursor", lambda: _SlowCursor(tracker))
|
|
|
|
threads = [
|
|
threading.Thread(target=repo.execute_all, args=("SELECT 1",))
|
|
for _ in range(n_threads)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
return tracker["max"]
|
|
|
|
|
|
def test_reads_overlap_up_to_limiter(tmp_path: Path, monkeypatch) -> None:
|
|
"""并发读 = 信号量容量 (默认 3), 而非旧的全局串行 (1)。"""
|
|
assert _read_overlap(tmp_path, monkeypatch, "3", 5) == 3
|
|
|
|
|
|
def test_reads_serialize_when_concurrency_one(tmp_path: Path, monkeypatch) -> None:
|
|
"""TICK_DUCKDB_READ_CONCURRENCY=1 恢复完全串行的旧行为。"""
|
|
assert _read_overlap(tmp_path, monkeypatch, "1", 4) == 1
|
|
|
|
|
|
def test_read_concurrency_env_clamped(monkeypatch) -> None:
|
|
monkeypatch.setenv("TICK_DUCKDB_READ_CONCURRENCY", "0")
|
|
assert _read_concurrency_env() == 1
|
|
monkeypatch.setenv("TICK_DUCKDB_READ_CONCURRENCY", "99")
|
|
assert _read_concurrency_env() == 16
|
|
monkeypatch.setenv("TICK_DUCKDB_READ_CONCURRENCY", "abc")
|
|
assert _read_concurrency_env() == 3
|
|
monkeypatch.delenv("TICK_DUCKDB_READ_CONCURRENCY", raising=False)
|
|
assert _read_concurrency_env() == 3
|
|
|
|
|
|
def test_refresh_view_updates_definition(tmp_path: Path) -> None:
|
|
"""视图重建生效: 指向新 glob 后查询立即取新数据。"""
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
a = _write_partition(tmp_path, "tbl_a", 2)
|
|
b = _write_partition(tmp_path, "tbl_b", 5)
|
|
|
|
assert repo.refresh_view("v_switch", f"{a}/**/*.parquet") is True
|
|
assert repo.execute_one("SELECT count(*) FROM v_switch")[0] == 2
|
|
assert repo.refresh_view("v_switch", f"{b}/**/*.parquet") is True
|
|
assert repo.execute_one("SELECT count(*) FROM v_switch")[0] == 5
|
|
|
|
|
|
def test_refresh_view_rejects_invalid_name(tmp_path: Path) -> None:
|
|
"""非标识符视图名直接拒绝, 不拼进 DDL。"""
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
a = _write_partition(tmp_path, "tbl_a", 2)
|
|
assert repo.refresh_view("evil; DROP VIEW x", f"{a}/**/*.parquet") is False
|
|
|
|
|
|
def test_concurrent_refresh_view_never_conflicts(tmp_path: Path) -> None:
|
|
"""DDL 串行: 并发重建同名视图全部成功 (无 write-write conflict),
|
|
且换视图期间并发读者始终读到某个一致版本 (MVCC)。"""
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
a = _write_partition(tmp_path, "tbl_a", 10)
|
|
b = _write_partition(tmp_path, "tbl_b", 20)
|
|
assert repo.refresh_view("v_swap", f"{a}/**/*.parquet") is True
|
|
|
|
results: list[bool] = []
|
|
seen_counts: set[int] = set()
|
|
errors: list[BaseException] = []
|
|
|
|
def ddl_worker(src: Path, rounds: int) -> None:
|
|
try:
|
|
results.extend(
|
|
repo.refresh_view("v_swap", f"{src}/**/*.parquet")
|
|
for _ in range(rounds)
|
|
)
|
|
except BaseException as exc: # noqa: BLE001 — 测试内收集任意失败
|
|
errors.append(exc)
|
|
|
|
def reader(rounds: int) -> None:
|
|
try:
|
|
for _ in range(rounds):
|
|
seen_counts.add(repo.execute_one("SELECT count(*) FROM v_swap")[0])
|
|
except BaseException as exc: # noqa: BLE001
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=ddl_worker, args=(a, 40)),
|
|
threading.Thread(target=ddl_worker, args=(b, 40)),
|
|
threading.Thread(target=reader, args=(120,)),
|
|
threading.Thread(target=reader, args=(120,)),
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert errors == []
|
|
assert all(results), "并发视图重建出现 write-write conflict (DDL 未串行)"
|
|
assert seen_counts == {10, 20}, "读者应看到两个一致版本, 不应有撕裂值"
|