وقتی در داشبورد Horizon عدد Failed Jobs ناگهان بالا می‌رود، اولین واکنش معمولاً زدن دکمه Retry روی همه jobهاست. گاهی جواب می‌دهد؛ گاهی همان خطا را هزار بار دیگر تکرار می‌کند، ایمیل تکراری می‌فرستد یا یک پرداخت را دو بار ثبت می‌کند. این مقاله یک runbook برای رفع خطای job شکست‌خورده در Laravel Horizon است: ترتیب کارهایی که باید انجام شود تا علت پیدا شود، آسیب محدود بماند و retry امن باشد.

اگر با مفاهیم پایه صف و Horizon آشنا نیستید، اول مقاله Queue و Horizon در Laravel را بخوانید. همه دستورهای این مقاله را با سورس Laravel 8.83 و مستندات Horizon برای Laravel 8 تطبیق داده‌ام؛ در نسخه‌های جدیدتر چند گزینه بیشتر اضافه شده، اما ترتیب کار همین است.

قدم صفر: retry نکنید

قبل از هر کاری، تا وقتی علت را نمی‌دانید، retry گروهی نزنید. سه دلیل:

  • اگر خطا از کد یا داده است، retry فقط همان خطا را تکرار می‌کند و صف را برای jobهای سالم شلوغ‌تر می‌کند.
  • اگر job کاملاً idempotent نباشد، ممکن است بخشی از کار در اجرای قبلی انجام شده باشد. ارسال پیامک، ایمیل، ثبت سفارش در سیستم بیرونی یا شارژ کیف پول، با اجرای دوباره تکرار می‌شوند.
  • اگر خطا از سرویس بیرونی است که هنوز در دسترس نیست، retry همزمان صدها job می‌تواند آن سرویس یا rate limit شما را بدتر کند.

قدم ۱: دامنه را بسنجید

به این سؤال‌ها در پنج دقیقه اول جواب بدهید:

  1. چند job و از چه کلاس‌هایی؟ در Horizon بخش Failed Jobs را بر اساس نام job ببینید. یک کلاس با هزار خطا، مسئله‌ای متفاوت از ده کلاس با چند خطاست.
  2. از چه زمانی؟ زمان اولین خطا را با زمان آخرین deploy، تغییر تنظیمات یا قطعی سرویس بیرونی مقایسه کنید.
  3. هنوز ادامه دارد؟ اگر نرخ خطا بالا مانده، اولویت متوقف‌کردن آسیب است، نه بررسی jobهای قبلی.
  4. کدام صف؟ در داشبورد بخش Metrics و Monitoring را ببینید. اگر صفی گیر کرده، jobهای منتظر هم در خطرند.

اگر از جدول failed_jobs هم استفاده می‌کنید (پیکربندی queue.failed)، فهرست در خط فرمان در دسترس است:

php artisan queue:failed

این دستور شناسه، اتصال، صف، کلاس و زمان شکست را نشان می‌دهد. برای تحلیل دقیق‌تر، مستقیم از دیتابیس گروه‌بندی کنید:

SELECT JSON_UNQUOTE(JSON_EXTRACT(payload, '$.displayName')) AS job,
       COUNT(*) AS failures,
       MIN(failed_at) AS first_failure,
       MAX(failed_at) AS last_failure
FROM failed_jobs
WHERE failed_at > NOW() - INTERVAL 1 DAY
GROUP BY job
ORDER BY failures DESC;

قدم ۲: آسیب را متوقف کنید

اگر خطا ادامه دارد، کم‌خطرترین گزینه را انتخاب کنید:

  • توقف موقت یک گروه worker: php artisan horizon:pause-supervisor supervisor-1 و بعد horizon:continue-supervisor. وقتی jobهای مشکل‌دار در supervisor جداگانه‌ای اجرا می‌شوند، بقیه صف‌ها کار می‌کنند.
  • توقف کامل Horizon: php artisan horizon:pause و بعد horizon:continue. jobها در Redis می‌مانند و از دست نمی‌روند.
  • برگرداندن deploy: اگر خطا با یک انتشار شروع شده، rollback معمولاً سریع‌تر از رفع عجولانه است. بعد از تغییر کد، php artisan horizon:terminate را اجرا کنید تا worker‌ها کد جدید را بخوانند؛ workerها کد را در حافظه نگه می‌دارند و بدون restart تغییر را نمی‌بینند.

