Pada chapter Retry, Backoff, dan Timeouts
kita membangun runner yang dengan senang hati menjalankan ulang job yang gagal. Pertanyaannya: apa jadinya jika efek samping job tersebut ikut terulang? Satu email ucapan selamat terkirim dua kali mungkin bisa dimaklumi, tetapi pemotongan kartu kredit dua kali adalah insiden. Chapter ini membahas idempotency — sifat “dijalankan sekali atau berkali-kali, hasilnya sama” — sebagai syarat wajib setiap job yang diberi retry. Kita akan melihat tiga lapis pertahanan (idempotensi alami lewat upsert, tabel deduplikasi dengan ID pesan, dan kompensasi sebagai pintu darurat), lalu menutupnya dengan pola uji run-twice yang membuktikan semuanya secara otomatis.
At-Least-Once: Job Bisa Tiba Lebih dari Sekali #
Semua queue dan message broker yang andal memilih jaminan at-least-once delivery: pesan dijamin sampai, tetapi boleh sampai lebih dari sekali. Redelivery terjadi pada situasi yang sehat-hati saja: worker menyelesaikan job lalu mati sebelum sempat mengirim acknowledgement, koneksi ke broker putus di tengah konfirmasi, atau retry dari chapter sebelumnya menyalakan eksekusi tambahan. Tiga semantik pengiriman yang perlu Anda kenal dirangkum berikut:
| Semantik | Arti | Konsekuensi |
|---|---|---|
| At-most-once | paling banyak sekali | job bisa hilang tanpa jejak |
| At-least-once | paling sedikit sekali | job bisa tiba dua kali (umum di queue nyata) |
| Exactly-once | tepat sekali | tidak tersedia secara harfiah; dibangun dari at-least-once + idempotency |
Tanpa idempotency, duplikat berubah menjadi kerusakan data. Lihat apa yang terjadi pada job sederhana “tambah poin”:
<?php
$saldoPoin = [
"[email protected]" => 100,
];
function tambahPoin(array &$saldoPoin, string $email, int $poin): void
{
$saldoPoin[$email] += $poin;
}
// worker memproses job "beri poin" dua kali karena redelivery
tambahPoin($saldoPoin, "[email protected]", 50);
tambahPoin($saldoPoin, "[email protected]", 50);
$total = $saldoPoin["[email protected]"];
echo "Poin yang seharusnya terkirim: 150\n";
echo "Poin yang benar-benar terkirim: {$total}\n";Output yang Dihasilkan:
Poin yang seharusnya terkirim: 150
Poin yang benar-benar terkirim: 200Operasi += bukanlah operasi yang aman diulang: memanggilnya dua kali menggandakan efeknya. Setiap kali Anda menulis body job, tanyakan pada diri sendiri — jika baris ini dijalankan ulang besok karena redelivery, apa yang rusak?
Idempotensi Alami lewat Upsert #
Pertahanan pertama adalah mendesain efek samping agar tertanggal sendiri: bentuk pernyataan “set status pesanan menjadi X” alami-idempoten, karena menyetel status yang sama dua kali tidak mengubah apa pun. SQL modern mendukungnya lewat INSERT ... ON CONFLICT ... DO UPDATE (dikenal sebagai upsert), yang bekerja untuk status terbaru maupun pembuatan sekali-saja:
<?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 status_pesanan (
nomor_pesanan TEXT PRIMARY KEY,
status TEXT NOT NULL,
diperbarui_pada TEXT NOT NULL
)");
function simpanStatusPesanan(PDO $pdo, string $nomor, string $status, string $waktu): void
{
$statement = $pdo->prepare(
"INSERT INTO status_pesanan (nomor_pesanan, status, diperbarui_pada)
VALUES (:nomor, :status, :waktu)
ON CONFLICT(nomor_pesanan) DO UPDATE
SET status = excluded.status,
diperbarui_pada = excluded.diperbarui_pada"
);
$statement->execute([
":nomor" => $nomor,
":status" => $status,
":waktu" => $waktu,
]);
}
// job "perbarui status" tiba dua kali dengan isi yang sama
simpanStatusPesanan($pdo, "ORD-1001", "dikirim", "2026-01-15 08:00:00");
simpanStatusPesanan($pdo, "ORD-1001", "dikirim", "2026-01-15 08:00:05");
foreach ($pdo->query("SELECT * FROM status_pesanan") as $baris) {
echo "{$baris['nomor_pesanan']} => {$baris['status']} pada {$baris['diperbarui_pada']}\n";
}
$jumlah = (int) $pdo->query("SELECT COUNT(*) FROM status_pesanan")->fetchColumn();
echo "Jumlah baris: {$jumlah}\n";Output yang Dihasilkan:
ORD-1001 => dikirim pada 2026-01-15 08:00:05
Jumlah baris: 1Perhatikan dua hal dari hasilnya:
ON CONFLICT(nomor_pesanan)menunjuk kolom primary key sebagai kunci konflik. Eksekusi kedua tidak gagal dan tidak menambah baris baru —COUNT(*)tetap 1 — melainkan menimpa baris yang sudah ada dengan nilai yang sama.- Kolom
diperbarui_padamemang berubah ke waktu eksekusi kedua, dan itu wajar: upsert mencatat “status terakhir diset kapan”. Untuk job yang menyimpan keadaan akhir (status, snapshot, konfigurasi), pola ini sudah cukup — duplikat eksekusi tidak mengubah keadaan yang dipedulikan bisnis.
Bentuk lain dari keluarga ini: INSERT OR IGNORE untuk data referensi yang cukup dibuat sekali, dan UPDATE ... SET saldo = saldo + 100 WHERE versi = :versiLama yang menolak menerapkan penambahan dua kali. Pilih bentuk yang menyatakan maksud Anda, bukan sekadar urutan perintah.
Tabel Deduplikasi dengan ID Pesan #
Upsert menyelesaikan efek yang menulis ke database, tetapi banyak job menimbulkan efek di luar database: mengirim email, memanggil API pihak ketiga, mencetak label pengiriman. Untuk efek seperti ini, kita perlu memori eksternal yang menjawab satu pertanyaan: “pesan ini pernah diproses?” Jawabannya adalah tabel deduplikasi berisi ID pesan yang sudah ditangani, dan ID tersebut diberikan oleh broker (message id) atau dibangun dari kunci bisnis — misalnya kirim_email:{orderId}:{jenisEmail}.
Kuncinya: klaim atas ID harus atomik, bukan “SELECT dulu, kalau tidak ada baru INSERT”. Dua worker yang memeriksa bersamaan akan sama-sama mendapat jawaban “belum ada” lalu sama-sama memproses — celah klasik check-then-act. INSERT dengan primary key menutup celah ini karena database menolak dua baris dengan ID sama:
<?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 job_diproses (
id_pesan TEXT PRIMARY KEY,
diproses_pada TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE riwayat_poin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
poin INTEGER NOT NULL
)");
function klaimPesan(PDO $pdo, string $idPesan, string $waktu): bool
{
$statement = $pdo->prepare(
"INSERT OR IGNORE INTO job_diproses (id_pesan, diproses_pada)
VALUES (:id, :waktu)"
);
$statement->execute([":id" => $idPesan, ":waktu" => $waktu]);
// rowCount 1 berarti baris baru berhasil dibuat: kita pemilik sah pesan ini
return $statement->rowCount() === 1;
}
function prosesKirimPoin(PDO $pdo, string $idPesan, string $email, int $poin): string
{
if (!klaimPesan($pdo, $idPesan, "2026-01-15 08:00:00")) {
return "LEWATI: pesan {$idPesan} sudah pernah diproses";
}
// efek samping sesungguhnya (email berisi kode poin) hanya terjadi di sini
$statement = $pdo->prepare("INSERT INTO riwayat_poin (email, poin) VALUES (:email, :poin)");
$statement->execute([":email" => $email, ":poin" => $poin]);
return "PROSES: {$poin} poin untuk {$email} (pesan {$idPesan})";
}
// broker mengirim ulang pesan yang sama setelah ack hilang
echo prosesKirimPoin($pdo, "msg-9001", "[email protected]", 50), "\n";
echo prosesKirimPoin($pdo, "msg-9001", "[email protected]", 50), "\n";
$total = (int) $pdo->query("SELECT COALESCE(SUM(poin), 0) FROM riwayat_poin")->fetchColumn();
echo "Total poin yang benar-benar terkirim: {$total}\n";Output yang Dihasilkan:
PROSES: 50 poin untuk [email protected] (pesan msg-9001)
LEWATI: pesan msg-9001 sudah pernah diproses
Total poin yang benar-benar terkirim: 50Mari kita bedah alurnya:
klaimPesan()mencoba INSERT baris ID pesan denganINSERT OR IGNORE. Jika baris sudah ada, SQLite mengabaikan perintah danrowCount()mengembalikan 0 — pesan pernah diproses. Jika baris baru tercipta,rowCount()bernilai 1 dan pemanggil adalah pemilik sah pesan. Satu perintah SQL sekaligus menjadi pemeriksaan dan gembok.prosesKirimPoin()hanya menjalankan efek samping setelah klaim berhasil. Kedatangan kedua berhenti di gerbangreturn "LEWATI...", sehinggariwayat_pointetap berisi satu baris.- Total poin tetap 50 meski fungsi dipanggil dua kali — inilah definisi operasional idempotency yang kita kejar.
Pola Test Run-Twice #
Idempotency adalah klaim, dan klaim dalam perangkat lunak sehat diuji. Pola paling langsung: jalankan job dua kali dalam satu test, lalu tegaskan bahwa keadaan setelah eksekusi pertama identik dengan keadaan setelah eksekusi kedua. Test berikut juga memastikan sisi lainnya tidak ikut rusak — pesan yang berbeda tetap harus diproses:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
function klaimPesan(PDO $pdo, string $idPesan, string $waktu): bool
{
$statement = $pdo->prepare(
"INSERT OR IGNORE INTO job_diproses (id_pesan, diproses_pada)
VALUES (:id, :waktu)"
);
$statement->execute([":id" => $idPesan, ":waktu" => $waktu]);
return $statement->rowCount() === 1;
}
function prosesKirimPoin(PDO $pdo, string $idPesan, string $email, int $poin): string
{
if (!klaimPesan($pdo, $idPesan, "2026-01-15 08:00:00")) {
return "LEWATI: pesan {$idPesan} sudah pernah diproses";
}
$statement = $pdo->prepare("INSERT INTO riwayat_poin (email, poin) VALUES (:email, :poin)");
$statement->execute([":email" => $email, ":poin" => $poin]);
return "PROSES: {$poin} poin untuk {$email} (pesan {$idPesan})";
}
final class KirimPoinJobTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO("sqlite::memory:");
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->exec("CREATE TABLE job_diproses (
id_pesan TEXT PRIMARY KEY,
diproses_pada TEXT NOT NULL
)");
$this->pdo->exec("CREATE TABLE riwayat_poin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
poin INTEGER NOT NULL
)");
}
public function testMenjalankanDuaKaliTidakMenggandakanEfek(): void
{
$hasilPertama = prosesKirimPoin($this->pdo, "msg-9001", "[email protected]", 50);
$keadaanSetelahPertama = $this->keadaanDatabase();
$hasilKedua = prosesKirimPoin($this->pdo, "msg-9001", "[email protected]", 50);
$keadaanSetelahKedua = $this->keadaanDatabase();
$this->assertSame("PROSES: 50 poin untuk [email protected] (pesan msg-9001)", $hasilPertama);
$this->assertSame("LEWATI: pesan msg-9001 sudah pernah diproses", $hasilKedua);
$this->assertSame($keadaanSetelahPertama, $keadaanSetelahKedua);
$this->assertSame(50, $this->totalPoinTerkirim());
}
public function testPesanBerbedaTetapDiproses(): void
{
prosesKirimPoin($this->pdo, "msg-9001", "[email protected]", 50);
prosesKirimPoin($this->pdo, "msg-9002", "[email protected]", 50);
$this->assertSame(100, $this->totalPoinTerkirim());
}
private function keadaanDatabase(): array
{
return [
"job_diproses" => $this->pdo->query("SELECT * FROM job_diproses")->fetchAll(),
"riwayat_poin" => $this->pdo->query("SELECT email, poin FROM riwayat_poin")->fetchAll(),
];
}
private function totalPoinTerkirim(): int
{
return (int) $this->pdo->query("SELECT COALESCE(SUM(poin), 0) FROM riwayat_poin")->fetchColumn();
}
}Berkas di atas berdiri sendiri (definisi fungsi disertakan ulang) sehingga bisa dijalankan tanpa setup:
php /home/z/my-project/bin/phpunit.phar testsBerikut output nyata dari perintah tersebut:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
.. 2 / 2 (100%)
Time: 00:00.002, Memory: 28.05 MB
OK (2 tests, 5 assertions)Empat assertion pada test pertama adalah inti pola run-twice: hasil eksekusi pertama harus PROSES, hasil eksekusi kedua harus LEWATI, potret seluruh database setelah kedua eksekusi harus identik, dan total efek samping tetap satu kali. Test kedua menjaga klaim sebaliknya — deduplikasi tidak boleh menelan pesan baru. Setiap kali Anda menulis job baru, test seperti ini lebih murah daripada satu insiden pembayaran ganda.
Kompensasi untuk Efek yang Terlanjur Terjadi #
Kadang duplikat tetap lolos: job lama yang belum punya tabel deduplikasi, bug pada kunci ID, atau pihak ketiga yang memang memproses dua kali dari sisi mereka. Lapis ketiga bukan pencegahan melainkan kompensasi — tindakan yang membatalkan efek berlebih setelah terjadi. Untuk pembayaran, kompensasinya adalah refund; untuk pengiriman barang ganda, penarikan salah satu; untuk email ganda, biasanya cukup permohonan maaf. Kalkulusnya sederhana:
<?php
function hitungKelebihanPembayaran(int $totalTagihan, array $pembayaranBerhasil): int
{
$kelebihan = array_sum($pembayaranBerhasil) - $totalTagihan;
return max($kelebihan, 0);
}
// retry job pembayaran menyelesaikan tagihan yang sama dua kali
$pembayaranBerhasil = [150000, 150000];
$kelebihan = hitungKelebihanPembayaran(150000, $pembayaranBerhasil);
echo "Kelebihan yang harus dikompensasi lewat refund: Rp{$kelebihan}\n";Output yang Dihasilkan:
Kelebihan yang harus dikompensasi lewat refund: Rp150000Dalam praktiknya, kompensasi jarang berdiri sendiri — kompensasi adalah satu langkah dalam alur yang lebih panjang (pola saga), di mana setiap langkah yang gagal memicu langkah pembalik untuk langkah-langkah sebelumnya. Kompensasi juga selalu lebih mahal daripada pencegahan: ia butuh deteksi (rekonsiliasi harian antara tagihan dan pembayaran), akses untuk membalikkan (refund API), dan komunikasi ke pelanggan. Anggaplah kompensasi sebagai sabuk pengaman, bukan menggantikan rem.
Ringkasan #
At-least-once delivery membuat duplikat bukan kemungkinan melainkan kepastian statistik, dan job yang siap menghadapinya adalah job yang idempoten. Tiga lapis pertahanannya berjenjang: idempotensi alami lewat upsert untuk efek yang menulis ke database, tabel deduplikasi dengan klaim atomik untuk efek eksternal, dan kompensasi sebagai pintu darurat ketika duplikat terlanjur menjadi kerusakan. Pola test run-twice membuktikan ketahanan itu setiap kali suite dijalankan. Dengan job yang aman diulang dan retry yang berbatas, satu bingkai terakhir masih hilang: tempat menampung job yang benar-benar menyerah, beserta klasifikasi dan alarmnya — bingkai itu kita susun pada chapter Job Failure Handling .