fix(tradein/proxy): один прокси на сессию браузера вместо смены на каждом запросе #2640

Merged
bot-backend merged 1 commit from fix/tradein-sticky-proxy-per-session into main 2026-08-04 18:45:38 +00:00
Owner

Summary

Проблема (живая регрессия, срочный фикс). BrowserFetcher._pool_proxy() делал acquire()+release() на КАЖДЫЙ /fetch. Пока пул был из 1 узла, это не было заметно (acquire всегда выдавал тот же прокси). После восстановления пула до 3 узлов (id 9/10/11, affinity=any) acquire() (ORDER BY last_ok_at NULLS LAST, id) стал гарантированно выдавать ДРУГОЙ прокси на каждый /fetch. tradein-browser релончит camoufox при любой смене желаемого прокси (server.py::_ensure_browser) — по прод-логам ~17 relaunch'ей за 15 минут, каждый 5-10с впустую + потеря сессионных cookie (усиливает антибот-подозрительность: поток «первых визитов» с пустой cookie-jar).

Фикс. BrowserFetcher живёт столько же, сколько прогон (создаётся в __aenter__ скрапера/pipeline, закрывается в __aexit__ — часто через целый city-sweep со множеством anchor'ов/detail-фетчей). Lease теперь берётся ОДИН раз в __aenter__ и хранится в self._lease на весь жизненный цикл; отпускается один раз в __aexit__finally — не течёт при исключении, включая исключение внутри async with-блока пользователя).

Коллизия с reap_stale_leases (главный риск, разобран явно)

STALE_LEASE_MINUTES=30, а прогоны бывают дольше (full-load Циана шёл часами). Держать lease весь прогон без изменений означало бы, что reaper отберёт его на середине прогона (следующий tick proxy_healthcheck, каденс 30 мин).

Решение — heartbeat, не увеличение порога. Не трогали STALE_LEASE_MINUTES — это ослабило бы реальную задачу reaper'а (освобождать lease мёртвого/зависшего run'а, который никогда не позовёт release). Вместо этого добавлен proxy_pool.touch(db, proxy_id) — лёгкий UPDATE ... SET leased_at = now() WHERE leased_by IS NOT NULL (no-op если уже не арендован). BrowserFetcher зовёт touch() на каждый /fetch (успешный и неуспешный — сам факт завершённого запроса доказывает, что процесс жив) через новый метод ProxyProvider.touch() (Protocol в contracts.py + реализация в RealProxyProvider). Пока идёт трафик — leased_at постоянно свежий, reaper не трогает. Упавший/зависший run (ни одного /fetch дольше 30 мин) по-прежнему реапится штатно — semantics crash-recovery не ослаблена, просто измеряется от «последней активности», а не от «момента acquire». Тесты test_touch_prevents_reap_of_long_running_session / test_touch_absence_still_reaps_dead_session в tests/services/test_proxy_pool.py проверяют оба плеча явно.

Остальное поведение

  • mark_health(ok) вызывается на каждый /fetch (не только по итогу сессии) — сохранена прежняя грануляция: DISABLE_THRESHOLD пула считает consecutive_fails по попыткам, огрубление до «одна оценка на сессию» замаскировало бы деградирующий узел.
  • Если lease оказался плох в середине сессии — после _LEASE_ROTATE_AFTER_FAILS=3 подряд неудачных /fetch lease ОСОЗНАННО меняется один раз (release старого + acquire нового), счётчик обнуляется успехом. Следующий /fetch несёт новый proxy-url → camoufox релончится РОВНО один раз (не мечемся на каждый запрос, как раньше).
  • run_id НЕ прокидывали в acquire() (в логах by=-1/NON_RUN_LEASE_MARKER) — это только log-attribution, не требуется для фикса; плумбинг потребовал бы трогать 20+ call-site'ов BrowserFetcher(...) (pipeline.py, tasks/*.py) ради косметики.

Curl-путь (providers/_proxy.py::curl_proxy_url) — НЕ тронут, той же проблемой не страдает

Проверено ПЕРЕД правкой: curl_cffi AsyncSession там уже создаётся per-request (нет персистентного процесса-браузера). Смена прокси между запросами не вызывает cost релонча — там нет процесса, который нужно перезапускать. Потеря session/cookie-continuity там та же, что и до введения proxy-пула (каждый curl_cffi-вызов и раньше создавал свежую сессию) — proxy-пул это свойство не ухудшил.

