python 114 lines · 3 tabs

Transactional Outbox with Timer-Based Flushing and Exponential Backoff in Django

Shared by codesnips Sep 2026
3 tabs
import uuid
from django.db import models
from django.utils import timezone


class OutboxManager(models.Manager):
    def available(self, limit=100):
        now = timezone.now()
        return (
            self.filter(status=OutboxMessage.PENDING, next_retry_at__lte=now)
            .order_by("created_at")[:limit]
        )


class OutboxMessage(models.Model):
    PENDING, SENT, FAILED = "pending", "sent", "failed"
    STATUS_CHOICES = [(PENDING, "Pending"), (SENT, "Sent"), (FAILED, "Failed")]

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    topic = models.CharField(max_length=200)
    payload = models.JSONField()
    dedupe_key = models.CharField(max_length=255, unique=True)
    status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=PENDING)
    attempts = models.PositiveIntegerField(default=0)
    max_attempts = models.PositiveIntegerField(default=8)
    last_error = models.TextField(blank=True, default="")
    next_retry_at = models.DateTimeField(default=timezone.now)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = OutboxManager()

    class Meta:
        indexes = [
            models.Index(fields=["status", "next_retry_at"]),
        ]

    def __str__(self):
        return "{}:{} ({})".format(self.topic, self.dedupe_key, self.status)
3 files · python Explain with highlit

The transactional outbox pattern solves a stubborn distributed-systems problem: how to atomically commit a database change and publish a message about it. Writing to the database and then calling a broker in the same request is not atomic — a crash between the two leaves the system inconsistent. Instead the message is written to an outbox table inside the same transaction as the business change, and a separate process later reads pending rows and publishes them. This snippet builds that flusher end to end in Django.

In models.py, OutboxMessage is the durable queue row. Each record carries a topic, a JSON payload, and a dedupe_key that is unique, giving idempotency: producers can safely retry writing the same logical event without creating duplicates. The status, attempts, and next_retry_at columns track delivery state, and available() is a manager method that selects rows that are PENDING and due, ordered oldest-first so delivery is roughly FIFO.

In flusher.py, flush_once() is the heart of the worker. It opens a transaction and uses select_for_update(skip_locked=True) so multiple flusher processes can run concurrently without stepping on each other — Postgres hands each worker a disjoint batch and skips rows another worker already locked. Each message is passed to a publish callable; on success the row is marked SENT, and on failure _schedule_retry computes an exponential backoff (2 ** attempts seconds, capped) and pushes next_retry_at into the future, or marks the row FAILED once max_attempts is exhausted. Because publishing happens outside the row's own commit isn't possible here, the code keeps the DB transaction short and tolerates at-least-once delivery — the consumer must dedupe on dedupe_key.

In tasks.py, flush_outbox is a Celery task wrapping flush_once, and Celery Beat schedules it on a fixed interval so pending messages drain on a timer even when no new writes arrive. enqueue_message shows the producer side: it writes the outbox row inside transaction.atomic() alongside whatever business logic committed the change. The main trade-offs are added write amplification and eventual (not instant) delivery, in exchange for never losing a message to a mid-flight crash. This approach is worth reaching for whenever a service must reliably emit events to Kafka, a webhook, or another service.


Related snips

Share this code

Here's the card — post it anywhere.

Transactional Outbox with Timer-Based Flushing and Exponential Backoff in Django — share card
Link copied