fix(tradein/proxy): упавшая проба присваивала себе бан боевого сбора (#2800) #2805

Merged
bot-backend merged 1 commit from fix/probe-must-not-steal-ban into main 2026-08-09 20:13:11 +00:00
Collaborator

Что сломано

Прод, замер 09.08.2026 — пара (proxy 1, cian):

момент reason ban_count banned_until
18:21:31 (banned_at) — боевой сбор banned:cian 1 00:21
19:43:53 (updated_at) — упавшая проба probe:browser 2 07:43

mark_banned в ON CONFLICT (proxy_id, source) DO UPDATE переписывал reason безусловно, поэтому проба забирала чужую строку себе — вместе с эскалацией: отдых пары вырос с 6 ч до 12 ч на счётчике, который проба не заработала.

Фильтр «снимаю только своё» (clear_source_bans(only_reason=...)) сделан правильно и работает: удалить чужую строку проба не может. Но присвоить может — и тогда фильтр перестаёт защищать: строка уже «своя», и следующий зелёный robots.txt снимет ею бан, поставленный боевым сбором по настоящему отказу площадки. Плюс метка перестаёт быть свидетельством: banned:cian («площадка нас отбила») и probe:browser («наша проба не смогла») — разные факты с разными последствиями, ровно ловушка #2764.

Правило

В WHERE у DO UPDATE: строку берём, только если она истекла (живого владельца нет), либо она уже наша, либо мы боевой сбор. Иначе не трогаем ничего — ни reason, ни ban_count, ни срок.

Вариант «продлить срок, это же безвредно» отвергнут: срок пересчитывается от now() по НАШЕЙ эскалации и способен укоротить уже эскалированный чужой бан. Бан и так стоит — делать нечего.

Зеркальный случай: да, боевой сбор строку пробы перехватывает — намеренно

Его вердикт сильнее (площадка отбила именно сейчас): пара остаётся забаненной, а метка становится точнее. Запретить и ему — значит оставить строку за пробой, и её же успешный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально и хуже. Асимметрия закреплена тестом test_live_ban_takes_over_the_probe_row.

Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного). Обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об эскалации боевых банов пары.

Красный прогон на текущем коде

Новые тесты приложены к чистому origin/main (worktree на 9cd6db02) без правки сервиса:

assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора"
E  AssertionError: assert 'probe:browser' == 'banned:cian'
WARNING proxy_pool: proxy id=1 BANNED by source=cian ... до 2026-08-10 08:05 (ban_count=2)
1 failed, 71 passed

Сценарий взят с прода дословно: активная banned:cian, ban_count=1, затем упавшая проба (page, заглушка Циана вместо robots.txt). После фикса — reason остался banned:cian, ban_count = 1, срок не пересчитан, pair_banned = 0. Мок FakeSession гейтит защиту по подстроке боевого SQL (как и остальные ban-предикаты в нём) — иначе тест был бы зелёным на сломанном коде.

