Mengirimkan event asinkron ke message broker seperti Kafka, RabbitMQ, atau Redis saat entitas database berubah adalah kebutuhan mendasar dalam arsitektur terdistribusi berbasis Django. Masalah klasik muncul ketika aplikasi harus melakukan dua operasi penulisan ke dua sistem penyimpanan terpisah: basis data relasional (PostgreSQL) dan message broker eksternal. Skenario ini dikenal sebagai Dual-Write Problem.

Kegagalan Arsitektur Dual-Write

Pola Dual-Write terjadi ketika kode aplikasi mengeksekusi operasi database lokal dan mem-publish pesan ke broker dalam satu alur eksekusi. Dua skenario kegagalan utama:

  1. Broker publish gagal setelah database commit: Data entitas tersimpan di database, tetapi event tidak pernah sampai ke broker. Layanan downstream tidak mengetahui perubahan data. Konsistensi sistem terdistribusi rusak.
  2. Broker publish berhasil tetapi transaksi database rollback: Event terkirim dan diproses oleh consumer, padahal state data di database dibatalkan (misalnya karena constraint error atau timeout). Hal ini memicu pemrosesan data hantu (phantom data).

Membungkus pemanggilan broker di dalam blok transaction.atomic() Django tidak menyelesaikan masalah. Protokol Two-Phase Commit (2PC) atau XA transactions secara praktis tidak didukung oleh broker modern karena latensi tinggi dan kompleksitas koordinasi. Pendekatan standar Django menggunakan transaction.on_commit() menurunkan risiko partial failure, tetapi tidak mengeliminasinya secara mutlak: jika proses aplikasi mati (OOM crash, SIGKILL, restart pod) tepat setelah transaksi database commit tetapi sebelum callback on_commit selesai dijalankan, event tetap hilang.

Pola Transactional Outbox: Mekanisme dan Garansi

Transactional Outbox Pattern menyelesaikan masalah atomisitas dengan meniadakan penulisan langsung ke broker dari siklus transaksi HTTP request. Sebaliknya, event disimpan ke dalam tabel relasional lokal (biasanya bernama outbox_events) di dalam blok transaksi ACID yang sama dengan mutasi data bisnis.

Jika transaksi database berhasil, event dipastikan tersimpan di tabel outbox. Jika transaksi dibatalkan, event ikut ter-rollback secara atomik. Komponen sekunder—yaitu Relay Worker—bertugas membaca record dari tabel outbox secara asinkron, meneruskannya ke message broker, dan menandai event sebagai terkirim.

Konsekuensi Desain: Pola ini memberikan garansi At-Least-Once Delivery. Gangguan jaringan antara Relay Worker dan broker dapat memicu pengiriman ulang event yang sama. Konsumen (consumer) diwajibkan mengimplementasikan mekanisme Idempotency Key.

Implementasi Pola Outbox pada Django

Berikut adalah implementasi konkret pola Transactional Outbox menggunakan Django ORM dan PostgreSQL.

1. Skema Model Outbox

import uuid
from django.db import models

class OutboxEvent(models.Model):
    class Status(models.TextChoices):
        PENDING = 'PENDING', 'Pending'
        PROCESSED = 'PROCESSED', 'Processed'
        FAILED = 'FAILED', 'Failed'

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    aggregate_type = models.CharField(max_length=255, db_index=True)
    aggregate_id = models.CharField(max_length=255)
    event_type = models.CharField(max_length=255, db_index=True)
    payload = models.JSONField()
    status = models.CharField(
        max_length=20, 
        choices=Status.choices, 
        default=Status.PENDING,
        db_index=True
    )
    retry_count = models.PositiveIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    processed_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        indexes = [
            models.Index(fields=['status', 'created_at'], name='idx_outbox_status_created'),
        ]

2. Penulisan Atomik dengan Integrasi on_commit

Untuk meminimalkan latensi pemrosesan tanpa mengorbankan integritas data, gunakan transaction.on_commit() hanya untuk memicu sinyal pemrosesan (notifikasi ringan) ke background task, bukan mengirim payload langsung.

from django.db import transaction
from .models import Order, OutboxEvent
from .tasks import trigger_outbox_relay_task

def create_order(user_id: int, total_amount: float) -> Order:
    with transaction.atomic():
        order = Order.objects.create(
            user_id=user_id,
            total_amount=total_amount,
            status='CREATED'
        )

        OutboxEvent.objects.create(
            aggregate_type='Order',
            aggregate_id=str(order.id),
            event_type='OrderCreated',
            payload={
                'order_id': order.id,
                'user_id': user_id,
                'total_amount': str(total_amount),
            }
        )

        # Trigger relay worker secara instan setelah DB commit sukses
        transaction.on_commit(lambda: trigger_outbox_relay_task.delay())

    return order

