Files
news/tests/test_cninfo.py
T
simon 2eaea2ee81 fix: cninfo 抓取限制浏览器并发,修复整机冻结 (06:00 任务压穿内存)
根因: _render_page 每次新建完整 headless Chromium(实测 857MB/实例),
crawl_watchlist 对 15 只股票全量并发 → 峰值需求 ≈12.5GB ≫ 7.9GB RAM,
2026-09-06/08/10 三次 06:0x 整机冻结(load 65 → 看门狗复位)。

- crawler/cninfo.py: 新增 MAX_RENDER_CONCURRENCY(默认 2,env 可配)
  + 模块级 _render_sem 信号量,渲染全程持槽;顺带清理 3 个死导入
- tests/test_cninfo.py: 新增 8 个(并发上限/串行/异常释放/env 解析)
- 实测: 峰值 1796MB / 22 进程 / load 2.75 / 耗时 441s(上限 900s)
- 全量 291 passed
2026-09-10 20:54:30 +08:00

168 lines
6.0 KiB
Python

"""cninfo 抓取并发限流测试。
背景:
每次 `_render_page` 都会启动一个完整的 headless Chromium(实测约 850MB /
10 进程),而 `crawl_watchlist` 对 15 只股票全量并发 → 峰值需求 ≈12.5GB,
远超本机 7.9GB RAM,曾导致 2026-09-06/08/10 三次整机冻结。
修复方式:模块级信号量限制同时存活的浏览器数(默认 2)。
本测试用假 AsyncWebCrawler 验证并发上限,不启动真实浏览器。
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
def _install_fake_crawler(monkeypatch: pytest.MonkeyPatch, tracker: dict) -> None:
"""把 crawl4ai.AsyncWebCrawler 换成记录并发峰值的假实现。"""
import crawl4ai
class FakeCrawler:
def __init__(self, config=None) -> None: # noqa: ARG002
pass
async def __aenter__(self) -> FakeCrawler:
tracker["current"] += 1
tracker["max"] = max(tracker["max"], tracker["current"])
return self
async def __aexit__(self, *exc: object) -> bool:
tracker["current"] -= 1
return False
async def arun(self, url: str, config=None) -> SimpleNamespace: # noqa: ARG002
# 模拟渲染耗时,制造并发窗口
await asyncio.sleep(0.05)
return SimpleNamespace(html=f"<html>{url}</html>")
monkeypatch.setattr(crawl4ai, "AsyncWebCrawler", FakeCrawler)
# --------------------------------------------------------------------------- #
# 并发上限解析
# --------------------------------------------------------------------------- #
def test_resolve_concurrency_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""未设 env 时使用默认值 2。"""
monkeypatch.delenv("CNINFO_RENDER_CONCURRENCY", raising=False)
from crawler.cninfo import DEFAULT_RENDER_CONCURRENCY, _resolve_render_concurrency
assert DEFAULT_RENDER_CONCURRENCY == 2
assert _resolve_render_concurrency() == 2
def test_resolve_concurrency_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""env CNINFO_RENDER_CONCURRENCY 可覆盖。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "4")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 4
def test_resolve_concurrency_invalid_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""非法值回退默认值,不抛异常。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "abc")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 2
def test_resolve_concurrency_clamped_to_one(monkeypatch: pytest.MonkeyPatch) -> None:
"""0/负数夹到 1,避免信号量死锁。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "0")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 1
# --------------------------------------------------------------------------- #
# 渲染并发限流
# --------------------------------------------------------------------------- #
def test_render_concurrency_capped(monkeypatch: pytest.MonkeyPatch) -> None:
"""6 个并发渲染请求,同时存活的浏览器数不超过信号量上限 2。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
# 独立信号量,避免跨测试污染模块级状态
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(2))
async def run() -> list[str]:
return await asyncio.gather(*[
cninfo._render_page(f"https://example.com/{i}") for i in range(6)
])
results = asyncio.run(run())
assert len(results) == 6, "全部请求都应完成(限流不应丢请求)"
assert tracker["max"] <= 2, f"并发峰值 {tracker['max']} 超过上限 2"
def test_render_concurrency_one_is_serial(monkeypatch: pytest.MonkeyPatch) -> None:
"""上限为 1 时完全串行执行。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(1))
async def run() -> list[str]:
return await asyncio.gather(*[
cninfo._render_page(f"https://example.com/{i}") for i in range(4)
])
results = asyncio.run(run())
assert len(results) == 4
assert tracker["max"] == 1, "上限 1 时不应出现并发"
def test_render_page_returns_html(monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染正常返回 html 字符串。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(2))
html = asyncio.run(cninfo._render_page("https://example.com/x"))
assert html == "<html>https://example.com/x</html>"
def test_render_semaphore_released_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染抛异常时也要释放槽位(否则后续渲染会死锁)。"""
import crawl4ai
from crawler import cninfo
class BoomCrawler:
def __init__(self, config=None) -> None: # noqa: ARG002
pass
async def __aenter__(self) -> BoomCrawler:
raise RuntimeError("浏览器启动失败")
async def __aexit__(self, *exc: object) -> bool:
return False
async def arun(self, url: str, config=None): # noqa: ARG002
raise RuntimeError("unreachable")
monkeypatch.setattr(crawl4ai, "AsyncWebCrawler", BoomCrawler)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(1))
async def run() -> None:
# 第一次失败后,槽位必须已释放,第二次才能拿到
with pytest.raises(RuntimeError):
await cninfo._render_page("https://example.com/fail")
with pytest.raises(RuntimeError):
await asyncio.wait_for(
cninfo._render_page("https://example.com/fail2"), timeout=2
)
asyncio.run(run())
assert cninfo._render_sem._value == 1, "异常后槽位未释放"