slot-booking/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

6.4 KiB
Raw Blame History

Slot Booking API

API бронирования слотов: горячий кеш доступности, временные холды с идемпотентностью, защита от оверсела.

Стек: Laravel 12 (PHP 8.2+), MySQL 8+. Внешних сервисов не требуется — кеш и блокировки хранятся в той же базе.

Описание устройства сервиса — docs/README.md. Коллекция Postman — docs/postman.

Быстрый старт

docker run -d --name stl-mysql \
  -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=slots \
  -p 3307:3306 mysql:8
cp .env.example .env && composer install && php artisan key:generate
php artisan migrate --seed
php artisan serve --port=8001

Сидер создаёт три слота с вместимостью 10, 5 и 1. Слот с одним местом позволяет воспроизвести конфликт оверсела двумя запросами.

Тесты

Тесты выполняются против MySQL (см. phpunit.xml), поэтому нужна отдельная база:

docker exec stl-mysql mysql -uroot -psecret -e "CREATE DATABASE IF NOT EXISTS slots_test"
php artisan test

27 тестов, 90 проверок: кеш и его инвалидация, поведение при потере гонки за пересчёт, идемпотентность, истечение холдов, защита от оверсела при конкурентном подтверждении.

Эндпоинты

Метод Путь Ответ
GET /slots/availability 200 — массив слотов
POST /slots/{id}/hold 201 создан / 200 повтор по тому же ключу / 409 мест нет / 422 некорректный Idempotency-Key / 404
POST /holds/{id}/confirm 200 / 409 мест нет либо холд неактивен / 404
DELETE /holds/{id} 200 / 404

Ошибки приходят в виде {"message": "...", "reason": "<код>"}. Коды: capacity_exhausted, hold_cancelled, hold_expired, idempotency_key_reuse, not_found.

Примеры запросов

Прогон против запущенного сервера. Слот 3 имеет capacity = 1.

Доступность

curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]

Создание холда

curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 201 Created
{"hold_id":1,"slot_id":3,"status":"held","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":null,"cancelled_at":null}

Повтор с тем же ключом — тот же холд, 200 вместо 201

curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"held","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":null,"cancelled_at":null}

Второй строки в holds не появляется.

Конфликт при оверселе

curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: bab7c4be-3184-40d6-ab90-b4118c40bcb3'
HTTP/1.1 409 Conflict
{"message":"Slot 3 has no remaining capacity.","reason":"capacity_exhausted"}

Переиспользование ключа для другого слота

curl -si -X POST http://127.0.0.1:8001/slots/1/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 422 Unprocessable Content
{"message":"Idempotency-Key 6e517e8e-... was already used for a different slot than 1.","reason":"idempotency_key_reuse"}

Подтверждение

curl -si -X POST http://127.0.0.1:8001/holds/1/confirm
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"confirmed","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":"2026-07-29T18:14:57+00:00","cancelled_at":null}

Кеш инвалидирован, остаток слота 3 обновился сразу:

curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":0}]

Повторный confirm того же холда возвращает 200 и тот же результат — место списывается один раз.

Отмена

curl -si -X DELETE http://127.0.0.1:8001/holds/1
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"cancelled","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":"2026-07-29T18:14:57+00:00","cancelled_at":"2026-07-29T18:14:57+00:00"}
curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]

Подтвердить отменённый холд уже нельзя:

curl -si -X POST http://127.0.0.1:8001/holds/1/confirm
HTTP/1.1 409 Conflict
{"message":"Hold 1 is no longer active and cannot be confirmed.","reason":"hold_cancelled"}

Структура

app/Enums/HoldStatus.php                    held | confirmed | cancelled
app/Exceptions/SlotBookingException.php     доменные отказы и их HTTP-статус
app/Http/Controllers/AvailabilityController.php
app/Http/Controllers/HoldController.php
app/Http/Requests/StoreHoldRequest.php      валидация заголовка Idempotency-Key
app/Http/Resources/HoldResource.php
app/Models/Slot.php
app/Models/Hold.php                         scopeActive() — определение активного холда
app/Services/SlotService.php                транзакции, кеш, идемпотентность
config/slots.php                            TTL кеша и TTL холда
routes/api.php
docs/README.md                              устройство сервиса
docs/postman/                               коллекция Postman