Під час перебудови свого персонального сайту Аарон Френсіс працює з багатьма сторонніми сервісами: завантажує відео з YouTube, транскрибує аудіо, просить OpenAI зробити підсумки транскриптів тощо. У статті він ділиться підходом, який назвав «резервованими моделями».
Вихідна ситуація: scopes та команди
Автор активно використовує Eloquent scopes, щоб визначити, що саме потрібно зробити. Ось кілька scopes із моделі Video:
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:
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 у розкладі (автор це рекомендує), але хтось усе одно може запустити команду вручну, і обробки перетнуться.
Друга проблема стосується не дублювання, а «стовпотворіння» при збоях. Якщо команда запускається щохвилини і одне конкретне відео постійно падає, можна спробувати завантажити його з YouTube 60 разів на годину, що навряд чи сподобається YouTube. Потрібен своєрідний бар'єр, який не дасть постійно навантажувати зовнішні ресурси при повторних помилках.
Резервування моделей через кеш-блокування
Замість домовленостей у команді автор пропонує вирішити проблему раз і назавжди через cache locks. У Laravel є надійний механізм атомарних блокувань, який працює з різними драйверами, зокрема з драйвером бази даних. Про атомарні блокування в Laravel також є відео автора: Laravel solved race conditions.
Атомарні блокування гарантують, що лише один процес утримує lock. Але замість прямої роботи з блокуванням автор звертається до нього через модель, адже мета - резервувати саме моделі. Для цього створюється трейт Reservable з двома методами:
trait Reservable
{
public function reserve(mixed $key, string|int|Carbon $duration = 60): bool
{
//
}
public function release(mixed $key): void
{
//
}
}
Використання виглядає так:
$reserved = $video->reserve('download', '+1 hour');
Розробнику не потрібно думати про деталі кеш-системи: він просто намагається зарезервувати модель для певної цілі та отримує відповідь «так» або «ні».
Метод reservation
Основна логіка живе в методі reservation, який повертає екземпляр Illuminate\Contracts\Cache\Lock:
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. Автор пояснює, навіщо це, далі в статті.
Завершена реалізація
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:
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:
Collection::macro('firstReserved', function (mixed $key, string|int|Carbon $duration = 60) {
return $this->first(fn($item) => $item->reserve($key, $duration));
});
Тоді команда виглядає так:
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:
Builder::macro('firstReserved', function (mixed $key, string|int|Carbon $duration = 60) {
return $this->get()->first(fn($item) => $item->reserve($key, $duration));
});
Тоді зайвий виклик get посередині не потрібен:
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.