Bagian 4 menutup dengan janji yang belum ditepati: upsert subscribe() menimpa baris langganan lama, sehingga pergantian plan tidak meninggalkan jejak apa pun — siapa yang berlangganan, kapan berubah, dan mengapa keanggotaan berubah tetap pertanyaan tanpa jawaban. Pada bagian ini kita membuka dua kemampuan operasional yang membuat aplikasi SaaS bisa diawasi dan dijalankan: audit trail yang merekam setiap keputusan penting per tenant, dan background jobs yang menangani pekerjaan hidup di luar umur request. Keduanya memakai satu disiplin yang sama sejak Bagian 1: setiap baris baru membawa tenant_id, setiap pembacaan menyaring tenant_id, dan setiap jam dibekukan sebagai parameter.
Audit: Menjawab Setelah Fakta #
Audit trail adalah asuransi untuk pertanyaan yang belum tentu ditanyakan hari ini: pemilik Toko Bunga Melati ingin tahu siapa yang menghapus proyek “Katalog Musim Hujan” tiga minggu lalu; tim dukungan perlu membuktikan bahwa penurunan plan memang dilakukan owner sendiri; dan Anda, sebagai pengembang, perlu tahu apa yang worker lakukan semalam pada data tenant orang lain. Ketiga jawaban itu hidup di satu tabel dengan tiga kolom kunci: siapa (user_id), apa (aksi pada entitas tertentu), dan kapan (dibuat_pada).
Dua sifat membedakan audit log dari tabel biasa. Pertama, sifat append-only: baris hanya boleh bertambah, tidak pernah diubah atau dihapus — riwayat yang bisa diedit adalah riwayat yang tidak bisa dipercaya. Kedua, pembacaan ter-scope tenant: riwayat proyek #6 milik tenant #1 adalah cerita tenant #1, dan “proyek #6” milik tenant #2 adalah cerita yang sama sekali berbeda meskipun nomornya kebetulan sama. Nomor baris AUTOINCREMENT hanya unik dalam satu tabel; pasangan tenant_id + entitas + entitas_id barulah identitas yang aman.
Migrasi: audit_log dan job_queue #
Kedua tabel datang sebagai migrasi ketiga, mengikuti presedur migrasi-billing.sql Bagian 4 — dimuat setelah schema.sql Bagian 1 pada koneksi yang sama. Berikut isi lengkap migrasi-audit-jobs.sql:
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
user_id INTEGER REFERENCES users(id),
aksi TEXT NOT NULL,
entitas TEXT NOT NULL,
entitas_id INTEGER NOT NULL,
detail TEXT NOT NULL DEFAULT '{}',
dibuat_pada TEXT NOT NULL
);
-- Index majemuk melayani pola baca riwayat(): filter tenant + entitas
-- + id dalam satu query.
CREATE INDEX IF NOT EXISTS idx_audit_log_riwayat
ON audit_log(tenant_id, entitas, entitas_id);
CREATE TABLE IF NOT EXISTS job_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'menunggu'
CHECK (status IN ('menunggu', 'diproses', 'selesai', 'gagal')),
attempts INTEGER NOT NULL DEFAULT 0,
tersedia_pada TEXT NOT NULL,
error TEXT
);
-- Index melayani claim(): job 'menunggu' yang sudah jatuh tempo,
-- urut waktu jatuh tempo. Index kedua melayani metrik per tenant.
CREATE INDEX IF NOT EXISTS idx_job_queue_due
ON job_queue(status, tersedia_pada);
CREATE INDEX IF NOT EXISTS idx_job_queue_tenant
ON job_queue(tenant_id);
-- Tamper guard: riwayat audit hanya boleh bertambah. Kedua trigger
-- membatalkan operasi tulis-ulang dengan RAISE(ABORT) sehingga
-- kesalahan kode (atau tangan jahil) gagal keras, bukan diam-diam
-- mengubah catatan sejarah.
CREATE TRIGGER IF NOT EXISTS trg_audit_log_blokir_update
BEFORE UPDATE ON audit_log
BEGIN
SELECT RAISE(ABORT, 'audit_log bersifat append-only: UPDATE ditolak');
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_log_blokir_delete
BEFORE DELETE ON audit_log
BEGIN
SELECT RAISE(ABORT, 'audit_log bersifat append-only: DELETE ditolak');
END;Enam keputusan pada migrasi tersebut perlu dibedah:
audit_log.user_idboleh NULL: aksi sistem — worker, tugas terjadwal — tidak dikerjakan manusia, dan NULL adalah representasi jujur untuk “bukan siapa-siapa”. Kolom tetap merujukusers(id)lewat foreign key saat terisi.entitas+entitas_idsebagai pasangan: nama entitas string ('project','subscription') menghindari satu kolom rujukan per jenis entitas;entitas_idmenunjuk baris pada tabel yang dimaksud. Pasangan ini hanya bermakna bersamatenant_id— itulah syarat tripel padariwayat().detail TEXTberisi JSON: kolom ini menampung parameter bebas (nama proyek yang dihapus, plan lama dan baru) tanpa mengubah struktur tabel setiap kali jenis audit baru muncul.job_queue.statusdijagaCHECKpada empat nilai: siklus hidup job tidak bisa berhenti di keadaan yang tidak dikenal — status asing ditolak database, bukan diam-diam tersimpan.tersedia_padaadalah waktu jatuh tempo job: baris boleh di-claim()hanya setelah waktu ini tercapai. Kolom inilah yang membuat penjadwalan dan backoff bisa diuji dengan jam beku.- Dua trigger append-only membatalkan
UPDATEdanDELETEpadaaudit_loglewatRAISE(ABORT). Perlindungan ini bekerja dari jalur mana pun — termasukphplangsung, skrip admin, maupun bug pada class berikutnya.
Class AuditLogger #
Repository bagian ini menulis dan membaca jejak tanpa pernah menyediakan jalan untuk mengubahnya. Berikut isi lengkap src/AuditLogger.php:
<?php
declare(strict_types=1);
/**
* Perekam dan pembaca jejak audit per tenant.
*
* Audit menjawab tiga pertanyaan setelah fakta: siapa yang melakukannya,
* apa yang dilakukannya, dan kapan. Tabel audit_log bersifat append-only
* - class ini hanya menyediakan INSERT (record) dan SELECT ter-scope
* tenant (riwayat); tidak ada method untuk mengubah atau menghapus
* catatan, dan dua trigger pada migrasi membatalkan upaya tulis-ulang
* dari jalur mana pun.
*
* Pembacaan riwayat wajib menyaring tenant_id: riwayat proyek #6 milik
* tenant A adalah cerita tenant A, bukan cerita tenant B yang kebetulan
* juga punya proyek bernomor 6.
*/
final class AuditLogger
{
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali class ini dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Rekam satu peristiwa audit dan kembalikan barisnya.
*
* $userId boleh null untuk aksi sistem (worker, tugas terjadwal)
* yang tidak dikerjakan manusia. $detailJson divalidasi lebih awal:
* kolom detail berisi JSON, dan JSON rusak yang lolos akan sulit
* dibaca ulang saat audit sedang dibutuhkan.
*
* @param string $entitas nama entitas, mis. 'project', 'subscription'
* @param int $entityId id baris entitas pada tenant tersebut
*
* @return array<string, mixed> baris audit_log yang baru tersimpan
*/
public function record(
int $tenantId,
?int $userId,
string $aksi,
string $entitas,
int $entityId,
string $detailJson,
DateTimeImmutable $now,
): array {
if (!json_validate($detailJson)) {
throw new InvalidArgumentException(
'detail audit harus JSON valid, diberikan: ' . $detailJson
);
}
$stmt = $this->pdo->prepare(
'INSERT INTO audit_log
(tenant_id, user_id, aksi, entitas, entitas_id, detail, dibuat_pada)
VALUES
(:tenant_id, :user_id, :aksi, :entitas, :entitas_id, :detail, :dibuat_pada)'
);
$stmt->execute([
'tenant_id' => $tenantId,
'user_id' => $userId,
'aksi' => $aksi,
'entitas' => $entitas,
'entitas_id' => $entityId,
'detail' => $detailJson,
'dibuat_pada' => $now->format('Y-m-d H:i:s'),
]);
return $this->rowById((int) $this->pdo->lastInsertId());
}
/**
* Riwayat satu entitas pada satu tenant, urut perekaman.
*
* Syarat tripel tenant_id + entitas + entitas_id menjadikan query
* ini ter-scope ganda: entitas "proyek #6" milik tenant #1 dan
* "proyek #6" milik tenant #2 adalah dua cerita yang berbeda,
* dan masing-masing hanya terbaca dari konteksnya sendiri.
*
* @return list<array<string, mixed>> baris: id, user_id, aksi, detail, dibuat_pada
*/
public function riwayat(int $tenantId, string $entitas, int $entityId): array
{
$stmt = $this->pdo->prepare(
'SELECT id, user_id, aksi, detail, dibuat_pada
FROM audit_log
WHERE tenant_id = :tenant_id
AND entitas = :entitas
AND entitas_id = :entitas_id
ORDER BY id'
);
$stmt->execute([
'tenant_id' => $tenantId,
'entitas' => $entitas,
'entitas_id' => $entityId,
]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return array<string, mixed>
*/
private function rowById(int $id): array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, user_id, aksi, entitas, entitas_id, detail, dibuat_pada
FROM audit_log WHERE id = :id'
);
$stmt->execute(['id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}Tiga hal pada class tersebut layak digarisbawahi. record() menerima seluruh identitas sebagai parameter eksplisit — tenant, user, aksi, entitas — bukan membacanya diam-diam dari TenantContext, sehingga class tetap bisa dipakai worker yang tidak punya context request. json_validate() (fitur PHP 8.3) menolak detail rusak di pintu masuk: JSON yang gagal diurai saat audit dibaca ulang adalah audit yang rusak. Terakhir, riwayat() memakai syarat tripel tenant_id + entitas + entitas_id — pola yang sama dengan syarat ganda id + tenant_id pada findForTenant() Bagian 2, hanya ditambah satu dimensi.
Walkthrough: Merekam dan Memutar Ulang Jejak #
Walkthrough pertama merekam tiga peristiwa dari dua tenant, memutar ulang riwayat satu entitas, membuktikan isolasi pembacaan, lalu mencoba menyerang tabel audit itu sendiri:
<?php
require __DIR__ . '/src/AuditLogger.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/migrasi-audit-jobs.sql'));
$z = fn (string $waktu): DateTimeImmutable
=> new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
$audit = new AuditLogger($pdo);
echo "== 1) Merekam tiga peristiwa ==\n";
$stmt = $pdo->prepare('INSERT INTO projects (tenant_id, nama) VALUES (:tenant_id, :nama)');
$stmt->execute(['tenant_id' => 1, 'nama' => 'Landing Page Lebaran']);
$idProyekBaru = (int) $pdo->lastInsertId();
$baris = $audit->record(
1, 1, 'project.create', 'project', $idProyekBaru,
json_encode(['nama' => 'Landing Page Lebaran']),
$z('2026-02-02 09:05:00'),
);
echo "audit #{$baris['id']}: tenant #{$baris['tenant_id']}, aksi {$baris['aksi']}, pada {$baris['dibuat_pada']}\n";
$pdo->exec('DELETE FROM projects WHERE tenant_id = 2 AND id = 5');
$baris = $audit->record(
2, 2, 'project.delete', 'project', 5,
json_encode(['nama' => 'Katalog Musim Hujan']),
$z('2026-02-02 10:15:00'),
);
echo "audit #{$baris['id']}: tenant #{$baris['tenant_id']}, aksi {$baris['aksi']}, pada {$baris['dibuat_pada']}\n";
$baris = $audit->record(
2, null, 'system.export-harian', 'tenant', 2,
json_encode(['tabel' => ['projects', 'memberships']]),
$z('2026-02-02 23:00:00'),
);
echo "audit #{$baris['id']}: user_id ";
var_dump($baris['user_id']);
echo "\n== 2) Replay: riwayat proyek baru dari konteks tenant #1 ==\n";
foreach ($audit->riwayat(1, 'project', $idProyekBaru) as $r) {
$oleh = $r['user_id'] === null ? 'sistem' : 'user #' . $r['user_id'];
echo " [{$r['dibuat_pada']}] {$r['aksi']} oleh {$oleh} — {$r['detail']}\n";
}
echo "\n== 3) Isolasi: nomor entitas sama, tenant berbeda ==\n";
$audit->record(2, 2, 'project.create', 'project', $idProyekBaru,
json_encode(['nama' => 'Duplikat nomor di tenant lain']), $z('2026-02-03 08:00:00'));
echo "riwayat(1, 'project', {$idProyekBaru}): " . count($audit->riwayat(1, 'project', $idProyekBaru)) . " baris\n";
echo "riwayat(2, 'project', {$idProyekBaru}): " . count($audit->riwayat(2, 'project', $idProyekBaru)) . " baris\n";
echo "\n== 4) Append-only: UPDATE dan DELETE ditolak trigger ==\n";
try {
$pdo->exec("UPDATE audit_log SET aksi = 'project.rename' WHERE id = 1");
echo "UPDATE lolos (seharusnya tidak!)\n";
} catch (PDOException $e) {
echo "UPDATE ditolak: ", $e->getMessage(), "\n";
}
try {
$pdo->exec('DELETE FROM audit_log WHERE id = 1');
echo "DELETE lolos (seharusnya tidak!)\n";
} catch (PDOException $e) {
echo "DELETE ditolak: ", $e->getMessage(), "\n";
}
echo "\n== 5) Validasi detail JSON ==\n";
try {
$audit->record(1, 1, 'project.view', 'project', 1, '{nama: tanpa kutip}', $z('2026-02-02 09:06:00'));
echo "record lolos (seharusnya tidak!)\n";
} catch (InvalidArgumentException $e) {
echo "Ditolak: ", $e->getMessage(), "\n";
}Output yang Dihasilkan:
== 1) Merekam tiga peristiwa ==
audit #1: tenant #1, aksi project.create, pada 2026-02-02 09:05:00
audit #2: tenant #2, aksi project.delete, pada 2026-02-02 10:15:00
audit #3: user_id NULL
== 2) Replay: riwayat proyek baru dari konteks tenant #1 ==
[2026-02-02 09:05:00] project.create oleh user #1 — {"nama":"Landing Page Lebaran"}
== 3) Isolasi: nomor entitas sama, tenant berbeda ==
riwayat(1, 'project', 6): 1 baris
riwayat(2, 'project', 6): 1 baris
== 4) Append-only: UPDATE dan DELETE ditolak trigger ==
UPDATE ditolak: SQLSTATE[23000]: Integrity constraint violation: 19 audit_log bersifat append-only: UPDATE ditolak
DELETE ditolak: SQLSTATE[23000]: Integrity constraint violation: 19 audit_log bersifat append-only: DELETE ditolak
== 5) Validasi detail JSON ==
Ditolak: detail audit harus JSON valid, diberikan: {nama: tanpa kutip}Baca output itu sebagai lima pengujian yang lulus. Bagian 1 menunjukkan tiga baris audit terekam: dua aksi manusia dengan user_id terisi, satu aksi sistem system.export-harian dengan user_id yang var_dump laporkan sebagai NULL — representasi jujur untuk pekerjaan otomatis pukul 23:00. Bagian 2 adalah replay: riwayat proyek baru tenant #1 mengembalikan tepat satu peristiwa project.create lengkap dengan waktu beku dan detail JSON-nya. Bagian 3 membuktikan isolasi pembacaan: tenant #2 sengaja merekam peristiwa pada nomor entitas yang sama (6), dan masing-masing tenant tetap hanya membaca satu baris miliknya — nomor entitas memang bukan identitas global.
Bagian 4 adalah serangan terhadap tabel audit itu sendiri: UPDATE untuk mengubah aksi baris #1 dan DELETE untuk menghapusnya sama-sama ditolak trigger dengan SQLSTATE[23000] dan pesan append-only — riwayat yang bisa diedit bukan lagi riwayat. Bagian 5 menutup dengan gerbang validasi: detail {nama: tanpa kutip} bukan JSON valid, dan record() menolaknya sebelum menyentuh database. Perhatikan juga bahwa DELETE FROM projects pada awal walkthrough tetap menyaring tenant_id = 2 — penghapusan data nyata mengikuti disiplin isolasi yang sama sejak Bagian 2.
Pekerjaan yang Hidup Lebih Lama dari Request #
Request HTTP berumur milidetik, tetapi pekerjaan SaaS sering harus menunggu atau berjalan lama: kirim email ringkasan mingguan, sapu langganan yang jatuh tempo, ekspor data besar. Menjalankan pekerjaan itu di dalam request membuat pengguna menunggu untuk hal yang tidak perlu ia tunggu, dan kegagalan di tengah jalan meninggalkan request menggantung. Pola yang dipakai hampir semua SaaS adalah memecahnya dua: request hanya menitipkan pekerjaan ke antrean lalu selesai, dan proses worker terpisah mengambil pekerjaan itu belakangan.
Ada satu bahaya khas multi-tenant di pola ini: request tahu tenant-nya lewat TenantContext, tetapi worker yang berjalan 30 menit kemudian tidak mengenal request itu — konteksnya sudah mati. Bila tenant_id tidak ikut tersimpan, worker bisa saja memproses data milik pelanggan yang salah; inilah “job yang berjalan lintas pelanggan” yang kita sebut-sebut sejak awal proyek. Solusinya ada pada desain enqueue(): tenant_id hidup dua kali — sebagai kolom tenant_id yang bisa di-query untuk metrik dan triase, dan sebagai salinan di dalam payload JSON agar pesan bersifat mandiri (handler cukup menerima string payload, pola yang sama dengan message broker sungguhan seperti Redis atau SQS).
Dua mekanisme membuat antrean ini layak dipercaya. Retry dengan backoff: job yang gagal dijadwalkan ulang dengan jeda yang membesar — percobaan ke-1 gagal menunggu 2 menit, ke-2 menunggu 4 menit — supaya layanan yang sedang tumbang diberi ruang pulih tanpa dihujani. Dead letter: setelah MAX_ATTEMPTS (3) percobaan, job dinyatakan gagal permanen dan menunggu ditriase manusia, bukan dicoba selamanya. Dan seperti SubscriptionService Bagian 4, seluruh method yang membaca waktu menerima DateTimeImmutable dari pemanggil — jeda backoff dan batas percobaan bisa diuji dengan jam beku dalam satu proses.
Class JobQueue #
Berikut isi lengkap src/JobQueue.php:
<?php
declare(strict_types=1);
/**
* Antrean pekerjaan latar belakang dengan konteks tenant.
*
* Request berumur milidetik, tetapi sebagian pekerjaan SaaS harus
* hidup lebih lama: kirim email ringkasan, sapu langganan kedaluwarsa,
* ekspor data. JobQueue menyimpan pekerjaan itu sebagai baris database
* yang membawa tenant_id - konteks yang membuatnya aman dieksekusi
* nanti oleh worker yang tidak mengenal request asalnya.
*
* Siklus hidup satu baris:
* menunggu -> (claim) -> diproses -> (complete) -> selesai
* diproses -> (fail) -> menunggu (retry)
* diproses -> (fail x3) -> gagal (dead letter)
*
* Seluruh method yang membaca waktu menerima DateTimeImmutable dari
* pemanggil, sama seperti SubscriptionService Bagian 4 - tidak ada
* time() atau 'now' di dalam class, sehingga jadwal retry dan batas
* percobaan dapat diuji dengan jam beku.
*/
final class JobQueue
{
/** Percobaan maksimal sebelum job dinyatakan gagal permanen. */
public const MAX_ATTEMPTS = 3;
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali class ini dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Masukkan satu job ke antrean dan kembalikan barisnya.
*
* $payloadJson adalah pesan mandiri: sertakan tenant_id dan seluruh
* parameter yang handler butuhkan, karena worker hanya menerima
* string ini - kolom tenant_id adalah salinannya yang ter-query
* untuk metrik dan triase dead letter.
*
* @return array<string, mixed> baris job_queue yang baru tersimpan
*/
public function enqueue(
int $tenantId,
string $type,
string $payloadJson,
DateTimeImmutable $availableAt,
): array {
if (!json_validate($payloadJson)) {
throw new InvalidArgumentException(
'payload job harus JSON valid, diberikan: ' . $payloadJson
);
}
$stmt = $this->pdo->prepare(
'INSERT INTO job_queue (tenant_id, type, payload, status, attempts, tersedia_pada)
VALUES (:tenant_id, :type, :payload, \'menunggu\', 0, :tersedia_pada)'
);
$stmt->execute([
'tenant_id' => $tenantId,
'type' => $type,
'payload' => $payloadJson,
'tersedia_pada' => $availableAt->format('Y-m-d H:i:s'),
]);
return $this->rowById((int) $this->pdo->lastInsertId());
}
/**
* Ambil job berikutnya yang jatuh tempo dan tandai sedang diproses.
*
* Job diambil urut waktu jatuh tempo (FIFO), hanya yang berstatus
* 'menunggu'. UPDATE bersyarat status menjaga dua worker tidak
* mengambil job yang sama; bila pembaruan kalah balapan, method
* mengembalikan null dan pemanggil cukup memanggil ulang - pada
* database produksi, padanannya SELECT ... FOR UPDATE SKIP LOCKED.
*/
public function claim(DateTimeImmutable $now): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id FROM job_queue
WHERE status = \'menunggu\' AND tersedia_pada <= :now
ORDER BY tersedia_pada, id
LIMIT 1'
);
$stmt->execute(['now' => $now->format('Y-m-d H:i:s')]);
$id = $stmt->fetchColumn();
if ($id === false) {
return null;
}
$update = $this->pdo->prepare(
'UPDATE job_queue
SET status = \'diproses\', attempts = attempts + 1
WHERE id = :id AND status = \'menunggu\''
);
$update->execute(['id' => $id]);
if ($update->rowCount() === 0) {
return null;
}
return $this->rowById((int) $id);
}
/**
* Tandai job selesai. Hanya job yang sedang diproses boleh
* diselesaikan - menyelesaikan job yang tidak dipegang adalah bug.
*/
public function complete(int $id): void
{
$stmt = $this->pdo->prepare(
'UPDATE job_queue SET status = \'selesai\'
WHERE id = :id AND status = \'diproses\''
);
$stmt->execute(['id' => $id]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException(
"Job #$id tidak sedang diproses - tidak bisa diselesaikan"
);
}
}
/**
* Catat kegagalan job: jadwalkan ulang dengan backoff, atau
* nyatakan gagal permanen setelah MAX_ATTEMPTS percobaan.
*
* Backoff eksponensial: percobaan ke-n yang gagal menunggu
* 2^n menit sebelum dicoba lagi (gagal ke-1 -> 2 menit,
* gagal ke-2 -> 4 menit). Mengembalikan status baru job:
* 'menunggu' bila akan dicoba lagi, 'gagal' bila mati.
*/
public function fail(int $id, string $error, DateTimeImmutable $now): string
{
$job = $this->rowById($id);
if ($job === null || $job['status'] !== 'diproses') {
throw new RuntimeException(
"Job #$id tidak sedang diproses - tidak bisa dicatat gagalnya"
);
}
if ((int) $job['attempts'] >= self::MAX_ATTEMPTS) {
$stmt = $this->pdo->prepare(
'UPDATE job_queue SET status = \'gagal\', error = :error WHERE id = :id'
);
$stmt->execute(['error' => $error, 'id' => $id]);
return 'gagal';
}
$menit = 2 ** (int) $job['attempts'];
$tersedia = $now->modify('+' . $menit . ' minutes')->format('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'UPDATE job_queue
SET status = \'menunggu\', tersedia_pada = :tersedia_pada, error = :error
WHERE id = :id'
);
$stmt->execute(['tersedia_pada' => $tersedia, 'error' => $error, 'id' => $id]);
return 'menunggu';
}
/**
* Daftar job yang gagal permanen, urut id.
*
* Dead letter adalah bahan triase operasional: setiap baris
* membawa tenant_id dan error, sehingga tim bisa memeriksa
* kegagalan per tenant lalu memutuskan perbaiki-jalankan-ulang.
*
* @return list<array<string, mixed>>
*/
public function deadLetters(): array
{
return $this->pdo
->query('SELECT id, tenant_id, type, payload, attempts, error FROM job_queue
WHERE status = \'gagal\' ORDER BY id')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return array<string, mixed>|null
*/
private function rowById(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, type, payload, status, attempts, tersedia_pada, error
FROM job_queue WHERE id = :id'
);
$stmt->execute(['id' => $id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}Empat keputusan pada class tersebut perlu dijelaskan. claim() melakukan dua langkah — pilih kandidat lalu UPDATE bersyarat status = 'menunggu' — dan hanya mengembalikan job bila pembaruan benar-benar mengubah baris; dua worker yang mengejar job yang sama akan menghasilkan satu pemenang dan satu null, dan pada PostgreSQL padanan idiomatiknya SELECT ... FOR UPDATE SKIP LOCKED. complete() dan fail() menolak job yang tidak berstatus diproses dengan RuntimeException — menyelesaikan atau menggagalkan job yang tidak sedang dipegang adalah bug pemanggil, dan bug harus gagal keras. fail() mengembalikan status baru ('menunggu' atau 'gagal') sehingga pemanggil tahu apakah job akan dicoba lagi atau sudah mati. Terakhir, deadLetters() sengaja mengembalikan tenant_id dan error untuk setiap baris — kegagalan latar belakang harus bisa ditriase per pelanggan.
Walkthrough: Siklus Hidup Job dengan Jam Beku #
Walkthrough kedua menjalankan pekerjaan satu hari penuh dalam satu proses: email laporan mingguan tenant #1 yang gagal berulang sampai mati permanen, dan sapuan langganan tenant #2 yang selesai mulus. Enam titik jam beku memainkan peran detik, menit, dan backoff:
<?php
require __DIR__ . '/src/JobQueue.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/migrasi-audit-jobs.sql'));
$z = fn (string $waktu): DateTimeImmutable
=> new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
$queue = new JobQueue($pdo);
$waktu = fn (?array $job): string => $job === null
? '(tidak ada)'
: sprintf('#%d %s %s, attempts=%d, tersedia %s', $job['id'], $job['status'], $job['type'], $job['attempts'], $job['tersedia_pada']);
echo "== 1) Enqueue dua job (payload membawa tenant_id) ==\n";
$job1 = $queue->enqueue(
1, 'email.laporan-mingguan',
json_encode(['tenant_id' => 1, 'email' => '[email protected]']),
$z('2026-02-02 08:00:00'),
);
echo "job ", $waktu($job1), "\n";
$job2 = $queue->enqueue(
2, 'subscription.sweep',
json_encode(['tenant_id' => 2, 'cek_pada' => '2026-02-02 08:05:00']),
$z('2026-02-02 08:05:00'),
);
echo "job ", $waktu($job2), "\n";
echo "\n== 2) Worker 08:00 — email gagal (SMTP mati) ==\n";
$now = $z('2026-02-02 08:00:00');
$job = $queue->claim($now);
echo "claim: ", $waktu($job), "\n";
$hasil = $queue->fail((int) $job['id'], 'SMTP tidak merespons dalam 5 detik', $now);
echo "fail: status baru '{$hasil}', backoff 2^{$job['attempts']} menit\n";
echo "\n== 3) Worker 08:01 — belum jatuh tempo ==\n";
$job = $queue->claim($z('2026-02-02 08:01:00'));
echo "claim: ", $waktu($job), "\n";
echo "\n== 4) Worker 08:02 — percobaan kedua, gagal lagi ==\n";
$now = $z('2026-02-02 08:02:00');
$job = $queue->claim($now);
echo "claim: ", $waktu($job), "\n";
$hasil = $queue->fail((int) $job['id'], 'SMTP tidak merespons dalam 5 detik', $now);
echo "fail: status baru '{$hasil}', backoff 2^{$job['attempts']} menit\n";
echo "\n== 5) Worker 08:06 — job sweep selesai, email mati permanen ==\n";
$now = $z('2026-02-02 08:06:00');
$job = $queue->claim($now);
echo "claim: ", $waktu($job), "\n";
$queue->complete((int) $job['id']);
echo "complete: job #{$job['id']} selesai\n";
$job = $queue->claim($now);
echo "claim: ", $waktu($job), "\n";
$hasil = $queue->fail((int) $job['id'], 'SMTP tidak merespons dalam 5 detik', $now);
echo "fail: status baru '{$hasil}' (percobaan ke-{$job['attempts']} = MAX_ATTEMPTS)\n";
echo "\n== 6) Dead letters ==\n";
foreach ($queue->deadLetters() as $dl) {
echo " job #{$dl['id']} [tenant #{$dl['tenant_id']}] {$dl['type']} — {$dl['error']} (attempts={$dl['attempts']})\n";
}
echo "\n== 7) complete() pada job yang tidak diproses ditolak ==\n";
try {
$queue->complete(999);
echo "complete(999) lolos (seharusnya tidak!)\n";
} catch (RuntimeException $e) {
echo "Ditolak: ", $e->getMessage(), "\n";
}Output yang Dihasilkan:
== 1) Enqueue dua job (payload membawa tenant_id) ==
job #1 menunggu email.laporan-mingguan, attempts=0, tersedia 2026-02-02 08:00:00
job #2 menunggu subscription.sweep, attempts=0, tersedia 2026-02-02 08:05:00
== 2) Worker 08:00 — email gagal (SMTP mati) ==
claim: #1 diproses email.laporan-mingguan, attempts=1, tersedia 2026-02-02 08:00:00
fail: status baru 'menunggu', backoff 2^1 menit
== 3) Worker 08:01 — belum jatuh tempo ==
claim: (tidak ada)
== 4) Worker 08:02 — percobaan kedua, gagal lagi ==
claim: #1 diproses email.laporan-mingguan, attempts=2, tersedia 2026-02-02 08:02:00
fail: status baru 'menunggu', backoff 2^2 menit
== 5) Worker 08:06 — job sweep selesai, email mati permanen ==
claim: #2 diproses subscription.sweep, attempts=1, tersedia 2026-02-02 08:05:00
complete: job #2 selesai
claim: #1 diproses email.laporan-mingguan, attempts=3, tersedia 2026-02-02 08:06:00
fail: status baru 'gagal' (percobaan ke-3 = MAX_ATTEMPTS)
== 6) Dead letters ==
job #1 [tenant #1] email.laporan-mingguan — SMTP tidak merespons dalam 5 detik (attempts=3)
== 7) complete() pada job yang tidak diproses ditolak ==
Ditolak: Job #999 tidak sedang diproses - tidak bisa diselesaikanBacalah output itu sebagai kronologi satu pagi. Pukul 08:00 dua job masuk dengan payload yang masing-masing membawa tenant_id — email milik tenant #1 langsung jatuh tempo, sapuan langganan tenant #2 dijadwalkan 08:05. Worker pukul 08:00 meng-claim job email (status berubah diproses, attempts naik ke 1), gagal, dan fail() menjadwalkan ulang dengan backoff 2^1 = 2 menit. Pukul 08:01 antrean kosong — job email belum jatuh tempo, job sapuan belum waktunya — dan claim() jujur mengembalikan (tidak ada). Pukul 08:02 percobaan kedua juga gagal; backoff membesar menjadi 2^2 = 4 menit, jatuh tempo 08:06.
Pukul 08:06 tibalah urutan yang penting: dua job sama-sama jatuh tempo, dan claim() memilih job sapuan karena tersedia_pada-nya (08:05) lebih awal — antrean melayani yang paling lama menunggu lebih dulu. Job sapuan selesai mulus lewat complete(). Panggilan claim() kedua pada menit yang sama mengambil job email untuk percobaan ketiga; kali ini attempts sudah menyentuh MAX_ATTEMPTS, sehingga fail() tidak lagi menjadwalkan ulang melainkan mengembalikan 'gagal' — job pindah ke dead letter. Dua bagian terakhir output membuktikannya: daftar dead letter menampilkan job #1 milik tenant #1 beserta error dan jumlah percobaannya, dan complete(999) pada job yang tidak pernah di-claim ditolak RuntimeException. Seluruh kronologi 6 menit ini berjalan dalam satu proses berkat jam beku — tanpa sleep(), tanpa menunggu.
Walkthrough: Worker Sapu Langganan #
Walkthrough penutup menyatukan Bagian 4 dan Bagian 5 dalam satu cerita utuh: request yang berlangganan, worker yang memeriksa 32 hari kemudian, dan audit yang merekam keduanya sebagai satu riwayat:
<?php
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';
require __DIR__ . '/src/AuditLogger.php';
require __DIR__ . '/src/JobQueue.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-4-billing-dan-subscriptions/migrasi-billing.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/migrasi-audit-jobs.sql'));
$z = fn (string $waktu): DateTimeImmutable
=> new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
$subscriptions = new SubscriptionService($pdo, new PlanRepository($pdo));
$audit = new AuditLogger($pdo);
$queue = new JobQueue($pdo);
echo "== Request: 2026-02-01 09:00, budi berlangganan plan pro ==\n";
$now = $z('2026-02-01 09:00:00');
$sub = $subscriptions->subscribe(1, 'pro', $now);
echo "subscription #{$sub['id']}: plan {$sub['plan_kode']}, berakhir {$sub['berakhir_pada']}\n";
$audit->record(
1, 1, 'billing.subscribe', 'subscription', (int) $sub['id'],
json_encode(['plan_kode' => 'pro', 'berakhir_pada' => $sub['berakhir_pada']]),
$now,
);
$job = $queue->enqueue(
1, 'subscription.sweep',
json_encode(['tenant_id' => 1, 'subscription_id' => (int) $sub['id']]),
$z('2026-03-04 09:00:00'),
);
echo "request selesai — audit terekam, job #{$job['id']} terjadwal {$job['tersedia_pada']}\n";
echo "\n== Worker: 2026-03-05 09:00, memproses job jatuh tempo ==\n";
$now = $z('2026-03-05 09:00:00');
$job = $queue->claim($now);
echo "claim: job #{$job['id']} {$job['type']} untuk tenant #{$job['tenant_id']}\n";
echo "payload: {$job['payload']}\n";
$payload = json_decode((string) $job['payload'], true);
$status = $subscriptions->status((int) $payload['tenant_id'], $now);
echo "status tenant #{$payload['tenant_id']} pada {$now->format('Y-m-d H:i:s')}: {$status}\n";
$audit->record(
(int) $payload['tenant_id'], null, 'subscription.sweep', 'subscription',
(int) $payload['subscription_id'],
json_encode(['status' => $status, 'isActive' => false]),
$now,
);
$queue->complete((int) $job['id']);
echo "audit terekam, job #{$job['id']} selesai\n";
echo "\n== Replay: riwayat subscription #1 dari konteks tenant #1 ==\n";
foreach ($audit->riwayat(1, 'subscription', 1) as $r) {
$oleh = $r['user_id'] === null ? 'sistem (worker)' : 'user #' . $r['user_id'];
echo " [{$r['dibuat_pada']}] {$r['aksi']} oleh {$oleh}\n";
echo " detail: {$r['detail']}\n";
}
echo "\n== Isolasi: cerita yang sama tidak terbaca dari tenant #2 ==\n";
echo "riwayat(2, 'subscription', 1): ", count($audit->riwayat(2, 'subscription', 1)), " baris\n";Output yang Dihasilkan:
== Request: 2026-02-01 09:00, budi berlangganan plan pro ==
subscription #1: plan pro, berakhir 2026-03-03 09:00:00
request selesai — audit terekam, job #1 terjadwal 2026-03-04 09:00:00
== Worker: 2026-03-05 09:00, memproses job jatuh tempo ==
claim: job #1 subscription.sweep untuk tenant #1
payload: {"tenant_id":1,"subscription_id":1}
status tenant #1 pada 2026-03-05 09:00:00: tenggang
audit terekam, job #1 selesai
== Replay: riwayat subscription #1 dari konteks tenant #1 ==
[2026-02-01 09:00:00] billing.subscribe oleh user #1
detail: {"plan_kode":"pro","berakhir_pada":"2026-03-03 09:00:00"}
[2026-03-05 09:00:00] subscription.sweep oleh sistem (worker)
detail: {"status":"tenggang","isActive":false}
== Isolasi: cerita yang sama tidak terbaca dari tenant #2 ==
riwayat(2, 'subscription', 1): 0 barisInilah seluruh alur yang dibangun proyek sejauh ini bekerja sebagai satu sistem. Request 2026-02-01 melakukan tiga hal sekaligus dalam milidetik: subscribe() menulis baris langganan (berakhir 2026-03-03, tepat 30 hari), record() menitipkan jejak billing.subscribe oleh user #1, dan enqueue() menjadwalkan sapuan pemeriksaan sehari setelah masa berlaku habis. Worker 2026-03-05 — satu bulan kemudian dalam cerita, mikrodetik kemudian dalam proses — meng-claim job, membaca tenant dari payload, menjalankan status(), dan menemukan tenant sudah tenggang (dua hari melewati 2026-03-03, masih dalam masa 7 hari). Worker kemudian merekam subscription.sweep dengan user_id null dan menyelesaikan job.
Bagian replay adalah inti audit: dua peristiwa dari dua proses berbeda pada dua titik waktu berbeda — billing.subscribe oleh user #1 pada 2026-02-01, subscription.sweep oleh sistem pada 2026-03-05 — terbaca sebagai satu riwayat koheren untuk subscription #1 tenant #1. Pada aplikasi nyata, worker di titik ini juga mengirim email peringatan tenggang; di sini cukup jejak auditnya. Dan baris terakhir menegaskan batasnya: cerita itu tidak terbaca dari tenant #2, karena riwayat langganan pun milik tenant yang mengalaminya.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1, migrasi-billing.sql dari Bagian 4 (hanya walkthrough ketiga), serta berkas-berkas di folder ini. Susun foldernya sebagai berikut:
aplikasi-saas-multi-tenant/
├── bagian-1-tenant-dan-domain-model/
│ └── schema.sql
├── bagian-4-billing-dan-subscriptions/
│ ├── migrasi-billing.sql
│ └── src/
│ ├── PlanRepository.php
│ └── SubscriptionService.php
└── bagian-5-audit-dan-background-jobs/
├── migrasi-audit-jobs.sql
├── src/
│ ├── AuditLogger.php
│ └── JobQueue.php
├── uji-audit.php
├── uji-jobs.php
└── uji-sweep.phpSimpan ketiga walkthrough di atas sebagai uji-audit.php, uji-jobs.php, dan uji-sweep.php di dalam folder bagian-5-audit-dan-background-jobs, lalu jalankan dari folder tersebut:
php uji-audit.php
php uji-jobs.php
php uji-sweep.phpUrutan pemuatan tetap penting: schema.sql Bagian 1 lebih dulu (tabel tenants dan users yang dirujuk foreign key kedua tabel baru), baru migrasi-audit-jobs.sql; walkthrough ketiga menyisipkan migrasi-billing.sql di tengah keduanya karena subscription.sweep memanggil SubscriptionService. Seluruh contoh memakai :memory: dan jam beku, sehingga setiap eksekusi menghasilkan kronologi yang identik — keenam titik waktu pada walkthrough kedua dan selisih 32 hari pada walkthrough ketiga tidak membutuhkan tunggu sesaat pun.
Apa yang Berubah #
Proyek kini punya memori operasional dan lengan kedua:
- Berkas
migrasi-audit-jobs.sqlbaru: tabelaudit_log(denganuser_idnullable untuk aksi sistem, index majemuk riwayat, dan dua trigger append-only) sertajob_queue(status empat tahap dijagaCHECK, index jatuh tempo dan per-tenant). - Berkas
src/AuditLogger.phpbaru:record()dengan validasijson_validate()dan waktu beku,riwayat()dengan syarat tripeltenant_id + entitas + entitas_id. - Berkas
src/JobQueue.phpbaru:enqueue()yang menyimpan tenant_id kolom dan payload mandiri,claim()dengan pembaruan bersyarat,complete()/fail()yang menolak job yang tidak dipegang, backoff eksponensial 2^n menit,MAX_ATTEMPTS3 lalu dead letter, dandeadLetters()yang mengembalikantenant_iduntuk triase. - Terbukti lewat output: replay dua proses pada satu riwayat, isolasi riwayat antar tenant, trigger menolak penulisan ulang audit, serta kronologi retry 08:00 → 08:02 → 08:06 yang berakhir dead letter — semuanya dalam satu proses berkat jam beku.
Ringkasan #
Audit dan background jobs melengkapi aplikasi SaaS dengan dua hal yang tidak dimiliki request singkat: ingatan dan kesinambungan. Audit menyimpan keputusan sebagai fakta yang tidak bisa ditulis ulang — siapa, apa, kapan, milik tenant mana — sementara job queue membawa pekerjaan melewati batas hidup request tanpa kehilangan konteks tenant yang menitipkannya. Backoff dan dead letter membuat kegagalan bisa dipulihkan dengan sopan, dan jam beku membuat semuanya teruji tanpa menunggu. Namun seluruh janji isolasi ini selama ini hanya dibuktikan oleh demo yang kita jalankan dan baca dengan mata; belum ada penjaga yang berteriak setiap kali refactor tidak sengaja memutus filter tenant_id. Bagian 6 membangun penjaga itu: test suite PHPUnit yang menguji invarian isolasi multi-tenant secara sungguhan.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
migrasi-audit-jobs.sql
Unduh berkas-- migrasi-audit-jobs.sql — Bagian 5: Audit dan Background Jobs
-- Dimuat SETELAH schema.sql Bagian 1 pada koneksi yang sama:
--
-- $pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
-- $pdo->exec(file_get_contents(__DIR__ . '/migrasi-audit-jobs.sql'));
--
-- Dua tabel baru menambah dua kemampuan operasional: audit_log
-- (jejak peristiwa yang tidak boleh diubah) dan job_queue (pekerjaan
-- yang hidup lebih lama dari request yang menciptakannya).
--
-- KEPUTUSAN DESAIN:
-- 1. Kedua tabel membawa tenant_id sesuai aturan Bagian 1: baris
-- audit adalah data milik tenant, dan job dieksekusi terhadap
-- data milik satu tenant — konteksnya wajib ikut tersimpan.
-- 2. audit_log.user_id BOLEH NULL: aksi sistem (worker, cron)
-- tidak dilakukan manusia, dan NULL adalah representasi yang
-- jujur untuk "bukan siapa-siapa".
-- 3. audit_log dilindungi trigger append-only: UPDATE dan DELETE
-- selalu dibatalkan database. Menulis riwayat boleh, menulis
-- ulang riwayat tidak.
-- 4. job_queue.status dibatasi CHECK pada empat nilai agar siklus
-- hidup job tidak bisa berhenti di keadaan yang tidak dikenal.
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
user_id INTEGER REFERENCES users(id),
aksi TEXT NOT NULL,
entitas TEXT NOT NULL,
entitas_id INTEGER NOT NULL,
detail TEXT NOT NULL DEFAULT '{}',
dibuat_pada TEXT NOT NULL
);
-- Index majemuk melayani pola baca riwayat(): filter tenant + entitas
-- + id dalam satu query.
CREATE INDEX IF NOT EXISTS idx_audit_log_riwayat
ON audit_log(tenant_id, entitas, entitas_id);
CREATE TABLE IF NOT EXISTS job_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'menunggu'
CHECK (status IN ('menunggu', 'diproses', 'selesai', 'gagal')),
attempts INTEGER NOT NULL DEFAULT 0,
tersedia_pada TEXT NOT NULL,
error TEXT
);
-- Index melayani claim(): job 'menunggu' yang sudah jatuh tempo,
-- urut waktu jatuh tempo. Index kedua melayani metrik per tenant.
CREATE INDEX IF NOT EXISTS idx_job_queue_due
ON job_queue(status, tersedia_pada);
CREATE INDEX IF NOT EXISTS idx_job_queue_tenant
ON job_queue(tenant_id);
-- Tamper guard: riwayat audit hanya boleh bertambah. Kedua trigger
-- membatalkan operasi tulis-ulang dengan RAISE(ABORT) sehingga
-- kesalahan kode (atau tangan jahil) gagal keras, bukan diam-diam
-- mengubah catatan sejarah.
CREATE TRIGGER IF NOT EXISTS trg_audit_log_blokir_update
BEFORE UPDATE ON audit_log
BEGIN
SELECT RAISE(ABORT, 'audit_log bersifat append-only: UPDATE ditolak');
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_log_blokir_delete
BEFORE DELETE ON audit_log
BEGIN
SELECT RAISE(ABORT, 'audit_log bersifat append-only: DELETE ditolak');
END;
src/AuditLogger.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Perekam dan pembaca jejak audit per tenant.
*
* Audit menjawab tiga pertanyaan setelah fakta: siapa yang melakukannya,
* apa yang dilakukannya, dan kapan. Tabel audit_log bersifat append-only
* - class ini hanya menyediakan INSERT (record) dan SELECT ter-scope
* tenant (riwayat); tidak ada method untuk mengubah atau menghapus
* catatan, dan dua trigger pada migrasi membatalkan upaya tulis-ulang
* dari jalur mana pun.
*
* Pembacaan riwayat wajib menyaring tenant_id: riwayat proyek #6 milik
* tenant A adalah cerita tenant A, bukan cerita tenant B yang kebetulan
* juga punya proyek bernomor 6.
*/
final class AuditLogger
{
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali class ini dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Rekam satu peristiwa audit dan kembalikan barisnya.
*
* $userId boleh null untuk aksi sistem (worker, tugas terjadwal)
* yang tidak dikerjakan manusia. $detailJson divalidasi lebih awal:
* kolom detail berisi JSON, dan JSON rusak yang lolos akan sulit
* dibaca ulang saat audit sedang dibutuhkan.
*
* @param string $entitas nama entitas, mis. 'project', 'subscription'
* @param int $entityId id baris entitas pada tenant tersebut
*
* @return array<string, mixed> baris audit_log yang baru tersimpan
*/
public function record(
int $tenantId,
?int $userId,
string $aksi,
string $entitas,
int $entityId,
string $detailJson,
DateTimeImmutable $now,
): array {
if (!json_validate($detailJson)) {
throw new InvalidArgumentException(
'detail audit harus JSON valid, diberikan: ' . $detailJson
);
}
$stmt = $this->pdo->prepare(
'INSERT INTO audit_log
(tenant_id, user_id, aksi, entitas, entitas_id, detail, dibuat_pada)
VALUES
(:tenant_id, :user_id, :aksi, :entitas, :entitas_id, :detail, :dibuat_pada)'
);
$stmt->execute([
'tenant_id' => $tenantId,
'user_id' => $userId,
'aksi' => $aksi,
'entitas' => $entitas,
'entitas_id' => $entityId,
'detail' => $detailJson,
'dibuat_pada' => $now->format('Y-m-d H:i:s'),
]);
return $this->rowById((int) $this->pdo->lastInsertId());
}
/**
* Riwayat satu entitas pada satu tenant, urut perekaman.
*
* Syarat tripel tenant_id + entitas + entitas_id menjadikan query
* ini ter-scope ganda: entitas "proyek #6" milik tenant #1 dan
* "proyek #6" milik tenant #2 adalah dua cerita yang berbeda,
* dan masing-masing hanya terbaca dari konteksnya sendiri.
*
* @return list<array<string, mixed>> baris: id, user_id, aksi, detail, dibuat_pada
*/
public function riwayat(int $tenantId, string $entitas, int $entityId): array
{
$stmt = $this->pdo->prepare(
'SELECT id, user_id, aksi, detail, dibuat_pada
FROM audit_log
WHERE tenant_id = :tenant_id
AND entitas = :entitas
AND entitas_id = :entitas_id
ORDER BY id'
);
$stmt->execute([
'tenant_id' => $tenantId,
'entitas' => $entitas,
'entitas_id' => $entityId,
]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return array<string, mixed>
*/
private function rowById(int $id): array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, user_id, aksi, entitas, entitas_id, detail, dibuat_pada
FROM audit_log WHERE id = :id'
);
$stmt->execute(['id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
src/JobQueue.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Antrean pekerjaan latar belakang dengan konteks tenant.
*
* Request berumur milidetik, tetapi sebagian pekerjaan SaaS harus
* hidup lebih lama: kirim email ringkasan, sapu langganan kedaluwarsa,
* ekspor data. JobQueue menyimpan pekerjaan itu sebagai baris database
* yang membawa tenant_id - konteks yang membuatnya aman dieksekusi
* nanti oleh worker yang tidak mengenal request asalnya.
*
* Siklus hidup satu baris:
* menunggu -> (claim) -> diproses -> (complete) -> selesai
* diproses -> (fail) -> menunggu (retry)
* diproses -> (fail x3) -> gagal (dead letter)
*
* Seluruh method yang membaca waktu menerima DateTimeImmutable dari
* pemanggil, sama seperti SubscriptionService Bagian 4 - tidak ada
* time() atau 'now' di dalam class, sehingga jadwal retry dan batas
* percobaan dapat diuji dengan jam beku.
*/
final class JobQueue
{
/** Percobaan maksimal sebelum job dinyatakan gagal permanen. */
public const MAX_ATTEMPTS = 3;
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali class ini dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Masukkan satu job ke antrean dan kembalikan barisnya.
*
* $payloadJson adalah pesan mandiri: sertakan tenant_id dan seluruh
* parameter yang handler butuhkan, karena worker hanya menerima
* string ini - kolom tenant_id adalah salinannya yang ter-query
* untuk metrik dan triase dead letter.
*
* @return array<string, mixed> baris job_queue yang baru tersimpan
*/
public function enqueue(
int $tenantId,
string $type,
string $payloadJson,
DateTimeImmutable $availableAt,
): array {
if (!json_validate($payloadJson)) {
throw new InvalidArgumentException(
'payload job harus JSON valid, diberikan: ' . $payloadJson
);
}
$stmt = $this->pdo->prepare(
'INSERT INTO job_queue (tenant_id, type, payload, status, attempts, tersedia_pada)
VALUES (:tenant_id, :type, :payload, \'menunggu\', 0, :tersedia_pada)'
);
$stmt->execute([
'tenant_id' => $tenantId,
'type' => $type,
'payload' => $payloadJson,
'tersedia_pada' => $availableAt->format('Y-m-d H:i:s'),
]);
return $this->rowById((int) $this->pdo->lastInsertId());
}
/**
* Ambil job berikutnya yang jatuh tempo dan tandai sedang diproses.
*
* Job diambil urut waktu jatuh tempo (FIFO), hanya yang berstatus
* 'menunggu'. UPDATE bersyarat status menjaga dua worker tidak
* mengambil job yang sama; bila pembaruan kalah balapan, method
* mengembalikan null dan pemanggil cukup memanggil ulang - pada
* database produksi, padanannya SELECT ... FOR UPDATE SKIP LOCKED.
*/
public function claim(DateTimeImmutable $now): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id FROM job_queue
WHERE status = \'menunggu\' AND tersedia_pada <= :now
ORDER BY tersedia_pada, id
LIMIT 1'
);
$stmt->execute(['now' => $now->format('Y-m-d H:i:s')]);
$id = $stmt->fetchColumn();
if ($id === false) {
return null;
}
$update = $this->pdo->prepare(
'UPDATE job_queue
SET status = \'diproses\', attempts = attempts + 1
WHERE id = :id AND status = \'menunggu\''
);
$update->execute(['id' => $id]);
if ($update->rowCount() === 0) {
return null;
}
return $this->rowById((int) $id);
}
/**
* Tandai job selesai. Hanya job yang sedang diproses boleh
* diselesaikan - menyelesaikan job yang tidak dipegang adalah bug.
*/
public function complete(int $id): void
{
$stmt = $this->pdo->prepare(
'UPDATE job_queue SET status = \'selesai\'
WHERE id = :id AND status = \'diproses\''
);
$stmt->execute(['id' => $id]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException(
"Job #$id tidak sedang diproses - tidak bisa diselesaikan"
);
}
}
/**
* Catat kegagalan job: jadwalkan ulang dengan backoff, atau
* nyatakan gagal permanen setelah MAX_ATTEMPTS percobaan.
*
* Backoff eksponensial: percobaan ke-n yang gagal menunggu
* 2^n menit sebelum dicoba lagi (gagal ke-1 -> 2 menit,
* gagal ke-2 -> 4 menit). Mengembalikan status baru job:
* 'menunggu' bila akan dicoba lagi, 'gagal' bila mati.
*/
public function fail(int $id, string $error, DateTimeImmutable $now): string
{
$job = $this->rowById($id);
if ($job === null || $job['status'] !== 'diproses') {
throw new RuntimeException(
"Job #$id tidak sedang diproses - tidak bisa dicatat gagalnya"
);
}
if ((int) $job['attempts'] >= self::MAX_ATTEMPTS) {
$stmt = $this->pdo->prepare(
'UPDATE job_queue SET status = \'gagal\', error = :error WHERE id = :id'
);
$stmt->execute(['error' => $error, 'id' => $id]);
return 'gagal';
}
$menit = 2 ** (int) $job['attempts'];
$tersedia = $now->modify('+' . $menit . ' minutes')->format('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'UPDATE job_queue
SET status = \'menunggu\', tersedia_pada = :tersedia_pada, error = :error
WHERE id = :id'
);
$stmt->execute(['tersedia_pada' => $tersedia, 'error' => $error, 'id' => $id]);
return 'menunggu';
}
/**
* Daftar job yang gagal permanen, urut id.
*
* Dead letter adalah bahan triase operasional: setiap baris
* membawa tenant_id dan error, sehingga tim bisa memeriksa
* kegagalan per tenant lalu memutuskan perbaiki-jalankan-ulang.
*
* @return list<array<string, mixed>>
*/
public function deadLetters(): array
{
return $this->pdo
->query('SELECT id, tenant_id, type, payload, attempts, error FROM job_queue
WHERE status = \'gagal\' ORDER BY id')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return array<string, mixed>|null
*/
private function rowById(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, type, payload, status, attempts, tersedia_pada, error
FROM job_queue WHERE id = :id'
);
$stmt->execute(['id' => $id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}