Lonjakan antrean background task di Celery sering memicu kegagalan database dengan pesan: FATAL: too many connections for role "app_user". Masalah ini biasanya bukan disebabkan oleh kebocoran memori (memory leak), melainkan retensi koneksi database terbuka (connection leak) yang timbul akibat ketidakcocokan antara siklus hidup worker Celery dan mekanisme CONN_MAX_AGE bawaan Django.

Gejala Sistem dan Anatomi Kegagalan

Masalah muncul saat lonjakan task (spike) terjadi. Backend API dan worker mulai mengalami timeout bertahap. Ketika kapasitas max_connections pada PostgreSQL tercapai, log PostgreSQL menampilkan catatan berikut:

FATAL: remaining connection slots are reserved for non-superuser connections
FATAL: too many connections for role "app_user"

Dampaknya, request HTTP standar pada web server (Gunicorn/Uvicorn) yang memerlukan koneksi database baru akan langsung gagal dan mengembalikan HTTP 500 Internal Server Error, meski beban CPU dan RAM server aplikasi masih dalam batas aman.

Root Cause: Lifecycle HTTP Django vs Celery Worker

Django mengelola koneksi database persisten melalui parameter CONN_MAX_AGE di dalam DATABASES settings. Pembersihan koneksi usang bergantung langsung pada sinyal siklus request-response HTTP.

1. Mekanisme pada Web Server (WSGI/ASGI)

Pada alur HTTP standar, Django memanfaatkan handler sinyal berikut:

  • request_started: Memeriksa koneksi yang kadaluwarsa melalui db.close_old_connections().
  • request_finished: Menutup koneksi jika umurnya melebihi batas CONN_MAX_AGE atau menandainya untuk reuse.

Siklus ini memastikan koneksi selalu diuji dan ditutup secara terprediksi setelah respons dikirimkan ke client.

2. Masalah pada Worker Celery

Celery mengeksekusi task di luar siklus request-response HTTP. Saat Celery worker mengimpor konfigurasi Django, Celery mewarisi nilai CONN_MAX_AGE yang sama. Namun, Celery tidak menembakkan sinyal request_started maupun request_finished.

Ketika satu worker process mengeksekusi task yang mengakses ORM Django:

  1. Worker membuka koneksi TCP ke PostgreSQL.
  2. Task selesai dieksekusi, tetapi sinyal request_finished tidak pernah dipanggil.
  3. Koneksi tetap berada dalam status idle di PostgreSQL.
  4. Worker beralih mengerjakan task berikutnya atau menunggu task baru tanpa pernah menutup koneksi tersebut.

Jika Celery berjalan dengan concurrency tinggi (misalnya 4 worker node dengan opsi --concurrency=16), sebanyak 64 koneksi langsung tertahan dalam status idle secara permanen. Jika diterapkan auto-scaling worker pod di Kubernetes, lonjakan pod akan melipatgandakan jumlah koneksi terbuka hingga melampaui limit PostgreSQL.

Investigasi Menggunakan pg_stat_activity

Identifikasi sumber koneksi yang tertahan menggunakan query views PostgreSQL berikut:

SELECT 
    pid, 
    usename, 
    application_name, 
    client_addr, 
    state, 
    state_change,
    NOW() - state_change AS idle_duration
FROM pg_stat_activity
WHERE datname = 'nama_database_anda'
ORDER BY idle_duration DESC;

Periksa distribusi koneksi berdasarkan state dan application name:

SELECT 
    application_name, 
    state, 
    COUNT(*)
FROM pg_stat_activity
WHERE datname = 'nama_database_anda'
GROUP BY application_name, state
ORDER BY count DESC;

Jika ditemukan puluhan hingga ratusan baris dengan status state: idle yang berasal dari IP node Celery dengan idle_duration berjam-jam, dipastikan Celery tidak menutup koneksi database setelah task selesai.

