Compare commits
1 commit
main
...
fix/3412-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ad2169cf5 |
2 changed files with 577 additions and 16 deletions
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
281
tradein-mvp/browser/test_server_instance_pool.py
Normal file
281
tradein-mvp/browser/test_server_instance_pool.py
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue