gendesign/tradein-mvp/browser/test_server_instance_pool.py
bot-backend 2ad2169cf5 fix(browser): пул тёплых инстансов camoufox по аренде + потолок живых (#3412)
Смена аренды прокси больше не убивает живой браузер: инстанс паркуется под
своей арендой и достаётся тёплым, когда эта аренда возвращается. Прокси у
camoufox задаётся на launch (geoip=True вяжет фингерпринт к IP аренды), сменить
его у живого браузера нельзя — поэтому пул, а не per-context proxy.

Замер прода за 25 ч (06-07.09, docker logs tradein-browser) НЕ подтвердил
гипотезу «relaunch перед каждым fetch»: 154 launch'а на ~2000 cian-фетчей,
из них 104 — recycle-порог (то есть порог срабатывает), 49 — смена аренды.
В час пиковой чехарды аренд (07.09 00:xx, 24 relaunch'а на 25 фетчей) доля
отказов была 1/26 = 3.8% — НИЖЕ суточной 4.8%. Первым фетчем на свежем
инстансе падают 19 ошибок из 99 (12.3% против 4.2% на прогретом), остальные
80 размазаны по 86 разным URL и всем часам — это тяжёлый хвост латентности
Циана, отдельная задача.

Что замер подтвердил: инстансов никто не считал. Инстанс поднимался лениво на
поставщика и не закрывался никогда, поэтому пять поставщиков давали пять вечных
camoufox (~425 МиБ каждый) в контейнере с mem_limit 2560m. Cgroup за те же 25 ч:
memory.events max=40781, oom_kill=111, sock_throttled=22177. Поэтому пул идёт
вместе с потолком BROWSER_MAX_INSTANCES (default 4) и LRU-закрытием: пиковое
число браузеров теперь МЕНЬШЕ прежнего, а не больше.

Гонок не добавляет: снятие/установка инстанса синхронны (нет await между
dict-операциями), жертву берём только среди припаркованных и активных
инстансов со свободным _locks[provider] — занятый лок значит «страница
in-flight». Новых локов нет.

Попутно: BROWSER_CONCURRENCY внесена в _RETIRED_ENV — её задаёт compose, её
цитируют оба обоснования потолка памяти, а код её не читает; параллелизм
задаёт _locks[provider] (≤1 страница на поставщика, но поставщиков пять).
2026-09-07 17:22:09 +05:00

281 lines
13 KiB
Python
Raw Permalink 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.

"""test_server_instance_pool.py — пул тёплых инстансов camoufox (#3412).
До правки инстанс был ОДИН на поставщика: приходила другая аренда прокси —
`_ensure_browser` закрывал живой браузер и поднимал холодный, а вернувшаяся
через запрос прежняя аренда платила холодный старт заново. Инстансы при этом
никто не считал: пять поставщиков = пять вечно живых camoufox в контейнере с
mem_limit 2560 МиБ (замер прода 07.09.2026: 4 живых = 1.70 ГиБ anon,
memory.events max=40781, oom_kill=111 за 25 ч).
Проверяет:
1. два fetch'а с ОДНОЙ арендой — один launch (счётчик запусков == 1);
2. смена аренды переключает инстанс (активный браузер другой, _launched_proxy
обновлён), а ВЕРНУВШАЯСЯ аренда берёт ТОТ ЖЕ тёплый инстанс, без launch'а;
3. env-аренда (proxy_override=None) паркуется и восстанавливается так же;
4. потолок живых инстансов соблюдается: лишний (LRU) закрывается.
camoufox НЕ запускается: _launch_browser подменён заглушкой, которая заполняет
_browsers/_browser_cms/_launched_proxy ровно как настоящий launch.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_instance_pool.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
_P1 = "http://u:p@node1:8080"
_P2 = "http://u:p@node2:8080"
_P3 = "http://u:p@node3:8080"
class _FakeBrowser:
"""Живой браузер: closed растёт, когда инстанс реально закрыли."""
def __init__(self, provider: str, proxy: str | None) -> None:
self.provider = provider
self.proxy = proxy
self.closed = 0
class _FakeCM:
"""AsyncCamoufox CM: __aexit__ помечает браузер закрытым (как настоящий teardown)."""
def __init__(self, browser: _FakeBrowser) -> None:
self._browser = browser
async def __aexit__(self, *_exc: object) -> None:
self._browser.closed += 1
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_contexts", {})
monkeypatch.setattr(server, "_anchor_pages", {})
monkeypatch.setattr(server, "_anchor_origins", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "_launched_proxy", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
# Состояние пула: raising=False — на версии сервера без пула этих имён нет,
# и тест обязан падать ПО ЗНАЧЕНИЮ (лишние launch'и / лишние живые инстансы),
# а не AttributeError'ом на подготовке.
monkeypatch.setattr(server, "_parked", {}, raising=False)
monkeypatch.setattr(server, "_instance_use_seq", {}, raising=False)
def _install_fake_launch(monkeypatch: pytest.MonkeyPatch) -> list[_FakeBrowser]:
"""Мок _launch_browser: заполняет состояние как настоящий, копит созданные браузеры."""
created: list[_FakeBrowser] = []
async def _fake_launch(provider: str, proxy_override: str | None = None) -> None:
proxy = proxy_override if proxy_override is not None else server._provider_proxy(provider)
browser = _FakeBrowser(provider, proxy)
created.append(browser)
server._browsers[provider] = browser
server._browser_cms[provider] = _FakeCM(browser)
server._page_counters[provider] = 0
server._launched_proxy[provider] = proxy
monkeypatch.setattr(server, "_launch_browser", _fake_launch)
return created
def _alive(created: list[_FakeBrowser]) -> list[_FakeBrowser]:
return [b for b in created if b.closed == 0]
# ── 1. одна аренда — один запуск ────────────────────────────────────────────────
def test_same_lease_two_fetches_launch_once(monkeypatch: pytest.MonkeyPatch) -> None:
"""Два подряд fetch'а с ОДНОЙ арендой не перезапускают браузер."""
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P1)
asyncio.run(_run())
assert len(created) == 1
assert created[0].closed == 0
# ── 2. смена аренды: переключение инстанса, возврат аренды — без launch'а ────────
def test_lease_switch_swaps_instance(monkeypatch: pytest.MonkeyPatch) -> None:
"""Другая аренда — другой инстанс: активный браузер сменился, прокси обновлён."""
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P2)
asyncio.run(_run())
assert len(created) == 2
assert server._launched_proxy["cian"] == _P2
assert server._browsers["cian"] is created[1]
assert server._browsers["cian"] is not created[0]
def test_returning_lease_reuses_warm_instance(monkeypatch: pytest.MonkeyPatch) -> None:
"""Аренда вернулась — берём ТОТ ЖЕ тёплый инстанс, третьего launch'а нет.
Красное до правки: каждый вход с чужой арендой убивал живой браузер, поэтому
p1 → p2 → p1 стоило трёх холодных стартов вместо двух.
"""
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P2)
await server._ensure_browser("cian", proxy_override=_P1)
asyncio.run(_run())
assert len(created) == 2, "вернувшаяся аренда обязана взять припаркованный инстанс"
assert server._browsers["cian"] is created[0]
assert server._launched_proxy["cian"] == _P1
assert created[0].closed == 0
def test_env_lease_none_override_is_parked_and_restored(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""proxy_override=None (env-аренда) паркуется и восстанавливается как обычная."""
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian") # env-аренда
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian") # вернулись на env
asyncio.run(_run())
assert len(created) == 2
assert server._browsers["cian"] is created[0]
assert server._launched_proxy["cian"] == "http://env:8080"
def test_page_counter_travels_with_instance(monkeypatch: pytest.MonkeyPatch) -> None:
"""Счётчик страниц едет С инстансом: у вернувшегося браузера свой пробег."""
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
server._page_counters["cian"] = 7
await server._ensure_browser("cian", proxy_override=_P2)
server._page_counters["cian"] = 2
await server._ensure_browser("cian", proxy_override=_P1)
asyncio.run(_run())
assert len(created) == 2
assert server._page_counters["cian"] == 7
# ── 3. потолок живых инстансов ─────────────────────────────────────────────────
def test_live_instances_capped_lru_closed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Потолок живых camoufox соблюдается: лишний (давно не использованный) закрыт.
Красное до правки: инстансы копились по одному на поставщика и не закрывались
никогда — пять поставщиков давали пять живых браузеров в контейнере, чьего
mem_limit хватает на четыре.
"""
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 3, raising=False)
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
for provider in ("avito", "cian", "yandex", "generic", "domclick"):
await server._ensure_browser(provider, proxy_override=_P1)
asyncio.run(_run())
assert len(created) == 5
alive = _alive(created)
assert len(alive) <= 3, f"живых инстансов {len(alive)} при потолке 3"
# Закрыты именно самые старые по использованию, свежие живы.
assert [b.provider for b in alive] == ["yandex", "generic", "domclick"]
def test_busy_provider_is_not_evicted(monkeypatch: pytest.MonkeyPatch) -> None:
"""Инстанс поставщика с ЗАНЯТЫМ локом не закрывают — он держит страницу in-flight.
Занятый лок — единственный признак «поставщик прямо сейчас внутри»: вся его
работа идёт под этим локом. Спутать его с простаивающим значит убить чужую
навигацию на середине.
"""
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2, raising=False)
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("avito", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P1)
# avito «работает»: его лок захвачен, значит трогать инстанс нельзя, хотя
# он и самый давний по использованию.
busy = await server._lock_for("avito")
await busy.acquire()
try:
await server._ensure_browser("yandex", proxy_override=_P1)
finally:
busy.release()
asyncio.run(_run())
avito, cian, _yandex = created
assert avito.closed == 0, "занятый поставщик закрыт — убили бы страницу in-flight"
assert cian.closed == 1, "жертвой стал следующий по давности, свободный"
def test_cleanup_closes_parked_instances(monkeypatch: pytest.MonkeyPatch) -> None:
"""Shutdown закрывает и припаркованные инстансы — иначе Firefox остаётся сиротой."""
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P2) # первый уходит в пул
await server._on_cleanup(None) # type: ignore[arg-type]
asyncio.run(_run())
assert [b.closed for b in created] == [1, 1]
def test_cap_counts_parked_instances(monkeypatch: pytest.MonkeyPatch) -> None:
"""Припаркованные (тёплые, но не активные) инстансы тоже занимают место под потолком."""
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2, raising=False)
created = _install_fake_launch(monkeypatch)
async def _run() -> None:
await server._ensure_browser("cian", proxy_override=_P1)
await server._ensure_browser("cian", proxy_override=_P2)
await server._ensure_browser("cian", proxy_override=_P3)
asyncio.run(_run())
assert len(created) == 3
alive = _alive(created)
assert len(alive) <= 2, f"живых инстансов {len(alive)} при потолке 2"
assert server._browsers["cian"] is created[2]