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:

KolomTipePeran
idINTEGER PRIMARY KEYNomor urut monotonic, dasar pengurutan FIFO
typeTEXTMenentukan handler mana yang dijalankan worker
payloadTEXT (JSON)Data domain yang dibawa job
attemptsINTEGERJumlah percobaan yang sudah dilakukan
available_atINTEGER (unix timestamp)Batas waktu job boleh diambil, mendukung job tertunda
reserved_atINTEGER (unix timestamp)Waktu job dikunci worker, dasar visibility timeout
created_atINTEGER (unix timestamp)Waktu job dititipkan, untuk audit

Mari kita wujudkan skema tersebut lengkap dengan operasi push yang mendukung penundaan:

php
<?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:

text
#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:

  1. pushJob() menerima $delayDetik — job tertunda diwujudkan murni lewat aritmetika available_at = $now + $delayDetik, tanpa tabel atau mekanisme kedua. Menunda job selama lima menit dan lima jam hanya berbeda pada satu angka.
  2. formatJam() mengubah unix timestamp kembali ke jam WIB agar keluaran mudah dibaca manusia. Pola new DateTimeImmutable("@{$timestamp}") diikuti setTimezone() dipakai karena konstruktor awalan @ selalu menghasilkan waktu UTC.
  3. Jam dibekukan pada 2026-01-15 08:00:00 — trik yang sama dengan chapter sebelumnya — sehingga output program identik pada setiap eksekusi.
  4. Baris #2 membuktikan penundaan tersimpan: email_pengingat baru 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
<?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:

text
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:

  1. Siklus pertama (08:00:00) memproses job #1 dan #3 namun tidak #2 — klausa available_at <= :cutoff menahan job pengingat yang baru boleh diambil pada 08:05:00.
  2. popNextJob() mengembalikan null tidak berarti queue kosong selamanya — null hanya berarti “tidak ada job yang boleh diambil sekarang”. Worker daemon sesungguhnya akan tidur sejenak lalu mengecek lagi.
  3. $now += 300 mensimulasikan berjalannya waktu lima menit. Pada siklus kedua, available_at milik #2 (08:05:00) akhirnya terpenuhi sehingga job bisa diklaim dan diproses.
  4. Kolom attempts bertambah setiap claim (bukan setiap keberhasilan) — nilai attempt #1 pada output berasal dari RETURNING setelah attempts = attempts + 1 dieksekusi.

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
<?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:

text
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:

  1. Worker A mengklaim job pada 08:00:00 — reserved_at terisi dan attempts naik menjadi 1 — lalu “crash” tanpa menyelesaikan pekerjaan.
  2. Pada 08:00:30, kondisi reserved_at + 60 < 08:00:30 bernilai salah (08:01:00 belum tercapai), sehingga popNextJob() mengembalikan NULL dan job tetap milik Worker A.
  3. Pada 08:01:01, reserved_at + 60 (yaitu 08:01:00) sudah terlewat, jadi Worker B berhasil mengklaim job yang sama — perhatikan attempts naik menjadi 2, tanda bahwa job ini sudah percobaan kedua.
  4. 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
<?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:

text
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 : 1

Mari kita bedah alur keputusannya:

  1. Cabang pertama (attempts < batas) memanggil releaseJob(): reserved_at dikosongkan agar job bisa diklaim lagi, sementara available_at digeser 60 detik ke depan sehingga retry tidak terjadi seketika — bentuk paling sederhana dari backoff.
  2. Cabang kedua (batas tercapai) menghapus baris dari jobs lalu menyisipkan salinannya ke failed_jobs beserta pesan error — job keluar dari jalur worker tapi jejaknya tetap lengkap untuk penyelidikan.
  3. $now += 60 di akhir loop memajukan jam beku sehingga percobaan berikutnya “terjadi” satu menit kemudian; tiga percobaan menghabiskan tiga menit simulasi.
  4. 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
<?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:

text
Di dalam transaksi (belum commit) : 0 job di queue
Setelah commit dan flush          : 1 job di queue

Ada tiga hal yang perlu dicermati dari kelas QueueDispatcher di atas:

  1. Metode push() memeriksa $this->pdo->inTransaction() — job yang dititipkan selama transaksi tidak pernah menyentuh tabel jobs, melainkan menumpuk di properti $tahanan milik proses request.
  2. flush() dipanggil setelah commit() sukses — baru pada titik ini job benar-benar disisipkan, saat data pesanan sudah pasti terlihat bagi worker mana pun.
  3. Bila transaksi di-rollback, daftar $tahanan tinggal 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
<?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:

AspekLaravel QueuesSymfony Messenger
Abstraksi utamaJob class + dispatch()Message class + message bus
Driver/transport bawaandatabase, redis, sqs, syncdoctrine, amqp, redis, sync
Konfigurasiconfig/queue.phpDSN transport di messenger.yaml
Perintah workerphp artisan queue:workphp bin/console messenger:consume
Retry dan backoffOpsi --tries, --backoffRetryStrategy per transport
Fitur khasJob batching, rate limiting, horizonMiddleware bus, routing per message class

Menjalankan worker kedua framework tersebut terlihat seperti ini:

bash
# Laravel: worker dengan 3 percobaan dan backoff 60 lalu 300 detik
php artisan queue:work database --tries=3 --backoff=60,300
bash
# Symfony: consumer dengan batas waktu dan memori per siklus restart
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

Worker 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:

ini
[program:worker-email]
command=/usr/bin/php /var/www/app/artisan queue:work database --queue=email
autostart=true
autorestart=true
user=www-data
numprocs=2

Baris 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.

Referensi #