test(ci): развести «нагрузку не создать» и «защита сломана» в двух флапающих гейтах (#2783) #2789

Merged
bot-backend merged 1 commit from fix/auth-flood-test-flake into main 2026-08-07 10:35:15 +00:00
Collaborator

Два гейта красили ЧУЖИЕ PR-ы по таймингу раннера. Болезнь одна, диагнозы разные — правки тоже.

1. tradein/test_auth_api.py::test_login_flood_capped_by_rate_… — подводило ПРЕДУСЛОВИЕ

Утверждение про потолок верное; красный давало assert len(codes) > connections — «каждое из ста соединений успело сходить дважды за секунду». Это мерило скорости раннера, а не нагрузки: на занятом первый круг из ста запросов сам съедает всю секунду (deadline проверяется ПЕРЕД запросом), выходит ровно 100 ответов, и невыполненное предусловие даёт красный, неотличимый от настоящей поломки потолка.

Замер частоты (2026-08-07, 8 ядер):

условие падений что было видно
свободная машина (load ~7) 0 из 20 722-1586 ответов, запас 7-15×
load average ~90 (12 счётчиков) 0 из 20 204-306 ответов, запас 2-3×
load average ~185 (40 счётчиков) 12 из 15, из них 10 — ровно на предусловии 100 ответов = один круг

Предусловие — первое, что ломается при росте нагрузки, поэтому в CI и вылезло именно оно.

Что сделано. Предусловие стало содержательным и завязано на ТОТ ЖЕ порог, с которым сверяется вердикт: флуд обязан предлагать больше ceiling × 1.5 запросов/с. Пока он предлагает меньше, следующее утверждение зелено даже на системе вовсе без потолка — то есть измерения нет. Красный при этом говорит «раннер не потянул», а не «потолок сломан». В падавших прогонах флуд предлагал 55-80 запросов/с против порога 30/с — мерить БЫЛО на чем, и теперь эти прогоны зелёные по делу, а не по снисхождению.

Остальные четыре утверждения не тронуты.

Проверка на способность краснеть. Сломал потолок в проде-коде (verify_slots_saturatedreturn False, пул max_workers=64 мимо настройки):

E  AssertionError: 957 сверок/с при потолке 20/с (1077 за 1.13с) — потолок темпа не работает

И то же самое ПОД НАГРУЗКОЙ, на прогонах, где раньше падало предусловие (100 за 1.49с) — 4 из 6 красные на вердикте о потолке. Новое предусловие поломку не маскирует.

2. backend/tests/services/test_weather_cache.py — подводило САМО утверждение

Тест требовал «16 потоков → ровно один сетевой вызов», а weather_cache.py:279-292 прямым текстом обещает обратное: сетевой вызов вынесен ЗА lock сознательно (#1370, иначе все analyze сериализуются на время httpx даже для разных координат), и «cold-start на ОДИН ключ может породить несколько параллельных запросов… приемлемо». Зелёным тест был по везению GIL. Комментарий # Микро-задержка — окно для других потоков в теле мока при этом врал: задержки там не было.

Замер (200 штормов подряд, тот же путь, что в тесте):

условие распределение сетевых вызовов
дефолтный switchinterval (5мс), вхолостую 1×199, 2×1 → ~0.5% прогонов красные
то же под load average ~120 1×199, 2×1 → те же ~0.5%
setswitchinterval(1e-6) (потоки реально чередуются) больше одного вызова в 197 из 200, все 16 — в 173

То есть утверждение ложно почти всегда, когда гонка вообще случается. ~0.5% на прогон — это и есть «раз в двести PR-ов красим чужой диф» (#2781).

Что сделано — вариант (1) из issue: тест приведён к фактическому контракту. Код НЕ тронут: поведение объявлено приемлемым в #1370 с обоснованием, лишние запросы бывают только на cold-start одного ключа и идемпотентны. Настоящий single-flight (per-key lock) — отдельная задача с отдельным обоснованием, а не побочный эффект правки теста.

Тест теперь утверждает то, что код правда гарантирует:

  • все 16 потоков получили ОДНО И ТО ЖЕ непустое значение;
  • сетевых вызовов 1 ≤ n ≤ 16 — не больше числа участников (повторный фетч = поломка);
  • в кэше ровно один ключ, и он округлённый;
  • после шторма кэш отвечает без сети — цена уступки #1370 платится один раз.

time.sleep(0.05) в моке делает гонку неслучайной: замер — 30 штормов из 30 дают ровно 16 вызовов, то есть тест теперь мерит худший случай уступки, а не везение планировщика, и верхняя граница проверяется на самой границе.

Проверка на способность краснеть — три поломки кэша, три разных красных:

поломка сообщение
потеряна запись в кэш assert [] == [(56.84, 60.59)]
запись под чужим ключом assert [(56.84, 61.09)] == [(56.84, 60.59)]
TTL мёртвый при живой записи после шторма кэш обязан отвечать без сети, а вызовов стало 17 против 16

Test plan

  • tradein-mvp/backend: 20 прогонов вхолостую — 20 зелёных; 15 под load average ~185 — 0 падений на предусловии (было 10 из 15)
  • tradein-mvp/backend: весь tests/test_auth_api.py — 48 passed
  • backend: 20 прогонов TestConcurrencySafe — 20 зелёных; весь tests/services/test_weather_cache.py — 20 passed
  • обе правки покрашены намеренной поломкой охраняемого механизма (см. выше)
  • зелёный CI на обоих гейтах (CI / backend-tests + ci-tradein)

Чего в этом PR НЕТ

  • Не трогал остальные четыре утверждения auth-теста (отзывчивость, темп, 429-вместо-очереди, ненулевые попытки) и продуктовый код обоих механизмов.
  • Под load average ~185 auth-тест продолжает изредка падать на ПЕРВОМ утверждении (худший сторонний запрос >500мс, проб <10) — 5 из 15 после правки. Это отдельное утверждение с отдельным диагнозом: под таким раннером «API встаёт» неотличимо от «машина встала». Сознательно не трогал — при загрузке, на которой ловилось предусловие (load ~90), эти утверждения не падали ни разу за 20 прогонов.

Refs #2783

Два гейта красили ЧУЖИЕ PR-ы по таймингу раннера. Болезнь одна, диагнозы разные — правки тоже. ## 1. `tradein/test_auth_api.py::test_login_flood_capped_by_rate_…` — подводило ПРЕДУСЛОВИЕ Утверждение про потолок верное; красный давало `assert len(codes) > connections` — «каждое из ста соединений успело сходить дважды за секунду». Это мерило скорости раннера, а не нагрузки: на занятом первый круг из ста запросов сам съедает всю секунду (`deadline` проверяется ПЕРЕД запросом), выходит ровно 100 ответов, и невыполненное предусловие даёт красный, неотличимый от настоящей поломки потолка. **Замер частоты (2026-08-07, 8 ядер):** | условие | падений | что было видно | |---|---|---| | свободная машина (load ~7) | **0 из 20** | 722-1586 ответов, запас 7-15× | | load average ~90 (12 счётчиков) | **0 из 20** | 204-306 ответов, запас 2-3× | | load average ~185 (40 счётчиков) | **12 из 15**, из них **10 — ровно на предусловии** | 100 ответов = один круг | Предусловие — первое, что ломается при росте нагрузки, поэтому в CI и вылезло именно оно. **Что сделано.** Предусловие стало содержательным и завязано на ТОТ ЖЕ порог, с которым сверяется вердикт: флуд обязан предлагать больше `ceiling × 1.5` запросов/с. Пока он предлагает меньше, следующее утверждение зелено даже на системе вовсе без потолка — то есть измерения нет. Красный при этом говорит «раннер не потянул», а не «потолок сломан». В падавших прогонах флуд предлагал 55-80 запросов/с против порога 30/с — мерить БЫЛО на чем, и теперь эти прогоны зелёные по делу, а не по снисхождению. Остальные четыре утверждения не тронуты. **Проверка на способность краснеть.** Сломал потолок в проде-коде (`verify_slots_saturated` → `return False`, пул `max_workers=64` мимо настройки): ``` E AssertionError: 957 сверок/с при потолке 20/с (1077 за 1.13с) — потолок темпа не работает ``` И то же самое ПОД НАГРУЗКОЙ, на прогонах, где раньше падало предусловие (`100 за 1.49с`) — 4 из 6 красные на вердикте о потолке. Новое предусловие поломку не маскирует. ## 2. `backend/tests/services/test_weather_cache.py` — подводило САМО утверждение Тест требовал «16 потоков → ровно один сетевой вызов», а `weather_cache.py:279-292` прямым текстом обещает обратное: сетевой вызов вынесен ЗА lock сознательно (#1370, иначе все `analyze` сериализуются на время httpx даже для разных координат), и «cold-start на ОДИН ключ может породить несколько параллельных запросов… приемлемо». Зелёным тест был по везению GIL. Комментарий `# Микро-задержка — окно для других потоков` в теле мока при этом врал: задержки там не было. **Замер (200 штормов подряд, тот же путь, что в тесте):** | условие | распределение сетевых вызовов | |---|---| | дефолтный `switchinterval` (5мс), вхолостую | `1`×199, `2`×1 → **~0.5% прогонов красные** | | то же под load average ~120 | `1`×199, `2`×1 → те же ~0.5% | | `setswitchinterval(1e-6)` (потоки реально чередуются) | больше одного вызова в **197 из 200**, все 16 — в **173** | То есть утверждение ложно почти всегда, когда гонка вообще случается. ~0.5% на прогон — это и есть «раз в двести PR-ов красим чужой диф» (#2781). **Что сделано — вариант (1) из issue: тест приведён к фактическому контракту.** Код НЕ тронут: поведение объявлено приемлемым в #1370 с обоснованием, лишние запросы бывают только на cold-start одного ключа и идемпотентны. Настоящий single-flight (per-key lock) — отдельная задача с отдельным обоснованием, а не побочный эффект правки теста. Тест теперь утверждает то, что код правда гарантирует: - все 16 потоков получили ОДНО И ТО ЖЕ непустое значение; - сетевых вызовов `1 ≤ n ≤ 16` — не больше числа участников (повторный фетч = поломка); - в кэше ровно один ключ, и он округлённый; - **после шторма кэш отвечает без сети** — цена уступки #1370 платится один раз. `time.sleep(0.05)` в моке делает гонку неслучайной: замер — 30 штормов из 30 дают ровно 16 вызовов, то есть тест теперь мерит худший случай уступки, а не везение планировщика, и верхняя граница проверяется на самой границе. **Проверка на способность краснеть** — три поломки кэша, три разных красных: | поломка | сообщение | |---|---| | потеряна запись в кэш | `assert [] == [(56.84, 60.59)]` | | запись под чужим ключом | `assert [(56.84, 61.09)] == [(56.84, 60.59)]` | | TTL мёртвый при живой записи | `после шторма кэш обязан отвечать без сети, а вызовов стало 17 против 16` | ## Test plan - [x] `tradein-mvp/backend`: 20 прогонов вхолостую — 20 зелёных; 15 под load average ~185 — 0 падений на предусловии (было 10 из 15) - [x] `tradein-mvp/backend`: весь `tests/test_auth_api.py` — 48 passed - [x] `backend`: 20 прогонов `TestConcurrencySafe` — 20 зелёных; весь `tests/services/test_weather_cache.py` — 20 passed - [x] обе правки покрашены намеренной поломкой охраняемого механизма (см. выше) - [ ] зелёный CI на обоих гейтах (`CI / backend-tests` + `ci-tradein`) ## Чего в этом PR НЕТ - Не трогал остальные четыре утверждения auth-теста (отзывчивость, темп, 429-вместо-очереди, ненулевые попытки) и продуктовый код обоих механизмов. - Под load average ~185 auth-тест продолжает изредка падать на ПЕРВОМ утверждении (`худший сторонний запрос >500мс`, `проб <10`) — 5 из 15 после правки. Это отдельное утверждение с отдельным диагнозом: под таким раннером «API встаёт» неотличимо от «машина встала». Сознательно не трогал — при загрузке, на которой ловилось предусловие (load ~90), эти утверждения не падали ни разу за 20 прогонов. Refs #2783
bot-backend added 1 commit 2026-08-07 10:13:41 +00:00
test(ci): развести «нагрузку не создать» и «защита сломана» в двух гейтах (#2783)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m58s
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
CI / backend-tests (pull_request) Successful in 16m3s
7e21ff2af9
Оба теста красили ЧУЖИЕ PR-ы по таймингу раннера, а на правиле «зелёный CI =
можно мержить» здесь держится весь самомерж. Диагнозы разные, поэтому и правки
разные.

1) tradein auth-флуд (`test_login_flood_capped_by_rate_…`). Утверждение про
потолок верное — подводило ПРЕДУСЛОВИЕ `len(codes) > connections`: «каждое из ста
соединений успело сходить дважды за секунду» мерит скорость раннера, а не
нагрузку. На занятом первый круг из ста запросов сам съедает всю секунду, и
невыполненное предусловие давало красный, неотличимый от настоящей поломки
потолка. Замер: свободная машина — 0 падений из 20 (~800 ответов/с), под load
average ~185 — 10 из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
против порога 30/с, то есть мерить БЫЛО на чем.

Теперь предусловие содержательное и с тем же порогом, с которым сверяется
вердикт: пока флуд предлагает меньше `ceiling × 1.5`, следующее утверждение
зелено даже без потолка вовсе — значит измерения нет, и красный говорит «раннер
не потянул», а не «потолок сломан». Остальные четыре утверждения не тронуты.

2) backend weather_cache. Здесь подводило САМО утверждение: тест требовал
«ровно один сетевой вызов на 16 потоков», а код (#1370) прямым текстом выносит
вызов за lock и обещает обратное — «может породить несколько параллельных
запросов… приемлемо». Зелёным он был по везению GIL: 200 штормов дали 2 вызова
один раз (~0.5% — этим и покрасило #2781), а при `setswitchinterval(1e-6)`
больше одного вызова дали 197 штормов из 200.

Тест приведён к тому, что код правда гарантирует: значение у всех потоков одно,
сетевых вызовов не больше числа участников, ключ один, и ПОСЛЕ шторма кэш
отвечает без сети. Код не тронут: поведение объявлено приемлемым в #1370 с
обоснованием; настоящий single-flight (per-key lock) — отдельная задача с
отдельным обоснованием, а не побочный эффект правки теста.

Оба сторожа проверены на способность краснеть: снятый гейт насыщения + пул мимо
настройки → «957 сверок/с при потолке 20/с» (и под нагрузкой тоже краснеет, на
том же прогоне, где раньше падало предусловие); потерянная запись в кэш / чужой
ключ / мёртвый TTL → три разных красных сообщения.

Refs #2783
bot-backend merged commit e4680082ea into main 2026-08-07 10:35:15 +00:00
bot-backend deleted branch fix/auth-flood-test-flake 2026-08-07 10:35:18 +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#2789
No description provided.