از horizon:clear در این مرحله استفاده نکنید. این دستور jobهای منتظر صف را حذف می‌کند، نه jobهای شکست‌خورده را، و برگشت‌پذیر نیست.

قدم ۳: نوع خطا را تشخیص دهید

exception ثبت‌شده برای job شکست‌خورده را بخوانید؛ در Horizon با باز کردن job و در جدول failed_jobs در ستون exception. تقریباً همه خطاها در یکی از این دسته‌ها جا می‌گیرند:

نشانه در exceptionعلت محتملretry امن است؟
MaxAttemptsExceededException با پیام «attempted too many times or run too long»job طولانی‌تر از timeout یا retry_after اجرا شده و دوباره برداشته شده استنه، تا وقتی timeoutها اصلاح نشده‌اند
ModelNotFoundExceptionمدلی که job سریالایز کرده بود حذف شده استمعمولاً بی‌فایده؛ job را فراموش کنید یا $deleteWhenMissingModels را در نظر بگیرید
timeout یا خطای اتصال به سرویس بیرونی، HTTP 5xx یا 429مشکل گذرای سرویس دیگربله، بعد از برگشت سرویس و با محدودیت تعداد
TypeError، متد تعریف‌نشده، خطای SQLباگ در کد یا migration ناقصفقط بعد از deploy اصلاح
خطای اعتبارسنجی یا داده نامعتبر از ورودیداده‌ای که هیچ‌وقت موفق نمی‌شودنه؛ داده را اصلاح یا job را فراموش کنید
کمبود حافظه یا worker killedjob داده زیادی را یک‌جا در حافظه می‌آوردبعد از تقسیم کار به بخش‌های کوچک‌تر

دام رایج: timeout و retry_after

در پیکربندی اتصال Redis در config/queue.php مقداری به نام retry_after هست. اگر jobی بیشتر از این مقدار طول بکشد، Laravel فرض می‌کند worker مرده و job را دوباره در صف قرار می‌دهد، در حالی که اجرای اول هنوز ادامه دارد. نتیجه: دو اجرای همزمان، و در نهایت MaxAttemptsExceededException. قاعده این است که timeout در تنظیم supervisor در config/horizon.php یا ویژگی $timeout روی job، همیشه چند ثانیه کمتر از retry_after باشد. در Supervisor سیستم‌عامل هم stopwaitsecs باید از طولانی‌ترین job بیشتر باشد تا هنگام deploy job وسط کار کشته نشود.

قدم ۴: علت را اصلاح کنید، نه فقط نشانه را

بسته به دسته خطا، اصلاح معمولاً یکی از این‌هاست. همه این امکانات در Laravel 8 وجود دارند:

class SyncOrderToCrm implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 5;
    public $backoff = [10, 60, 300];      // فاصله retryها به ثانیه
    public $timeout = 50;                 // کمتر از retry_after
    public $maxExceptions = 3;            // بعد از ۳ exception واقعی متوقف شو
    public $deleteWhenMissingModels = true;

    public function __construct(public Order $order) {}

    public function middleware()
    {
        return [
            (new WithoutOverlapping($this->order->id))->releaseAfter(30),
            new ThrottlesExceptions(10, 5),   // بعد از ۱۰ خطا، ۵ دقیقه صبر
        ];
    }

    public function handle(CrmClient $crm)
    {
        // کلید idempotency تا retry سفارش تکراری نسازد
        $crm->upsertOrder($this->order->toCrmPayload(), idempotencyKey: 'order-'.$this->order->id);
    }

    public function failed(Throwable $e)
    {
        // اطلاع به تیم با زمینه کافی؛ اینجا کار اصلی را دوباره انجام ندهید
        report($e);
    }
}

مهم‌ترین خط این مثال کلید idempotency است. تا وقتی اجرای دوباره job ممکن است اثر تکراری بگذارد، هیچ تنظیم retry امن نیست. در مقاله Reliable Webhooks in Laravel الگوهای idempotency را با جزئیات توضیح داده‌ام.

قدم ۵: retry کنترل‌شده

وقتی علت برطرف شد، retry را در مقیاس کوچک شروع کنید:

