Worker yang kita bangun di chapter-chapter sebelumnya cepat atau lambat akan bertemu job yang gagal: API pihak ketiga sedang down, payload rusak, atau disk server penuh. Persoalannya, kegagalan di latar belakang tidak terlihat dari request — tidak ada halaman error yang muncul dan tidak ada pengguna yang langsung melapor. Tanpa strategi penanganan yang eksplisit, job yang gagal cukup menguap beserta seluruh pekerjaannya. Chapter ini membahas siklus hidup kegagalan secara utuh: mengklasifikasikannya, merekamnya ke tabel failed_jobs, memberi tahu manusia pada ambang yang tepat, memutuskan retry atau dead-letter, mengenali poison message, dan mematikan worker dengan anggun.
Taksonomi Kegagalan: Transien atau Permanen #
Pertanyaan pertama yang harus dijawab saat sebuah job gagal adalah: apakah mencoba ulang akan membantu? Jawabannya membagi semua kegagalan ke dalam dua kategori. Kegagalan transien bersumber dari lingkungan yang sedang tidak sehat — timeout koneksi, rate limit, deadlock database — dan punya peluang sembuh sendiri setelah beberapa detik atau menit. Kegagalan permanen bersumber dari job itu sendiri — payload tidak valid, data referensi hilang, bug logika — dan akan gagal secara identik selamanya berapa kali pun diulang.
| Kategori | Contoh penyebab | Apakah retry membantu? |
|---|---|---|
| Transien | Timeout koneksi, rate limit, deadlock database | Ya, dengan jeda backoff |
| Permanen | Payload tidak valid, data referensi hilang, bug logika | Tidak, gagal identik setiap percobaan |
Keputusan transien-versus-permanen sebaiknya tidak disimpan sebagai pengetahuan lisan di kepala tim, melainkan ditulis sebagai kode yang bisa diuji. Cara termudahnya adalah menandai kelas exception dengan marker interface kosong, lalu menyediakan satu fungsi pengklasifikasi:
<?php
interface KegagalanTransien
{
}
interface KegagalanPermanen
{
}
class TimeoutKoneksi extends RuntimeException implements KegagalanTransien
{
}
class RateLimitTerlampaui extends RuntimeException implements KegagalanTransien
{
}
class PayloadTidakValid extends InvalidArgumentException implements KegagalanPermanen
{
}
function klasifikasikanKegagalan(Throwable $kegagalan): string
{
if ($kegagalan instanceof KegagalanTransien) {
return "transien";
}
return "permanen";
}
$contohKegagalan = [
new TimeoutKoneksi("API pembayaran tidak merespons dalam 5 detik"),
new RateLimitTerlampaui("Batas 100 permintaan per menit terlampaui"),
new PayloadTidakValid("Field orderId wajib berupa integer"),
];
foreach ($contohKegagalan as $kegagalan) {
echo get_class($kegagalan), " => ", klasifikasikanKegagalan($kegagalan), "\n";
}Output yang Dihasilkan:
TimeoutKoneksi => transien
RateLimitTerlampaui => transien
PayloadTidakValid => permanenMari kita bedah komponen-komponennya:
KegagalanTransiendanKegagalanPermanenadalah interface kosong tanpa method. Tugasnya semata menandai kelas exception supaya bisa dikenali lewat operatorinstanceof— pola yang disebut marker interface.- Kelas exception domain mewarisi kelas bawaan yang paling sesuai (
RuntimeExceptionuntuk kegagalan runtime,InvalidArgumentExceptionuntuk input yang salah) sekaligus mengimplementasikan penanda kategorinya. klasifikasikanKegagalan()hanya memeriksa satu hal: apakah exception menandai dirinya transien. Semua exception lain — termasuk yang belum kita kategorikan — dianggap permanen.- Loop
foreachmembuktikan klasifikasi bekerja untuk ketiga contoh: dua kegagalan lingkungan terdeteksi transien, satu kegagalan payload terdeteksi permanen.
Merekam Kegagalan ke Tabel failed_jobs #
Aturan pertama menangani kegagalan yang tidak terlihat adalah menuliskannya ke tempat yang bisa dibaca ulang. Pola yang sudah menjadi standar de facto di ekosistem PHP (Laravel menyebutnya failed_jobs) adalah tabel khusus yang menyimpan identitas job, payload untuk diputar ulang, pesan error, dan waktu kejadian. Dengan catatan ini, job yang gagal tidak hilang — job tersebut menunggu untuk diperiksa dan dijalankan ulang setelah akar masalahnya diperbaiki.
Worker mencatat kegagalan dengan menangkap Throwable di level paling luar loop, seperti berikut:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec("CREATE TABLE failed_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_type TEXT NOT NULL,
payload TEXT NOT NULL,
error TEXT NOT NULL,
failed_at TEXT NOT NULL
)");
function catatKegagalan(PDO $pdo, string $tipeJob, array $payload, Throwable $kegagalan): int
{
$statement = $pdo->prepare(
"INSERT INTO failed_jobs (job_type, payload, error, failed_at)
VALUES (:tipe, :payload, :error, :waktu)"
);
$statement->execute([
":tipe" => $tipeJob,
":payload" => json_encode($payload),
":error" => mb_substr(get_class($kegagalan) . ": " . $kegagalan->getMessage(), 0, 180),
":waktu" => "2026-01-15 08:00:00",
]);
return (int) $pdo->lastInsertId();
}
function prosesPesanan(array $payload): string
{
if (!isset($payload["orderId"]) || !ctype_digit((string) $payload["orderId"])) {
throw new InvalidArgumentException("Field orderId wajib berupa integer");
}
return "pesanan #{$payload['orderId']} diproses";
}
$jobGagal = ["type" => "proses_pesanan", "payload" => ["orderId" => "ABC-1024"]];
try {
prosesPesanan($jobGagal["payload"]);
echo "Job selesai tanpa masalah\n";
} catch (Throwable $kegagalan) {
$idKegagalan = catatKegagalan($pdo, $jobGagal["type"], $jobGagal["payload"], $kegagalan);
echo "Job gagal dan dicatat sebagai failed_jobs #{$idKegagalan}\n";
}
foreach ($pdo->query("SELECT job_type, error, failed_at FROM failed_jobs") as $baris) {
echo "Inspeksi [{$baris['job_type']}]: {$baris['error']} (pada {$baris['failed_at']})\n";
}Output yang Dihasilkan:
Job gagal dan dicatat sebagai failed_jobs #1
Inspeksi [proses_pesanan]: InvalidArgumentException: Field orderId wajib berupa integer (pada 2026-01-15 08:00:00)Ada beberapa keputusan desain di dalam contoh tersebut:
catatKegagalan()menyimpan payload utuh denganjson_encode()sehingga job bisa diputar ulang begitu akar masalahnya diperbaiki — tanpa payload, replay tidak mungkin dilakukan.- Kolom
errormenggabungkan nama kelas exception dan pesannya, lalu dipangkasmb_substr()hingga 180 karakter. Pesan stack trace yang panjang membuat baris tabel membengkak tanpa menambah nilai diagnostik; detail penuh sebaiknya tetap dicatat di log terstruktur. - Kolom
failed_atpada demo memakai waktu beku"2026-01-15 08:00:00"agar output contoh deterministik; di worker produksi Anda, gunakan waktu saat ini, misalnya(new DateTimeImmutable())->format("Y-m-d H:i:s"). - Query inspeksi di akhir membuktikan catatan benar-benar bisa dibaca ulang: siapa yang gagal, kenapa, dan kapan — tiga pertanyaan pertama saat insiden terjadi.
Peringatan untuk Manusia pada Ambang yang Tepat #
Tabel failed_jobs yang tidak pernah dibaca siapa pun sama saja dengan kegagalan yang tidak terlihat. Karena itu worker perlu mekanisme untuk mengangkat kegagalan menjadi peringatan — tetapi tidak untuk setiap kegagalan tunggal. Satu timeout API jam 3 pagi bukanlah keadaan darurat; lima kegagalan dalam satu menit adalah tanda sistem sedang sakit. Memicu alarm per kegagalan hanya melatih tim untuk mengabaikan alarm.
Pola yang sehat adalah menghitung jumlah kegagalan di dalam jendela waktu (time window) dan membandingkannya dengan ambang:
<?php
function hitungKegagalanDalamJendela(array $detikKegagalan, int $lebarJendela, int $waktuSekarang): int
{
$jumlah = 0;
foreach ($detikKegagalan as $detik) {
if ($waktuSekarang - $detik <= $lebarJendela) {
$jumlah++;
}
}
return $jumlah;
}
// detik sejak worker menyala ketika setiap kegagalan terjadi
$detikKegagalan = [100, 103, 105, 140, 160];
$waktuSekarang = 165;
$ambangPeringatan = 3;
foreach ([30, 60] as $lebarJendela) {
$jumlah = hitungKegagalanDalamJendela($detikKegagalan, $lebarJendela, $waktuSekarang);
$status = $jumlah >= $ambangPeringatan ? "KIRIM PERINGATAN" : "masih aman";
echo "Jendela {$lebarJendela} detik: {$jumlah} kegagalan => {$status}\n";
}Output yang Dihasilkan:
Jendela 30 detik: 2 kegagalan => masih aman
Jendela 60 detik: 3 kegagalan => KIRIM PERINGATANPada lima kegagalan tersebut, jendela 30 detik hanya memuat dua kegagalan terakhir sehingga masih di bawah ambang tiga; jendela 60 detik memuat tiga kegagalan dan peringatan pun dikirim. Di sistem produksi, penghitung ini biasanya diimplementasikan sebagai query COUNT(*) pada failed_jobs dengan filter failed_at >= NOW() - INTERVAL 1 MINUTE, dan peringatan dikirim ke saluran yang benar-benar dibaca tim — chat ops, email, atau layanan paging. Nilai ambang dan lebar jendela adalah keputusan bisnis: berapa banyak kegagalan per menit yang boleh terjadi sebelum seorang manusia harus bangun dari tidur.
Keputusan Retry atau Dead-Letter #
Dua informasi sebelumnya — klasifikasi kegagalan dan jumlah percobaan — kini bisa digabungkan menjadi satu keputusan akhir. Job transien yang masih di bawah batas percobaan dijadwalkan retry dengan backoff (materi lengkap ada di chapter Retry, Backoff, dan Timeouts ). Selebihnya dipindahkan ke dead-letter — area parkir untuk job yang menyerah, dibaca manusia, dan diputar ulang secara manual setelah diperbaiki. Alur keputusannya digambarkan berikut:
Diagram tersebut membaca: setiap kegagalan masuk ke satu pemeriksaan klasifikasi, kemudian satu pemeriksaan jumlah percobaan, dan hanya kombinasi “transien dan masih ada kesempatan” yang menuju retry. Wujud kodenya adalah fungsi kecil murni — mudah diuji karena tidak bergantung pada database ataupun waktu:
<?php
function putuskanAksiKegagalan(string $klasifikasi, int $percobaanKe, int $batasPercobaan): string
{
if ($klasifikasi !== "transien") {
return "pindah ke dead-letter";
}
if ($percobaanKe >= $batasPercobaan) {
return "pindah ke dead-letter";
}
return "jadwalkan retry";
}
$kasus = [
["transien", 1, 3],
["transien", 3, 3],
["permanen", 1, 3],
];
foreach ($kasus as [$klasifikasi, $percobaanKe, $batasPercobaan]) {
$aksi = putuskanAksiKegagalan($klasifikasi, $percobaanKe, $batasPercobaan);
echo "klasifikasi={$klasifikasi}, percobaan={$percobaanKe}/{$batasPercobaan} => {$aksi}\n";
}Output yang Dihasilkan:
klasifikasi=transien, percobaan=1/3 => jadwalkan retry
klasifikasi=transien, percobaan=3/3 => pindah ke dead-letter
klasifikasi=permanen, percobaan=1/3 => pindah ke dead-letterTiga baris output memetakan tiga nasib yang berbeda: transien percobaan pertama dijadwalkan retry, transien yang kehabisan kesempatan diparkir, dan permanen langsung diparkir sejak percobaan pertama. Memisahkan fungsi keputusan seperti ini juga menyenangkan saat pengujian — nanti di Section Testing dan Quality Assurance kita akan melihat pola serupa diuji sebagai unit test.
Poison Message dan Karantina #
Ada kelas kegagalan yang lebih licik dari kegagalan biasa: poison message, yaitu job yang membuat worker itu sendiri mati sebelum sempat melempar exception yang bisa ditangkap. Penyebabnya biasanya payload yang rusak berat — misalnya string berukuran ratusan megabyte yang menghabiskan memori, atau struktur rekursif yang membuat parser crash. Gejalanya khas: worker berjalan normal, lalu restart, lalu mati lagi pada job yang sama, berulang tanpa akhir — supervisor yang setia me-restart worker justru menjadi mesin pemvaksin racun berulang.
Pertahanan pertama adalah batas percobaan dari chapter Retry, Backoff, dan Timeouts : setelah job mencapai batas, job tersebut keluar dari rotasi dan berhenti membunuh worker. Pertahanan kedua adalah karantina — worker mendeteksi “saya baru saja mati pada job #N” (misalnya dengan menulis ID job terakhir ke berkas kecil sebelum memprosesnya) dan memindahkan job tersebut langsung ke dead-letter tanpa mencoba lagi. Pertahanan ketiga adalah memantau laju restart worker itu sendiri; worker yang restart lebih dari sekali per menit hampir pasti sedang mencerna poison message. Saat menulis ulang job yang dikarantina, pastikan juga sisi idempotensinya sudah diperiksa mengikuti chapter Idempotent Jobs agar replay-nya tidak menimbulkan efek ganda.
Mematikan Worker dengan Anggun #
Penanganan kegagalan tidak lengkap tanpa menangani “kegagalan” yang direncanakan: pemadaman worker saat deploy versi kode baru. Mematikan worker secara paksa di tengah job (kill -9) meninggalkan pekerjaan setengah jadi — berkas setengah tertulis, email setengah terkirim, status order setengah diperbarui. shutdown yang anggun bekerja sebaliknya: worker menerima sinyal berhenti, menyelesaikan job yang sedang digarap sampai tuntas, lalu keluar; job yang masih menunggu di queue tetap utuh dan akan dimiliki worker pengganti:
<?php
$sisaJobDiQueue = 4;
$sinyalBerhentiDiterima = false;
$jobSelesai = 0;
while ($sisaJobDiQueue > 0) {
if ($sinyalBerhentiDiterima) {
echo "Berhenti dengan anggun: {$sisaJobDiQueue} job dibiarkan menunggu di queue\n";
break;
}
// ... memproses satu job sampai tuntas ...
$sisaJobDiQueue--;
$jobSelesai++;
// simulasi: sinyal SIGTERM tiba setelah job kedua selesai
if ($jobSelesai === 2) {
$sinyalBerhentiDiterima = true;
}
}
echo "Worker mati setelah menyelesaikan {$jobSelesai} job, tidak ada job terpotong setengah jalan\n";Output yang Dihasilkan:
Berhenti dengan anggun: 2 job dibiarkan menunggu di queue
Worker mati setelah menyelesaikan 2 job, tidak ada job terpotong setengah jalanLoop simulasi tersebut memperlihatkan kontrak inti graceful shutdown: pemeriksaan bendera berhenti dilakukan di antara job, bukan di tengah job. Dua job selesai sepenuhnya, dua job lain dibiarkan utuh di queue, dan tidak ada yang terpotong. Di worker nyata, bendera $sinyalBerhentiDiterima di-set oleh signal handler SIGTERM — mekanisme penangkapan sinyal ini akan kita praktikkan langsung dengan ekstensi pcntl pada chapter berikutnya.
Ringkasan #
Kegagalan job butuh taksonomi (transien atau permanen), catatan yang bisa dibaca ulang (failed_jobs dengan payload dan pesan error), ambang peringatan yang menghormati tidur manusia, keputusan retry-versus-dead-letter yang ditulis sebagai kode kecil murni, kewaspadaan terhadap poison message, dan pemadaman worker yang menyelesaikan job yang sedang digarap. Semua keputusan tersebut berpusat pada satu aktor yang sampai sekarang masih kita perlakukan sebagai kotak hitam: proses worker itu sendiri. Chapter berikutnya membuka kotak itu — bagaimana PHP memulai proses baru, membaca exit code, menangkap sinyal, dan menghindari zombie — pada Process Management
.