Files
FinDataHub/Scripts/bin/tests/test_backfill.py
T
FinDataHub f43b442bf1 fix: 修连接/句柄泄漏 + 修脚本版本契约 + 去掉 stk_factor 假启用
P1-13 连接池位泄漏(Scripts/bin/backfill.py)
---------------------------------------------
三处 `get_conn()` 都把 `put_conn(conn)` 放在**成功路径**,异常时连接永不归还:
  get_source_map / get_existing_ids / insert_news
连接池有容量上限,异常路径不归还即永久占住池位,反复失败会耗尽池,
表现为后续所有取连接的操作阻塞(与 db_pool 取连接超时修复是同一类问题)。
修法:全部改为 try/finally 无条件归还。
**自查发现并修正一处残留缺陷**:`finally` 里写成 `cur.close(); put_conn(conn)`,
若 close 本身抛错(连接已断时常见)仍会跳过归还 → 改为嵌套 try/finally,
归还作为无条件最后一步。回归用例正是靠这一点抓出来的。

P1-13 后端 stderr 句柄泄漏(Scripts/bin/newsflash_collect.py)
--------------------------------------------------------------
`stderr=open(BACKEND_STDERR_LOG, "w")` 把 open() 返回值直接丢给 Popen,
父进程这份句柄没有任何引用可关 → 每启动一次后端泄漏一个 fd。
改为先存局部变量、Popen 后立即 close(子进程已持有独立 fd 副本,写入不受影响)。

P2-16 bundle-python.sh 版本契约冲突
------------------------------------
`uv venv --python 3.14` 与 .python-version(=3.12) 及所有 pyproject 的
requires-python=">=3.12,<3.13" 冲突。改为读取 .python-version 作为单一事实来源。
同时 `set -e` → `set -eo pipefail`:原先 `uv venv ... | tail -3` 会吞掉失败退出码,
与文件头注释声称的"确保失败中止"相矛盾。
并注明该脚本**当前不在构建链路内**(build-app.sh 未调用,全仓仅文档提及),
避免后人误以为它是打包的一个阶段。

stk_factor / stk_factor_pro 假启用
----------------------------------
config.yaml 与 config/default.yaml 写 `true`,但 fetcher.py:306 对该表显式跳过
(接口限 5 次/天,无法批量)。改为 false 并注明原因。
另外查明:`tables:` 开关只在「未显式指定表」时才生效(fetcher.py:239-243),
调度脚本总是显式传参 → **不能以 config 为准判断某项是否在跑**。

回归
----
Scripts/bin 213 passed(新增 6 条连接归还用例,已证伪:
旧写法下 `test_get_source_map_returns_conn_on_error` 报 assert 0 == 1)、
core 121、tushare 174、akshare 183、kuaixun 407 —— 合计 1098 passed。

本次一并完成的非代码清理
------------------------
- P2-17 陈旧可写 venv:`~/Library/Application Support/FinDataHub/venvs/` 下
  akshare_fetcher(3.14.6, 308M) 与 kuaixun-service(3.14.5, 156M) 两个副本
  来自 v1.4.7 时期,与 bundle 的 3.12 不符、且每次启动刷 WARN。
  已移出(464M → 12K,保留 venv_versions.json)。应用日志本身提示
  "如需 pip install,请在设置中重建可写副本",故可安全清理。
- P2-21 构建残留:findatahub-core/build、findatahub_core.egg-info、
  findatahub_client.egg-info 已移出(移出前先 mv 到 /tmp 再后台删除,规避沙箱批量删除限制)。
2026-09-19 00:27:21 +08:00

