slot-booking/app/Services/SlotService.php
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

262 lines
9.3 KiB
PHP
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.

<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\HoldStatus;
use App\Exceptions\SlotBookingException;
use App\Models\Hold;
use App\Models\Slot;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Бронирование слотов: кеш доступности, транзакционные холды, подтверждение без оверсела.
*/
final class SlotService
{
private const CACHE_KEY = 'slots:availability';
private const STALE_KEY = 'slots:availability:stale';
private const LOCK_KEY = 'slots:availability:lock';
/** Секунды удержания лока пересчёта. */
private const LOCK_TTL = 10;
/** Секунды ожидания лока на холодном кеше. */
private const LOCK_WAIT = 3;
/**
* Проекция доступности из кеша.
*
* От stampede защищают лок на пересчёт, stale-копия для остальных и
* ограниченное ожидание с прямым запросом в базу как последним средством.
*
* @return list<array{slot_id: int, capacity: int, remaining: int}>
*/
public function availability(): array
{
$fresh = Cache::get(self::CACHE_KEY);
if ($fresh !== null) {
return $fresh;
}
$lock = Cache::lock(self::LOCK_KEY, self::LOCK_TTL);
if ($lock->get()) {
try {
return $this->refreshAvailability();
} finally {
$lock->release();
}
}
// Пересчёт уже идёт: отдать данные, устаревшие на пару секунд, дешевле,
// чем послать в базу всех сразу.
$stale = Cache::get(self::STALE_KEY);
if ($stale !== null) {
return $stale;
}
// Нет ни свежих, ни stale: первый запрос либо момент сразу после
// инвалидации. Ждём владельца лока — перепроверка вернёт его результат.
try {
return $lock->block(self::LOCK_WAIT, fn (): array => $this->refreshAvailability());
} catch (LockTimeoutException) {
return $this->computeAvailability();
}
}
/**
* Создать холд либо вернуть созданный ранее по этому ключу.
*/
public function hold(int $slotId, string $idempotencyKey): Hold
{
$existing = Hold::query()->where('idempotency_key', $idempotencyKey)->first();
if ($existing !== null) {
return $this->assertBelongsToSlot($existing, $slotId, $idempotencyKey);
}
try {
return DB::transaction(function () use ($slotId, $idempotencyKey): Hold {
// Лок на строке слота сериализует холды по нему, иначе подсчёт
// мест ниже недостоверен.
$slot = Slot::query()->lockForUpdate()->findOrFail($slotId);
if ($this->bookableSeats($slot) < 1) {
throw SlotBookingException::capacityExhausted($slot->id);
}
return $slot->holds()->create([
'idempotency_key' => $idempotencyKey,
'status' => HoldStatus::Held,
'expires_at' => now()->addSeconds((int) config('slots.hold_ttl')),
]);
});
} catch (UniqueConstraintViolationException) {
// Гонка двух запросов с одним ключом: победитель уже вставил строку,
// его холд и есть ответ для этого ключа.
$hold = Hold::query()->where('idempotency_key', $idempotencyKey)->firstOrFail();
return $this->assertBelongsToSlot($hold, $slotId, $idempotencyKey);
}
}
/**
* Подтвердить холд, списав одно место.
*/
public function confirm(int $holdId): Hold
{
$hold = DB::transaction(function () use ($holdId): Hold {
$hold = Hold::query()->lockForUpdate()->findOrFail($holdId);
if ($hold->status === HoldStatus::Confirmed) {
return $hold;
}
if ($hold->status === HoldStatus::Cancelled || $hold->isExpired()) {
throw SlotBookingException::holdNotActive($hold);
}
// Условие в UPDATE, а не в отдельном SELECT: между проверкой и
// списанием нет промежутка для конкурента. Ноль строк — мест не было.
$consumed = Slot::query()
->whereKey($hold->slot_id)
->where('remaining', '>', 0)
->decrement('remaining');
if ($consumed === 0) {
throw SlotBookingException::capacityExhausted($hold->slot_id);
}
$hold->update([
'status' => HoldStatus::Confirmed,
'confirmed_at' => now(),
]);
return $hold;
});
// Только после коммита: иначе читатель закеширует незафиксированные данные.
$this->invalidateAvailability();
return $hold;
}
/**
* Отменить холд, вернув место, если оно было списано.
*/
public function cancel(int $holdId): Hold
{
$hold = DB::transaction(function () use ($holdId): Hold {
$hold = Hold::query()->lockForUpdate()->findOrFail($holdId);
if ($hold->status === HoldStatus::Cancelled) {
return $hold;
}
// Место списывало только подтверждение, поэтому возвращает только оно.
// Холд в статусе held просто перестаёт попадать в активные.
if ($hold->status === HoldStatus::Confirmed) {
Slot::query()
->whereKey($hold->slot_id)
->whereColumn('remaining', '<', 'capacity')
->increment('remaining');
}
$hold->update([
'status' => HoldStatus::Cancelled,
'cancelled_at' => now(),
]);
return $hold;
});
$this->invalidateAvailability();
return $hold;
}
public function invalidateAvailability(): void
{
Cache::forget(self::CACHE_KEY);
Cache::forget(self::STALE_KEY);
}
/**
* Пересчитать и закешировать проекцию. Вызывается под локом.
*
* @return list<array{slot_id: int, capacity: int, remaining: int}>
*/
private function refreshAvailability(): array
{
// Владелец лока до нас мог уже всё посчитать.
$fresh = Cache::get(self::CACHE_KEY);
if ($fresh !== null) {
return $fresh;
}
$ttl = (int) config('slots.availability_cache_ttl');
$data = $this->computeAvailability();
Cache::put(self::CACHE_KEY, $data, $ttl);
Cache::put(self::STALE_KEY, $data, $ttl * 6);
return $data;
}
/**
* Места, доступные к бронированию: подтверждённые брони уже вычтены из
* remaining, активные холды вычитаются дополнительно.
*
* @return list<array{slot_id: int, capacity: int, remaining: int}>
*/
private function computeAvailability(): array
{
$activeHolds = Hold::query()
->active()
->selectRaw('slot_id, COUNT(*) AS held_count')
->groupBy('slot_id');
return Slot::query()
->leftJoinSub($activeHolds, 'active_holds', 'active_holds.slot_id', '=', 'slots.id')
->orderBy('slots.id')
->get([
'slots.id',
'slots.capacity',
'slots.remaining',
DB::raw('COALESCE(active_holds.held_count, 0) AS held_count'),
])
->map(fn (Slot $slot): array => [
'slot_id' => $slot->id,
'capacity' => $slot->capacity,
'remaining' => max(0, $slot->remaining - (int) $slot->getAttribute('held_count')),
])
->all();
}
/**
* Свободные места слота, строка которого заблокирована вызывающим.
*/
private function bookableSeats(Slot $slot): int
{
return $slot->remaining - $slot->holds()->active()->count();
}
private function assertBelongsToSlot(Hold $hold, int $slotId, string $idempotencyKey): Hold
{
if ($hold->slot_id !== $slotId) {
throw SlotBookingException::idempotencyKeyReuse($idempotencyKey, $slotId);
}
return $hold;
}
}