Mengirim email langsung di dalam request HTTP tampak praktis, tetapi pendekatan tersebut menyimpan risiko besar: SMTP server bisa lambat, koneksi bisa terputus di tengah percakapan, dan penyedia email menerapkan batas rate yang ketat. Jika proses kirim berjalan di dalam request, pengguna harus menunggu seluruh proses tersebut selesai sebelum melihat respons. Pola email queueing memisahkan niat mengirim email dari proses pengiriman sesungguhnya: aplikasi cukup menyimpan job email ke queue, lalu worker terpisah mengirimkannya di latar belakang. Pada chapter ini kita akan membangun queue email berbasis database, memahami siklus hidup job dari enqueue sampai complete, dan melihat mengapa pola ini membuat aplikasi lebih cepat sekaligus lebih tangguh terhadap kegagalan.

Mengapa Email Perlu Masuk Queue #

Percakapan SMTP bukan proses instan. Client harus membuka koneksi, berjabat tangan (handshake), menukar beberapa perintah, lalu menunggu jawaban dari server penerima. Beberapa server sengaja menunda percakapan (greylisting) pada kiriman pertama, sehingga satu email bisa memakan waktu ratusan milidetik sampai beberapa detik. Jika satu request harus mengirim lima email, durasi request tersebut langsung mengikuti email terselewannya.

Queue membalik situasi tersebut dengan tiga keuntungan sekaligus:

AspekKirim langsung di requestKirim lewat queue
Waktu responsMengikuti kecepatan SMTP (ratusan ms sampai detik)Sekitar durasi satu INSERT (milidetk)
Kegagalan sementaraRequest gagal, email hilangWorker mengulang job secara otomatis
Batas rate penyediaMudah terlampaui saat lonjakan trafficDikendalikan lewat batching dan jeda worker
Skala pengirimanTerbatas durasi satu requestWorker dapat ditambah terpisah dari web server

Implementasi queue paling sederhana yang tetap realistis adalah queue berbasis tabel database. Karena lingkungan belajar ini tidak memiliki server MySQL, PostgreSQL, maupun Redis, kita memakai SQLite :memory:; pola SQL-nya identik ketika dipindahkan ke database production.

Struktur Tabel Job Email #

Queue berbasis database hanya butuh dua tabel. Tabel email_jobs menampung job yang menunggu atau sedang diproses, sedangkan failed_email_jobs menjadi tempat pemakaman (dead letter) untuk job yang gagal permanen:

sql
CREATE TABLE email_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    body TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    attempts INTEGER NOT NULL DEFAULT 0,
    available_at TEXT NOT NULL
);

CREATE TABLE failed_email_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id INTEGER NOT NULL,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    reason TEXT NOT NULL,
    failed_at TEXT NOT NULL
);

Kolom status mencatat posisi job dalam siklus hidup: pending (menunggu), processing (sedang dikirim worker), dan done (selesai). Kolom attempts menghitung berapa kali worker mencoba mengirim, dan kolom available_at menentukan kapan job boleh diklaim — kolom terakhir inilah yang menjadi fondasi retry terjadwal pada chapter berikutnya. Tabel failed_email_jobs menyimpan alasan kegagalan agar mudah diperiksa manusia.

Siklus Hidup Job: Enqueue, Claim, Complete #

Program berikut memperlihatkan siklus hidup lengkap satu queue email: memasukkan job, mengklaimnya secara atomik, menandai job sukses, lalu memindahkan job lain yang gagal permanen ke tabel failed:

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 email_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    body TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    attempts INTEGER NOT NULL DEFAULT 0,
    available_at TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE failed_email_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id INTEGER NOT NULL,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    reason TEXT NOT NULL,
    failed_at TEXT NOT NULL
)");

function enqueueEmail(PDO $pdo, string $recipient, string $subject, string $body, string $availableAt): int
{
    $stmt = $pdo->prepare(
        "INSERT INTO email_jobs (recipient, subject, body, available_at)
         VALUES (:recipient, :subject, :body, :available_at)"
    );
    $stmt->execute([
        ":recipient" => $recipient,
        ":subject" => $subject,
        ":body" => $body,
        ":available_at" => $availableAt,
    ]);

    return (int) $pdo->lastInsertId();
}

