GET /slots/availability, POST /slots/{id}/hold, POST /holds/{id}/confirm
and DELETE /holds/{id} on Laravel 12 and MySQL 8.
- availability cached for 10s behind a single-flight lock with a stale
copy, invalidated after commit on confirm and cancel
- oversell prevented by a conditional decrement, verified by the number
of affected rows rather than a preceding select
- idempotency enforced by a unique index on holds.idempotency_key
- active holds counted with an expires_at filter, so no background
cleanup is required for correctness
- 27 feature tests against MySQL, service documentation, Postman collection
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| postman | ||
| README.md | ||
Как работает сервис
Документ описывает устройство API бронирования слотов: модель данных, поведение каждого эндпоинта и механизмы, обеспечивающие корректность под нагрузкой. Инструкции по запуску и примеры запросов — в README в корне.
Предметная область
Слот — окно с ограниченной вместимостью: складское окно, интервал доставки, время приёма. Имеет неизменную capacity и расходуемый остаток remaining.
Холд — временная заявка на одно место, живёт 5 минут. Место придержано, но ещё не продано.
Подтверждение — превращение холда в бронь. Только здесь место списывается окончательно.
Состояния холда:
┌──────────────► confirmed место списано
│ │
создан → held │ │ DELETE /holds/{id}
│ ▼
├──────────────► cancelled место возвращено
│
└── прошло 5 минут ► истёк статус в базе остаётся held,
но место больше не занимает
Истечение холда не меняет его статус в базе. Холд перестаёт занимать место в тот момент, когда expires_at уходит в прошлое, — это следует из условий запросов, а не из отдельной операции. Поэтому фоновая задача очистки не требуется для корректности.
Инварианты
Свойства, которые сервис поддерживает при любой последовательности и конкурентности запросов:
capacity неизменна
remaining = capacity − количество подтверждённых броней
0 ≤ remaining ≤ capacity
доступно к бронированию = remaining − количество активных холдов
Оверсел — нарушение третьего условия. Механизмы, описанные ниже, существуют именно для его предотвращения.
Схема данных
slots
| Колонка | Назначение |
|---|---|
starts_at, ends_at |
границы окна |
capacity |
вместимость, не меняется |
remaining |
остаток: вместимость минус подтверждённые брони |
Оба счётчика объявлены unsignedInteger: база не позволит записать отрицательное значение, даже если приложение ошибётся.
holds
| Колонка | Назначение |
|---|---|
slot_id |
внешний ключ на слот, каскадное удаление |
idempotency_key |
UUID с уникальным индексом |
status |
held, confirmed, cancelled |
expires_at |
момент истечения |
confirmed_at, cancelled_at |
отметки времени переходов |
Уникальный индекс на idempotency_key — не ограничение целостности «на всякий случай», а механизм идемпотентности: две одновременные вставки с одним ключом невозможны физически.
Составной индекс (slot_id, status, expires_at) покрывает единственный горячий запрос — подсчёт активных холдов слота.
Понятие «холд занимает место» определено один раз, в Hold::scopeActive():
$query->where('status', HoldStatus::Held)->where('expires_at', '>', now());
Это определение используют и проверка вместимости, и проекция доступности, поэтому разойтись они не могут.
GET /slots/availability
Возвращает список слотов с числом мест, доступных к бронированию.
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5}]
remaining в ответе — это остаток за вычетом активных холдов, то есть то, что клиент действительно может забронировать. Без вычета клиент видел бы свободные места и получал отказ при попытке их занять.
Проекция считается одним запросом:
select slots.id, slots.capacity, slots.remaining,
coalesce(active_holds.held_count, 0) as held_count
from slots
left join (
select slot_id, count(*) as held_count from holds
where status = 'held' and expires_at > ?
group by slot_id
) as active_holds on active_holds.slot_id = slots.id
order by slots.id
Кеширование
Ответ кешируется на 10 секунд (SLOT_AVAILABILITY_CACHE_TTL, допустимый по требованиям диапазон 5–15).
Наивное кеширование через Cache::remember уязвимо к cache stampede: в момент истечения TTL все параллельные запросы одновременно обнаруживают промах и все идут в базу — пик нагрузки приходится на момент высокого трафика. Защита построена в три слоя:
Single-flight. Пересчитывает только процесс, захвативший Cache::lock. Внутри блокировки кеш перечитывается повторно: предыдущий владелец мог его уже заполнить.
Stale-копия. Хранится в шесть раз дольше основной записи. Процессы, не получившие блокировку, отдают её немедленно, не обращаясь к базе. Данные при этом устаревают на единицы секунд — для справочного списка это допустимо.
Ограниченное ожидание. На холодном кеше stale-копии нет: это первый запрос после старта либо момент сразу после инвалидации. Такой запрос ждёт владельца блокировки до трёх секунд и читает записанный им результат. Если не дождался — обращается к базе напрямую: чтение не должно завершаться ошибкой.
Инвалидация удаляет оба ключа — основной и stale, иначе после подтверждения брони клиенты продолжали бы получать устаревшие остатки.
POST /slots/{id}/hold
Создаёт холд. Требует заголовок Idempotency-Key с корректным UUID, иначе 422.
Последовательность:
select * from holds where idempotency_key = ? limit 1
select * from slots where slots.id = ? limit 1 for update
select count(*) from holds
where holds.slot_id = ? and status = 'held' and expires_at > ?
insert into holds (idempotency_key, status, expires_at, slot_id, ...) values (...)
Шаги 2–4 выполняются в транзакции.
Идемпотентность
Повторный запрос с тем же ключом возвращает исходный холд и код 200 вместо 201. Новая строка не создаётся.
Поиск по ключу перед вставкой — оптимизация для обычного повтора, не гарантия. Два одновременных запроса с одним ключом оба не найдут ничего и оба пойдут вставлять; второй получит нарушение уникального индекса. Исключение перехватывается, холд перечитывается и возвращается как результат. Гарантию даёт база, а не порядок операций в приложении.
Ключ, уже использованный для другого слота, приводит к 422: молча вернуть найденный холд означало бы отдать клиенту бронь на слот, которого он не запрашивал.
Проверка вместимости
Число доступных мест — это remaining минус количество активных холдов, то есть значение из двух таблиц. Одной атомарной операцией это не выражается, поэтому строка слота блокируется SELECT ... FOR UPDATE: конкурентные запросы по одному слоту выстраиваются в очередь, и посчитанное значение остаётся достоверным до конца транзакции. Блокировка затрагивает одну строку — бронирования разных слотов друг друга не задерживают.
Порядок обращений к базе внутри транзакции существен. MySQL на уровне изоляции REPEATABLE READ создаёт снимок данных при первом неблокирующем чтении; SELECT ... FOR UPDATE таким не является. Блокировка выполняется первой, поэтому снимок создаётся уже после её получения и подсчёт видит холды, вставленные конкурентом. Любое обычное чтение, добавленное перед блокировкой, зафиксировало бы снимок раньше и открыло бы окно для оверсела.
Создание холда не уменьшает remaining — место списывается только при подтверждении.
POST /holds/{id}/confirm
Переводит холд в confirmed и списывает одно место.
select * from holds where holds.id = ? limit 1 for update
update slots set remaining = remaining - 1, updated_at = ?
where slots.id = ? and remaining > 0
update holds set status = ?, confirmed_at = ?, updated_at = ? where id = ?
Блокируется строка холда, а не слота: решение зависит от состояния самого холда, и блокировка нужна, чтобы два одновременных подтверждения одного холда не прошли проверки одновременно.
Защита от оверсела
Предварительной проверки остатка нет. Условие находится в самом UPDATE, и результат определяется числом затронутых строк:
$consumed = Slot::query()
->whereKey($hold->slot_id)
->where('remaining', '>', 0)
->decrement('remaining');
if ($consumed === 0) {
throw SlotBookingException::capacityExhausted($hold->slot_id);
}
Новое значение вычисляет база (remaining = remaining - 1), а не приложение, поэтому чужое списание невозможно затереть. Проверка и списание — одна операция, промежутка, в который мог бы вклиниться конкурент, не существует. Ноль затронутых строк означает, что мест не было, и приводит к 409.
Этот приём предпочтительнее блокировки, когда условие выражается в том же запросе, который меняет данные: ожидания не возникает, конкуренты не выстраиваются в очередь.
Гарантия дублируется на уровне схемы: remaining объявлен без знака, поэтому даже при ошибке в приложении база отвергнет уход в минус.
Идемпотентность и проверки состояния
Повторное подтверждение возвращает 200 и тот же результат; место списывается один раз.
Порядок проверок: сначала «уже подтверждён», затем «отменён или истёк». Подтверждённый холд с истёкшим expires_at — нормальная ситуация: бронь действительна, срок относился к периоду ожидания подтверждения. Проверка истечения, поставленная первой, ломала бы идемпотентность спустя пять минут после подтверждения.
Инвалидация кеша
Кеш сбрасывается после фиксации транзакции, а не внутри неё. Сброс внутри оставлял бы окно, в котором читатель обращается к базе, видит ещё не зафиксированные значения и записывает в кеш устаревшие данные на весь TTL.
Создание холда кеш намеренно не инвалидирует: требования предписывают инвалидацию после подтверждения и отмены. Это согласуется и с назначением кеша — поток холдов сбрасывал бы его непрерывно, обнуляя смысл кеширования. Доступность носит справочный характер, авторитетная проверка выполняется при создании холда.
DELETE /holds/{id}
Переводит холд в cancelled.
Место возвращается только при отмене подтверждённого холда, поскольку только подтверждение его списывало. Холд в статусе held при отмене перестаёт учитываться в активных, и слот становится доступен без изменения remaining. Инкремент дополнительно ограничен условием remaining < capacity, поэтому никакая последовательность отмен не увеличит остаток выше вместимости.
Повторная отмена возвращает 200; место возвращается один раз.
Конкурентность: два механизма
| Операция | Механизм | Причина выбора |
|---|---|---|
hold |
SELECT ... FOR UPDATE по строке слота |
решение зависит от подсчёта в другой таблице, одним запросом не выражается |
confirm |
условие в UPDATE, проверка по числу затронутых строк |
условие выражается в том же запросе, который меняет данные |
| идемпотентность | уникальный индекс | зазор между проверкой и вставкой средствами приложения не закрывается |
Проверено под реальной конкуренцией (20 параллельных запросов, сервер в 8 воркеров):
| Сценарий | Результат |
|---|---|
| 20 холдов на слот с одним местом | один 201, девятнадцать 409, одна строка в базе |
| 20 подтверждений за одно место | один 200, девятнадцать 409, remaining = 0 |
| 20 холдов с одинаковым ключом | один 201, девятнадцать 200, одна строка в базе |
Формат ошибок
{"message": "Slot 3 has no remaining capacity.", "reason": "capacity_exhausted"}
reason |
Код | Когда |
|---|---|---|
capacity_exhausted |
409 | мест нет при создании или подтверждении |
hold_cancelled |
409 | подтверждение отменённого холда |
hold_expired |
409 | подтверждение истёкшего холда |
idempotency_key_reuse |
422 | ключ уже использован для другого слота |
not_found |
404 | слот или холд не существует |
Ошибки валидации отдаются в стандартном для Laravel формате с объектом errors.
Сервисный слой не работает с HTTP: он бросает SlotBookingException с машиночитаемым кодом, а сопоставление с кодами ответа описано в bootstrap/app.php. Тот же сервис можно вызвать из консольной команды или обработчика очереди без изменений.
Реализационные решения
Активные холды считаются запросом, а не хранятся счётчиком. Фоновая очистка по условиям задачи не требуется, а счётчик без неё разошёлся бы с реальностью в момент, когда первый холд истекает молча. Фильтр по expires_at освобождает место автоматически и точно в срок.
Кеш и блокировки хранятся в MySQL (CACHE_STORE=database), поэтому проект поднимается без дополнительных сервисов. Код обращается только к фасаду Cache, так что переход на Redis — изменение одной переменной окружения.
Префикс api у маршрутов отключён, чтобы пути совпадали с указанными в задании. Маршруты при этом объявлены в routes/api.php и получают stateless-мидлвары группы api.
Привязка моделей к маршрутам не используется: сервису нужно читать строку внутри собственной транзакции с блокировкой, а мидлвар сделал бы это раньше и без неё.
Ответы не заворачиваются в data: формат доступности задан в требованиях как массив, остальные эндпоинты приведены к той же конвенции.
Тесты
27 функциональных тестов, 90 проверок. Выполняются против MySQL, а не SQLite: под проверкой находятся SELECT ... FOR UPDATE и условный декремент, которые SQLite не воспроизводит.
Покрыто, помимо основных сценариев:
- невозможность оверсела при конкурентном подтверждении последнего места;
- отсутствие обращений к базе у запроса, проигравшего гонку за пересчёт кеша;
- истечение TTL кеша и его инвалидация после подтверждения;
- отсутствие инвалидации при создании холда;
- освобождение мест истёкшими и отменёнными холдами;
- идемпотентность создания, подтверждения и отмены;
- отказ при переиспользовании ключа для другого слота.
Границы
Не реализовано, так как выходит за рамки задания:
- фоновая очистка истёкших холдов (для корректности не нужна, в продакшене — чтобы таблица не росла);
- аутентификация и привязка холда к пользователю;
- пагинация и фильтры доступности;
- ограничение частоты запросов, уведомления о смене статуса холда.
При высокой конкуренции на один популярный слот блокировка строки станет узким местом: запросы к нему выстроятся в очередь. В этом случае имеет смысл перейти к счётчику, уменьшающемуся уже при создании холда, и добавить возврат мест по истечении. Такая схема сложнее и требует фоновой очистки, поэтому при текущих требованиях выбрана простая.