0, 'cart_reservations' => 0, 'orphan_reservations' => 0, 'failed' => 0, ]; $lastReservationId = 0; do { $reservationIds = StockReservation::query() ->where('status', StockReservation::STATUS_ACTIVE) ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->where('id', '>', $lastReservationId) ->orderBy('id') ->limit(self::BATCH_SIZE) ->pluck('id'); foreach ($reservationIds as $reservationId) { $lastReservationId = (int) $reservationId; try { $owner = $this->expireReservation($lastReservationId); if ($owner !== null) { $summary[$owner]++; } } catch (Throwable $exception) { $summary['failed']++; Log::channel('commands')->error('Failed to expire overdue stock reservation.', [ 'command' => 'reservations:expire', 'stock_reservation_id' => $lastReservationId, 'exception' => $exception, ]); } } } while ($reservationIds->count() === self::BATCH_SIZE); Log::channel('commands')->info('Stock reservation cleanup completed.', [ 'command' => 'reservations:expire', 'expired_purchases' => $summary['purchases'], 'expired_cart_reservations' => $summary['cart_reservations'], 'expired_orphan_reservations' => $summary['orphan_reservations'], 'failed_reservations' => $summary['failed'], 'total_expired' => $summary['purchases'] + $summary['cart_reservations'] + $summary['orphan_reservations'], ]); return $summary; } public function expireIfOverdue(int $reservationId): bool { /** @var StockReservation|null $reservation */ $reservation = StockReservation::query()->find($reservationId); if ($reservation?->status === StockReservation::STATUS_EXPIRED) { return true; } if (! $this->isOverdue($reservation)) { return false; } return $this->expireReservation($reservationId) !== null; } /** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */ private function expireReservation(int $reservationId): ?string { $purchaseId = Purchase::query() ->where('stock_reservation_id', $reservationId) ->value('id'); if ($purchaseId !== null) { return $this->expirePurchase((int) $purchaseId); } $cartId = Cart::query() ->where('current_stock_reservation_id', $reservationId) ->where('status', 'active') ->value('id'); if ($cartId !== null) { return $this->expireCart((int) $cartId, $reservationId); } return $this->expireOrphan($reservationId); } /** @return 'purchases'|null */ private function expirePurchase(int $purchaseId): ?string { /** @var Purchase|null $purchase */ $purchase = Purchase::query()->find($purchaseId); if ($purchase === null) { return null; } $purchase = $this->purchases->expire($purchase); if ($purchase->status === Purchase::STATUS_EXPIRED) { return 'purchases'; } $reservation = $purchase->stockReservation; if ($this->isOverdue($reservation)) { throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.'); } return null; } /** @return 'cart_reservations'|null */ private function expireCart(int $cartId, int $reservationId): ?string { return DB::transaction(function () use ($cartId, $reservationId): ?string { /** @var Cart|null $cart */ $cart = Cart::query() ->whereKey($cartId) ->where('current_stock_reservation_id', $reservationId) ->where('status', Cart::STATUS_ACTIVE) ->lockForUpdate() ->first(); if ($cart === null) { return null; } /** @var StockReservation|null $reservation */ $reservation = StockReservation::query()->lockForUpdate()->find($reservationId); if (! $this->isOverdue($reservation)) { return null; } $this->reservations->expire($reservation); $cart->update(['status' => Cart::STATUS_EXPIRED]); return 'cart_reservations'; }); } /** @return 'orphan_reservations'|null */ private function expireOrphan(int $reservationId): ?string { /** @var StockReservation|null $reservation */ $reservation = StockReservation::query()->find($reservationId); if (! $this->isOverdue($reservation)) { return null; } $this->reservations->expire($reservation); return 'orphan_reservations'; } private function isOverdue(?StockReservation $reservation): bool { return $reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE && $reservation->expires_at !== null && ! $reservation->expires_at->isFuture(); } }