*/ 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 */ 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 */ 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; } }