Смена аренды прокси больше не убивает живой браузер: инстанс паркуется под своей арендой и достаётся тёплым, когда эта аренда возвращается. Прокси у 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 страница на поставщика, но поставщиков пять).
281 lines
13 KiB
Python
281 lines
13 KiB
Python
"""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]
|