Прод-замер (read-only, ssh gendesign, docker logs --since 1h)

  • tradein-scraper: 255 leased proxy-событий за последний час (пары acquire/release почти на каждый /fetch).
  • tradein-browser: 35 срабатываний "прокси изменился ... — relaunch перед fetch" за тот же час (~1 relaunch каждые ~1.7 мин) — тот же класс регрессии, что и заявленные ~17/15мин (разное окно замера, тот же порядок величины).
  • После фикса: relaunch происходит максимум 1 раз на сессию (создание BrowserFetcher, обычно = один scrape-run/city-sweep с множеством anchor'ов) + опционально 1 раз при осознанной ротации на 3 подряд провалах — ожидаемое снижение на 90%+ по количеству relaunch'ей.

Тесты

Все новые тесты falsified: сохранена сигнатура, обнулено поведение под git stash source-файлов → 15 failed / 35 passed на pre-fix коде (11 AssertionError — genuine value-based падения ключевого регресса: test_two_sequential_fetches_use_same_proxy, test_acquire_happens_once_in_aenter_not_per_fetch, test_lease_released_on_session_close/..._on_exception_inside_session, ротация после N провалов, mark_health/touch грануляция и т.д.; 4 AttributeErrortouch()/rotate-constant реально не существовали до фикса, не что-то замаскированное). На фиксе: 50/50 passed.

Полный pytest в tradein-mvp/backend: 3219 passed, 1 failed (pre-existing test_search_api.py::test_search_cache_hit, 401 RBAC, не наш скоуп — упомянут в задаче), 9 skipped.

ruff check чист на всех изменённых файлах (app/services/proxy_pool.py, app/services/scraper_adapters.py, packages/scraper-kit/src/scraper_kit/browser_fetcher.py, packages/scraper-kit/src/scraper_kit/contracts.py, соответствующие тесты).

Test plan

  • pytest tests/test_kit_browser_fetcher_proxy_pool.py tests/services/test_proxy_pool.py tests/test_scraper_adapters_contracts.py — 50/50 green
  • Falsification: те же тесты на pre-fix коде (git stash source) — 15/50 failed, все по существу (value/genuinely-new-capability)
  • Полный pytest tradein-mvp/backend — 3219 passed, 1 pre-existing fail, 9 skipped
  • ruff check чист
  • Post-merge: смотреть прод-логи tradein-scraper/tradein-browser — частота leased proxy/прокси изменился должна упасть на порядок

Refs #2164

## Summary **Проблема (живая регрессия, срочный фикс).** `BrowserFetcher._pool_proxy()` делал `acquire()`+`release()` на КАЖДЫЙ `/fetch`. Пока пул был из 1 узла, это не было заметно (acquire всегда выдавал тот же прокси). После восстановления пула до 3 узлов (id 9/10/11, `affinity=any`) `acquire()` (`ORDER BY last_ok_at NULLS LAST, id`) стал гарантированно выдавать ДРУГОЙ прокси на каждый `/fetch`. `tradein-browser` релончит camoufox при любой смене желаемого прокси (`server.py::_ensure_browser`) — по прод-логам ~17 relaunch'ей за 15 минут, каждый 5-10с впустую + потеря сессионных cookie (усиливает антибот-подозрительность: поток «первых визитов» с пустой cookie-jar). **Фикс.** `BrowserFetcher` живёт столько же, сколько прогон (создаётся в `__aenter__` скрапера/pipeline, закрывается в `__aexit__` — часто через целый city-sweep со множеством anchor'ов/detail-фетчей). Lease теперь берётся ОДИН раз в `__aenter__` и хранится в `self._lease` на весь жизненный цикл; отпускается один раз в `__aexit__` (в `finally` — не течёт при исключении, включая исключение внутри `async with`-блока пользователя). ## Коллизия с `reap_stale_leases` (главный риск, разобран явно) `STALE_LEASE_MINUTES=30`, а прогоны бывают дольше (full-load Циана шёл часами). Держать lease весь прогон без изменений означало бы, что reaper отберёт его на середине прогона (следующий tick `proxy_healthcheck`, каденс 30 мин). **Решение — heartbeat, не увеличение порога.** Не трогали `STALE_LEASE_MINUTES` — это ослабило бы реальную задачу reaper'а (освобождать lease мёртвого/зависшего run'а, который никогда не позовёт `release`). Вместо этого добавлен `proxy_pool.touch(db, proxy_id)` — лёгкий `UPDATE ... SET leased_at = now() WHERE leased_by IS NOT NULL` (no-op если уже не арендован). `BrowserFetcher` зовёт `touch()` на каждый `/fetch` (успешный и неуспешный — сам факт завершённого запроса доказывает, что процесс жив) через новый метод `ProxyProvider.touch()` (Protocol в `contracts.py` + реализация в `RealProxyProvider`). Пока идёт трафик — `leased_at` постоянно свежий, reaper не трогает. Упавший/зависший run (ни одного `/fetch` дольше 30 мин) по-прежнему реапится штатно — semantics crash-recovery не ослаблена, просто измеряется от «последней активности», а не от «момента acquire». Тесты `test_touch_prevents_reap_of_long_running_session` / `test_touch_absence_still_reaps_dead_session` в `tests/services/test_proxy_pool.py` проверяют оба плеча явно. ## Остальное поведение - `mark_health(ok)` вызывается на каждый `/fetch` (не только по итогу сессии) — сохранена прежняя грануляция: `DISABLE_THRESHOLD` пула считает `consecutive_fails` по попыткам, огрубление до «одна оценка на сессию» замаскировало бы деградирующий узел. - Если lease оказался плох в середине сессии — после `_LEASE_ROTATE_AFTER_FAILS=3` подряд неудачных `/fetch` lease ОСОЗНАННО меняется один раз (`release` старого + `acquire` нового), счётчик обнуляется успехом. Следующий `/fetch` несёт новый proxy-url → camoufox релончится РОВНО один раз (не мечемся на каждый запрос, как раньше). - `run_id` НЕ прокидывали в `acquire()` (в логах `by=-1`/`NON_RUN_LEASE_MARKER`) — это только log-attribution, не требуется для фикса; плумбинг потребовал бы трогать 20+ call-site'ов `BrowserFetcher(...)` (`pipeline.py`, `tasks/*.py`) ради косметики. ## Curl-путь (`providers/_proxy.py::curl_proxy_url`) — НЕ тронут, той же проблемой не страдает Проверено ПЕРЕД правкой: curl_cffi `AsyncSession` там уже создаётся per-request (нет персистентного процесса-браузера). Смена прокси между запросами не вызывает cost релонча — там нет процесса, который нужно перезапускать. Потеря session/cookie-continuity там та же, что и до введения proxy-пула (каждый curl_cffi-вызов и раньше создавал свежую сессию) — proxy-пул это свойство не ухудшил. ## Прод-замер (read-only, `ssh gendesign`, `docker logs --since 1h`) - `tradein-scraper`: 255 `leased proxy`-событий за последний час (пары acquire/release почти на каждый `/fetch`). - `tradein-browser`: 35 срабатываний `"прокси изменился ... — relaunch перед fetch"` за тот же час (~1 relaunch каждые ~1.7 мин) — тот же класс регрессии, что и заявленные ~17/15мин (разное окно замера, тот же порядок величины). - После фикса: relaunch происходит максимум 1 раз на сессию (создание `BrowserFetcher`, обычно = один scrape-run/city-sweep с множеством anchor'ов) + опционально 1 раз при осознанной ротации на 3 подряд провалах — ожидаемое снижение на 90%+ по количеству relaunch'ей. ## Тесты Все новые тесты falsified: сохранена сигнатура, обнулено поведение под `git stash` source-файлов → **15 failed / 35 passed** на pre-fix коде (11 `AssertionError` — genuine value-based падения ключевого регресса: `test_two_sequential_fetches_use_same_proxy`, `test_acquire_happens_once_in_aenter_not_per_fetch`, `test_lease_released_on_session_close`/`..._on_exception_inside_session`, ротация после N провалов, mark_health/touch грануляция и т.д.; 4 `AttributeError` — `touch()`/rotate-constant реально не существовали до фикса, не что-то замаскированное). На фиксе: **50/50 passed**. Полный `pytest` в `tradein-mvp/backend`: **3219 passed, 1 failed (pre-existing `test_search_api.py::test_search_cache_hit`, 401 RBAC, не наш скоуп — упомянут в задаче), 9 skipped**. `ruff check` чист на всех изменённых файлах (`app/services/proxy_pool.py`, `app/services/scraper_adapters.py`, `packages/scraper-kit/src/scraper_kit/browser_fetcher.py`, `packages/scraper-kit/src/scraper_kit/contracts.py`, соответствующие тесты). ## Test plan - [x] `pytest tests/test_kit_browser_fetcher_proxy_pool.py tests/services/test_proxy_pool.py tests/test_scraper_adapters_contracts.py` — 50/50 green - [x] Falsification: те же тесты на pre-fix коде (git stash source) — 15/50 failed, все по существу (value/genuinely-new-capability) - [x] Полный `pytest tradein-mvp/backend` — 3219 passed, 1 pre-existing fail, 9 skipped - [x] `ruff check` чист - [ ] Post-merge: смотреть прод-логи `tradein-scraper`/`tradein-browser` — частота `leased proxy`/`прокси изменился` должна упасть на порядок Refs #2164
Light1YT force-pushed fix/tradein-sticky-proxy-per-session from c6590c5ae9 to 5e247c6264 2026-08-04 18:39:58 +00:00 Compare
bot-backend merged commit ef2b07ee9c into main 2026-08-04 18:45:38 +00:00
bot-backend deleted branch fix/tradein-sticky-proxy-per-session 2026-08-04 18:45:38 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2640
No description provided.