slot-booking/docs
Dmitriy 16f3f851b1 feat: slot booking API with hot cache and oversell protection
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>
2026-07-31 14:47:18 +03:00
..
postman feat: slot booking API with hot cache and oversell protection 2026-07-31 14:47:18 +03:00
README.md feat: slot booking API with hot cache and oversell protection 2026-07-31 14:47:18 +03:00

Как работает сервис

Документ описывает устройство 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, допустимый по требованиям диапазон 515).

Наивное кеширование через 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 (...)

Шаги 24 выполняются в транзакции.

Идемпотентность

Повторный запрос с тем же ключом возвращает исходный холд и код 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 кеша и его инвалидация после подтверждения;
  • отсутствие инвалидации при создании холда;
  • освобождение мест истёкшими и отменёнными холдами;
  • идемпотентность создания, подтверждения и отмены;
  • отказ при переиспользовании ключа для другого слота.

Границы

Не реализовано, так как выходит за рамки задания:

  • фоновая очистка истёкших холдов (для корректности не нужна, в продакшене — чтобы таблица не росла);
  • аутентификация и привязка холда к пользователю;
  • пагинация и фильтры доступности;
  • ограничение частоты запросов, уведомления о смене статуса холда.

При высокой конкуренции на один популярный слот блокировка строки станет узким местом: запросы к нему выстроятся в очередь. В этом случае имеет смысл перейти к счётчику, уменьшающемуся уже при создании холда, и добавить возврат мест по истечении. Такая схема сложнее и требует фоновой очистки, поэтому при текущих требованиях выбрана простая.