168 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# coding=utf-8
"""backfill.py 连接归还回归测试.
缺陷(2026-09-19 修复)
----------------------
`backfill.py` 的三处 `get_conn()` 都把 `put_conn(conn)` 放在**成功路径**:
conn = get_conn()
cur = conn.cursor()
cur.execute(...) # ← 这里抛错
put_conn(conn) # ← 永远执行不到
连接池有容量上限,异常路径不归还就会**永久占住池位**;反复失败即耗尽池,
表现为后续所有取连接的操作全部阻塞(详见 db_pool 的取连接超时修复)。
本测试锁住契约:**无论正常还是异常,put_conn 都必须被调用恰好一次**。
"""
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("MYSQL_PASSWORD", "")
_bin_dir = os.path.join(os.path.dirname(__file__), "..")
if _bin_dir not in sys.path:
sys.path.insert(0, _bin_dir)
# ── 与 test_newsflash_collect.py 同款:导入前预注入 mock,避免顶层依赖失败 ──
import importlib.util
_orig_sys_modules = dict(sys.modules)
_mock_common = MagicMock()
_mock_common.KX_HOME = "/tmp"
_mock_common.KXS_HOME = "/tmp"
_mock_common.BUNDLED_KXS_HOME = None
_mock_common.BIN_DIR = "/tmp"
_mock_common.MYSQL_CFG = {}
_mock_common.database_url = "mysql://localhost"
_mock_common.setup_kuaixun_paths = MagicMock()
_mock_common.load_env = MagicMock()
_mock_db_pool = MagicMock()
_mock_extend = MagicMock()
_mock_extend_fetchers = MagicMock()
_mock_extend_fetchers_manager = MagicMock()
_patches = {
"common": _mock_common,
"pymysql": MagicMock(),
"db_pool": _mock_db_pool,
"extend": _mock_extend,
"extend.fetchers": _mock_extend_fetchers,
"extend.fetchers.manager": _mock_extend_fetchers_manager,
}
try:
sys.modules.update(_patches)
_spec = importlib.util.spec_from_file_location(
"backfill_mod", os.path.join(_bin_dir, "backfill.py")
)
_bf = importlib.util.module_from_spec(_spec)
sys.modules["backfill_mod"] = _bf
_spec.loader.exec_module(_bf)
finally:
for _k, _v in _orig_sys_modules.items():
sys.modules[_k] = _v
for _k in _patches:
if _k not in _orig_sys_modules:
sys.modules.pop(_k, None)
backfill = _bf
class _FakeCursor:
def __init__(self, rows=None, raise_on_execute=False):
self._rows = rows or []
self._raise = raise_on_execute
def execute(self, sql, params=None):
if self._raise:
raise RuntimeError("模拟数据库错误")
def fetchall(self):
return list(self._rows)
def close(self):
pass
@property
def rowcount(self):
return 0
class _FakeConn:
def __init__(self, cur):
self._cur = cur
def cursor(self):
return self._cur
def commit(self):
pass
@pytest.fixture
def pool(monkeypatch):
"""替换 get_conn/put_conn,记录调用次数。"""
calls = {"get": 0, "put": 0}
def _get():
calls["get"] += 1
return _FakeConn(_FakeCursor())
def _put(conn):
calls["put"] += 1
monkeypatch.setattr(backfill, "get_conn", _get)
monkeypatch.setattr(backfill, "put_conn", _put)
return calls
class TestConnectionReturned:
def test_get_source_map_returns_conn_on_success(self, pool, monkeypatch):
conn = _FakeConn(_FakeCursor(rows=[(1, "cls")]))
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
backfill.get_source_map()
assert pool["put"] == 1
def test_get_source_map_returns_conn_on_error(self, pool, monkeypatch):
"""核心回归:execute 抛错时连接仍须归还."""
conn = _FakeConn(_FakeCursor(raise_on_execute=True))
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
with pytest.raises(RuntimeError):
backfill.get_source_map()
assert pool["put"] == 1, "异常路径未归还连接 → 池位泄漏"
def test_get_existing_ids_returns_conn_on_error(self, pool, monkeypatch):
conn = _FakeConn(_FakeCursor(raise_on_execute=True))
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
backfill.get_existing_ids("cls", 0, 1) # 内部吞掉异常
assert pool["put"] == 1, "异常路径未归还连接 → 池位泄漏"
def test_get_existing_ids_returns_conn_on_success(self, pool, monkeypatch):
conn = _FakeConn(_FakeCursor(rows=[("n1",)]))
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
backfill.get_existing_ids("cls", 0, 1)
assert pool["put"] == 1
def test_insert_news_returns_conn_on_error(self, pool, monkeypatch):
"""cur.close() 抛错时也必须归还(finally 覆盖). """
cur = _FakeCursor()
cur.close = MagicMock(side_effect=RuntimeError("close 失败"))
conn = _FakeConn(cur)
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
with pytest.raises(RuntimeError):
backfill.insert_news([])
assert pool["put"] == 1
def test_insert_news_returns_conn_on_success(self, pool, monkeypatch):
conn = _FakeConn(_FakeCursor())
monkeypatch.setattr(backfill, "get_conn", lambda: conn)
backfill.insert_news([])
assert pool["put"] == 1