From 9f264f8bd0749ec666efe9dfa9ccc1dd5f283a41 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 1 Sep 2026 12:54:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(provider):=20sqlite=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=94=B9thread-local=E6=A0=B9=E6=B2=BB=E8=B7=A8=E6=97=A5?= =?UTF-8?q?=E5=AD=98=E6=B4=BB=E8=B7=A8=E7=BA=BF=E7=A8=8B=E5=B4=A9=E2=80=94?= =?UTF-8?q?=E2=80=9409-01=2009:30=20live=5F19=E5=AE=9E=E5=BD=95:=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E9=A6=96=E6=AC=A1=E8=B7=A8=E6=97=A5=E5=AD=98=E6=B4=BB?= =?UTF-8?q?=E5=90=8E=E5=9B=9E=E8=B0=83=E7=BA=BF=E7=A8=8B=E5=8F=98=E5=8C=96?= =?UTF-8?q?(=E8=BF=9E=E6=8E=A5=E5=BB=BA=E4=BA=8E=E7=BA=BF=E7=A8=8B19932/?= =?UTF-8?q?=E7=94=A8=E5=9C=A8=E7=BA=BF=E7=A8=8B4184),=5Fconnect=E5=8D=95?= =?UTF-8?q?=E4=BE=8B=E8=BF=9E=E6=8E=A5=E8=B7=A8=E7=BA=BF=E7=A8=8B=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E7=9B=B4=E6=8E=A5ProgrammingError=E2=86=92momentum=5F?= =?UTF-8?q?timing=E7=89=9B=E7=86=8A=E5=88=86=E7=95=8C=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E8=B7=B3=E8=BF=87=E5=BD=93=E6=97=A5=E8=B0=83=E4=BB=93?= =?UTF-8?q?(=E5=AE=89=E5=85=A8=E9=99=8D=E7=BA=A7=E4=BD=86=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E5=A4=8D=E5=8F=91=E7=9B=B4=E5=88=B0=E9=87=8D=E5=90=AF?= =?UTF-8?q?);=E4=BF=AE=3Dthreading.local=E6=AF=8F=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=90=84=E6=8C=81=E8=BF=9E=E6=8E=A5(fetchers=E5=9B=9B=E5=A4=84?= =?UTF-8?q?ctx.=5Fconnect()=E9=9B=B6=E6=94=B9=E5=8A=A8);=E5=BD=B1=E5=AD=90?= =?UTF-8?q?=E5=BC=95=E6=93=8E0=E4=BE=8B=E4=B8=AD=E6=8B=9B;2=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A4=8D=E7=8E=B0=E5=90=8C=E6=AC=BE=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=96=87=E6=A1=88=E9=92=89=E6=AD=BB=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/local_unified_provider.py | 17 +++++--- .../portfolio/test_local_unified_provider.py | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/sanguo_portfolio/providers/local_unified_provider.py b/sanguo_portfolio/providers/local_unified_provider.py index caf22dd..eb0a9e2 100644 --- a/sanguo_portfolio/providers/local_unified_provider.py +++ b/sanguo_portfolio/providers/local_unified_provider.py @@ -15,6 +15,7 @@ import logging import os import re import sqlite3 +import threading from concurrent.futures import ThreadPoolExecutor from datetime import datetime from typing import Any, Dict, List, Optional, Union @@ -161,16 +162,20 @@ class LocalUnifiedProvider(DataProvider): # type: ignore[misc] cfg = config or {} self.db_path: str = cfg.get("db_path", _DEFAULT_DB) self.data_dir: str = cfg.get("data_dir", _DEFAULT_DATA_DIR) - self._conn: Optional[sqlite3.Connection] = None + # thread-local:引擎跨日存活后回调线程会变(2026-09-01 live_19 事故), + # 单例连接跨线程复用直接 ProgrammingError;每线程各持一条连接 + self._tls = threading.local() self._val_bs_cache: Dict[int, pd.DataFrame] = {} # year -> valuation_baostock self._lpp_helper: Any = None def _connect(self) -> sqlite3.Connection: - """惰性连接 dbbardata sqlite(单连接复用)。""" - if self._conn is None: - self._conn = sqlite3.connect(self.db_path, timeout=30) - self._conn.execute("PRAGMA busy_timeout = 30000") - return self._conn + """惰性连接 dbbardata sqlite(thread-local 连接复用)。""" + conn = getattr(self._tls, "conn", None) + if conn is None: + conn = sqlite3.connect(self.db_path, timeout=30) + conn.execute("PRAGMA busy_timeout = 30000") + self._tls.conn = conn + return conn @staticmethod def _to_date_str(value: Optional[Union[str, datetime]]) -> Optional[str]: diff --git a/tests/portfolio/test_local_unified_provider.py b/tests/portfolio/test_local_unified_provider.py index 25edbab..70f5d91 100644 --- a/tests/portfolio/test_local_unified_provider.py +++ b/tests/portfolio/test_local_unified_provider.py @@ -12,6 +12,7 @@ Mac 本地 TDD: sqlite tmp_path + tmp parquet fixture,零 VPS 依赖,零网络 from __future__ import annotations import sqlite3 +import threading from typing import Any, Dict, List import pandas as pd @@ -863,3 +864,45 @@ class TestMixedDatetimeFormat: p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_price("600519.XSHG", start_date="2024-09-25", end_date="2024-09-27", fq="raw") assert len(df) == 3 # 不崩 + 返 3 行(混合格式解析 OK) + + +# ======================== Task N: _connect thread-local ======================== +class TestConnectThreadLocal: + """2026-09-01 live_19 事故:引擎首次跨日存活后回调线程变化(连接建于线程A、 + 用在线程B),单例 sqlite 连接跨线程复用直接 ProgrammingError → 牛熊分界 + 不可用跳过调仓。_connect 改 thread-local 后每线程自建连接,互不共享。""" + + def test_connect_cross_thread_usable(self, tmp_path): + db = _make_security_info_fixture(tmp_path) + p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) + conn_main = p._connect() # 主线程先建连(旧实现会缓存单例) + + errs: List[Any] = [] + + def worker(): + try: + c = p._connect() + c.execute("SELECT 1").fetchall() # 子线程直接用,不得抛错 + except Exception as exc: # noqa: BLE001 - 收集给断言看 + errs.append(exc) + + t = threading.Thread(target=worker) + t.start() + t.join() + assert not errs, f"跨线程取连接报错: {errs}" + # 主线程连接仍是原对象(thread-local 各持各的) + assert p._connect() is conn_main + + def test_two_threads_get_distinct_connections(self, tmp_path): + db = _make_security_info_fixture(tmp_path) + p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) + got: Dict[str, Any] = {} + + def worker(): + got["conn"] = p._connect() + + t = threading.Thread(target=worker) + t.start() + t.join() + assert got["conn"] is not None + assert got["conn"] is not p._connect() # 不是同一个连接对象