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>
262 lines
9.3 KiB
PHP
262 lines
9.3 KiB
PHP
<?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;
|
||
}
|
||
}
|