← Back to the section

Almost every application has this task: accumulate rows in a table and process them in the background. Payments, notifications, reports — anything. The first solution is quick to write: a schedule, fetch a batch, loop. It even works — until the first deploy with two replicas, or the first error in an external system.

This article explains what exactly breaks and how to make background processing reliable.

The Typical First Approach

Here's the code you'll find in most projects:

@app.task
def run_payment_batch() -> None:
    with session_factory() as session:
        batch = session.execute(
            select(Payment)
            .where(Payment.status == PaymentStatus.NEW)
            .order_by(Payment.created_at)
            .limit(100)
        ).scalars().all()
        for payment in batch:
            try:
                payment_service.process(payment.id)
            except Exception:
                logger.warning("Failed: %s", payment.id, exc_info=True)


app.conf.beat_schedule = {
    "payment-batch": {"task": "run_payment_batch", "schedule": 300.0},
}

Looks reasonable: every 5 minutes we take 100 rows with status NEW and process them. But this code has several serious problems.

What Breaks Here

One Bad Record Jams the Queue

The query is sorted by creation date: ORDER BY created_at ASC. This means that on the next run the worker will pick up the very same rows it did last time.

If one payment fails with an error every time, it never goes away. It stays at the front of the queue and, together with a hundred equally "stuck" records, blocks all the new ones. New payments pile up but never get processed.

There is no attempt counter. There is no delayed retry. There is no terminal "gave up" status.

Stuck Records After a Restart

The worker takes a record and immediately sets its status to PROCESSING. Then it calls the external system. If the service crashes at that moment, the status update back to NEW never happens. The record stays in PROCESSING forever.

The worker selects only NEW, so it will never pick up such a "stuck" record again. After every restart under load, a few of these records accumulate — and nobody knows about them until a customer complains.

Double Processing With Two Replicas

You start two instances of the service — both workers read the same 100 rows at the same time. One payment gets sent twice. For notifications that's an annoyance; for payments it's a serious incident.

There is no locking on the query, and several parallel workers have no way to coordinate with each other.

One Hung Call Stops Everything

Processing is sequential: one payment → a call to the external system → the next payment. If the external system "thinks" about one request, the whole loop stalls and waits. There are no timeouts. The other 99 payments sit in the queue.

How to Do It Right: a Task Queue on PostgreSQL

For most cases you don't need a separate message broker — the database is already there, and transactions already work. You just need the right mechanics on top of it.

Step 1: Add the Necessary Columns

ALTER TABLE payment
    ADD COLUMN attempts        int         NOT NULL DEFAULT 0,
    ADD COLUMN next_attempt_at timestamptz NOT NULL DEFAULT now(),
    ADD COLUMN claimed_until   timestamptz;
  • attempts — how many times we've tried to process it.
  • next_attempt_at — when it can be tried again (for retries with backoff).
  • claimed_until — until when the record is "held" by a worker.

Step 2: Atomically Claim a Batch

The key idea is that claiming and processing must be separate transactions. Claiming is a short operation that atomically marks records as "taken for work" right inside the UPDATE statement:

CLAIM_BATCH_SQL = text("""
    UPDATE payment SET
        status        = 'PROCESSING',
        attempts      = attempts + 1,
        claimed_until = now() + interval '5 minutes'
    WHERE id IN (
        SELECT id FROM payment
        WHERE (status = 'NEW' AND next_attempt_at <= now())
           OR (status = 'PROCESSING' AND claimed_until < now())
        ORDER BY created_at
        LIMIT :limit
        FOR UPDATE SKIP LOCKED
    )
    RETURNING id
""")


def claim_batch(limit: int) -> list[UUID]:
    with session_factory.begin() as session:
        rows = session.execute(CLAIM_BATCH_SQL, {"limit": limit})
        return [row.id for row in rows]

The key line is FOR UPDATE SKIP LOCKED. It's a PostgreSQL construct: several workers run this query at the same time, but each one skips rows that are already locked and takes the next free ones. There will be no double processing — the database distributes the work between workers itself, without any coordinator.