# یک job برای امتحان
php artisan queue:retry 1042

# همه jobهای شکست‌خورده یک صف مشخص
php artisan queue:retry --queue=crm-sync

# یک بازه از شناسه‌ها
php artisan queue:retry --range=1040-1100

# همه، فقط وقتی مطمئن هستید
php artisan queue:retry all

این دستورها روی جدول failed_jobs کار می‌کنند. در داشبورد Horizon هم برای هر job دکمه retry وجود دارد. بعد از retry آزمایشی، چند دقیقه داشبورد را نگاه کنید: job موفق شد؟ نرخ خطای سرویس بیرونی بالا نرفت؟ بعد دسته‌های بزرگ‌تر را اجرا کنید.

jobهایی را که هیچ‌وقت نباید دوباره اجرا شوند (داده نامعتبر، مدل حذف‌شده، کاری که به روش دیگری انجام شده) حذف کنید:

php artisan queue:forget 1042      # از جدول failed_jobs
php artisan horizon:forget 1042    # از فهرست failed jobs در Horizon

قدم ۶: نگهداری و پیشگیری

  • پاک‌سازی دوره‌ای: php artisan queue:prune-failed --hours=168 را در scheduler قرار دهید تا جدول failed_jobs بی‌نهایت بزرگ نشود. Horizon فهرست خودش را بر اساس تنظیم trim در config/horizon.php کوتاه می‌کند.
  • هشدار قبل از بحران: تنظیم waits در Horizon به‌همراه Horizon::routeSlackNotificationsTo یا ایمیل، وقتی زمان انتظار صف از آستانه بیشتر شود خبر می‌دهد. دستور queue:monitor هم برای صف‌هایی که از حد مشخص طولانی‌تر می‌شوند event می‌فرستد.
  • متریک: horizon:snapshot را هر پنج دقیقه در scheduler اجرا کنید تا نمودار throughput و runtime داشته باشید. بدون آن، مقایسه قبل و بعد از اصلاح ممکن نیست.
  • جداسازی صف‌ها: jobهای کند یا وابسته به سرویس بیرونی را در صف و supervisor جداگانه اجرا کنید تا مشکل یک سرویس، ایمیل‌ها و اعلان‌ها را متوقف نکند.
  • deploy امن: horizon:terminate را به اسکریپت deploy اضافه کنید و مطمئن شوید Supervisor آن را دوباره بالا می‌آورد.

چک‌لیست خلاصه برای روز حادثه

  1. retry گروهی نزنید.
  2. دامنه را بسنجید: کدام job، چند تا، از کی، هنوز ادامه دارد؟
  3. آسیب را متوقف کنید: pause supervisor، pause Horizon یا rollback.
  4. exception را دسته‌بندی کنید: timeout، مدل حذف‌شده، سرویس بیرونی، باگ، داده نامعتبر، حافظه.
  5. idempotency را پیش از هر retry تأیید کنید.
  6. علت را اصلاح و با horizon:terminate deploy کنید.
  7. retry کوچک، مشاهده، سپس retry دسته‌ای.
  8. jobهای بی‌فایده را forget کنید.
  9. هشدار، متریک و پاک‌سازی دوره‌ای را اضافه کنید تا دفعه بعد زودتر بفهمید.

در پروژه‌هایی که صف‌ها کار حیاتی مثل پرداخت، همگام‌سازی انبار یا اعلان انجام می‌دهند، طراحی retry و idempotency باید از ابتدا بخشی از معماری باشد. اگر می‌خواهید سیستم صف پروژه Laravel شما بازبینی شود، در خدمت توسعه اختصاصی PHP و Laravel یا ممیزی فنی با من در تماس باشید.

پرسش‌های متداول

چرا job من با خطای MaxAttemptsExceededException شکست می‌خورد در حالی که tries زیاد است؟

اغلب چون اجرای job از retry_after طولانی‌تر شده و همان job همزمان دوباره برداشته شده است. timeout را کمتر از retry_after تنظیم کنید.

horizon:clear jobهای شکست‌خورده را پاک می‌کند؟

نه. این دستور jobهای منتظر صف را حذف می‌کند. برای حذف job شکست‌خورده از horizon:forget یا queue:forget استفاده کنید.