All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 40s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m0s
CI Trade-In / backend-tests (pull_request) Successful in 8m5s
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод 15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08; медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5. Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser, через который идут все запуски, включая фоновый retry. На потолке закрывается простаивающий инстанс другого поставщика: без context'а первым, по давности использования внутри класса. Занятый лок не трогается, если закрыть некого, запуск идёт сверх потолка с предупреждением. Слот резервируется на время запуска. _close_browser снимает инстанс со словарей до первого await и больше не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси, как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch закрыты тестами, каждый краснеет при снятии своей части фикса. init: true у сервиса browser: PID 1 был python server.py и не собирал осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
|
||
|
||
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
|
||
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
|
||
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
|
||
relaunch) или сам потолок; проверка — по тому, какие инстансы ОСТАЛИСЬ живыми.
|
||
|
||
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_instance_limit.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
import sys
|
||
import types
|
||
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)
|
||
|
||
|
||
class _FakeCM:
|
||
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
|
||
|
||
launch_delay = 0.0
|
||
closed: list[str] = []
|
||
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
|
||
visible_while_closing: dict[str, bool] = {}
|
||
|
||
def __init__(self, **kwargs: object) -> None:
|
||
pass
|
||
|
||
async def __aenter__(self) -> object:
|
||
await asyncio.sleep(_FakeCM.launch_delay)
|
||
return object()
|
||
|
||
async def __aexit__(self, *args: object) -> None:
|
||
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
|
||
_FakeCM.visible_while_closing[provider] = provider in server._browsers
|
||
await asyncio.sleep(0)
|
||
_FakeCM.closed.append(provider)
|
||
|
||
|
||
_cms_by_provider: dict[str, _FakeCM] = {}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
|
||
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
|
||
"_launched_proxy", "_last_used"):
|
||
monkeypatch.setattr(server, name, {})
|
||
monkeypatch.setattr(server, "_launching", set())
|
||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
|
||
_FakeCM.launch_delay = 0.0
|
||
_FakeCM.closed = []
|
||
_FakeCM.visible_while_closing = {}
|
||
_cms_by_provider.clear()
|
||
|
||
fake_mod = types.ModuleType("camoufox.async_api")
|
||
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
|
||
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
|
||
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
|
||
|
||
|
||
async def _launch(provider: str) -> None:
|
||
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
|
||
_cms_by_provider[provider] = server._browser_cms[provider]
|
||
|
||
|
||
def _live() -> set[str]:
|
||
return set(server._browsers)
|
||
|
||
|
||
def test_launch_at_cap_closes_idle_instance() -> None:
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
await _launch("generic")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _live() == {"yandex", "generic"}
|
||
assert _FakeCM.closed == ["cian"]
|
||
|
||
|
||
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
|
||
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
|
||
|
||
async def _run() -> None:
|
||
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
|
||
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
|
||
server._anchor_pages["avito"] = object()
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _live() == {"avito", "yandex"}
|
||
assert _FakeCM.closed == ["cian"]
|
||
|
||
|
||
async def _noop() -> None:
|
||
return None
|
||
|
||
|
||
def test_eviction_keeps_victim_pacing() -> None:
|
||
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
|
||
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
server._last_goto_at["cian"] = 12345.0
|
||
await _launch("yandex")
|
||
await _launch("generic")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert "cian" not in _live()
|
||
assert server._last_goto_at.get("cian") == 12345.0
|
||
|
||
|
||
def test_background_retry_path_respects_cap() -> None:
|
||
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
|
||
|
||
async def _run() -> bool:
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
return await server._try_launch_browser("avito")
|
||
|
||
assert asyncio.run(_run()) is True
|
||
assert len(_live()) == 2
|
||
assert "avito" in _live()
|
||
|
||
|
||
def test_fresh_instance_is_not_the_next_victim() -> None:
|
||
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
|
||
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
|
||
await _launch("yandex") # свежий, после последнего обращения к cian
|
||
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _FakeCM.closed == ["cian"]
|
||
|
||
|
||
def test_use_refreshes_lru_order() -> None:
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
|
||
await _launch("generic")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _FakeCM.closed == ["yandex"]
|
||
|
||
|
||
def test_busy_instance_is_never_closed() -> None:
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
for provider in ("cian", "yandex"):
|
||
await (await server._lock_for(provider)).acquire()
|
||
await _launch("generic")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _FakeCM.closed == []
|
||
assert _live() == {"cian", "yandex", "generic"}
|
||
|
||
|
||
def test_parallel_launches_reserve_their_slot() -> None:
|
||
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
|
||
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
_FakeCM.launch_delay = 0.01
|
||
await asyncio.gather(_launch("avito"), _launch("yandex"))
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _live() == {"avito", "yandex"}
|
||
|
||
|
||
def test_evicted_instance_disappears_before_close_awaits() -> None:
|
||
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
|
||
|
||
async def _run() -> None:
|
||
await _launch("cian")
|
||
await _launch("yandex")
|
||
await _launch("generic")
|
||
|
||
asyncio.run(_run())
|
||
|
||
assert _FakeCM.visible_while_closing == {"cian": False}
|