مقاله

Failed Jobs در Laravel — retry، dead letter و عیب‌یابی

Failed Jobs در Laravel — retry، dead letter و عیب‌یابی

جدول failed_jobs، retry دستی و خودکار، متد failed() و alerting — وقتی job سومین بار هم شکست خورد چه کنید.

Job ایمیل فاکتور fail شد — اگر متوجه نشوید، مشتری فاکتور ندارد و شما نمی‌دانید. Laravel failed jobs را در جدول failed_jobs ذخیره می‌کند — با Horizon UI retry می‌زنید. این مقاله چرخه خطا تا resolution است.

خلاصه: وقتی یک job همهٔ تلاش‌هایش را مصرف کند (یا زمان retryUntil آن تمام شود)، Laravel آن را با payload و متن کامل exception در storage مربوط به failed jobs ثبت می‌کند. مسیر درست بازیابی این است: اول با queue:failed و متن exception علت را پیدا کنید، علت را رفع کنید، بعد فقط همان jobهایی را که تکرارشان امن است با queue:retry برگردانید. retry کورکورانه قبل از رفع علت، معمولاً فقط همان خطا را دوباره تولید می‌کند.

فعال‌سازی ذخیره failed

php artisan make:queue-failed-table
php artisan migrate

در config/queue معمولاً پیش‌فرض فعال است.

در Laravel 11 و 12، migration پیش‌فرض پروژه (0001_01_01_000002_create_jobs_table) جدول failed_jobs را از قبل می‌سازد؛ دستور بالا (نام قدیمی‌اش queue:failed-table هنوز به‌عنوان alias کار می‌کند) فقط برای پروژه‌هایی لازم است که این جدول را ندارند. محل ذخیره در بخش failed فایل config/queue.php تعیین می‌شود:

'failed' => [
    'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
    'database' => env('DB_CONNECTION', 'sqlite'),
    'table' => 'failed_jobs',
],

درایورهای پشتیبانی‌شده database-uuids، dynamodb، file و null هستند. اگر روی null باشد، jobهای شکست‌خورده جایی ذخیره نمی‌شوند و چیزی برای retry باقی نمی‌ماند.

Laravel از یک job شکست‌خورده چه چیزی ثبت می‌کند؟

هر ردیف failed_jobs شامل این‌هاست: uuid (شناسه‌ای که در دستورهای retry و forget استفاده می‌شود)، connection و queue، payload (کلاس job و داده‌های serialize‌شده‌اش)، exception (پیام و stack trace کامل) و failed_at. یعنی برای عیب‌یابی معمولاً همه چیز همین‌جاست — به شرطی که آن را قبل از retry بخوانید.

payload ممکن است داده‌های حساس مثل ایمیل یا شناسهٔ مشتری داشته باشد؛ دسترسی به این جدول و خروجی‌هایش را محدود نگه دارید.

تنظیم retry در Job

public int $tries = 3;
public array $backoff = [10, 60, 300];

public function handle(): void
{
    // ...
}

بعد از tries تمام → failed table.

tries، backoff و retryUntil چطور با هم کار می‌کنند؟

  • $tries سقف تعداد تلاش است؛ اگر روی job تعریف نشود، مقدار --tries worker استفاده می‌شود (پیش‌فرض queue:work برابر ۱ است).
  • $backoff فاصلهٔ بین تلاش‌ها بعد از exception است؛ آرایه یعنی فاصلهٔ هر تلاش جدا تعیین می‌شود.
  • retryUntil() به‌جای تعداد، یک مهلت زمانی می‌دهد. وقتی این متد تعریف شده باشد، Laravel تا قبل از آن زمان job را بر اساس تعداد تلاش fail نمی‌کند و بعد از گذشتن مهلت آن را شکست‌خورده ثبت می‌کند.
  • $maxExceptions اجازه می‌دهد job چند بار release شود ولی بعد از تعداد مشخصی exception واقعی fail شود.
public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}

متد failed

public function failed(?Throwable $exception): void
{
    Log::error('Order confirmation failed', [
        'order_id' => $this->order->id,
        'error' => $exception?->getMessage(),
    ]);
    // notify admin
}

