Merge remote-tracking branch 'origin/main' into fix/migrations-lock-timeout
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
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) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m34s
CI / backend-tests (pull_request) Successful in 15m57s

# Conflicts:
#	tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql
This commit is contained in:
bot-backend 2026-08-07 16:04:27 +05:00
commit 80b40d5414
3 changed files with 80 additions and 150 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

@ -1,134 +0,0 @@
-- 250_drop_duplicate_expires_at_index.sql
-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
--
-- WHY:
-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09)
-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль
-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql.
--
-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени):
-- name indkey indclass indoption indcollation pred am
-- trade_in_estimates_expires_idx 22 3127 0 0 — btree
-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree
-- Совпадает всё: колонка, класс операторов, направление сортировки,
-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация,
-- отсутствие частичного предиката, метод доступа. Ни один не привязан к
-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них
-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует
-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты
-- FDW-пользователю). Гранты живут на таблице, не на индексе.
--
-- ── Почему у «нулевого» дубля появились сканы ────────────────────────────────
-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у
-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый
-- +0, новый +4). То есть планировщик перевёл на новый ВЕСЬ живой трафик.
--
-- Причина не семантическая, а физическая: индексы идентичны, но новый
-- собран вчера с нуля и плотнее упакован — relpages 5 против 6 у старого,
-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
-- и при прочих равных выигрывает. Никакого нового запроса не появилось:
-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
-- 0.93 у нового) — это один и тот же класс точечных lookup'ов, просто
-- переехавший на более свежий индекс. Со временем новый забронзовеет так же
-- и они поменялись бы местами обратно.
--
-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ────────────────────────────
-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу
-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE
-- делал бы full scan». На проде это НЕ так. Фактический план боевого
-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод
-- 2026-08-07):
-- Limit → Sort (Sort Key: expires_at)
-- → Bitmap Heap Scan Filter: (expires_at < now())
-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at
-- Index Cond: (created_by IS NULL)
-- Задача purge ограничена `AND created_by IS NULL` (129 строк из 1061), и
-- планировщик берёт именно этот, более селективный индекс, а expires_at
-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
-- под конкретный запрос» не существует — запрос его не использует.
-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
--
-- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
-- меняется только имя индекса в строке плана и cost на одну страницу спуска.
-- Проверено на чистом PostgreSQL 16.4 (та же минорная версия, что на проде)
-- с воспроизведённым перекосом плотности:
-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84)
-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30)
-- Форма плана, Index Cond и Filter идентичны; отличается только имя.
-- На проде разрыв плотности меньше (5 против 6 страниц, а не 5 против 8),
-- то есть и дельта cost будет меньше синтетической.
--
-- ── Стоимость блокировки ─────────────────────────────────────────────────────
-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь это дёшево:
-- trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сам
-- сносимый индекс 40 kB. DROP INDEX ничего не переписывает — это удаление
-- строк каталога плюс unlink файла, единицы миллисекунд. CONCURRENTLY не
-- нужен и был бы хуже: он не может выполняться внутри блока транзакции, а
-- значит файл пришлось бы оставить без BEGIN/COMMIT (см. разбор механики
-- раннера в 225_listing_source_snapshots_run_id_idx.sql).
--
-- ── ЧТО ПОШЛО НЕ ТАК ПРИ ПЕРВОМ ПРИМЕНЕНИИ (2026-08-07) ──────────────────────
-- Разбор выше верен ровно в одном: дёшево УДЕРЖАНИЕ лока. Дорого ОЖИДАНИЕ его
-- выдачи, и об этом файл молчал. На проде шла чужая ручная аналитическая
-- psql-сессия (ACCESS SHARE на этой же таблице, ~час), и DROP встал в очередь:
-- первая попытка деплоя ждала 29 минут, вторая — ещё 16, обе сняты вручную.
-- Схема не изменилась, оба индекса на месте.
--
-- Вред не в простое деплоя, а в том, что ждущий ACCESS EXCLUSIVE встаёт в
-- очередь ПЕРЕД новыми запросами: любой SELECT приложения по
-- trade_in_estimates начал бы ждать за ним. За 40 минут наблюдения ни один
-- запрос приложения в очередь не встал — обошлось, но механика такова.
--
-- Отсюда `SET LOCAL lock_timeout` ниже. Он ограничивает ТОЛЬКО ожидание;
-- на саму работу (единицы мс) не влияет никак. Почему 5 s:
-- - снизу: deadlock_timeout на проде = 1 s (default, замер 2026-08-07).
-- Автоотмена мешающего autovacuum срабатывает только после того, как
-- ждущий отстоял deadlock_timeout, поэтому 1 s гонялся бы с рутинным
-- autovacuum и делал деплой хрупким на ровном месте. 5 s = 5× запас.
-- - сверху: столько максимум может простоять очередь запросов приложения.
-- Против наблюдённых 1740 s это в 348 раз меньше.
-- Срабатывание таймаута = миграция НЕ применилась и деплой красный
-- (ON_ERROR_STOP=on в раннере) — честный отказ вместо тихой очереди.
-- Лечение: повторить деплой, когда чужая сессия закончится.
--
-- Проверено, что `SET LOCAL` доживает до DROP именно в форме запуска раннера
-- (`psql ... < файл`, PostgreSQL 16.4, встречная сессия держит ACCESS SHARE):
-- со строкой — `ERROR: canceling statement due to lock timeout` через 5 s,
-- exit 3, оба индекса на месте; без неё — та же команда всё ещё висела в
-- очереди, когда её убили на 15-й секунде. Работает это потому, что файл идёт
-- ОДНОЙ psql-сессией и весь завёрнут в BEGIN/COMMIT: `SET LOCAL` живёт до
-- COMMIT. В файле без BEGIN каждая команда — своя транзакция, и `SET LOCAL`
-- не дожил бы до следующей строки.
--
-- IDEMPOTENCY / SAFETY:
-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE.
-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет.
-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы
-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует).
--
-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс),
-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль).
-- Deploy order: standalone. Ничего не ждёт и никого не блокирует.
--
-- Критерий приёмки (записан ДО применения):
-- после деплоя pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx
-- должен СДВИНУТЬСЯ с 234 в течение часа. Если он останется 234, значит
-- трафик ушёл не на переживший индекс, а в Seq Scan — это опровергло бы
-- разбор выше и требовало бы отката.
BEGIN;
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке.
SET LOCAL lock_timeout = '5s';
DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;
COMMENT ON INDEX trade_in_estimates_expires_idx IS
'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
COMMIT;

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