# Как работает сервис Документ описывает устройство 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`, допустимый по требованиям диапазон 5–15). Наивное кеширование через `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 (...) ``` Шаги 2–4 выполняются в транзакции. ### Идемпотентность Повторный запрос с тем же ключом возвращает исходный холд и код `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 кеша и его инвалидация после подтверждения; - отсутствие инвалидации при создании холда; - освобождение мест истёкшими и отменёнными холдами; - идемпотентность создания, подтверждения и отмены; - отказ при переиспользовании ключа для другого слота. ## Границы Не реализовано, так как выходит за рамки задания: - фоновая очистка истёкших холдов (для корректности не нужна, в продакшене — чтобы таблица не росла); - аутентификация и привязка холда к пользователю; - пагинация и фильтры доступности; - ограничение частоты запросов, уведомления о смене статуса холда. При высокой конкуренции на один популярный слот блокировка строки станет узким местом: запросы к нему выстроятся в очередь. В этом случае имеет смысл перейти к счётчику, уменьшающемуся уже при создании холда, и добавить возврат мест по истечении. Такая схема сложнее и требует фоновой очистки, поэтому при текущих требованиях выбрана простая.