متد failed() فقط یک بار و بعد از شکست نهایی اجرا می‌شود — جای مناسبی برای ثبت شناسهٔ رکورد مربوط، اطلاع به مسئول یا برگرداندن وضعیت یک سفارش است. برای هشدار سراسری روی همهٔ jobها می‌توانید در AppServiceProvider از Queue::failing() استفاده کنید:

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;

Queue::failing(function (JobFailed $event) {
    Log::error('Queue job failed', [
        'job' => $event->job->resolveName(),
        'queue' => $event->job->getQueue(),
        'error' => $event->exception->getMessage(),
    ]);
});

قبل از retry: بررسی job شکست‌خورده

اول فهرست را ببینید:

php artisan queue:failed

خروجی شامل شناسه، connection، queue، کلاس job و زمان شکست است. بعد متن کامل خطای یک مورد را بخوانید:

php artisan tinker
>>> DB::table('failed_jobs')->where('uuid', '5b2a...')->value('exception');

به این سؤال‌ها جواب دهید: خطا از کد خودمان است یا از سرویس بیرونی؟ همهٔ موارد شکست‌خورده یک exception مشترک دارند یا متفاوت‌اند؟ اولین شکست از چه زمانی شروع شد و با کدام deploy یا تغییر تنظیمات هم‌زمان است؟ لاگ برنامه در storage/logs (یا ابزار مانیتورینگ خطا) را برای همان بازهٔ زمانی هم بررسی کنید.

retry همان job یا اول رفع علت؟

  • retry منطقی است وقتی علت موقتی و برطرف‌شده است: سرویس بیرونی مدتی در دسترس نبود، دیتابیس یا Redis موقتاً قطع بود، یا باگ را رفع و deploy کرده‌اید و workerها را restart کرده‌اید.
  • اول باید علت را رفع کرد وقتی خطا در کد یا داده است: exception منطقی، داده‌ای که اعتبارسنجی را رد می‌کند، یا رکوردی که دیگر وجود ندارد. retry در این حالت فقط ردیف جدیدی با همان خطا به جدول اضافه می‌کند.
  • retry نکنید وقتی اجرای دوباره اثر جانبی تکراری دارد و job idempotent نیست — مثلاً پرداخت، پیامک یا ایمیلی که ممکن است قبلاً بخشی از آن انجام شده باشد. پایین‌تر دربارهٔ idempotency توضیح داده‌ایم.

retry دستی

php artisan queue:retry all
php artisan queue:retry 5b2a...
php artisan queue:retry --queue=emails

Horizon: دکمه Retry روی هر failed job.

queue:retry یک یا چند شناسه (با فاصله)، کلمهٔ all، گزینهٔ --queue برای همهٔ موارد یک صف، و --range برای بازهٔ شناسه‌های عددی (مثل --range=1-5) را می‌پذیرد. job دوباره روی صف اصلی‌اش قرار می‌گیرد و از جدول failed حذف می‌شود؛ اگر دوباره شکست بخورد، ردیف تازه‌ای ثبت می‌شود.

چک‌لیست قبل از retry در production

  1. علت را پیدا و رفع کرده‌اید و کد جدید deploy شده است
  2. بعد از deploy، workerها با php artisan queue:restart (یا horizon:terminate اگر از Horizon استفاده می‌کنید) کد جدید را بارگذاری کرده‌اند
  3. سرویس‌های وابسته (API بیرونی، Redis، دیتابیس) در دسترس‌اند
  4. مطمئن هستید اجرای دوبارهٔ job اثر تکراری ندارد
  5. اگر تعداد زیاد است، اول یک یا چند شناسه را retry کنید و نتیجه را ببینید، بعد سراغ --queue یا all بروید

flush و forget

php artisan queue:flush  # همه failed پاک — با احتیاط
php artisan queue:forget {id}

هشدار: این دستورها ردیف‌ها را حذف می‌کنند و بعد از اجرا دیگر چیزی برای retry یا بررسی باقی نمی‌ماند. queue:forget فقط یک مورد را پاک می‌کند — مثلاً jobی که بررسی کرده‌اید و می‌دانید نباید دوباره اجرا شود. queue:flush بدون گزینه همهٔ failed jobها را پاک می‌کند؛ با --hours=48 فقط مواردی حذف می‌شوند که بیش از ۴۸ ساعت از شکستشان گذشته است. قبل از flush، اگر لازم است، خروجی یا گزارشی از موارد نگه دارید.

