Race condition в системе версионирования: как два ревьюера поймали потерю данных

Когда два ревьюера находят одни и те же баги: история о том, как система версионирования может потерять данные
Работаешь над feature branch feat/scoring-v2-tavily-citations в проекте trend-analisis, пилишь систему многоуровневого анализа трендов. Задача звучит просто: позволить анализировать один тренд несколько раз с разными параметрами (depth, time_horizon), сохранять все варианты и отправлять их на фронт. Казалось бы, что может быть проще?
Потом коммит отправляешь на ревью двум коллегам. И они оба, независимо друг от друга, находят одну и ту же критическую ошибку — race condition в функции next_version(). Момент волшебства: когда разные люди пришли к одному выводу, значит, ошибка точно смертельна.
Вот что происходит. Функция next_version() считает максимальный номер версии анализа для тренда и возвращает max + 1. Звучит логично, но представь: два запроса одновременно анализируют один тренд. Оба вызывают next_version(), получают одинаковый номер (например, version=3), затем пытаются сохранить результат через save_analysis(). Один INSERT успешен, второй молча пропадает в чёрной дыре except Exception: pass. Данные потеряны, пользователь не узнает о проблеме.
Но это ещё не всё. Коллеги заметили вторую проблему: функция видит только завершённые анализы (статус completed), поэтому запущенный анализ (статус running) остаётся невидимым для системы версионирования. Получается, что второй запрос стартует с того же номера версии, какой уже занят висящим процессом. Классическая ловушка асинхронности.
Обнаружилось ещё несколько багов: фронт ожидает получить один объект getAnalysisForTrend, а бэкенд начал отправлять массив анализов. TypeScript тип AnalysisReport не знает про новые поля (version, depth, time_horizon, parent_job_id) — они приходят с сервера и сразу теряются. Параметр parent_job_id вообще ни на что не валидируется, что открывает дверь для инъекций. И depth может быть любым числом — никакого лимита, хоть 100 передай.
Интересный момент: многие разработчики думают, что except Exception: pass это “временно”, но на практике эта конструкция часто уходит в production как постоянное решение, маскируя критические ошибки. Это называется exception swallowing, и это один из самых подлых антипаттернов асинхронного кода.
Решение оказалось не очень сложным, но требовало думать о транзакциях иначе. Нужно либо переместить next_version() внутрь save_analysis() с retry-логикой на IntegrityError, либо использовать атомарный SQL-запрос INSERT...SELECT MAX(version)+1, чтобы гарантировать уникальность версии за одно действие. Плюс резервировать версию сразу при старте анализа (INSERT со статусом running), чтобы параллельные запросы их видели.
Для фронта пришлось добавить новый endpoint getAnalysesForTrend (а старый getAnalysisForTrend оставить для обратной совместимости). TypeScript типы расширены, валидация на parent_job_id добавлена, depth ограничен до 7 через Pydantic Field(ge=1, le=7).
Главный урок: код, который “работает на примере”, и код, который справляется с race conditions, это два разных животных. Всегда думай про параллелизм, даже если сейчас система однопоточная. И когда два ревьюера независимо находят один и тот же баг — это не совпадение, это сигнал, что нужно переделывать архитектуру, а не чинить синтаксис.
😄 Prometheus: решение проблемы, о существовании которой ты не знал, способом, который не понимаешь.
Метаданные
- Session ID:
- grouped_trend-analisis_20260208_1527
- Branch:
- feat/scoring-v2-tavily-citations
- Dev Joke
- Prometheus: решение проблемы, о существовании которой ты не знал, способом, который не понимаешь.
Часть потока:
Разработка: trend-analisis