Solusi dan Langkah Perbaikan

1. Implementasi Celery Signal Hooks (Wajib)

Paksa Django untuk membersihkan koneksi usang di setiap awal dan akhir eksekusi task Celery menggunakan sinyal task_prerun dan task_postrun.

Tambahkan konfigurasi berikut pada inisialisasi Celery aplikasi (misal di celery.py atau app/celery.py):

import os
from celery import Celery
from celery.signals import task_prerun, task_postrun
from django.db import close_old_connections

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

@task_prerun.connect
def on_task_prerun(*args, **kwargs):
    # Tutup koneksi yang sudah invalid atau melebihi CONN_MAX_AGE sebelum task jalan
    close_old_connections()

@task_postrun.connect
def on_task_postrun(*args, **kwargs):
    # Tutup koneksi usang segera setelah task selesai dieksekusi
    close_old_connections()

Catatan Teknis: Panggilan close_old_connections() memeriksa connection.close_if_unusable_or_obsolete(). Jika umur koneksi melebihi batas CONN_MAX_AGE, socket TCP akan ditutup secara eksplisit.

2. Pisahkan Konfigurasi CONN_MAX_AGE untuk Celery

Pendekatan paling aman untuk background worker adalah menonaktifkan connection reuse secara default (CONN_MAX_AGE = 0). Ini memastikan koneksi langsung ditutup setelah digunakan, mencegah kebocoran koneksi antar-task.

Ubah konfigurasi pada settings.py:

import sys

# Nilai default untuk WSGI/ASGI web server
CONN_MAX_AGE = 60 

# Override jika proses dijalankan via Celery CLI
IS_CELERY_PROCESS = any('celery' in arg for arg in sys.argv)
if IS_CELERY_PROCESS:
    CONN_MAX_AGE = 0

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'myproject_db',
        'USER': 'app_user',
        'PASSWORD': 'secret_password',
        'HOST': '127.0.0.1',
        'PORT': '5432',
        'CONN_MAX_AGE': CONN_MAX_AGE,
    }
}

3. Solusi Arsitektur: Gunakan PgBouncer

Mengatur CONN_MAX_AGE = 0 menimbulkan overhead TCP handshake dan TLS negotiation untuk setiap task yang dieksekusi. Pada sistem dengan throughput ribuan task per detik, overhead ini dapat membebani CPU PostgreSQL.

Gunakan connection pooler eksternal seperti PgBouncer dengan mode Transaction Pooling:

  • Aplikasi Django dan Celery terhubung ke PgBouncer pada port lokal (contoh: 6432).
  • PgBouncer mempertahankan pool koneksi persisten ke server PostgreSQL riil.
  • Koneksi fisik ke database hanya dialokasikan saat ada transaksi aktif, lalu segera dikembalikan ke pool begitu transaksi selesai, meskipun worker Celery masih aktif.

Pengaturan Django saat menggunakan PgBouncer transaction pooling mewajibkan CONN_MAX_AGE = 0 di sisi Django dan menonaktifkan prepared statements jika menggunakan mode transaksi murni:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'myproject_db',
        'USER': 'app_user',
        'PASSWORD': 'secret_password',
        'HOST': '127.0.0.1', # PgBouncer host
        'PORT': '6432',      # PgBouncer port
        'CONN_MAX_AGE': 0,   # PgBouncer yang mengelola lifecycle pool
        'DISABLE_SERVER_SIDE_CURSORS': True,
    }
}

Ringkasan Tindakan

Untuk menuntaskan connection exhaustion akibat Celery:

  1. Pasang hook close_old_connections() pada sinyal task_prerun dan task_postrun Celery.
  2. Set CONN_MAX_AGE = 0 khusus untuk proses runtime worker Celery guna menghentikan retensi koneksi idle.
  3. Terapkan PgBouncer jika volume task sangat tinggi untuk meniadakan latency overhead dari pembuatan koneksi baru.