Chapter sebelumnya sudah membuktikan bahwa sebuah tabel database cukup untuk menjadi queue: tiga kolom ditambah claim atomik menghasilkan queue FIFO yang bisa kita bangun dan uji sendiri. Namun queue produksi menghadapi tiga situasi yang tidak disentuh demo tersebut: job yang harus ditunda (“kirim pengingat lima menit lagi”), worker yang crash di tengah pekerjaan, dan job yang gagal terus-menerus hingga harus disingkirkan dengan terkendali. Ketiganya diselesaikan bukan dengan perangkat lunak baru, melainkan dengan menambahkan kolom pada tabel yang sama — persis pendekatan yang dipakai driver database pada Laravel Queues. Pada chapter ini kita membangun versi tabel jobs yang siap produksi, lalu mengujinya dengan jam yang dibekukan agar hasilnya deterministik.
Anatomi Tabel Jobs #
Tabel produksi memisahkan tiga gagasan waktu yang berbeda. Kolom created_at mencatat kapan job dititipkan dispatcher, available_at menentukan kapan job boleh diambil worker, dan reserved_at mencatat kapan job sedang dikunci oleh worker tertentu. Kolom attempts menghitung berapa kali job pernah dicoba — angka yang akan menjadi dasar seluruh keputusan retry pada chapter Retry, Backoff, dan Timeouts
. Peran setiap kolom dapat dirangkum sebagai berikut:
| Kolom | Tipe | Peran |
|---|---|---|
id | INTEGER PRIMARY KEY | Nomor urut monotonic, dasar pengurutan FIFO |
type | TEXT | Menentukan handler mana yang dijalankan worker |
payload | TEXT (JSON) | Data domain yang dibawa job |
attempts | INTEGER | Jumlah percobaan yang sudah dilakukan |
available_at | INTEGER (unix timestamp) | Batas waktu job boleh diambil, mendukung job tertunda |
reserved_at | INTEGER (unix timestamp) | Waktu job dikunci worker, dasar visibility timeout |
created_at | INTEGER (unix timestamp) | Waktu job dititipkan, untuk audit |
Mari kita wujudkan skema tersebut lengkap dengan operasi push yang mendukung penundaan:
<?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 jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at INTEGER NOT NULL,
reserved_at INTEGER,
created_at INTEGER NOT NULL
)");
function pushJob(PDO $pdo, string $type, array $payload, int $delayDetik, int $now): int
{
$stmt = $pdo->prepare(
"INSERT INTO jobs (type, payload, available_at, created_at)
VALUES (:type, :payload, :availableAt, :createdAt)"
);
$stmt->execute([
":type" => $type,
":payload" => json_encode($payload),
":availableAt" => $now + $delayDetik,
":createdAt" => $now,
]);
return (int) $pdo->lastInsertId();
}
function formatJam(int $timestamp): string
{
return (new DateTimeImmutable("@{$timestamp}"))
->setTimezone(new DateTimeZone("Asia/Jakarta"))
->format("H:i:s");
}
$now = (new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")))->getTimestamp();
pushJob($pdo, "kirim_email", ["recipient" => "[email protected]", "orderId" => 1042], 0, $now);
pushJob($pdo, "email_pengingat", ["recipient" => "[email protected]"], 300, $now);
pushJob($pdo, "buat_pdf", ["orderId" => 1042], 0, $now);
foreach ($pdo->query("SELECT id, type, available_at, attempts FROM jobs ORDER BY id") as $job) {
echo sprintf(
"#%d %-16s tersedia pada %s (attempts: %d)\n",
$job["id"],
$job["type"],
formatJam((int) $job["available_at"]),
$job["attempts"]
);
}Output yang Dihasilkan:
#1 kirim_email tersedia pada 08:00:00 (attempts: 0)
#2 email_pengingat tersedia pada 08:05:00 (attempts: 0)
#3 buat_pdf tersedia pada 08:00:00 (attempts: 0)Mari kita bedah bagian-bagian pentingnya:
pushJob()menerima$delayDetik— job tertunda diwujudkan murni lewat aritmetikaavailable_at = $now + $delayDetik, tanpa tabel atau mekanisme kedua. Menunda job selama lima menit dan lima jam hanya berbeda pada satu angka.formatJam()mengubah unix timestamp kembali ke jam WIB agar keluaran mudah dibaca manusia. Polanew DateTimeImmutable("@{$timestamp}")diikutisetTimezone()dipakai karena konstruktor awalan@selalu menghasilkan waktu UTC.- Jam dibekukan pada
2026-01-15 08:00:00— trik yang sama dengan chapter sebelumnya — sehingga output program identik pada setiap eksekusi. - Baris
#2membuktikan penundaan tersimpan:email_pengingatbaru boleh diambil pada pukul 08:05:00, tepat 300 detik setelah kedua job lainnya.
Siklus hidup seluruh kolom tersebut berjalan seperti diagram berikut:
Diagram tersebut memperlihatkan bahwa hampir semua transisi berakhir kembali ke kondisi “siap diambil”: job yang gagal dilepas lagi dengan available_at baru, job milik worker yang crash dikembalikan setelah masa kuncinya habis, dan hanya dua jalan keluar final — sukses atau dead letter. Dua jalan keluar itulah yang kita bangun satu per satu pada bagian berikutnya.
Mengambil Job dan Menunda Eksekusi #
Fungsi popNextJob() versi produksi memfilter dua kondisi sekaligus: baris belum dikunci (reserved_at IS NULL) dan sudah boleh diambil (available_at <= now). Kedua kondisi tetap dievaluasi di dalam satu statement UPDATE ... RETURNING, sehingga sifat claim atomik dari chapter sebelumnya tetap terjaga meski beberapa worker berjalan bersamaan:
<?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 jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at INTEGER NOT NULL,
reserved_at INTEGER
)");
function pushJob(PDO $pdo, string $type, array $payload, int $delayDetik, int $now): int
{
$stmt = $pdo->prepare(
"INSERT INTO jobs (type, payload, available_at) VALUES (:type, :payload, :availableAt)"
);
$stmt->execute([
":type" => $type,
":payload" => json_encode($payload),
":availableAt" => $now + $delayDetik,
]);
return (int) $pdo->lastInsertId();
}
function popNextJob(PDO $pdo, int $now): ?array
{
$stmt = $pdo->prepare(
"UPDATE jobs
SET reserved_at = :now, attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE reserved_at IS NULL AND available_at <= :cutoff
ORDER BY id LIMIT 1
)
RETURNING id, type, attempts"
);
$stmt->execute([":now" => $now, ":cutoff" => $now]);
$job = $stmt->fetch();
return $job === false ? null : $job;
}
function formatJam(int $timestamp): string
{
return (new DateTimeImmutable("@{$timestamp}"))
->setTimezone(new DateTimeZone("Asia/Jakarta"))
->format("H:i:s");
}
$now = (new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")))->getTimestamp();
pushJob($pdo, "kirim_email", ["orderId" => 1042], 0, $now);
pushJob($pdo, "email_pengingat", ["orderId" => 1042], 300, $now);
pushJob($pdo, "buat_pdf", ["orderId" => 1042], 0, $now);
echo "Siklus worker pada " . formatJam($now) . "\n";
while (($job = popNextJob($pdo, $now)) !== null) {
echo " memproses #{$job['id']} {$job['type']} (attempt #{$job['attempts']})\n";
}
echo "email_pengingat masih tertahan 5 menit oleh available_at\n\n";
$now += 300;
echo "Siklus worker pada " . formatJam($now) . "\n";
while (($job = popNextJob($pdo, $now)) !== null) {
echo " memproses #{$job['id']} {$job['type']} (attempt #{$job['attempts']})\n";
}Output yang Dihasilkan:
Siklus worker pada 08:00:00
memproses #1 kirim_email (attempt #1)
memproses #3 buat_pdf (attempt #1)
email_pengingat masih tertahan 5 menit oleh available_at
Siklus worker pada 08:05:00
memproses #2 email_pengingat (attempt #1)Mari kita amati perilakunya langkah demi langkah:
- Siklus pertama (08:00:00) memproses job
#1dan#3namun tidak#2— klausaavailable_at <= :cutoffmenahan job pengingat yang baru boleh diambil pada 08:05:00. popNextJob()mengembalikannulltidak berarti queue kosong selamanya — null hanya berarti “tidak ada job yang boleh diambil sekarang”. Worker daemon sesungguhnya akan tidur sejenak lalu mengecek lagi.$now += 300mensimulasikan berjalannya waktu lima menit. Pada siklus kedua,available_atmilik#2(08:05:00) akhirnya terpenuhi sehingga job bisa diklaim dan diproses.- Kolom
attemptsbertambah setiap claim (bukan setiap keberhasilan) — nilaiattempt #1pada output berasal dariRETURNINGsetelahattempts = attempts + 1dieksekusi.
Pola yang sama menopang berbagai kebutuhan nyata: email pengingat, pembatalan pesanan otomatis yang belum dibayar, hingga laporan mingguan — semuanya hanya berbeda angka penundaan.
Visibility Timeout saat Worker Crash #
Bagian paling berbahaya dari siklus hidup job adalah jendela antara claim dan penyelesaian. Worker yang mengambil job lalu mati (kehabisan memori, terbunuh deploy, listrik padam) tidak sempat menandai job selesai, padahal reserved_at sudah terisi — job menjadi “hantu” yang tidak bisa diambil siapa pun. Solusinya adalah memberi kunci masa berlaku: jika reserved_at plus batas waktu tertentu sudah terlewat, worker lain berhak mengambil alih job tersebut. Masa berlaku inilah yang disebut visibility timeout, dan nilainya harus lebih panjang dari durasi job terlama agar job yang benar-benar masih diproses tidak dicuri:
<?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 jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at INTEGER NOT NULL,
reserved_at INTEGER
)");
function popNextJob(PDO $pdo, int $now, int $visibilityTimeout): ?array
{
$stmt = $pdo->prepare(
"UPDATE jobs
SET reserved_at = :now, attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE available_at <= :cutoff
AND (reserved_at IS NULL OR reserved_at + :timeout < CAST(:nowB AS INTEGER))
ORDER BY id LIMIT 1
)
RETURNING id, type, attempts"
);
$stmt->execute([
":now" => $now,
":cutoff" => $now,
":timeout" => $visibilityTimeout,
":nowB" => $now,
]);
$job = $stmt->fetch();
return $job === false ? null : $job;
}
function formatJam(int $timestamp): string
{
return (new DateTimeImmutable("@{$timestamp}"))
->setTimezone(new DateTimeZone("Asia/Jakarta"))
->format("H:i:s");
}
$now = (new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")))->getTimestamp();
$pdo->prepare("INSERT INTO jobs (type, payload, available_at) VALUES (:type, :payload, :availableAt)")
->execute([":type" => "buat_laporan", ":payload" => "{}", ":availableAt" => $now]);
$job = popNextJob($pdo, $now, 60);
echo "Worker A mengambil job #{$job['id']} pada " . formatJam($now) . " lalu crash sebelum selesai\n";
$now += 30;
$job = popNextJob($pdo, $now, 60);
echo formatJam($now) . ": masih terkunci Worker A, popNextJob() mengembalikan "
. var_export($job, true) . "\n";
$now += 31;
$job = popNextJob($pdo, $now, 60);
echo formatJam($now) . ": masa kunci habis, Worker B mengambil job #{$job['id']} (attempt #{$job['attempts']})\n";Output yang Dihasilkan:
Worker A mengambil job #1 pada 08:00:00 lalu crash sebelum selesai
08:00:30: masih terkunci Worker A, popNextJob() mengembalikan NULL
08:01:01: masa kunci habis, Worker B mengambil job #1 (attempt #2)Alur simulasi di atas berjalan sebagai berikut:
- Worker A mengklaim job pada 08:00:00 —
reserved_atterisi danattemptsnaik menjadi 1 — lalu “crash” tanpa menyelesaikan pekerjaan. - Pada 08:00:30, kondisi
reserved_at + 60 < 08:00:30bernilai salah (08:01:00 belum tercapai), sehinggapopNextJob()mengembalikanNULLdan job tetap milik Worker A. - Pada 08:01:01,
reserved_at + 60(yaitu 08:01:00) sudah terlewat, jadi Worker B berhasil mengklaim job yang sama — perhatikanattemptsnaik menjadi 2, tanda bahwa job ini sudah percobaan kedua. - Konsekuensi yang harus disadari: job mungkin sudah setengah dikerjakan Worker A sebelum crash, sehingga job bisa saja berjalan dua kali. Inilah janji at-least-once pada queue — kita akan menangani duplikatnya secara khusus pada chapter Idempotent Jobs .
Menangani Job Gagal Permanen #
Job yang gagal tidak langsung dibuang. Selama attempts masih di bawah batas, worker melepasnya kembali ke queue dengan available_at baru — inilah mekanisme release yang menjadi fondasi retry. Namun ketika batas percobaan tercapai, job harus dipindahkan ke tabel terpisah bernama failed_jobs agar tidak menumpuk di queue aktif namun tetap bisa diperiksa. Program berikut mensimulasikan handler yang selalu gagal (misalnya API gudang sedang down) dan menunjukkan kedua transisi tersebut:
<?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 jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at INTEGER NOT NULL,
reserved_at INTEGER
)");
$pdo->exec("CREATE TABLE failed_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT NOT NULL,
error TEXT NOT NULL
)");
function popNextJob(PDO $pdo, int $now): ?array
{
$stmt = $pdo->prepare(
"UPDATE jobs
SET reserved_at = :now, attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE reserved_at IS NULL AND available_at <= :cutoff
ORDER BY id LIMIT 1
)
RETURNING id, type, payload, attempts"
);
$stmt->execute([":now" => $now, ":cutoff" => $now]);
$job = $stmt->fetch();
return $job === false ? null : $job;
}
function releaseJob(PDO $pdo, int $jobId, int $retryAt): void
{
$pdo->prepare("UPDATE jobs SET reserved_at = NULL, available_at = :retryAt WHERE id = :id")
->execute([":retryAt" => $retryAt, ":id" => $jobId]);
}
$now = (new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")))->getTimestamp();
$pdo->prepare("INSERT INTO jobs (type, payload, available_at) VALUES (:type, :payload, :availableAt)")
->execute([
":type" => "sinkronisasi_stok",
":payload" => json_encode(["produkId" => 501]),
":availableAt" => $now,
]);
$batasPercobaan = 3;
while (true) {
$job = popNextJob($pdo, $now);
if ($job === null) {
break;
}
// handler selalu gagal dalam simulasi ini (API gudang 503)
if ($job["attempts"] < $batasPercobaan) {
releaseJob($pdo, $job["id"], $now + 60);
echo "Percobaan #{$job['attempts']} gagal - job dirilis, dicoba lagi 60 detik kemudian\n";
} else {
$pdo->prepare("DELETE FROM jobs WHERE id = :id")->execute([":id" => $job["id"]]);
$pdo->prepare(
"INSERT INTO failed_jobs (job_id, type, payload, error)
VALUES (:jobId, :type, :payload, :error)"
)->execute([
":jobId" => $job["id"],
":type" => $job["type"],
":payload" => $job["payload"],
":error" => "API gudang merespons 503 setelah {$job['attempts']} percobaan",
]);
echo "Percobaan #{$job['attempts']} gagal - batas tercapai, job dipindah ke failed_jobs\n";
}
$now += 60;
}
$sisaQueue = (int) $pdo->query("SELECT COUNT(*) FROM jobs")->fetchColumn();
$totalGagal = (int) $pdo->query("SELECT COUNT(*) FROM failed_jobs")->fetchColumn();
echo "\nJob tersisa di queue : {$sisaQueue}\n";
echo "Job di tabel failed_jobs : {$totalGagal}\n";Output yang Dihasilkan:
Percobaan #1 gagal - job dirilis, dicoba lagi 60 detik kemudian
Percobaan #2 gagal - job dirilis, dicoba lagi 60 detik kemudian
Percobaan #3 gagal - batas tercapai, job dipindah ke failed_jobs
Job tersisa di queue : 0
Job di tabel failed_jobs : 1Mari kita bedah alur keputusannya:
- Cabang pertama (
attempts < batas) memanggilreleaseJob():reserved_atdikosongkan agar job bisa diklaim lagi, sementaraavailable_atdigeser 60 detik ke depan sehingga retry tidak terjadi seketika — bentuk paling sederhana dari backoff. - Cabang kedua (batas tercapai) menghapus baris dari
jobslalu menyisipkan salinannya kefailed_jobsbeserta pesan error — job keluar dari jalur worker tapi jejaknya tetap lengkap untuk penyelidikan. $now += 60di akhir loop memajukan jam beku sehingga percobaan berikutnya “terjadi” satu menit kemudian; tiga percobaan menghabiskan tiga menit simulasi.- Hasil akhir membuktikan queue bersih (0 job tersisa) dan satu baris menetap di
failed_jobs— persis keadaan yang diinginkan: queue aktif tidak tercemar job yang sudah putus asa.
Pola Dispatch setelah Commit #
Masalah halus berikutnya muncul dari interaksi queue dengan transaksi database. Dispatcher yang menitipkan job di tengah transaksi bisnis membuka jendela race: worker yang gesit bisa mengklaim job, membaca database, dan mendapati data pesanan belum ada — karena transaksi bisnis belum di-commit. Worker kemudian menandai job gagal untuk alasan yang keliru. Solusinya sederhana: tahan job di memori selama transaksi berjalan, lalu benar-benar menitipkannya hanya setelah commit() sukses:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
available_at INTEGER NOT NULL
)");
final class QueueDispatcher
{
/** @var list<array{type: string, payload: array<string, mixed>}> */
private array $tahanan = [];
public function __construct(
private PDO $pdo,
private int $now,
) {
}
public function push(string $type, array $payload): void
{
if ($this->pdo->inTransaction()) {
// transaksi masih berjalan: tahan job di memori dulu
$this->tahanan[] = ["type" => $type, "payload" => $payload];
return;
}
$this->insert($type, $payload);
}
public function flush(): void
{
foreach ($this->tahanan as $job) {
$this->insert($job["type"], $job["payload"]);
}
$this->tahanan = [];
}
private function insert(string $type, array $payload): void
{
$this->pdo->prepare(
"INSERT INTO jobs (type, payload, available_at) VALUES (:type, :payload, :availableAt)"
)->execute([
":type" => $type,
":payload" => json_encode($payload),
":availableAt" => $this->now,
]);
}
}
function hitungJob(PDO $pdo): int
{
return (int) $pdo->query("SELECT COUNT(*) FROM jobs")->fetchColumn();
}
$now = (new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")))->getTimestamp();
$dispatcher = new QueueDispatcher($pdo, $now);
$pdo->beginTransaction();
// ... query penyimpanan pesanan dijalankan di sini ...
$dispatcher->push("kirim_email_konfirmasi", ["orderId" => 1042]);
echo "Di dalam transaksi (belum commit) : " . hitungJob($pdo) . " job di queue\n";
$pdo->commit();
$dispatcher->flush();
echo "Setelah commit dan flush : " . hitungJob($pdo) . " job di queue\n";Output yang Dihasilkan:
Di dalam transaksi (belum commit) : 0 job di queue
Setelah commit dan flush : 1 job di queueAda tiga hal yang perlu dicermati dari kelas QueueDispatcher di atas:
- Metode
push()memeriksa$this->pdo->inTransaction()— job yang dititipkan selama transaksi tidak pernah menyentuh tabeljobs, melainkan menumpuk di properti$tahananmilik proses request. flush()dipanggil setelahcommit()sukses — baru pada titik ini job benar-benar disisipkan, saat data pesanan sudah pasti terlihat bagi worker mana pun.- Bila transaksi di-rollback, daftar
$tahanantinggal dibuang: job yang merujuk data yang tidak pernah ada tidak akan pernah dikirim. Ini menghilangkan seluruh kelas bug “job mengejar data hantu”.
Framework besar memakai pola yang persis sama. Laravel menyediakannya sebagai modifier afterCommit():
<?php
// Membutuhkan aplikasi Laravel lengkap (tidak tersedia di lingkungan belajar ini)
use App\Jobs\KirimInvoice;
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($pesanan) {
$pesanan->save();
// dispatch ditahan sampai transaksi selesai di-commit
KirimInvoice::dispatch($pesanan)->afterCommit();
});Laravel Queues dan Symfony Messenger #
Semua pola yang kita bangun manual di atas telah dipaketkan oleh dua framework PHP terbesar. Laravel Queues menyediakan job class berbasis serialize, koneksi database/Redis/SQS, serta worker queue:work dengan opsi retry bawaan. Symfony Messenger memisahkan konsep message (nilai yang dikirim) dari transport (ke mana dikirim), sehingga message class yang sama bisa dijalankan sinkron saat development dan lewat AMQP di produksi hanya dengan mengganti konfigurasi:
| Aspek | Laravel Queues | Symfony Messenger |
|---|---|---|
| Abstraksi utama | Job class + dispatch() | Message class + message bus |
| Driver/transport bawaan | database, redis, sqs, sync | doctrine, amqp, redis, sync |
| Konfigurasi | config/queue.php | DSN transport di messenger.yaml |
| Perintah worker | php artisan queue:work | php bin/console messenger:consume |
| Retry dan backoff | Opsi --tries, --backoff | RetryStrategy per transport |
| Fitur khas | Job batching, rate limiting, horizon | Middleware bus, routing per message class |
Menjalankan worker kedua framework tersebut terlihat seperti ini:
# Laravel: worker dengan 3 percobaan dan backoff 60 lalu 300 detik
php artisan queue:work database --tries=3 --backoff=60,300# Symfony: consumer dengan batas waktu dan memori per siklus restart
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128MWorker daemon tidak boleh dibiarkan tanpa pengawas. Proses PHP bisa crash karena kehabisan memori atau terbunuh saat deployment, dan tugas pengawas adalah membangunkannya kembali. Alat klasik untuk ini adalah supervisor dengan konfigurasi berikut:
[program:worker-email]
command=/usr/bin/php /var/www/app/artisan queue:work database --queue=email
autostart=true
autorestart=true
user=www-data
numprocs=2Baris autorestart=true memerintahkan supervisor memulai ulang worker yang mati, sedangkan numprocs=2 menjalankan dua salinan worker sekaligus untuk memperbesar throughput. Nilai numprocs aman dinaikkan karena claim atomik kita menjamin dua worker tidak akan mengambil job yang sama — kecuali, sesuai rancangan, saat visibility timeout sengaja mengizinkan pengambilalihan.
Ringkasan #
Queue produksi adalah satu tabel dengan disiplin ketat: available_at menahan job hingga waktunya tiba, reserved_at menjadi kunci ber masa berlaku yang menyelamatkan job dari worker yang crash, dan attempts mengantarkan job yang putus asa ke tabel failed_jobs untuk diperiksa dan di-replay. Dispatch harus menunggu commit transaksi bisnis agar worker tidak mengejar data yang belum ada, dan worker harus hidup di bawah pengawas proses. Ketika volume dan jumlah layanan tumbuh melebihi kemampuan satu tabel, langkah berikutnya adalah memindahkan tanggung jawab pengiriman pesan ke perangkat khusus — message broker — yang kita bahas pada chapter berikutnya.