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
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
Оба теста красили ЧУЖИЕ 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
This commit is contained in:
parent
a9096f125a
commit
7e21ff2af9
2 changed files with 80 additions and 16 deletions
|
|
@ -10,8 +10,10 @@
|
||||||
DNS-fail повторяет timeout на каждый analyze.
|
DNS-fail повторяет timeout на каждый analyze.
|
||||||
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
|
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
|
||||||
раздельные таблицы внутри модуля).
|
раздельные таблицы внутри модуля).
|
||||||
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start →
|
4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
|
||||||
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
|
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
|
||||||
|
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
|
||||||
|
(#1370, см. сам тест).
|
||||||
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
||||||
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
|
||||||
|
|
||||||
|
|
||||||
class TestConcurrencySafe:
|
class TestConcurrencySafe:
|
||||||
def test_single_flight_cold_start_one_network_call(self) -> None:
|
def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
|
||||||
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
|
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
|
||||||
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
|
все получают одно и то же значение, и после шторма кэш отвечает без сети.
|
||||||
|
|
||||||
|
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
|
||||||
|
было требование, которого код НЕ выполняет и выполнять не собирается:
|
||||||
|
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
|
||||||
|
сериализуются на время httpx-вызова даже для разных координат), а рядом с
|
||||||
|
ним написано, что cold-start на один ключ «может породить несколько
|
||||||
|
параллельных запросов… приемлемо». Тест зеленел не потому, что защита
|
||||||
|
работает, а потому что при GIL первый поток обычно успевал сложить
|
||||||
|
результат раньше остальных.
|
||||||
|
|
||||||
|
Замер 2026-08-07, 200 штормов подряд: при дефолтном
|
||||||
|
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
|
||||||
|
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
|
||||||
|
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
|
||||||
|
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
|
||||||
|
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
|
||||||
|
всегда, когда гонка вообще случается, — чинить надо было тест.
|
||||||
|
|
||||||
|
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
|
||||||
|
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
|
||||||
|
запросы бывают только на cold-start одного ключа и они идемпотентны.
|
||||||
|
Понадобится — это отдельная задача с отдельным обоснованием, а не
|
||||||
|
побочный эффект правки теста.
|
||||||
|
|
||||||
|
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
|
||||||
|
промах кэша до первой записи. Так тест мерит худший случай той самой
|
||||||
|
уступки, а не везение планировщика.
|
||||||
|
"""
|
||||||
start_barrier = threading.Barrier(16)
|
start_barrier = threading.Barrier(16)
|
||||||
get_call_count = 0
|
get_call_count = 0
|
||||||
get_lock = threading.Lock()
|
get_lock = threading.Lock()
|
||||||
|
|
@ -253,8 +284,7 @@ class TestConcurrencySafe:
|
||||||
nonlocal get_call_count
|
nonlocal get_call_count
|
||||||
with get_lock:
|
with get_lock:
|
||||||
get_call_count += 1
|
get_call_count += 1
|
||||||
# Микро-задержка — окно для других потоков добраться до lock'а.
|
time.sleep(0.05) # окно, в котором остальные потоки видят промах
|
||||||
# Не делаем sleep большим, чтобы тест не висел.
|
|
||||||
return _make_httpx_response(_make_forecast_response())
|
return _make_httpx_response(_make_forecast_response())
|
||||||
|
|
||||||
client_ctx = MagicMock()
|
client_ctx = MagicMock()
|
||||||
|
|
@ -276,11 +306,27 @@ class TestConcurrencySafe:
|
||||||
t.start()
|
t.start()
|
||||||
for t in threads:
|
for t in threads:
|
||||||
t.join()
|
t.join()
|
||||||
|
storm_calls = get_call_count
|
||||||
|
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
|
||||||
|
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
|
||||||
|
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
|
||||||
|
|
||||||
assert len(results) == 16
|
assert len(results) == 16
|
||||||
assert all(r is not None for r in results)
|
assert results[0] is not None
|
||||||
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
|
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
|
||||||
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
|
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
|
||||||
|
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
|
||||||
|
# запись в кэш); меньше единицы невозможно, кэш был пуст.
|
||||||
|
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
|
||||||
|
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
|
||||||
|
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
|
||||||
|
# снятого single-flight — без этого уступка превращается в дыру.
|
||||||
|
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
|
||||||
|
assert after_storm == results[0]
|
||||||
|
assert get_call_count == storm_calls, (
|
||||||
|
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
|
||||||
|
f"{get_call_count} против {storm_calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -839,13 +839,31 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
|
||||||
len(probe_latencies) >= 10
|
len(probe_latencies) >= 10
|
||||||
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
||||||
|
|
||||||
# 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их
|
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
|
||||||
# доехало не больше потолка (запас ×1.5 на планировщик).
|
# потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
|
||||||
assert len(codes) > connections, (
|
max_attempts_per_s = ceiling_per_s * 1.5
|
||||||
"флуд не состоялся: на каждое соединение вышло не больше одного ответа — "
|
offered_per_s = len(codes) / elapsed
|
||||||
"мерить потолок не на чем"
|
|
||||||
|
# ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не
|
||||||
|
# удалось» и «потолок не работает» — разные новости, и красный обязан их
|
||||||
|
# различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не
|
||||||
|
# совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже
|
||||||
|
# на системе вовсе без потолка, то есть измерения нет.
|
||||||
|
#
|
||||||
|
# Раньше условием было `len(codes) > connections` — «каждое соединение
|
||||||
|
# успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а
|
||||||
|
# не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду,
|
||||||
|
# и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07:
|
||||||
|
# свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 —
|
||||||
|
# 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
|
||||||
|
# против порога 30/с, то есть мерить было на чем).
|
||||||
|
assert offered_per_s > max_attempts_per_s, (
|
||||||
|
f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с "
|
||||||
|
f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки "
|
||||||
|
f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не "
|
||||||
|
f"потянул», НЕ «потолок сломан»"
|
||||||
)
|
)
|
||||||
assert attempts_per_s <= ceiling_per_s * 1.5, (
|
assert attempts_per_s <= max_attempts_per_s, (
|
||||||
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
|
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
|
||||||
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
|
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue