Một queued listener đang xử lý đơn hàng thì worker bị dừng giữa chừng vì deploy, eviction hoặc vượt giới hạn bộ nhớ. Job quay lại queue, chạy lại, rồi worker lại biến mất. Nếu class đã có #[MaxExceptions(2)], trực giác cho rằng lần thứ hai phải chạm ngưỡng failed. Trước Laravel 13.38, điều đó chưa đúng với listener, mailable và notification dù đã gắn thêm #[CountCrashesAsExceptions].
Bản 13.38 sửa đúng đường truyền metadata này. Fixture cô lập trên Windows, PHP 8.4.24 cho thấy cùng một class có attribute: job trực tiếp đã tạo payload đúng ở 13.37, còn ba kiểu queue bọc qua wrapper đều nhận false. Nâng lên 13.38, cả ba chuyển thành true. Đây là thay đổi nhỏ trong source nhưng có thể quyết định một tác vụ được retry tiếp hay chuyển sang failed.
Mục lục [Hiển thị]
Worker crash khác một exception bình thường ở đâu?
Khi handle() ném exception, worker còn sống để bắt lỗi, tăng exception count và quyết định release hay fail job. Crash là trường hợp process biến mất trước khi hoàn tất đường xử lý đó: bị hệ điều hành kill, process manager dừng, container bị thu hồi hoặc hết bộ nhớ. Queue backend chỉ thấy message đã được reserve nhưng chưa được xóa; sau retry_after, message có thể xuất hiện lại.
tài liệu queue của Laravel 13 nói mặc định lần thử kết thúc vì worker crash hoặc bị kill không được tính vào maxExceptions. Khi có CountCrashesAsExceptions, worker đặt một marker trong cache lúc bắt đầu xử lý. Nếu lần chạy kế tiếp vẫn thấy marker, Laravel xem attempt trước là một exception. Cơ chế này không tạo lại stack trace của process đã chết; nó chỉ đưa crash vào cùng bộ đếm giới hạn.
tries và maxExceptions cũng không phải một thứ. tries giới hạn tổng số attempt, kể cả các lần job được release có chủ đích. maxExceptions nhắm vào số lỗi chưa được xử lý. Attribute mới bổ sung crash vào nhóm thứ hai, chứ không thay đổi cách queue bảo đảm message, timeout hay thời điểm message được reserve lại.
Lỗi cũ nằm ở wrapper, không nằm ở attribute
Job do ứng dụng dispatch trực tiếp trở thành chính object được serialize vào payload. Listener, mailable và notification đi một đường khác: framework bọc chúng lần lượt trong CallQueuedListener, SendQueuedMailable và SendQueuedNotifications. Payload được tạo từ wrapper, không đọc thẳng class gốc.
PR #62048, merge ngày 16/10/2026, chỉ ra ba wrapper trước đó không sao chép cờ countCrashesAsExceptions. Vì vậy attribute vẫn tồn tại trên listener hoặc notification, nhưng tới lúc payload được dựng thì thông tin đã rơi mất. release Laravel 13.38.0 ngày 20/10 thêm property vào ba wrapper và truyền giá trị theo cùng hướng framework đã dùng cho failOnTimeout.

Điểm này giải thích vì sao code trông hoàn toàn hợp lệ nhưng hành vi không đổi. Attribute CountCrashesAsExceptions chỉ là marker class; bản thân nó không có logic. Logic tạo payload phải nhìn thấy marker trên đúng object được queue. Với job trực tiếp, object đó là job. Với ba trường hợp còn lại, object đó là wrapper.
Fixture đã kiểm những gì?
Hai fixture dùng lockfile riêng cho laravel/framework 13.37.0 và 13.38.0. Cùng một script tạo bốn class có #[CountCrashesAsExceptions] và #[MaxExceptions(2)]: job, listener, mailable và notification. Listener được đưa qua chính method truyền option của Dispatcher; mailable và notification được dựng bằng wrapper thật của framework. Sau đó một queue probe gọi đường tạo payload và đọc key countCrashesAsExceptions.
| Đối tượng | 13.37.0 | 13.38.0 |
|---|---|---|
| Job trực tiếp có attribute | true | true |
| Queued listener có attribute | false | true |
| Queued mailable có attribute | false | true |
| Queued notification có attribute | false | true |
| Ba control không có attribute | false | false |

Kết quả tái hiện đúng ranh giới của bản vá: job trực tiếp đã hoạt động ở bản cũ; ba wrapper đổi từ false sang true; class không opt-in vẫn giữ false. Ngoài giá trị payload, reflection còn cho thấy ba wrapper không có property tương ứng ở 13.37 và đều có ở 13.38.
Phạm vi kiểm tra dừng ở payload regression. Fixture không gây OOM, không kill worker và không chạy đủ hai vòng reserve để tạo record trong failed_jobs. Do đó bài không nhận đã đo số attempt hay bắt sự kiện JobFailed end-to-end trên Windows. Ý nghĩa của cờ sau khi vào payload dựa trên tài liệu queue và source chính thức; bằng chứng local chỉ xác nhận phần metadata từng bị bỏ sót nay đã được truyền đúng.
Cách gắn attribute mà không lẫn với tries
Với một queued listener, cấu hình tối thiểu có thể viết như sau. Con số thực tế phải dựa vào chi phí tác vụ, thời gian phục hồi và khả năng side effect đã xảy ra trước lúc worker chết.
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\CountCrashesAsExceptions;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
#[Tries(10)]
#[MaxExceptions(2)]
#[CountCrashesAsExceptions]
final class SendOrderToWarehouse implements ShouldQueue
{
public function handle(OrderPaid $event): void
{
// Gọi service theo business key có thể chạy lặp an toàn.
}
}
Tries(10) cho phép tối đa mười attempt; MaxExceptions(2) đặt ngưỡng hai lỗi; CountCrashesAsExceptions khiến crash được đưa vào bộ đếm lỗi. Nếu bỏ attribute cuối, một process chết không tự tiêu thụ exception budget. Nếu đặt tries quá thấp, job có thể chạm giới hạn attempt trước khi maxExceptions trở thành yếu tố quyết định.
Đừng quên ghép retry_after với timeout worker. Laravel khuyên timeout ngắn hơn retry_after vài giây để worker cũ thoát trước khi job có thể được lấy lại. Bài cấu hình queue:work --memory theo phần trăm cũng cho thấy process exit là một phần vận hành bình thường; điều cần kiểm là job có trở lại đúng lúc và supervisor có khởi động worker mới hay không.
Attribute không thay cho idempotency
Một worker có thể đã gửi request tới kho, gửi email hoặc ghi dữ liệu xong rồi mới chết trước bước acknowledge message. Attempt tiếp theo không biết chắc side effect bên ngoài đã xảy ra. Đưa crash vào exception count chỉ giới hạn số lần retry; nó không biến hệ thống thành exactly-once.
Với webhook và giao dịch, vẫn cần business key ổn định, unique constraint, ledger hoặc idempotency key ở dịch vụ nhận. Với mail, cân nhắc lưu trạng thái gửi theo notification/order thay vì dựa vào số attempt. Bài gửi lỗi người dùng tới email trong Laravel là một ngữ cảnh dễ thấy: chặn retry vô hạn giúp giảm thư lặp, nhưng không chứng minh thư ở attempt trước chưa tới SMTP server.
Tương tự, failed_jobs là nơi quan sát và phục hồi, không phải rollback tự động. Khi retry thủ công, code vẫn phải an toàn trước dữ liệu đã ghi một phần. Đây là khác biệt quan trọng với bài toán job chạy hai lần: bản 13.38 sửa failure boundary, không sửa business transaction của ứng dụng.
Checklist nâng lên Laravel 13.38
Đầu tiên, kiểm tra đúng phiên bản framework bằng lockfile hoặc composer show laravel/framework; đừng chỉ nhìn version của skeleton ứng dụng. Sau đó tìm các listener, mailable và notification vừa có maxExceptions, vừa có rủi ro process biến mất giữa attempt. Những class không muốn crash tính vào exception budget không cần thêm attribute; bản vá giữ control không opt-in ở false.
Trên staging, nên dùng một queue riêng và handler vô hại. Ghi marker “đã bắt đầu”, dừng đúng subprocess worker của fixture, chờ qua retry_after, rồi chạy worker mới. Kiểm tra attempts, cache marker, failed job record và event giám sát. Không gây OOM thật, không kill worker production và không dùng secret thật chỉ để chứng minh một nhánh lỗi.
Nếu hệ thống có scheduler khởi tạo nhiều worker, cần phân biệt khóa scheduler với trạng thái queue. Bài always-on-one-server cho scheduler xử lý chuyện chỉ một scheduler thực thi task; nó không quyết định message đang reserve có được tính là crash exception hay không.
Khi nào nên bật?
Nên cân nhắc CountCrashesAsExceptions khi worker biến mất là một tín hiệu thất bại đáng kể: xử lý file dễ vượt memory, gọi dịch vụ ngoài có thể treo, hoặc môi trường deploy thường terminate process giữa job. Exception budget hữu hạn giúp tác vụ sớm đi vào luồng điều tra thay vì âm thầm quay lại queue mãi.
Không nên bật theo thói quen cho mọi class. Một worker bị restart có thể là sự kiện hạ tầng tạm thời, còn job đủ rẻ và idempotent để thử lại. Khi đó giới hạn tries, retryUntil và cảnh báo queue lag có thể phù hợp hơn. Quyết định cần đi cùng SLO, chi phí một attempt và cách xử lý failed job.
Chốt lại
Laravel 13.38 không đổi ý nghĩa của CountCrashesAsExceptions; bản này làm ý nghĩa đó tới được đúng ba wrapper. Fixture xác nhận 13.37 làm rơi cờ ở queued listener, mailable và notification, trong khi 13.38 truyền đủ vào payload và không ảnh hưởng class không opt-in.
Nếu đang dựa vào maxExceptions cho ba kiểu tác vụ này, nên nâng framework, kiểm payload trên staging rồi chạy một crash test có kiểm soát. Và nhớ giữ idempotency riêng: giới hạn được số lần thử là tốt, nhưng side effect xảy ra đúng một lần vẫn là bài toán khác.