3. Relay Worker Menggunakan SELECT FOR UPDATE SKIP LOCKED

Relay worker dapat berjalan via periodic job (Celery Beat) atau triggered task. Gunakan query PostgreSQL SELECT FOR UPDATE SKIP LOCKED untuk mencegah race condition antar beberapa instance worker paralel.

from django.db import transaction
from django.utils import timezone
from .models import OutboxEvent
from .producer import send_to_broker

def process_outbox_batch(batch_size: int = 100):
    with transaction.atomic():
        events = list(
            OutboxEvent.objects.select_for_update(skip_locked=True)
            .filter(status=OutboxEvent.Status.PENDING)
            .order_by('created_at')[:batch_size]
        )

        if not events:
            return

        for event in events:
            try:
                send_to_broker(
                    topic=event.aggregate_type.lower(),
                    key=event.aggregate_id,
                    value=event.payload
                )
                event.status = OutboxEvent.Status.PROCESSED
                event.processed_at = timezone.now()
            except Exception:
                event.retry_count += 1
                if event.retry_count >= 5:
                    event.status = OutboxEvent.Status.FAILED
            
            event.save(update_fields=['status', 'processed_at', 'retry_count'])

Evaluasi Trade-Off Teknis

1. Atomisitas dan Mitigasi Data Loss

Dual-Write memiliki zero guarantee terhadap konsistensi sistem terdistribusi. Transactional Outbox memindahkan koordinasi transaksi ke engine basis data ACID lokal. Risiko data loss ditekan hingga mendekati nol pada layer backend, dengan kompensasi event berpotensi terkirim lebih dari satu kali jika relay crash di tengah proses update status.

2. Dampak I/O pada PostgreSQL

Pola Outbox bukan solusi gratis; arsitektur ini memindahkan beban jaringan broker menjadi beban I/O pada database:

  • Write Amplification: Setiap transaksi bisnis menghasilkan insert tambahan ke tabel outbox, update status setelah pengiriman, dan log WAL (Write-Ahead Logging) ganda.
  • Table Bloat: Operasi UPDATE status secara intensif menghasilkan dead tuples di PostgreSQL. Autovacuum harus dikonfigurasi lebih agresif pada tabel ini.
  • Partitioning dan Purging: Tabel outbox membutuhkan strategi housekeeping rutin (misal: partitioning bulanan atau cronjob penghapusan event PROCESSED yang berusia lebih dari 7 hari) agar ukuran index tetap efisien di dalam memory buffer cache.

3. Kompleksitas Operasional: Polling vs CDC (Change Data Capture)

Terdapat dua variasi implementasi relay outbox:

  • Polling Publisher (Pendekatan di atas): Menggunakan worker berkala atau Celery task. Kelebihan: Sederhana, tidak membutuhkan infrastruktur baru. Kelemahan: Beban query ke database meningkat seiring skala trafik.
  • Transaction Log Mining / CDC: Menggunakan engine seperti Debezium yang membaca PostgreSQL Write-Ahead Log (WAL) via logical replication decoding. Kelebihan: Tidak membebani I/O query database via polling SQL. Kelemahan: Biaya operasional tinggi (membutuhkan Kafka Connect cluster, ZooKeeper/KRaft, monitoring replication slot lag).

4. Maintainability Kode

Dual-Write tampak lebih sederhana di awal tetapi menyebarkan logic failure handling di banyak tempat. Pola Outbox memisahkan secara tegas antara mutasi domain model dan integrasi message delivery (Separation of Concerns), menyederhanakan unit testing karena developer hanya perlu memverifikasi apakah record outbox tercipta di database lokal.

Matriks Keputusan: Kapan Beralih ke Outbox Pattern?

Pertimbangkan checklist berikut sebelum mengubah arsitektur:

  • Tetap gunakan Dual-Write / transaction.on_commit() langsung ke broker jika: Volume event rendah hingga menengah, event bersifat analitik/metrik (data loss tidak berdampak fatal terhadap integritas finansial), atau sistem consumer tidak mampu menangani duplicate messages.
  • Wajib migrasi ke Transactional Outbox jika: Inkonsistensi data menyebabkan dampak finansial langsung (ledger, mutasi saldo, order fulfillment), arsitektur mengandalkan Saga Pattern untuk orkestrasi microservices, atau sistem diwajibkan memenuhi audit trail ketat tanpa toleransi kehilangan data.
  • Tingkatkan Polling Outbox ke CDC (Debezium) jika: Frekuensi event melampaui kemampuan PostgreSQL menangani I/O polling (ribuan event per detik) atau tabel outbox mulai menciptakan contention pada autovacuum dan replica lag.