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 再后台删除,规避沙箱批量删除限制)。
168 lines
5.2 KiB
Python
168 lines
5.2 KiB
Python
# 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
|