Eksekusi ganda pada background task Celery sering terjadi pada task berdurasi panjang saat menggunakan Redis sebagai message broker. Worker kedua tiba-tiba menjalankan task yang sama persis padahal worker pertama masih memprosesnya. Masalah ini bukan bug pada kode aplikasi, melainkan ketidaksesuaian antara durasi pemrosesan task dengan parameter visibility_timeout pada Redis broker.

Akar Masalah: Bagaimana Redis Mensimulasikan Message Acknowledgment

Redis bukan native message broker seperti RabbitMQ yang memiliki protokol AMQP dan mekanisme heartbeat-based unacknowledged message tracking. Untuk mengelola siklus hidup pesan, Celery mengimplementasikan mekanisme konfirmasi manual di atas struktur data Redis.

Ketika worker mengambil task dari antrean Redis (biasanya berupa Redis List):

  1. Pesan dipindahkan dari antrean utama ke set khusus bernama unacked dengan timestamp eksekusi.
  2. Broker memulai perhitungan mundur berdasarkan nilai visibility_timeout.
  3. Jika task selesai sebelum batas waktu, worker mengirimkan ACK (acknowledgment) dan menghapus pesan dari set unacked.
  4. Jika durasi task melampaui batas visibility_timeout sebelum worker mengirim ACK, broker menganggap worker tersebut telah mati (crashed). Pesan otomatis dikembalikan ke antrean utama dan diambil oleh worker lain.

Akibatnya, dua worker memproses task yang sama secara bersamaan. Fenomena ini menyebabkan duplikasi transaksi database, lonjakan beban server, dan mutasi data yang tidak konsisten.

Visibility Timeout vs Prefetch Multiplier

Banyak pengembang keliru mengira duplikasi task berkaitan dengan worker_prefetch_multiplier. Keduanya mengontrol aspek antrean yang berbeda secara mendasar:

  • visibility_timeout: Menentukan batas waktu broker menunggu konfirmasi (ACK) sebelum pesan yang sedang diproses dialihkan ke worker lain. Pengaturan ini berada di level broker transport.
  • worker_prefetch_multiplier: Menentukan berapa banyak pesan yang diambil dan disimpan di memori worker per proses/thread konkurensi sebelum dieksekusi. Pengaturan ini berada di level worker lokal untuk mencegah kelaparan task (task starvation).

Mengubah worker_prefetch_multiplier = 1 hanya memastikan worker tidak menimbun antrean lokal, tetapi tidak akan mencegah Redis menjadwalkan ulang task yang durasi berjalannya melampaui batas visibilitas.

Konfigurasi Tepat di Django settings.py

Penyelesaian masalah ini membutuhkan sinkronisasi antara tiga parameter: visibility_timeout, task_time_limit, dan task_acks_late. Rumus dasarnya adalah: visibility_timeout > task_time_limit > task_soft_time_limit.

# settings.py

# 1. Pastikan visibility_timeout lebih besar dari durasi terlama yang mungkin dicapai task.
# Contoh: Task maksimal berjalan 2 jam (7200 detik), set visibility_timeout ke 2.5 jam (9000 detik).
CELERY_BROKER_TRANSPORT_OPTIONS = {
    'visibility_timeout': 9000,
}

# 2. Aktifkan acks_late agar pesan hanya di-ACK setelah task SELESAI dijalankan,
# bukan saat task baru diambil oleh worker.
CELERY_TASK_ACKS_LATE = True

# 3. Tetapkan batasan waktu eksekusi agar task tidak menggantung selamanya.
CELERY_TASK_SOFT_TIME_LIMIT = 7000  # Melempar exception SoftTimeLimitExceeded untuk clean-up
CELERY_TASK_TIME_LIMIT = 7200       # Hard kill proses worker jika melampaui batas ini

# 4. Hindari penumpukan pesan berdurasi panjang pada memori worker
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
Catatan Kritis: Jika Anda mengaktifkan CELERY_TASK_ACKS_LATE = True tanpa menaikkan visibility_timeout, potensi dobel eksekusi justru meningkat. Pesan belum di-ACK saat proses berjalan, sehingga begitu batas visibilitas terlewati, broker langsung mengirim ulang task tersebut.

Mitigasi Arsitektural: Hindari Task Raksasa

Menaikkan visibility_timeout hingga berjam-jam membawa konsekuensi negatif: jika worker benar-benar mati akibat kegagalan sistem (misalnya OOM killer atau listrik padam), pesan baru akan dijadwalkan ulang setelah waktu timeout tersebut habis. Solusi yang lebih sehat secara arsitektural adalah mendesain task berdurasi singkat.

1. Pemecahan Job Granular (Task Chunking)

Alih-alih memproses 100.000 data dalam satu task berdurasi satu jam, pecah data menjadi batch kecil yang selesai dalam hitungan detik menggunakan Celery group atau chunks:

from celery import shared_task, group

@shared_task
def process_batch(record_ids):
    # Memproses sub-kumpulan data dalam rentang waktu < 30 detik
    for record_id in record_ids:
        handle_record(record_id)

def dispatch_records(all_ids):
    chunk_size = 500
    chunks = [all_ids[i:i + chunk_size] for i in range(0, len(all_ids), chunk_size)]
    job = group(process_batch.s(c) for c in chunks)
    job.apply_async()

2. Idempotensi Melalui Redis Distributed Lock

Untuk task yang tidak dapat dipecah, terapkan idempotensi dengan distributed lock menggunakan Redis. Jika worker kedua menerima task duplikat, lock akan mencegah eksekusi ulang:

from contextlib import contextmanager
from django.core.cache import cache
from celery import shared_task

@contextmanager
def task_lock(lock_id, expire_seconds=3600):
    # cache.add bersifat atomik; bernilai False jika key sudah ada
    acquired = cache.add(lock_id, 'true', timeout=expire_seconds)
    try:
        yield acquired
    finally:
        if acquired:
            cache.delete(lock_id)

@shared_task(bind=True)
def generate_monthly_report(self, report_id):
    lock_key = f"celery_lock:report:{report_id}"
    
    with task_lock(lock_key, expire_seconds=3600) as acquired:
        if not acquired:
            # Task sudah berjalan di worker lain, lewati eksekusi
            return f"Report {report_id} is already being processed."
        
        # Eksekusi operasi berat
        build_report(report_id)
        return f"Report {report_id} completed."

Kesimpulan

Dobel eksekusi Celery pada broker Redis bukan disebabkan oleh bug worker, melainkan mekanisme perlindungan Redis yang mengembalikan pesan unacked ke antrean setelah visibility_timeout terlampaui. Sinkronkan konfigurasi dengan aturan visibility_timeout > task_time_limit, gunakan task_acks_late secara cermat, dan prioritaskan arsitektur task granular dengan pengamanan distributed lock untuk sistem antrean yang andal.