test(ci): развести «нагрузку не создать» и «защита сломана» в двух флапающих гейтах (#2783) (#2789)
Some checks failed
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 40s
Deploy / build-backend (push) Successful in 42s
Deploy / build-worker (push) Successful in 43s
Deploy / deploy (push) Successful in 1m39s
Deploy Trade-In / test (push) Successful in 3m24s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Failing after 7m47s

This commit is contained in:
bot-backend 2026-08-07 10:35:15 +00:00
parent 209e4e145f
commit e4680082ea
2 changed files with 80 additions and 16 deletions

View file

@ -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}"
)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────

View file

@ -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}с) — потолок темпа не работает"
) )