Menggunakan F() expression di Django ORM adalah standar untuk mencegah race condition saat memperbarui field numerik secara atomik langsung di level database. Namun, implementasi ini sering memicu bug anomali data: saldo, kuota, atau counter bertambah dua kali lipat dari nilai yang seharusnya. Masalah ini bukan bug pada engine database, melainkan akibat pemanggilan save() sekunder pada instance model yang sama tanpa membersihkan state in-memory.

Gejala: Nilai Numerik Terakumulasi Tidak Terduga

Anomali umumnya muncul ketika alur mutasi data melibatkan callback, Django Signals (seperti post_save), atau middleware pasca-eksekusi. Contoh kasus tipikal pada sistem dompet digital (wallet):

from django.db.models import F
from django.utils import timezone
from .models import Wallet

def add_balance(wallet_id, amount):
    wallet = Wallet.objects.get(id=wallet_id)
    wallet.balance = F('balance') + amount
    wallet.save()

    # Operasi sekunder di service layer yang sama atau via signal
    wallet.last_transaction_at = timezone.now()
    wallet.save()

Jika saldo awal adalah 10.000 dan amount sebesar 5.000, saldo akhir yang diharapkan adalah 15.000. Namun, database justru menyimpan nilai 20.000. Tidak ada exception yang dilempar, seluruh transaksi lolos validasi, tetapi data numerik terakumulasi ganda.

Root Cause: Persistensi F() Expression pada Objek In-Memory

Saat Anda menetapkan wallet.balance = F('balance') + amount, atribut balance pada instance Python tidak langsung menampung nilai numerik baru. Atribut tersebut memegang objek bertipe django.db.models.expressions.CombinedExpression.

Ketika wallet.save() pertama dieksekusi, Django mengompilasi ekspresi tersebut menjadi query SQL:

UPDATE "wallet" 
SET "balance" = ("wallet"."balance" + 5000),
    "last_transaction_at" = '...'
WHERE "wallet"."id" = 1;

Query berjalan sukses dan database mengupdate nilai baris tersebut. Akan tetapi, Django tidak mengubah nilai atribut wallet.balance in-memory kembali menjadi integer. Objek model Python Anda tetap menyimpan referensi CombinedExpression yang sama.

Ketika wallet.save() kedua dipanggil (baik secara eksplisit untuk memperbarui field lain seperti last_transaction_at, maupun secara implisit di dalam signal receiver), Django kembali mengompilasi seluruh field instance ke fragmen SQL UPDATE jika parameter update_fields tidak ditentukan. Akibatnya, query penambahan saldo dieksekusi untuk kedua kalinya di database:

-- Eksekusi dari save() kedua:
UPDATE "wallet" 
SET "balance" = ("wallet"."balance" + 5000),
    "last_transaction_at" = '2023-10-27 10:00:00'
WHERE "wallet"."id" = 1;

Catatan Kritis: Signal post_save yang memodifikasi atribut instance dan memanggil instance.save() tanpa filter adalah penyebab paling umum dari evaluasi ganda F() tanpa disadari oleh developer.

Solusi Teknis dan Perbaikan

1. Refresh State In-Memory Pasca-Save

Jika instance model harus terus digunakan dalam alur eksekusi yang sama setelah save(), panggil refresh_from_db(). Batasi field yang di-refresh untuk menghindari overhead query berlebih:

def add_balance_safe(wallet_id, amount):
    wallet = Wallet.objects.get(id=wallet_id)
    wallet.balance = F('balance') + amount
    wallet.save(update_fields=['balance'])
    
    # Reload atribut dari database untuk mengonversi F() kembali menjadi integer murni
    wallet.refresh_from_db(fields=['balance'])

    wallet.last_transaction_at = timezone.now()
    wallet.save(update_fields=['last_transaction_at'])

2. Isolasi Update via QuerySet API (Rekomendasi Utama)

Pendekatan paling bersih adalah memisahkan mutasi counter atomik dari daur hidup (lifecycle) instance model Python. Gunakan QuerySet.update() untuk bypass in-memory assignment dan lifecycle save() sepenuhnya:

def add_balance_isolated(wallet_id, amount):
    # Eksekusi atomic update langsung di database
    Wallet.objects.filter(id=wallet_id).update(
        balance=F('balance') + amount,
        last_transaction_at=timezone.now()
    )

Keunggulan metode ini:

  • Tidak ada state F() yang tersimpan pada memori Python.
  • Tidak memicu side effect dari signal model yang tidak diinginkan.
  • Jauh lebih efisien secara performa database I/O.

Unit Test Regresi untuk Mencegah Evaluasi F() Ganda

Gunakan TestCase bawaan Django untuk memvalidasi bahwa pemanggilan berantai tidak mengevaluasi F() lebih dari satu kali:

from django.test import TestCase
from django.utils import timezone
from .models import Wallet
from .services import add_balance

class WalletRegressionTests(TestCase):
    def setUp(self):
        self.wallet = Wallet.objects.create(balance=10000)

    def test_add_balance_evaluates_atomically_once(self):
        deposit_amount = 5000
        
        # Eksekusi fungsi bisnis yang dites
        add_balance(self.wallet.id, deposit_amount)
        
        # Pastikan data yang tersimpan di DB tepat bertambah satu kali
        self.wallet.refresh_from_db()
        expected_balance = 10000 + deposit_amount
        
        self.assertEqual(
            self.wallet.balance,
            expected_balance,
            f"Expected balance {expected_balance}, but got {self.wallet.balance}. F() likely evaluated twice."
        )

    def test_consecutive_saves_do_not_duplicate_f_expression(self):
        self.wallet.balance = F('balance') + 2000
        self.wallet.save(update_fields=['balance'])
        self.wallet.refresh_from_db(fields=['balance'])
        
        # Simulasi pemanggilan save kedua oleh middleware atau signal
        self.wallet.last_transaction_at = timezone.now()
        self.wallet.save(update_fields=['last_transaction_at'])
        
        self.wallet.refresh_from_db()
        self.assertEqual(self.wallet.balance, 12000)

Terapkan QuerySet.update() untuk operasi hitungan cepat, atau wajibkan refresh_from_db(fields=[...]) setiap kali Anda menetapkan F() pada atribut model instance yang masih aktif dalam thread eksekusi.