Классическая тестовая задача про интернет-магазин: списать товар со склада и создать заказ. Кандидат приносит решение, тест зелёный, все довольны. Через неделю тот же код на проде продаёт последнюю единицу дважды.
Схема ошибки простая. Два воркера одновременно читают остаток по SKU-1, у обоих на руках единица. Оба записывают оплаченный заказ. Первого воркера можно спрятать за намеренно медленным обработчиком платежа и отложенным коммитом — тогда второй спокойно успевает прочитать тот же неизменённый снимок строки.
Почему один процесс врёт
Пока весь набор тестов крутится в одном интерпретаторе с одним event loop, локальный мьютекс выглядит как рабочая защита. Внутри одного процесса он и правда работает. Проба, которая поднимает второй процесс к той же базе и тому же sku, ломает иллюзию мгновенно: оба читают остаток 1 до того, как кто-то из них сделает запись.
Спасти положение может только условие в самой записи. Предикат на UPDATE — единственное, что заставит одну из двух записей упасть. Ни глобальный dict, ни threading.Lock, ни файловый лок на одной машине этого не заменят.
Найм часто останавливается на зелёном счастливом пути, потому что зелёный тест выглядит как покрытие. Привычка становится дорогой, когда обработчик переписывает кодинг-агент, а ревьюер ни разу не вышел за пределы одного процесса.
Что должно быть в задании
Промпт держат коротким, чтобы кандидат потратил время на гонку, а не на вкусовые детали продукта. Формулировка примерно такая:
Реализуйте reserve_and_order(conn, sku, qty, request_id).
Списывайте on_hand только если в строке всё ещё хватает единиц.
Вставьте ровно один заказ для request_id, если списание прошло.
Откатывайте обе записи, если любая из них не удалась.
Не опирайтесь на блокировку, которая живёт только внутри этого процесса.
Добавьте тест, который падает, если два вызывающих могут продать одну и ту же последнюю единицу.
Менять кандидат должен только функцию резервирования и её тесты. Служебные файлы пробы остаются read-only, иначе финальная уборка кода снесёт подсаженную гонку.
Фикстуру кандидат не редактирует
Фикстура кладёт один sku с единственной единицей и колонкой version в нуле. Вспомогательный хелпер приостанавливает первого воркера после select и до записи в остаток — пауза достаточно длинная, чтобы второй процесс успел увидеть тот же снимок. Тот, кто удалит паузу, чтобы «успокоить» набор тестов, теряет баллы по рубрике.
В таблице заказов есть уникальное ограничение на request_id. Оно ловит наивный retry, вставляющий заказ второй раз, но не ловит два разных идентификатора, продавших последнюю единицу. Эту разницу проговаривают вслух на разборе: агенты часто путают уникальность запроса с уникальностью остатка. Поэтому фикстура выдаёт два разных request_id — так гонка по остатку остаётся видимой.
Рубрика, которая не верит одинокому зелёному тесту
Двухпроцессную пробу проверяют раньше имён, комментариев и прочей косметики. Счастливый путь в одном процессе нужен, но как единственный сигнал он не работает. Ниже таблица, по которой удобно ставить pass/fail, а не полагаться на общее впечатление.
Таблица pass/fail
| Проверка | Проходит, если | Провал, если |
|---|---|---|
| Счастливый путь | Заказ один, on_hand равен нулю | Исключение, нет заказа или остаток остался |
| Проба на двух процессах | Ровно один заказ, остаток не уходит в минус | Два заказа или отрицательный on_hand |
| Предикат в записи | База отклоняет устаревшее списание | Новая величина считается в Python и пишется вслепую |
| Область блокировки | Защита держится между процессами | threading.Lock, глобальный dict или файловый лок в пределах одной машины |
| Форма транзакции | Резерв и заказ коммитятся либо откатываются вместе | Заказ вставлен после неудачного резерва или резерв остался после неудачной вставки |
| Результат конфликта | Вызывающий получает конфликт | Исключение проглочено, вернулось значение успеха |
| Повторная отправка | Тот же request_id не создаёт второй заказ | Слепой retry вставляет ещё заказ или списывает дважды |
Патч может пройти первую строку и завалить вторую — в этом весь смысл пакета. Фиксируйте, какая именно строка рубрики упала, вместо одной общей заметки. Числовые веса тут опциональны, а провал по строке «два процесса» блокирует положительное решение. Замечания про стиль подождут, пока проба не станет зелёной целиком.
Эталонное решение
Ниже пример, который ревьюер разбирает на бумаге: запусков не было. Идея — сначала условный UPDATE, потом вставка заказа, и только если UPDATE вернул строку. Колонка version растёт, чтобы поздний писатель заметил устаревшее чтение других полей. Уникальный ключ на request_id ловит повторную отправку того же запроса. Защитой остатка он не становится.
UPDATE inventory
SET on_hand = on_hand - ?qty, version = version + 1
WHERE sku = ? AND on_hand >= ?qty
RETURNING on_hand, version;
-- строк нет — StockConflict, insert в orders не выполняем
INSERT INTO orders (request_id, sku, qty) VALUES (?, ?, ?);
Собственно забор — предикат по количеству: две транзакции не могут одновременно его удовлетворить для последней единицы. RETURNING сообщает вызывающему, выиграла ли конкретная транзакция запись на склад. Пустая выборка означает, что единицу уже забрал другой воркер, и вставлять заказ нельзя. Обе операции живут в одной транзакции, поэтому неудачная вставка не оставит после себя молчаливое списание.
Строгий вариант со сравнением version тоже годится, если приложение уже загрузило эту версию — он полезнее, когда меняются другие колонки. Для чистого списания on_hand хватает предиката по количеству. Обе формы проваливаются по рубрике, если новая величина посчитана в памяти процесса и записана вслепую.
Как прогнать пробу на двух процессах
Сначала узкий тест схемы, чтобы сломанная таблица упала до начала гонки. Потом проба, которая форкает двух воркеров к одному файлу базы или к серверу.
pytest -q tests/test_reserve.py::test_single_buyer
python reserve_probe.py --workers 2 --sku SKU-1 --qty 1 --pause-ms 200
Проба печатает одно «reserved» и один конфликт по остатку, а затем выходит с ненулевым кодом, если заказов не ровно один. Нулевой код возврата при двух вставленных заказах — провал, даже когда оба процесса завершились чисто. Файл базы после прогона сохраняют: он единственное строчное доказательство, которое имеет вес на разборе.
SELECT on_hand, version FROM inventory WHERE sku = 'SKU-1';
SELECT request_id, qty FROM orders WHERE sku = 'SKU-1';
Ожидаемые значения: остаток ноль, version вырос ровно на единицу, строка заказа одна. Две строки заказов означают, что предиката по количеству не было или он применился слишком поздно. Отрицательный остаток — признак слепой записи из устаревшего питоновского целого. Оба этих исхода переносят в письменную заметку без последующего смягчения.
Провалы, которые стоит оценивать
- threading.Lock пропускает тест в одном процессе, а второй процесс всё равно продаёт последнюю единицу.
- Обработчик читает остаток, вычитает в Python и пишет результат обратно без защитного условия.
- Вставка заказа идёт раньше проверки количества обновлённых строк — конфликт оставляет оплаченный заказ.
- Широкий retry повторяет вставку с новым request_id и списывает остаток второй раз.
- Исключение о конфликте проглочено, функция возвращает успех тому, кто уже собирается отгружать товар.
- Уборочный проход удаляет комментарий о том, почему предикат обязан жить внутри UPDATE, и следующая правка сносит условие.
Последний пункт относится к ясности, а не к конкурентности, и всё равно предсказывает следующую регрессию. Агенты, у которых «чистый код» равно «меньше строк», первыми вырезают то самое предложение про lost update. За это можно ставить отдельную пометку, не позволяя ей перевесить неверную запись. Комментарий сохраняют, если он формулирует инвариант, которого нет в тесте рядом.
Когда пакет не подходит
Он не доказывает поведение изоляции на широком наборе аномалий и не моделирует склад с сотнями sku. Командам с мультирегиональным запасом или формальным доказательством линеаризуемости нужен другой пакет. Кандидата, которого просили использовать лизинг из очереди, по этому заданию оценивать нельзя. Оплата платежей, налоговый движок, консенсус — тоже другая история.
SQLite в режиме rollback-journal сериализует писателей настолько плотно, что небрежный патч выглядит безопасным. Если база — SQLite, включайте WAL и всё равно запускайте два процесса, либо направьте оба воркера на серверную базу. Файловый лок в пределах одной машины валит строку про область блокировки, даже когда локальная проба выглядит спокойной.
Сигнал найма — условная запись, которая выживает при двух процессах, плюс конфликт, видимый вызывающему коду, когда предикат не совпал. Всё остальное в присланном диффе — комментарий вокруг этого одного сохранённого результата. Нулевой код возврата от теста с одним покупателем ничего не подтверждает, а аккуратная функция, возвращающая true после оверселла, — провал.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.