function claimNextJob(PDO $pdo, string $now): ?array
{
    $stmt = $pdo->prepare(
        "UPDATE email_jobs
         SET status = 'processing', attempts = attempts + 1
         WHERE id = (
             SELECT id FROM email_jobs
             WHERE status = 'pending' AND available_at <= :now
             ORDER BY id
             LIMIT 1
         )
         RETURNING id, recipient, subject, attempts"
    );
    $stmt->execute([":now" => $now]);
    $job = $stmt->fetch();

    return $job === false ? null : $job;
}

function markJobDone(PDO $pdo, int $jobId): void
{
    $pdo->prepare("UPDATE email_jobs SET status = 'done' WHERE id = :id")
        ->execute([":id" => $jobId]);
}

function moveToFailed(PDO $pdo, array $job, string $reason, string $failedAt): void
{
    $pdo->prepare(
        "INSERT INTO failed_email_jobs (job_id, recipient, subject, reason, failed_at)
         VALUES (:job_id, :recipient, :subject, :reason, :failed_at)"
    )->execute([
        ":job_id" => $job["id"],
        ":recipient" => $job["recipient"],
        ":subject" => $job["subject"],
        ":reason" => $reason,
        ":failed_at" => $failedAt,
    ]);
    $pdo->prepare("DELETE FROM email_jobs WHERE id = :id")
        ->execute([":id" => $job["id"]]);
}

$jobId = enqueueEmail(
    $pdo,
    "[email protected]",
    "Verifikasi Akun",
    "Klik tautan verifikasi dalam 24 jam ke depan.",
    "2026-01-15 08:00:00"
);
echo "Email masuk queue: job #{$jobId}\n";

$jobA = claimNextJob($pdo, "2026-01-15 08:00:05");
$jobB = claimNextJob($pdo, "2026-01-15 08:00:06");

echo "Worker A mengklaim: #{$jobA['id']} untuk {$jobA['recipient']} ({$jobA['subject']})\n";
echo $jobB === null
    ? "Worker B: tidak mendapat job (sudah diklaim Worker A)\n"
    : "Worker B: konflik klaim!\n";

markJobDone($pdo, (int) $jobA["id"]);

enqueueEmail($pdo, "[email protected]", "Invoice #1042", "Invoice Anda sudah tersedia.", "2026-01-15 08:00:00");
$jobC = claimNextJob($pdo, "2026-01-15 08:00:07");
moveToFailed($pdo, $jobC, "SMTP 550: mailbox unavailable", "2026-01-15 08:00:07");

foreach ($pdo->query("SELECT status, COUNT(*) AS jumlah FROM email_jobs GROUP BY status ORDER BY status") as $row) {
    echo "email_jobs [{$row['status']}]: {$row['jumlah']}\n";
}

$failed = $pdo->query("SELECT job_id, recipient, reason FROM failed_email_jobs")->fetchAll();
echo "failed_email_jobs: #{$failed[0]['job_id']} {$failed[0]['recipient']} - {$failed[0]['reason']}\n";

Output yang Dihasilkan:

text
Email masuk queue: job #1
Worker A mengklaim: #1 untuk [email protected] (Verifikasi Akun)
Worker B: tidak mendapat job (sudah diklaim Worker A)
email_jobs [done]: 1
failed_email_jobs: #2 [email protected] - SMTP 550: mailbox unavailable