claimed_until solves the "stuck records" problem: if a worker died before finishing, after 5 minutes the condition claimed_until < now() brings the record back into the selection.

Step 3: Process Outside the Transaction

We've claimed the records and committed — now we call the external system. The HTTP call happens between transactions, not inside one:

@app.task
def run_payment_batch() -> None:
    claimed = claim_batch(limit=100)
    for payment_id in claimed:
        process_one.delay(payment_id)


@app.task
def process_one(payment_id: UUID) -> None:
    try:
        decision = limits_client.check(payment_id)
        finish_successfully(payment_id, decision)
    except Exception as ex:
        schedule_retry(payment_id, ex)


app.conf.beat_schedule = {
    "payment-batch": {"task": "run_payment_batch", "schedule": 5.0},
}

Several important changes compared to the first approach:

  • Records are processed in parallel — each one goes to the celery worker pool as a separate task instead of running sequentially in one loop. One slow call doesn't block the rest.
  • The schedule is now every 5 seconds instead of every 5 minutes — an empty poll is cheap (one fast query), and processing latency drops from minutes to seconds.
  • The limits_client HTTP client must have timeouts. Without an explicit timeout, one hung call will hold a pool worker forever.

Step 4: Retries With Backoff and Terminal Failure

When processing fails, the record does not stay at the front of the queue. It goes to the back with a delay:

def schedule_retry(payment_id: UUID, ex: Exception) -> None:
    with session_factory.begin() as session:
        payment = session.get(Payment, payment_id)
        if payment.attempts >= MAX_ATTEMPTS:
            payment.mark_failed(str(ex))  # terminal FAILED status
        else:
            payment.schedule_next_attempt(backoff(payment.attempts))  # back to NEW with a delay

backoff is a function that increases the pause with each attempt: for example, 1 min → 2 min → 4 min → 8 min. After N attempts the record gets the FAILED status with the error reason — a signal for a human to look into it manually. The queue never jams.

When You Need a Batch Processing Framework

Airflow, Dagster, Prefect — these are tools for finite jobs: export a monthly payment register to a file, migrate ten million rows, re-price all customers once a quarter.

Their key capability is restart from the point of failure: "it crashed on the 7 millionth row — restarted, continued from the 7 millionth." To do that, the framework stores the run's state and supports skip and retry policies at the level of data chunks.

For our task (a continuous queue of incoming payments) such a framework is overkill: the stream has no end, restart-from-a-point isn't needed, and the questions of parallel claiming and retries stay the same — the framework doesn't solve them.

A simple rule: a finite batch with restart → a framework; an endless stream of tasks → a task queue on PostgreSQL.

When to Add a Message Broker

A broker (RabbitMQ, Kafka) solves the same problems as a queue on PostgreSQL, but adds new capabilities: several independent types of consumers for a single event, scaling workers without loading the main database, built-in dead-letter queues.

Moving to a broker makes sense when volumes grow to tens of thousands of tasks per minute, or when a single event has to be processed by several different services. Before that, a broker is just extra infrastructure with operational costs.

In Short

  • A plain celery beat loop breaks with parallel replicas, stuck records, and sequential HTTP calls without timeouts.
  • The right way to claim tasks is an atomic UPDATE with FOR UPDATE SKIP LOCKED: the database distributes the work between workers itself.
  • claimed_until (the "task lease") protects against stuck records after a restart — an expired lease is picked up by workers automatically.
  • HTTP calls go between transactions, not inside them; processing is parallel through a worker pool, not sequential.
  • Retries with growing backoff and a terminal FAILED status keep the queue from jamming.
  • A batch processing framework is for finite jobs with restart; a broker is for scale and multiple consumers.
  • Resilience Patterns — timeouts, retries, and the circuit breaker for calling external systems.
  • Distributed Patterns — transactional outbox and idempotency when working with a broker.
  • @Transactional in Spring — why transaction boundaries matter and where declarative management fails to kick in.