---
title: "Reservable-моделі в Laravel: як уникнути дублювання обробки за допомогою атомарних блокувань"
url: https://laravelukraine.com/blog/reservable-modeli-v-laravel-iak-uniknuti-dubliuvannia-obrobki-za-dopomogoiu-atomarnix-blokuvan
date: 2026-10-05
source: https://aaronfrancis.com/2024/reservable-models-in-laravel-990d6e9e
---

# Reservable-моделі в Laravel: як уникнути дублювання обробки за допомогою атомарних блокувань

Під час перебудови свого персонального сайту Аарон Френсіс працює з багатьма сторонніми сервісами: завантажує відео з YouTube, транскрибує аудіо, просить OpenAI зробити підсумки транскриптів тощо. У [статті](https://aaronfrancis.com/2024/reservable-models-in-laravel-990d6e9e) він ділиться підходом, який назвав «резервованими моделями».

## Вихідна ситуація: scopes та команди

Автор активно використовує Eloquent scopes, щоб визначити, що саме потрібно зробити. Ось кілька scopes із моделі `Video`:

```php
public function scopeReadyForDownload(Builder $query): void
{
    $query->whereNull('video_path');
}

public function scopeReadyForAudio(Builder $query): void
{
    $query->whereNotNull('video_path')->whereNull('audio_path');
}

public function scopeReadyForTranscript(Builder $query): void
{
    $query->whereNotNull('audio_path')->whereDoesntHave('transcripts');
}

public function scopeReadyForThumbnails(Builder $query): void
{
    $query->whereNotNull('video_path')->whereNull('thumbnails');
}
```

Для кожного завдання існує окрема команда, яка бере моделі й виконує роботу. Ось команда `Download`:

```php
class Download extends Command
{
    protected $signature = 'youtube:download';

    protected $description = 'Download YouTube videos and put them on S3.';

    public function handle(): void
    {
        Video::readyForDownload()->each(function (Video $video) {
            Log::info('Downloading ' . $video->title);
            // Do something interesting
        });
    }
}
```

## Дві проблеми нерезервованого підходу

Перша проблема: залежно від тривалості виконання команди, частоти її запуску та інших реальних чинників модель може пройти через воркфлоу більше одного разу. Можна додати [`withoutOverlapping` у розкладі](https://laravel.com/docs/11.x/scheduling#preventing-task-overlaps) (автор це рекомендує), але хтось усе одно може запустити команду вручну, і обробки перетнуться.

Друга проблема стосується не дублювання, а «стовпотворіння» при збоях. Якщо команда запускається щохвилини і одне конкретне відео постійно падає, можна спробувати завантажити його з YouTube 60 разів на годину, що навряд чи сподобається YouTube. Потрібен своєрідний бар'єр, який не дасть постійно навантажувати зовнішні ресурси при повторних помилках.

## Резервування моделей через кеш-блокування

Замість домовленостей у команді автор пропонує вирішити проблему раз і назавжди через cache locks. У Laravel є надійний [механізм атомарних блокувань](https://laravel.com/docs/11.x/cache#atomic-locks), який працює з різними драйверами, зокрема з [драйвером бази даних](https://aaronfrancis.com/2021/the-exceeding-cleverness-of-laravels-atomic-database-locks-424ac77e). Про атомарні блокування в Laravel також є відео автора: [Laravel solved race conditions](https://www.youtube.com/watch?v=jGb5zIgwL4c).

Атомарні блокування гарантують, що лише один процес утримує lock. Але замість прямої роботи з блокуванням автор звертається до нього *через модель*, адже мета - резервувати саме моделі. Для цього створюється трейт `Reservable` з двома методами:

```php
trait Reservable
{
    public function reserve(mixed $key, string|int|Carbon $duration = 60): bool
    {
        //
    }

    public function release(mixed $key): void
    {
        //
    }
}
```

Використання виглядає так:

```php
$reserved = $video->reserve('download', '+1 hour');
```

Розробнику не потрібно думати про деталі кеш-системи: він просто намагається зарезервувати модель для певної цілі та отримує відповідь «так» або «ні».

### Метод reservation

Основна логіка живе в методі `reservation`, який повертає екземпляр `Illuminate\Contracts\Cache\Lock`:

```php
public function reservation(mixed $key, string|int|Carbon $duration = 60): Lock
{
    // Convert e.g. +6 hours to a Carbon instance
    if (is_string($duration)) {
        $duration = Carbon::make($duration);
    }

    // Convert Carbon to seconds from now
    if ($duration instanceof Carbon) {
        $duration = max(0, $duration->diffInSeconds(now()));
    }

    // Convert enums to strings
    if ($key instanceof UnitEnum) {
        $key = $key->name;
    }

    // Convert objects to strings
    if (is_object($key)) {
        $key = get_class($key);
    }

    // Use the most stable methods of representing a model.
    return Cache::lock(
        "{$this->getMorphClass()}:{$this->getKey()}:{$key}", $duration
    );
}
```

Інтерфейс вийшов гнучким: як `$key` можна передати майже будь-що, а як `$duration` - рядок, число секунд або `Carbon`. Автор пояснює, навіщо це, далі в статті.

### Завершена реалізація

```php
public function reserve(mixed $key, string|int|Carbon $duration = 60): bool
{
    return $this->reservation($key, $duration)->get();
}

public function release(mixed $key): void
{
    $this->reservation($key)->forceRelease();
}
```

У `reserve` створюється lock і виконується спроба його `get`, що повертає булеве значення успіху чи невдачі. У `release` lock створюється і звільняється через `forceRelease`. Тут потрібен саме `forceRelease`, бо поточний код не є власником блокування: `$owner` не передавався, тож він має випадкове значення. Зазвичай чужі блокування звільняти не варто, тому цей метод слід використовувати, лише чітко розуміючи навіщо. У цьому випадку це виправдано, бо блокування дуже вузько обмежені за призначенням.

## Використання трейта Reservable

Тепер трейт легко додати до команди `Download`:

```php
class Download extends Command
{
    protected $signature = 'youtube:download';

    protected $description = 'Download YouTube videos and put them on S3.';

    public function handle(): void
    {
        $video = Video::query()
            ->readyForDownload()
            ->get()
            // Get the first one that can be reserved for this command, for 6 hours.
            ->first(fn(Video $video) => $video->reserve($this, '+6 hours'));

        if (!$video) {
            return;
        }

        Log::info('Downloading ' . $video->title);
        // Do something interesting
    }
}
```

Оскільки як ключ резервування можна передати майже будь-що, автор передає саму команду. Ключ у кеші виглядатиме приблизно так:

```
video:249:App\Console\Commands\YouTube\Download
```

Звільняти блокування не потрібно - його можна просто залишити до завершення терміну дії. Якщо команда виконалась успішно, блокування вже неважливе: відео не треба завантажувати знову, а scope виключить його з вибірки. Якщо ж команда впала, модель *має* лишатися заблокованою на 6 годин, щоб запобігти безперервним помилкам.

## Макроси

Рядок із `first(...)` можна зробити охайнішим за допомогою макросу для Eloquent Collection:

```php
Collection::macro('firstReserved', function (mixed $key, string|int|Carbon $duration = 60) {
    return $this->first(fn($item) => $item->reserve($key, $duration));
});
```

Тоді команда виглядає так:

```php
class Download extends Command
{
    public function handle(): void
    {
        $video = Video::readyForDownload()->get()->firstReserved($this, '+6 hours');

        if (!$video) {
            return;
        }

        Log::info('Downloading ' . $video->title);
        // Do something interesting
    }
}
```

Макрос можна додати навіть до Builder:

```php
Builder::macro('firstReserved', function (mixed $key, string|int|Carbon $duration = 60) {
    return $this->get()->first(fn($item) => $item->reserve($key, $duration));
});
```

Тоді зайвий виклик `get` посередині не потрібен:

```php
class Download extends Command
{
    public function handle(): void
    {
        $video = Video::readyForDownload()->firstReserved($this, '+6 hours');

        if (!$video) {
            return;
        }

        Log::info('Downloading ' . $video->title);
        // Do something interesting
    }
}
```

Автор нещодавно почав користувати цей підхід і вважає його зручним, але запрошує читачів ділитися ідеями щодо покращень - зворотний зв'язок можна залишити [у його акаунті в Twitter](https://twitter.com/@aarondfrancis).