Mari kita bedah bagian-bagian penting program di atas:

  1. enqueueEmail() menjalankan satu INSERT dengan prepared statement lalu mengembalikan ID job. Waktu “sekarang” sengaja dikirim sebagai parameter $availableAt (bukan date()), sehingga contoh ini deterministik dan mudah diuji — di production Anda mengisi parameter tersebut dengan waktu sesungguhnya.
  2. claimNextJob() adalah jantung queue. Statement UPDATE mencari job pending tertua yang sudah tersedia lewat subquery, mengubah statusnya menjadi processing, menaikkan attempts, lalu klausa RETURNING mengirimkan kembali baris hasil pembaruan tersebut sebagai data job.
  3. markJobDone() menandai job sukses. Job yang sudah done tidak akan pernah terpilih subquery lagi, sehingga aman ditinggalkan di tabel untuk audit.
  4. moveToFailed() menyalin job ke tabel failed_email_jobs beserta alasannya, lalu menghapus job dari tabel utama. Pemindahan ini yang disebut dead letter — konsep yang dibedah tuntas pada chapter retry.
  5. Baris demo dua worker: Worker A mengklaim job #1, dan ketika Worker B mencoba mengklaim pada detik berikutnya, subquery tidak lagi menemukan job pending, sehingga claimNextJob() mengembalikan null.

Mengklaim Job Secara Atomik #

Coba bayangkan claim dilakukan dengan dua langkah terpisah: SELECT untuk mencari job pending, lalu UPDATE untuk mengubah statusnya. Pada celah di antara dua langkah tersebut, worker lain masih bisa membaca baris yang sama karena statusnya belum berubah — kedua worker lalu mengirim email yang sama dua kali. Pola ini disebut race condition, dan frekuensinya naik seiring jumlah worker.

Solusinya adalah menggabungkan pencarian dan penandaan menjadi satu statement. Subquery WHERE id = (SELECT id ... LIMIT 1) hanya bisa menemukan job berstatus pending; begitu UPDATE selesai dieksekusi, status job berubah menjadi processing sehingga claim berikutnya tidak akan melihat job tersebut lagi. SQLite mengeksekusi satu statement secara atomik, dan klausa RETURNING (didukung SQLite sejak versi 3.35 serta PostgreSQL) membuat hasil pembaruan langsung tersedia tanpa query kedua.

Worker sebagai Proses Daemon #

Semua kode sejauh ini berjalan singkat lalu selesai, padahal worker sebenarnya adalah proses panjang yang hidup terus-menerus (daemon). Worker berputar dalam loop: klaim job, kirim email, catat hasil, dan tidur sebentar ketika queue kosong. Di server production, daemon seperti ini dijalankan lewat manajer proses seperti systemd atau supervisor yang otomatis memulai ulang worker saat crash. Berikut sketsa bentuk dasar loop worker:

php
<?php
// Sketsa worker daemon: proses terpisah yang berjalan terus-menerus.
// Fungsi claimNextJob(), markJobDone(), dan retryOrFail() berasal dari
// contoh sebelumnya; sendEmail() menghubungi SMTP server.
while (true) {
    $job = claimNextJob($pdo, "2026-01-15 08:00:00");

    if ($job === null) {
        usleep(500000); // queue kosong: jeda 0,5 detik sebelum memeriksa ulang
        continue;
    }

    try {
        sendEmail($job["recipient"], $job["subject"], $job["body"]);
        markJobDone($pdo, (int) $job["id"]);
    } catch (Throwable $e) {
        retryOrFail($pdo, $job, $e);
    }
}

Pengiriman email sesungguhnya membutuhkan koneksi ke SMTP server, baik lewat fungsi mail() dengan konfigurasi sendmail_path maupun library seperti Symfony Mailer. Karena lingkungan belajar ini tidak memiliki SMTP server, contoh integrasi berikut tidak dieksekusi — tetapi bentuknya persis seperti inilah yang Anda panggil dari loop worker:

php
<?php
// Membutuhkan SMTP server nyata - tidak dijalankan di lingkungan belajar ini.
function sendEmail(string $recipient, string $subject, string $body): bool
{
    $headers = "From: [email protected]\r\n";
    $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";

    return mail($recipient, $subject, $body, $headers);
}

Enqueue dalam Transaksi yang Sama dengan Data Domain #

