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 страница на поставщика, но поставщиков пять).
This commit is contained in:
bot-backend 2026-09-07 17:22:09 +05:00
parent 278f8055a4
commit 2ad2169cf5
2 changed files with 577 additions and 16 deletions

View file

@ -34,6 +34,9 @@ Per-provider модель (#1793):
Переменные окружения:
BROWSER_PORT TCP-порт HTTP-сервера (default: 3000)
BROWSER_MAX_INSTANCES потолок ОДНОВРЕМЕННО живых camoufox на весь сайдкар:
активные + припаркованные в пуле аренд (default: 4, #3412).
Потолок памяти, а не производительности см. константу.
BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный
дефолт для провайдеров без код-дефолта (default: 15).
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
@ -161,6 +164,20 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
IS_PROD: bool = ENVIRONMENT == "production"
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
# Потолок ОДНОВРЕМЕННО живых camoufox-инстансов на весь сайдкар (#3412): активные
# (_browsers) + припаркованные в пуле (_parked). До #3412 потолка не было вообще —
# инстанс поднимался лениво на поставщика и не закрывался никогда, так что пять
# поставщиков давали пять вечных браузеров.
#
# Откуда 4. Замер прода 07.09.2026 (tradein-browser, mem_limit 2560 МиБ,
# memswap 3g): 4 живых инстанса = 1.70 ГиБ anon (~425 МиБ на инстанс, парент-
# процессы 1.35 ГиБ), memory.current 2.01 ГиБ = 80% лимита. Шестой в лимит не
# влезает, пятый живёт впритык — и это видно в cgroup за те же 25 ч:
# memory.events max=40781, oom_kill=111, sock_throttled=22177. 4 — потолок, при
# котором остаётся запас на саму страницу.
# ⚠ Поднимаешь BROWSER_MAX_INSTANCES — поднимай mem_limit в docker-compose.prod.yml
# (грубо ~0.5 ГиБ на инстанс сверх текущих 2560m).
BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "4")))
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
@ -838,6 +855,36 @@ _last_response_status: dict[str, int | None] = {}
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
_launched_proxy: dict[str, str | None] = {}
class _Instance(NamedTuple):
"""Всё, что принадлежит ОДНОМУ инстансу camoufox, снятое с per-provider словарей.
Существует, чтобы инстанс можно было отложить в пул целиком: браузер без своего
context'а/якорной вкладки/пробега — это уже другой инстанс.
"""
cm: object | None
browser: object | None
context: object | None
anchor_page: object | None
anchor_origin: str | None
pages: int
# (provider, proxy_url) → ТЁПЛЫЙ, но не активный инстанс (#3412). Прокси у camoufox
# задаётся НА LAUNCH (geoip=True строит фингерпринт по IP аренды), сменить его у
# живого браузера нельзя — можно только держать второй инстанс. До #3412 второго не
# держали: приход чужой аренды закрывал живой браузер, и вернувшаяся через запрос
# прежняя аренда платила холодный старт заново (прод 06-07.09.2026: 49 таких
# закрытий за 25 ч, пачками до 24 в час — в такие часы холодный старт стоял перед
# КАЖДЫМ fetch'ем).
_parked: dict[tuple[str, str | None], _Instance] = {}
# (provider, proxy_url) → порядковый номер последнего использования. Счётчик, а не
# часы: LRU нужен строгий порядок, а два вызова в одну миллисекунду часами
# неразличимы.
_instance_use_seq: dict[tuple[str, str | None], int] = {}
_use_counter: int = 0
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
# явный guard на случай будущей сложной инициализации. Создаётся в _on_startup.
@ -1052,6 +1099,194 @@ async def _close_browser(provider: str) -> None:
_launched_proxy.pop(provider, None)
# ── пул тёплых инстансов (#3412) ───────────────────────────────────────────────
#
# Инвариант параллельности: ВСЯ работа поставщика идёт под _locks[provider], а
# снятие/установка инстанса делается СИНХРОННО (подряд идущие dict-операции без
# единого await). Поэтому чужая корутина никогда не видит наполовину снятое
# состояние и не может получить ссылку на браузер, который вот-вот закроют:
# закрывает его только тот, кто первым сделал pop. Новых локов не заводим.
def _touch_instance(provider: str, proxy_url: str | None) -> None:
"""Отмечает инстанс как только что использованный (для LRU-выбора жертвы)."""
global _use_counter
_use_counter += 1
_instance_use_seq[(provider, proxy_url)] = _use_counter
def _live_instances() -> int:
"""Сколько camoufox сейчас живо: активные + припаркованные."""
return len(_browsers) + len(_parked)
def _detach_active(provider: str) -> _Instance | None:
"""Синхронно снимает активный инстанс поставщика со ВСЕХ per-provider словарей.
Возвращает снятое (None инстанса не было). Ни одного await внутри: снятие
обязано быть атомарным относительно других корутин, иначе параллельный запрос
успеет схватить ссылку на браузер, который уже решено закрыть.
_last_goto_at НЕ трогает: пейсинг это ритм обращений К ПЛОЩАДКЕ, он переживает
смену инстанса. Вызывающий, который закрывает инстанс насовсем, чистит его сам.
"""
browser = _browsers.pop(provider, None)
cm = _browser_cms.pop(provider, None)
_launched_proxy.pop(provider, None)
context = _contexts.pop(provider, None)
anchor_page = _anchor_pages.pop(provider, None)
anchor_origin = _anchor_origins.pop(provider, None)
pages = _page_counters.pop(provider, 0)
if browser is None and cm is None:
return None
return _Instance(cm, browser, context, anchor_page, anchor_origin, pages)
async def _close_instance(provider: str, inst: _Instance) -> None:
"""Закрывает СНЯТЫЙ инстанс (уже недостижимый ни для кого): context → браузер.
Best-effort, как _close_browser: ошибка teardown'а не должна ронять вызывающего.
Якорная вкладка умирает вместе с context'ом — отдельно её не закрываем.
"""
if inst.context is not None:
try:
await inst.context.close() # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при закрытии context снятого инстанса: %s",
provider,
type(exc).__name__,
)
if inst.cm is not None:
try:
await inst.cm.__aexit__(None, None, None) # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при закрытии снятого инстанса: %s",
provider,
type(exc).__name__,
)
async def _park_active(provider: str) -> None:
"""Убирает активный инстанс поставщика в пул под ключом ЕГО аренды.
No-op, если активного инстанса нет. Ключ берём ДО снятия: _detach_active
вычищает _launched_proxy.
"""
proxy = _launched_proxy.get(provider)
inst = _detach_active(provider)
if inst is None:
return
key = (provider, proxy)
displaced = _parked.pop(key, None) # дубля быть не должно; если есть — не течём
_parked[key] = inst
_touch_instance(provider, proxy)
if displaced is not None:
await _close_instance(provider, displaced)
def _adopt_parked(provider: str, proxy_url: str | None) -> bool:
"""Делает припаркованный инстанс этой аренды активным. False — такого нет.
Пробег страниц едет ВМЕСТЕ с инстансом: recycle-порог считает страницы одного
браузера, а не сумму по поставщику.
"""
inst = _parked.pop((provider, proxy_url), None)
if inst is None:
return False
_browsers[provider] = inst.browser
if inst.cm is not None:
_browser_cms[provider] = inst.cm
_launched_proxy[provider] = proxy_url
_page_counters[provider] = inst.pages
if inst.context is not None:
_contexts[provider] = inst.context
if inst.anchor_page is not None:
_anchor_pages[provider] = inst.anchor_page
if inst.anchor_origin is not None:
_anchor_origins[provider] = inst.anchor_origin
_touch_instance(provider, proxy_url)
return True
def _pick_evictable(exclude: str) -> tuple[str, str | None] | None:
"""LRU-жертва среди тех инстансов, которые ТОЧНО никто не использует.
Кандидаты: все припаркованные (по построению не заняты их держит только
словарь) плюс активные инстансы ЧУЖИХ поставщиков со СВОБОДНЫМ _locks: вся
работа поставщика идёт под его локом, значит свободный лок = никто не внутри.
Свой (exclude) поставщик не трогается его инстанс нужен прямо сейчас.
None свободных нет.
"""
candidates: list[tuple[str, str | None]] = list(_parked)
for provider in list(_browsers):
if provider == exclude:
continue
lock = _locks.get(provider)
if lock is not None and lock.locked():
continue # поставщик прямо сейчас работает на этом инстансе
candidates.append((provider, _launched_proxy.get(provider)))
if not candidates:
return None
return min(candidates, key=lambda key: _instance_use_seq.get(key, 0))
async def _evict_instance(key: tuple[str, str | None]) -> bool:
"""Закрывает выбранный инстанс: сначала синхронный pop, потом await close.
Возвращает True, если слот реально освободился. False (жертва уже уехала)
сигнал вызывающему остановиться, а не выбирать снова: цикл, который не
уменьшает счётчик живых, крутился бы вечно под локом поставщика.
"""
provider, proxy = key
_instance_use_seq.pop(key, None)
inst = _parked.pop(key, None)
if inst is not None:
logger.info(
"tradein-browser[%s]: припаркованный инстанс закрыт по потолку (%d)",
provider,
BROWSER_MAX_INSTANCES,
)
else:
if _launched_proxy.get(provider) != proxy:
return False # состояние уехало между выбором и снятием
inst = _detach_active(provider)
_last_goto_at.pop(provider, None) # инстанс уходит насовсем
if inst is None:
return True # запись из _browsers всё равно снята — слот свободен
logger.info(
"tradein-browser[%s]: инстанс закрыт по потолку (%d) — простаивал дольше всех",
provider,
BROWSER_MAX_INSTANCES,
)
await _close_instance(provider, inst)
return True
async def _trim_instances(exclude: str) -> None:
"""Освобождает место под НОВЫЙ инстанс: закрывает LRU, пока живых >= потолка.
ponytail: потолок best-effort если все прочие инстансы заняты, поднимаемся
сверх него и говорим об этом в лог, а не отказываем клиенту и не убиваем
чужую страницу на середине навигации. Апгрейд очередь ожидания на слот,
если предупреждение начнёт появляться регулярно.
"""
while _live_instances() >= BROWSER_MAX_INSTANCES:
key = _pick_evictable(exclude)
if key is None:
logger.warning(
"tradein-browser[%s]: живых инстансов %d при потолке %d, свободных для "
"закрытия нет (все заняты) — поднимаем сверх потолка",
exclude,
_live_instances(),
BROWSER_MAX_INSTANCES,
)
return
if not await _evict_instance(key):
return
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
"""Закрывает и заново запускает per-provider браузер (recycle / crash-recovery).
@ -1098,12 +1333,15 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
"""Гарантирует поднятый инстанс поставщика с нужным прокси; lazy launch / relaunch.
proxy_override (#2164 P4): желаемый динамический прокси из пула (тело /fetch). None →
желаемый прокси = env (_provider_proxy). Политика «relaunch ТОЛЬКО при реальной смене»:
- инстанса нет launch с желаемым прокси;
- инстанс есть, но _launched_proxy[provider] != желаемого close + relaunch;
- инстанс есть и прокси совпадает no-op (НЕ релончим camoufox дорого поднимать).
желаемый прокси = env (_provider_proxy). Инстанс привязан к аренде НА LAUNCH, поэтому
политика такая (#3412 — пул вместо teardown'а):
- инстанс есть и аренда совпадает no-op;
- аренда сменилась активный инстанс ПАРКУЕТСЯ (не закрывается) под своей
арендой, а желаемая аренда берётся из пула тёплой; нет такой в пуле launch
нового инстанса, предварительно освободив место под потолком;
- инстанса нет пул, иначе launch.
При выключенном флаге на scraper-стороне proxy_override всегда None желаемый=env=
текущий релонча нет, поведение идентично прежнему (golden-parity).
текущий ни парковки, ни launch'а, поведение идентично прежнему (golden-parity).
Caller держит _locks[provider] (весь доступ к инстансу сериализован), поэтому здесь
своей сериализации нет двух одновременных launch'ей на один provider быть не может.
@ -1117,19 +1355,40 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
для тех, к кому был запрос, а не для всех сразу.
"""
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
if _browsers.get(provider) is not None:
if _launched_proxy.get(provider) == desired_proxy:
return True
# Реальная смена прокси → пересобрать инстанс с новым прокси.
had_instance = _browsers.get(provider) is not None
if had_instance and _launched_proxy.get(provider) == desired_proxy:
_touch_instance(provider, desired_proxy)
return True
if had_instance:
# Аренда сменилась. Живой браузер НЕ убиваем — откладываем в пул под ЕГО
# арендой: вернётся эта аренда (в проде они чередуются между параллельными
# сессиями) — возьмём его тёплым, без холодного старта.
await _park_active(provider)
if _adopt_parked(provider, desired_proxy):
logger.info(
"tradein-browser[%s]: прокси изменился (override=%s) — relaunch перед fetch",
"tradein-browser[%s]: аренда сменилась (override=%s) — тёплый инстанс из "
"пула (живых %d/%d)",
provider,
proxy_override is not None,
_live_instances(),
BROWSER_MAX_INSTANCES,
)
await _close_browser(provider)
return True
if had_instance:
logger.info(
"tradein-browser[%s]: аренда сменилась (override=%s) — в пуле такой нет, "
"новый инстанс (живых %d/%d)",
provider,
proxy_override is not None,
_live_instances(),
BROWSER_MAX_INSTANCES,
)
await _trim_instances(provider)
await _try_launch_browser(provider, proxy_override=proxy_override)
if _browsers.get(provider) is not None:
_touch_instance(provider, desired_proxy)
return True
_start_retry_task(provider)
return False
@ -1179,10 +1438,12 @@ async def _on_startup(app: web.Application) -> None:
# НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на
# первом /fetch (или /login) к нему через _ensure_browser под _locks[provider].
#
# Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM:
# до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS
# каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном
# прокси) запускается при первом обращении к поставщику, не для всех сразу.
# Eager-launch всех поставщиков на старте поднял бы все camoufox сразу → OOM:
# ~425 МиБ на инстанс (прод-замер 07.09.2026), mem_limit контейнера 2560m.
# Сколько их держим одновременно — BROWSER_MAX_INSTANCES (#3412); раньше
# потолка не было и пять поставщиков давали пять вечных браузеров. Resilience
# (фоновый retry при недоступном прокси) запускается при первом обращении к
# поставщику, не для всех сразу.
global _locks_guard
_locks_guard = asyncio.Lock()
logger.info(
@ -1193,6 +1454,11 @@ async def _on_startup(app: web.Application) -> None:
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
logger.info(
"tradein-browser: пул инстансов — потолок живых camoufox %d (активные + "
"припаркованные по аренде)",
BROWSER_MAX_INSTANCES,
)
_warn_retired_env()
@ -1208,6 +1474,16 @@ _RETIRED_ENV: dict[str, str] = {
"BROWSER_BLOCK_RESOURCES": (
"BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)"
),
# Задана в docker-compose.prod.yml, но кодом НЕ читается ни здесь, ни в backend
# (проверено grep'ом 07.09.2026, #3412). Опаснее прочих мёртвых ручек: ею
# ОБОСНОВАН потолок памяти контейнера («2.5g — потолок для 1-за-раз») и
# обоснование в app/core/config.py («браузер сериализован BROWSER_CONCURRENCY=1»).
# На деле параллелизм задаёт _locks[provider]: ≤1 страница НА ПОСТАВЩИКА, но
# поставщиков пять — до пяти страниц и пяти браузеров одновременно.
"BROWSER_CONCURRENCY": (
"_locks[provider] (≤1 операция на поставщика) + BROWSER_MAX_INSTANCES "
"(потолок живых инстансов на весь сайдкар)"
),
}
@ -1242,9 +1518,13 @@ async def _on_cleanup(app: web.Application) -> None:
)
_retry_tasks.clear()
# Закрываем все поднятые инстансы.
# Закрываем все поднятые инстансы — и активные, и припаркованные в пуле (#3412):
# припаркованный camoufox — такой же живой процесс, забыть его на shutdown значит
# оставить Firefox сиротой до SIGKILL от docker.
for provider in list(_browsers.keys()):
await _close_browser(provider)
for key in list(_parked):
await _evict_instance(key)
# ── handlers ───────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,281 @@
"""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]