slot-booking/docs/README.md
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

268 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Как работает сервис
Документ описывает устройство API бронирования слотов: модель данных, поведение каждого эндпоинта и механизмы, обеспечивающие корректность под нагрузкой. Инструкции по запуску и примеры запросов — в [README](../README.md) в корне.
## Предметная область
**Слот** — окно с ограниченной вместимостью: складское окно, интервал доставки, время приёма. Имеет неизменную `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()`:
```php
$query->where('status', HoldStatus::Held)->where('expires_at', '>', now());
```
Это определение используют и проверка вместимости, и проекция доступности, поэтому разойтись они не могут.
## `GET /slots/availability`
Возвращает список слотов с числом мест, доступных к бронированию.
```json
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5}]
```
`remaining` в ответе — это остаток за вычетом активных холдов, то есть то, что клиент действительно может забронировать. Без вычета клиент видел бы свободные места и получал отказ при попытке их занять.
Проекция считается одним запросом:
```sql
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`.
Последовательность:
```sql
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` и списывает одно место.
```sql
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`, и результат определяется числом затронутых строк:
```php
$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`, одна строка в базе |
## Формат ошибок
```json
{"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 кеша и его инвалидация после подтверждения;
- отсутствие инвалидации при создании холда;
- освобождение мест истёкшими и отменёнными холдами;
- идемпотентность создания, подтверждения и отмены;
- отказ при переиспользовании ключа для другого слота.
## Границы
Не реализовано, так как выходит за рамки задания:
- фоновая очистка истёкших холдов (для корректности не нужна, в продакшене — чтобы таблица не росла);
- аутентификация и привязка холда к пользователю;
- пагинация и фильтры доступности;
- ограничение частоты запросов, уведомления о смене статуса холда.
При высокой конкуренции на один популярный слот блокировка строки станет узким местом: запросы к нему выстроятся в очередь. В этом случае имеет смысл перейти к счётчику, уменьшающемуся уже при создании холда, и добавить возврат мест по истечении. Такая схема сложнее и требует фоновой очистки, поэтому при текущих требованиях выбрана простая.