Второй тест из задачи — «успешная проба не снимает чужой бан» — уже существовал (test_probe_clears_only_its_own_ban, #2803); добавлено, что чужая строка не только цела, но и не переписана (reason/ban_count).

Синтаксис самого UPSERT проверен парсером PostgreSQL (pglast.parse_sql) — локального Postgres в контуре нет, юнит-тесты идут через FakeSession.

Диагностика

0 rows у апсерта теперь означает три разные вещи — чужой активный владелец / защита последнего узла / нет узла. mark_banned возвращает исход строкой (banned | deferred | protected | missing) и пишет отдельный лог на каждый, а счётчик pair_banned больше не объявляет баном то, чего не записал. Раньше «не перебил чужой бан» было бы залогировано как «это последний узел» — ложный диагноз, который читался бы дальше как факт.

Прод: сколько строк затронуто и что с ними будет

Сейчас одна строка с перехваченным происхождением — (1, cian), probe:browser, до 10.08 07:43. Всего активных строк 4, probe-owned из них 1.

Честная оговорка: из самой таблицы перехват в общем случае не доказуем — повторный отказ пробы даёт такую же картину (probe:browser, ban_count>1). Про эту строку известно точно, потому что banned_at (18:21, вставка боевого сбора) не совпадает с updated_at (19:43, запись пробы) при probe-метке, и потому что предыдущее состояние строки видел автор #2803.

Что с ней станет: сама не «дозреет» до 07:43. Строка теперь носит метку пробы, поэтому первый же зелёный robots.txt по паре (1, cian) её удалит — фикс не восстанавливает украденное происхождение задним числом. Практический риск мал: если Циан всё ещё отбивает этот узел, боевой сбор запишет бан заново (уже своей меткой, и с этого момента проба его не тронет). Отдельный шаг руками возможен, но это решение владельца, не моё — правка была бы UPDATE scrape_proxy_source_bans SET reason='banned:cian', ban_count=1, banned_until=banned_at + interval '6 hours' WHERE proxy_id=1 AND source='cian'; я к проду только на чтение.

Про мигающую пару

Ограничение из #2803 (17:55 отказ → 19:44 успех → 19:50 снова отказ) правка не усугубляет: поведение «успешная проба снимает СВОЙ бан» не тронуто, а случай «успешная проба гасит бан боевого сбора» теперь невозможен в принципе. Преждевременное снятие на мигающей паре остаётся открытым — оно про порог подтверждения успеха, не про владельца строки.

Остаточное (не чиню здесь)

Истёкшую чужую строку проба по-прежнему занимает вместе с её ban_count (это необходимо: иначе проба не смогла бы записать вердикт по паре, которую когда-либо банил сбор). Последствие — её успех может удалить строку с накопленной памятью об эскалации боевых банов. Строка к тому моменту уже истёкшая, и purge всё равно снёс бы её через SOURCE_BAN_PURGE_DAYS.

Test plan

  • pytest tests/test_2800_per_source_probe.py tests/services/test_proxy_pool.py — 72 passed
  • проксирующая подсистема целиком: 11 файлов, 200 passed / 1 skipped
  • ruff check + ruff format
  • красный прогон новых тестов на origin/main — 1 failed (см. выше)
  • после деплоя: в логах healthcheck при активном чужом бане появляется бан пары уже стоит от 'banned:...', а reason в scrape_proxy_source_bans больше не меняется на probe:browser

Refs #2800

## Что сломано Прод, замер 09.08.2026 — пара `(proxy 1, cian)`: | момент | reason | ban_count | banned_until | |---|---|---|---| | 18:21:31 (`banned_at`) — боевой сбор | `banned:cian` | 1 | 00:21 | | 19:43:53 (`updated_at`) — упавшая проба | `probe:browser` | **2** | **07:43** | `mark_banned` в `ON CONFLICT (proxy_id, source) DO UPDATE` переписывал `reason` безусловно, поэтому проба забирала чужую строку себе — вместе с эскалацией: отдых пары вырос с 6 ч до 12 ч на счётчике, который проба не заработала. Фильтр «снимаю только своё» (`clear_source_bans(only_reason=...)`) сделан правильно и работает: **удалить** чужую строку проба не может. Но **присвоить** может — и тогда фильтр перестаёт защищать: строка уже «своя», и следующий зелёный robots.txt снимет ею бан, поставленный боевым сбором по настоящему отказу площадки. Плюс метка перестаёт быть свидетельством: `banned:cian` («площадка нас отбила») и `probe:browser` («наша проба не смогла») — разные факты с разными последствиями, ровно ловушка #2764. ## Правило В `WHERE` у `DO UPDATE`: строку берём, только если она **истекла** (живого владельца нет), **либо она уже наша**, **либо мы боевой сбор**. Иначе не трогаем ничего — ни `reason`, ни `ban_count`, ни срок. Вариант «продлить срок, это же безвредно» отвергнут: срок пересчитывается от `now()` по НАШЕЙ эскалации и способен **укоротить** уже эскалированный чужой бан. Бан и так стоит — делать нечего. ## Зеркальный случай: да, боевой сбор строку пробы перехватывает — намеренно Его вердикт сильнее (площадка отбила именно сейчас): пара остаётся забаненной, а метка становится **точнее**. Запретить и ему — значит оставить строку за пробой, и её же успешный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально и хуже. Асимметрия закреплена тестом `test_live_ban_takes_over_the_probe_row`. Цена перехвата — `ban_count` наследуется (отдых чуть длиннее заслуженного). Обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об эскалации боевых банов пары. ## Красный прогон на текущем коде Новые тесты приложены к чистому `origin/main` (worktree на 9cd6db02) без правки сервиса: ``` assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" E AssertionError: assert 'probe:browser' == 'banned:cian' WARNING proxy_pool: proxy id=1 BANNED by source=cian ... до 2026-08-10 08:05 (ban_count=2) 1 failed, 71 passed ``` Сценарий взят с прода дословно: активная `banned:cian, ban_count=1`, затем упавшая проба (`page`, заглушка Циана вместо robots.txt). После фикса — `reason` остался `banned:cian`, `ban_count` = 1, срок не пересчитан, `pair_banned` = 0. Мок `FakeSession` гейтит защиту по подстроке боевого SQL (как и остальные ban-предикаты в нём) — иначе тест был бы зелёным на сломанном коде. Второй тест из задачи — «успешная проба не снимает чужой бан» — уже существовал (`test_probe_clears_only_its_own_ban`, #2803); добавлено, что чужая строка не только цела, но и не переписана (`reason`/`ban_count`). Синтаксис самого UPSERT проверен парсером PostgreSQL (`pglast.parse_sql`) — локального Postgres в контуре нет, юнит-тесты идут через `FakeSession`. ## Диагностика 0 rows у апсерта теперь означает три разные вещи — чужой активный владелец / защита последнего узла / нет узла. `mark_banned` возвращает исход строкой (`banned` | `deferred` | `protected` | `missing`) и пишет отдельный лог на каждый, а счётчик `pair_banned` больше не объявляет баном то, чего не записал. Раньше «не перебил чужой бан» было бы залогировано как «это последний узел» — ложный диагноз, который читался бы дальше как факт. ## Прод: сколько строк затронуто и что с ними будет Сейчас **одна** строка с перехваченным происхождением — `(1, cian)`, `probe:browser`, до **10.08 07:43**. Всего активных строк 4, probe-owned из них 1. Честная оговорка: из самой таблицы перехват в общем случае **не доказуем** — повторный отказ пробы даёт такую же картину (`probe:browser`, `ban_count>1`). Про эту строку известно точно, потому что `banned_at` (18:21, вставка боевого сбора) не совпадает с `updated_at` (19:43, запись пробы) при probe-метке, и потому что предыдущее состояние строки видел автор #2803. Что с ней станет: **сама не «дозреет» до 07:43**. Строка теперь носит метку пробы, поэтому первый же зелёный robots.txt по паре (1, cian) её удалит — фикс не восстанавливает украденное происхождение задним числом. Практический риск мал: если Циан всё ещё отбивает этот узел, боевой сбор запишет бан заново (уже своей меткой, и с этого момента проба его не тронет). Отдельный шаг руками возможен, но это решение владельца, не моё — правка была бы `UPDATE scrape_proxy_source_bans SET reason='banned:cian', ban_count=1, banned_until=banned_at + interval '6 hours' WHERE proxy_id=1 AND source='cian'`; я к проду только на чтение. ## Про мигающую пару Ограничение из #2803 (17:55 отказ → 19:44 успех → 19:50 снова отказ) правка **не усугубляет**: поведение «успешная проба снимает СВОЙ бан» не тронуто, а случай «успешная проба гасит бан боевого сбора» теперь невозможен в принципе. Преждевременное снятие на мигающей паре остаётся открытым — оно про порог подтверждения успеха, не про владельца строки. ## Остаточное (не чиню здесь) Истёкшую чужую строку проба по-прежнему занимает вместе с её `ban_count` (это необходимо: иначе проба не смогла бы записать вердикт по паре, которую когда-либо банил сбор). Последствие — её успех может удалить строку с накопленной памятью об эскалации боевых банов. Строка к тому моменту уже истёкшая, и purge всё равно снёс бы её через `SOURCE_BAN_PURGE_DAYS`. ## Test plan - [x] `pytest tests/test_2800_per_source_probe.py tests/services/test_proxy_pool.py` — 72 passed - [x] проксирующая подсистема целиком: 11 файлов, 200 passed / 1 skipped - [x] ruff check + ruff format - [x] красный прогон новых тестов на `origin/main` — 1 failed (см. выше) - [ ] после деплоя: в логах healthcheck при активном чужом бане появляется `бан пары уже стоит от 'banned:...'`, а `reason` в `scrape_proxy_source_bans` больше не меняется на `probe:browser` Refs #2800
bot-backend added 1 commit 2026-08-09 20:08:24 +00:00
fix(tradein/proxy): упавшая проба присваивала себе бан боевого сбора (#2800)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 / backend-tests (pull_request) Successful in 3m51s
6e50d267ba
Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21`
после упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. ON CONFLICT
DO UPDATE в mark_banned переписывал reason безусловно, поэтому проба забирала
чужую строку себе.

Фильтр «снимаю только своё» (clear_source_bans(only_reason=...)) при этом цел и
работает — удалить чужую строку проба не может. Но присвоенная строка уже
«своя», и следующий зелёный robots.txt снял бы ею бан, поставленный боевым
сбором по настоящему отказу площадки. Плюс метка перестаёт быть свидетельством:
'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба не смогла»)
— разные факты (ловушка #2764), а ban_count складывает события разного рода в
одну эскалацию: отдых пары вырос с 6 ч до 12 ч на счётчике, который заработан
не был.

Правило теперь в WHERE у DO UPDATE: строку берём, только если она истекла
(живого владельца нет), либо она уже наша, либо мы боевой сбор. Продление чужого
бана вместо «ничего не делать» отвергнуто: срок пересчитывается от now() по
НАШЕЙ эскалации и способен укоротить уже эскалированный чужой бан.

Асимметрия намеренная: боевой сбор строку пробы перехватывает. Его вердикт
сильнее, пара остаётся забаненной, метка становится точнее. Запрет и ему оставил
бы строку за пробой — и её же успех снёс бы настоящий бан площадки, то есть тот
же дефект зеркально. ban_count при перехвате наследуется: обнуление на смене
владельца стирало бы память об эскалации боевых банов.

Диагностика: 0 rows у апсерта теперь означает три разные вещи (чужой владелец /
защита последнего узла / нет узла) — mark_banned возвращает исход строкой и
пишет отдельный лог на каждый, а счётчик pair_banned больше не объявляет баном
то, чего не записал.
bot-backend merged commit 27e199e370 into main 2026-08-09 20:13:11 +00:00
bot-backend deleted branch fix/probe-must-not-steal-ban 2026-08-09 20:13:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2805
No description provided.