عیب‌یابی رایج

  • API timeout خارجی — timeout job را بالا ببرید
  • Model deleted — SerializesModels
  • Memory — chunk کار بزرگ
  • Bug کد — fix و retry

چند نکته که این علت‌ها را دقیق‌تر می‌کند:

  • timeout: مقدار $timeout job (یا --timeout worker) باید چند ثانیه کمتر از retry_after همان connection در config/queue.php باشد؛ در غیر این صورت ممکن است job هنوز در حال اجرا باشد و worker دیگری همان را دوباره بردارد. اگر می‌خواهید timeout مستقیم به شکست منجر شود، public $failOnTimeout = true; را تعریف کنید.
  • مدل حذف‌شده: با SerializesModels، اگر رکورد قبل از اجرا حذف شده باشد، ModelNotFoundException رخ می‌دهد. اگر چنین jobی بی‌معنا شده، public $deleteWhenMissingModels = true; آن را بی‌صدا حذف می‌کند.
  • تنظیمات صف: worker روی صف یا connection دیگری گوش می‌دهد، یا مقدار QUEUE_CONNECTION روی سرور با آنچه انتظار دارید فرق دارد — این‌ها معمولاً به «job گم شد» منجر می‌شوند، نه به ردیف در failed_jobs.
  • وابستگی در دسترس نیست: Redis، دیتابیس یا سرویس ذخیره‌سازی موقتاً قطع است؛ این‌ها معمولاً بعد از رفع، با retry درست می‌شوند.

لاگ در storage/logs + Sentry.

Prune قدیمی‌ها

php artisan queue:prune-failed --hours=168

در Schedule هفتگی.

پیش‌فرض queue:prune-failed نگه‌داشتن ۲۴ ساعت آخر است؛ با --hours بازهٔ بیشتری نگه دارید تا فرصت بررسی داشته باشید.

idempotent jobs

retry باید امن باشد — ارسال دوباره ایمیل بدتر از fail است مگر dedupe داشته باشید.

یک job idempotent است اگر اجرای دوباره‌اش نتیجه را تغییر ندهد؛ مثلاً قبل از ارسال، وضعیت «ارسال‌شده» را روی رکورد بررسی کند، یا برای هر عملیات یک کلید یکتا ثبت کند. برای جلوگیری از اجرای هم‌زمان چند نسخه از یک job هم می‌توانید از ShouldBeUnique یا middleware WithoutOverlapping استفاده کنید.

تست رفتار شکست و retry

برای تست اینکه job در شرایط خطا fail یا release می‌شود، بدون اجرای worker واقعی، می‌توانید تعامل‌های صف را fake کنید. این مثال فرض می‌کند job در صورت پاسخ ناموفق API، خودش $this->fail() را صدا می‌زند (اگر exception پرتاب کند، باید همان exception را در تست انتظار داشته باشید):

public function test_job_fails_when_invoice_cannot_be_sent(): void
{
    Http::fake(['api.example.com/*' => Http::response(status: 500)]);

    $job = (new SendInvoiceJob($order))->withFakeQueueInteractions();

    $job->handle();

    $job->assertFailed();
}

متدهای assertReleased()، assertDeleted() و assertNotFailed() هم برای حالت‌های دیگر موجودند. منطق متد failed() را هم می‌توانید مستقیم با یک exception نمونه صدا بزنید و نتیجه (مثلاً تغییر وضعیت سفارش) را بررسی کنید.

جمع‌بندی

tries + backoff + failed() + مانیتورینگ. ساخت job: راهنما. مقدمه: Queue.

ترتیب امن بازیابی: خطا را بخوانید، علت را رفع و deploy کنید، workerها را restart کنید، چند مورد را آزمایشی retry کنید، و فقط بعد از اطمینان سراغ retry گروهی بروید. پاک کردن (forget/flush) آخرین قدم است، نه اولین. اگر صف‌ها با Redis و Horizon اجرا می‌شوند، پایش تأخیر و حجم صف در همان راهنمای Redis Queue و Horizon آمده است.

queue پایدار

اشتراک‌گذاری