Aturan penting berikutnya: job email sebaiknya masuk queue pada transaction database yang sama dengan perubahan data yang memicu email tersebut. Misalnya, email “Pembayaran Berhasil” hanya boleh masuk queue jika status order benar-benar berubah menjadi paid. Jika enqueue dilakukan di luar transaction, dua kejadian buruk mungkin terjadi: order dibatalkan tetapi email tetap terkirim (email bohong), atau email masuk queue lalu transaction order di-rollback sehingga worker mengirim email untuk order yang tidak pernah ada.

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_email TEXT NOT NULL,
    status TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE email_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending'
)");
$pdo->exec("INSERT INTO orders (id, customer_email, status) VALUES (1042, '[email protected]', 'awaiting_payment')");

$pdo->beginTransaction();
try {
    $pdo->exec("UPDATE orders SET status = 'paid' WHERE id = 1042");
    $pdo->prepare("INSERT INTO email_jobs (recipient, subject) VALUES ('[email protected]', 'Pembayaran Berhasil')")
        ->execute();

    // Simulasi: konfirmasi dari payment gateway gagal setelah email masuk queue
    throw new RuntimeException("timeout dari payment gateway");
} catch (RuntimeException $e) {
    $pdo->rollBack();
    echo "Transaksi dibatalkan: " . $e->getMessage() . "\n";
}

echo "Status order: " . $pdo->query("SELECT status FROM orders WHERE id = 1042")->fetchColumn() . "\n";
echo "Email di queue: " . (int) $pdo->query("SELECT COUNT(*) FROM email_jobs")->fetchColumn() . "\n";

Output yang Dihasilkan:

text
Transaksi dibatalkan: timeout dari payment gateway
Status order: awaiting_payment
Email di queue: 0

Ketika exception memaksa rollBack(), kedua perubahan hilang bersamaan: status order kembali ke awaiting_payment dan queue kembali kosong. Konsistensi ini mustahil dicapai jika enqueue dilakukan setelah commit, karena tidak ada lagi kesempatan membatalkan job yang sudah tersimpan. Sebaliknya, jika seluruh langkah sukses, commit akan mengunci keduanya sekaligus — order berstatus paid dan job siap dikirim worker.

Batching untuk Menghormati Batas Rate #

Penyedia email membatai jumlah kiriman per satuan waktu, misalnya 100 email per detik untuk satu domain pengirim. Lonjakan traffic yang menerobos batas tersebut membuat kiriman berikutnya ditolak. Batching meredam risiko itu: alasan membuat satu job per penerima, aplikasi mengelompokkan penerima ke dalam beberapa job berukuran tetap dengan array_chunk():

php
<?php
$recipients = [
    "[email protected]", "[email protected]", "[email protected]",
    "[email protected]", "[email protected]", "[email protected]",
    "[email protected]",
];

$batches = array_chunk($recipients, 3);

foreach ($batches as $i => $batch) {
    echo "Batch #" . ($i + 1) . " (" . count($batch) . " penerima): " . implode(", ", $batch) . "\n";
}

Output yang Dihasilkan:

text

Tujuh penerima terbagi menjadi tiga batch: dua batch penuh berisi tiga penerima dan satu batch sisa berisi satu penerima. Setiap batch menjadi satu job email — dalam skenario nyata, job menyimpan daftar penerima pada kolom recipient (misalnya sebagai JSON), lalu SMTP mengirim satu percakapan dengan banyak RCPT TO. Selain mengurangi jumlah job, batching memberi worker kendali atas tempo pengiriman: cukup tambahkan jeda antar-job agar laju total tetap di bawah batas penyedia.

Ringkasan #

Queue email mengubah pengiriman dari proses yang menghambat request menjadi pipeline yang terkontrol: enqueue hanya satu INSERT, claim dilakukan atomik dengan UPDATE ... RETURNING, hasil tercatat pada kolom status, dan job gagal permanen dipindahkan ke tabel failed untuk diperiksa manusia. Konsistensi antara data domain dan job dijaga dengan transaction yang sama, sementara batching menjaga laju pengiriman tetap bersahabat dengan batas rate penyedia. Namun queue baru menjawab setengah masalah — bagaimana seharusnya worker bereaksi ketika pengiriman gagal? Pertanyaan tersebut dibahas tuntas pada chapter Retry dan Delivery Failures .

Referensi #