Bug alokasi biaya per tenant akibat race job biasanya muncul saat sistem billing atau cost accounting mulai menerima lonjakan event yang jauh lebih tinggi dari pola normal. Gejalanya terlihat sederhana: biaya tenant tiba-tiba melonjak, sebagian tenant justru negatif, invoice tidak sinkron dengan dashboard, dan tim finance atau ops mengira ada kesalahan harga, padahal sumber masalahnya ada di pipeline backend.

Studi kasus ini membahas insiden nyata yang sering terjadi pada sistem multi-tenant: ledakan workload AI memicu backlog queue, job agregasi biaya berjalan paralel untuk tenant dan periode yang sama, lalu retry memperparah keadaan karena prosesnya tidak idempoten. Akibatnya, cost entry yang sama bisa dihitung dua kali, sebagian offset diterapkan tanpa pasangan, dan saldo biaya per tenant menjadi tidak masuk akal.

Sebagai konteks, pola ini mirip dengan perbedaan antara arus kas dan beban utang yang dibahas dalam publikasi BIS: ketika volume aktivitas meningkat tajam, angka agregat bisa tampak sehat atau sebaliknya terlihat memburuk jika mekanisme pencatatan, timing, dan kewajiban yang belum terselesaikan bercampur dalam satu laporan. Dalam backend billing, analoginya adalah perbedaan antara usage event mentah, cost accrual, dan invoice state. Namun fokus artikel ini tetap teknis: bagaimana bug itu terjadi, bagaimana menginvestigasinya, dan bagaimana memperbaikinya.

Arsitektur singkat sistem yang mengalami insiden

Sebelum masuk ke debugging, penting memahami pola umum sistemnya:

  • Setiap request AI menghasilkan usage event, misalnya jumlah token, durasi inferensi, atau unit komputasi lain.
  • Usage event masuk ke database operasional atau event store.
  • Queue worker menjalankan job agregasi biaya per tenant dan per periode tertentu.
  • Hasil agregasi ditulis ke tabel ringkasan biaya, lalu dipakai oleh dashboard internal dan proses invoice.

Pada kondisi normal, desain ini cukup lazim. Masalah muncul ketika volume event naik drastis, misalnya karena tenant enterprise menjalankan batch summarization besar, embedding massal, atau evaluasi model internal. Dalam situasi seperti ini, antrean job memanjang, timeout meningkat, worker autoscale menambah paralelisme, dan asumsi lama seperti “job ini kecil dan aman dijalankan ulang” mulai runtuh.

Gejala insiden yang terlihat di produksi

1. Biaya per tenant melonjak tidak proporsional

Tenant yang biasanya stabil tiba-tiba menunjukkan kenaikan biaya beberapa kali lipat dalam kurun satu jam, padahal usage event mentah tidak naik setinggi itu.

2. Sebagian laporan menjadi negatif

Ini lebih membingungkan. Nilai negatif biasanya muncul ketika sistem memakai pola upsert increment/decrement, atau saat ada koreksi parsial yang dieksekusi ulang tanpa proteksi idempoten. Hasil akhirnya, subtotal bisa berkurang dua kali atau kredit diterapkan tanpa debit yang tepat.

3. Invoice tidak cocok dengan dashboard internal

Dashboard near-real-time mengambil data dari tabel agregat yang rusak, sedangkan invoice harian mungkin menghitung ulang dari snapshot berbeda. Tim support melihat dua angka yang saling bertentangan.

4. Log tampak “bersih”

Inilah jebakannya. Banyak insiden race condition tidak menghasilkan exception jelas. Semua job terlihat success, tetapi efek gabungannya salah.

Timeline insiden: bagaimana bug ini terpicu

Berikut timeline yang realistis untuk insiden semacam ini:

  1. 09:05 - Beberapa tenant menjalankan workload AI batch besar. Rate usage event naik tajam.
  2. 09:12 - Queue backlog mulai meningkat. Worker autoscaling menambah jumlah konsumen.
  3. 09:18 - Job agregasi biaya untuk tenant yang sama mulai berjalan paralel karena pemicu berbasis event dan scheduler harian bertemu pada waktu yang sama.
  4. 09:21 - Sebagian job timeout saat membaca event besar lalu di-retry oleh queue system.
  5. 09:25 - Karena retry tidak idempoten, sejumlah event cost diproses ulang. Di jalur lain, job koreksi saldo mengeksekusi decrement berdasarkan state lama.
  6. 09:40 - Dashboard internal menunjukkan spike biaya dan beberapa tenant bernilai negatif.
  7. 10:05 - Tim finance melaporkan invoice draft tidak konsisten dengan dashboard.

Poin pentingnya: bug bukan berasal dari satu query salah, tetapi dari interaksi antara paralelisme, retry, dan model penulisan data yang tidak aman terhadap duplikasi.

Log dan metrik yang menyesatkan

Pada insiden seperti ini, observability sering memberi sinyal yang terlihat masuk akal tetapi menyesatkan.

Metrik yang tampak normal

  • Success rate job tinggi karena job duplikat tetap menyelesaikan eksekusi tanpa error.
  • CPU worker stabil sehingga tim mengira tidak ada bottleneck aplikasi.
  • Error rate API rendah karena bug ada di pipeline asinkron, bukan request sinkron pengguna.

Sinyal yang seharusnya dicari

  • Jumlah job agregasi per tenant-periode yang berjalan bersamaan.
  • Jumlah retry untuk job billing/cost.
  • Perbedaan antara total usage event mentah vs total nilai agregat setelah diposting.
  • Distribusi nilai negatif atau delta ekstrem per tenant.

Contoh query investigasi awal

Misalkan ada tabel usage_events, cost_ledger, dan tenant_cost_summary. Langkah pertama adalah membandingkan event mentah dengan ledger dan summary.

-- Total unit usage mentah per tenant dalam interval insiden
SELECT tenant_id,
       COUNT(*) AS event_count,
       SUM(units) AS total_units
FROM usage_events
WHERE created_at >= '2026-05-10 09:00:00'
  AND created_at < '2026-05-10 10:00:00'
GROUP BY tenant_id
ORDER BY total_units DESC;

-- Total posting cost ke ledger pada interval sama
SELECT tenant_id,
       COUNT(*) AS ledger_rows,
       SUM(amount) AS total_amount
FROM cost_ledger
WHERE created_at >= '2026-05-10 09:00:00'
  AND created_at < '2026-05-10 10:00:00'
GROUP BY tenant_id
ORDER BY total_amount DESC;

-- Tenant yang memiliki summary negatif
SELECT tenant_id, period_start, period_end, total_amount
FROM tenant_cost_summary
WHERE total_amount < 0
ORDER BY total_amount ASC;

Jika perbandingan antara usage_events dan cost_ledger menunjukkan ledger rows terlalu banyak untuk tenant tertentu, itu indikasi kuat ada job duplikat atau retry yang menulis ulang posting yang sama.

Contoh pencarian job duplikat di log

grep "AggregateTenantCost" app.log \
  | grep "tenant_id=tenant_123" \
  | grep "period=2026-05-10T09"

Yang dicari bukan sekadar error, tetapi beberapa start log untuk tenant dan periode yang sama dalam jendela waktu berdekatan.

Root cause: race condition pada job agregasi biaya + retry tidak idempoten

Pola bug yang umum

Masalah inti biasanya ada pada desain seperti ini:

  • Job membaca sekumpulan usage event untuk tenant dan periode tertentu.
  • Job menghitung total biaya.
  • Job melakukan UPDATE summary SET total = total + x atau menulis ledger tanpa kunci deduplikasi.
  • Jika job timeout atau worker mati setelah sebagian write berhasil, queue me-retry job yang sama.
  • Job lain untuk tenant-periode sama ikut berjalan karena scheduler atau trigger event lain.

Jika dua job memproses himpunan event yang tumpang tindih, keduanya merasa benar. Tanpa lock dan idempotency key, database akan menerima dua penulisan sah secara sintaksis, walaupun secara bisnis hasilnya salah.

Mengapa bisa muncul nilai negatif

Nilai negatif sering terjadi ketika sistem mencoba “mengkoreksi” posting lama. Misalnya:

  • Job A menambahkan biaya +100.
  • Job A timeout setelah commit pertama, lalu retry.
  • Retry membaca state yang berbeda dan menjalankan logika kompensasi -100 sebelum menghitung ulang.
  • Job B yang berjalan paralel juga menambahkan +100 atau menghapus posting lama.

Urutan interleaving semacam ini menciptakan ringkasan yang kadang menjadi +200, 0, bahkan -100 tergantung siapa yang commit terakhir dan bagaimana koreksi dihitung.

Contoh kode yang rawan race

// Pseudocode yang bermasalah
async function aggregateTenantCost(tenantId, periodStart, periodEnd) {
  const events = await db.query(`
    SELECT id, units, unit_price
    FROM usage_events
    WHERE tenant_id = ? AND created_at >= ? AND created_at < ?
  `, [tenantId, periodStart, periodEnd]);

  const total = events.reduce((sum, e) => sum + (e.units * e.unit_price), 0);

  await db.execute(`
    UPDATE tenant_cost_summary
    SET total_amount = total_amount + ?
    WHERE tenant_id = ? AND period_start = ? AND period_end = ?
  `, [total, tenantId, periodStart, periodEnd]);

  await db.execute(`
    INSERT INTO cost_ledger (tenant_id, period_start, period_end, amount)
    VALUES (?, ?, ?, ?)
  `, [tenantId, periodStart, periodEnd, total]);
}

Ada beberapa masalah sekaligus:

  • Bukan idempoten: menjalankan fungsi dua kali menambah nilai dua kali.
  • Tidak ada lock: dua worker bisa mengeksekusi tenant-periode yang sama bersamaan.
  • Tidak ada deduplikasi ledger: insert kedua tetap lolos.
  • Incremental update berisiko: basis perhitungan bergantung pada urutan commit, bukan state final yang deterministik.

Strategi investigasi yang efektif

1. Tentukan sumber kebenaran sementara

Saat insiden berlangsung, pilih satu sumber kebenaran untuk audit. Umumnya usage_events lebih cocok daripada tabel summary. Tujuannya agar tim tidak memperbaiki berdasarkan data yang sudah terkontaminasi.

2. Identifikasi domain duplikasi

Tanyakan dengan spesifik: duplikasi berdasarkan apa?

  • Tenant + periode?
  • Tenant + invoice window?
  • Event usage individual?
  • Satu batch ingestion?

Tanpa definisi ini, sulit membuat idempotency key yang benar.

3. Cocokkan jejak job dengan write ke database

Ambil satu tenant bermasalah, lalu susun kronologi:

  1. Kapan job dijadwalkan?
  2. Berapa kali job start?
  3. Apakah ada retry?
  4. Write mana yang sudah committed?
  5. Apakah ada koreksi atau rollback parsial?

4. Cari write yang tidak punya pasangan logis

Contohnya, ada record ledger koreksi negatif tanpa record posting positif asal, atau ada dua posting dengan payload yang identik tetapi job_id berbeda.

Contoh query menemukan potensi duplikasi

SELECT tenant_id,
       period_start,
       period_end,
       amount,
       COUNT(*) AS duplicate_count
FROM cost_ledger
GROUP BY tenant_id, period_start, period_end, amount
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC;

Query ini belum sempurna, tetapi cukup untuk menemukan pola duplikasi kasar. Jika sistem sudah menyimpan source_event_id atau idempotency_key, gunakan kolom itu karena jauh lebih presisi.

Perbaikan: idempotency, locking, dan model write yang deterministik

1. Tambahkan idempotency key yang merepresentasikan unit kerja bisnis

Setiap posting biaya harus punya identitas unik yang stabil untuk satu operasi bisnis. Misalnya:

  • tenant_id + period_start + period_end + source_batch_id
  • atau tenant_id + usage_event_id jika posting dilakukan per event

Kunci ini harus disimpan di database dan dilindungi unique constraint. Jangan hanya memeriksanya di memori atau cache, karena itu tidak cukup kuat terhadap race lintas worker.

ALTER TABLE cost_ledger
ADD COLUMN idempotency_key VARCHAR(255) NOT NULL;

ALTER TABLE cost_ledger
ADD CONSTRAINT uq_cost_ledger_idempotency UNIQUE (idempotency_key);

Lalu ubah penulisan ledger menjadi aman terhadap retry:

async function postCostLedger(entry) {
  const key = `${entry.tenantId}:${entry.periodStart}:${entry.periodEnd}:${entry.batchId}`;

  await db.execute(`
    INSERT INTO cost_ledger (
      tenant_id, period_start, period_end, amount, idempotency_key
    ) VALUES (?, ?, ?, ?, ?)
    ON CONFLICT (idempotency_key) DO NOTHING
  `, [entry.tenantId, entry.periodStart, entry.periodEnd, entry.amount, key]);
}

Jika database Anda tidak mendukung sintaks yang sama, prinsipnya tetap: pakai unique constraint dan tangani konflik sebagai duplicate-safe no-op.

2. Lock unit agregasi tenant-periode

Untuk mencegah dua job agregasi yang sama berjalan bersamaan, gunakan salah satu pendekatan berikut:

  • Database row lock jika state agregasi tersentralisasi di database yang sama.
  • Advisory lock jika butuh lock berbasis kunci logis.
  • Distributed lock bila worker tersebar dan koordinasi perlu melalui Redis atau sistem serupa.

Trade-off penting:

  • Lock di database lebih sederhana dan kuat terhadap konsistensi data, tetapi bisa menambah kontensi.
  • Distributed lock lebih fleksibel, tetapi implementasinya harus hati-hati terhadap expiry dan split-brain logis.
// Pseudocode dengan lock logis
async function aggregateTenantCost(tenantId, periodStart, periodEnd) {
  const lockKey = `cost-aggregate:${tenantId}:${periodStart}:${periodEnd}`;
  const lock = await lockManager.acquire(lockKey, { ttlMs: 30000 });

  if (!lock.acquired) {
    return;
  }

  try {
    await db.transaction(async (tx) => {
      const events = await tx.query(`
        SELECT id, units, unit_price
        FROM usage_events
        WHERE tenant_id = ?
          AND created_at >= ?
          AND created_at < ?
      `, [tenantId, periodStart, periodEnd]);

      const total = events.reduce((sum, e) => sum + (e.units * e.unit_price), 0);
      const aggKey = `${tenantId}:${periodStart}:${periodEnd}`;

      await tx.execute(`
        INSERT INTO cost_ledger (tenant_id, period_start, period_end, amount, idempotency_key)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT (idempotency_key) DO NOTHING
      `, [tenantId, periodStart, periodEnd, total, aggKey]);

      await tx.execute(`
        INSERT INTO tenant_cost_summary (tenant_id, period_start, period_end, total_amount)
        VALUES (?, ?, ?, ?)
        ON CONFLICT (tenant_id, period_start, period_end)
        DO UPDATE SET total_amount = EXCLUDED.total_amount
      `, [tenantId, periodStart, periodEnd, total]);
    });
  } finally {
    await lock.release();
  }
}

Perhatikan dua perubahan penting:

  • Summary tidak lagi di-increment, tetapi ditulis sebagai hasil akhir deterministik untuk jendela tertentu.
  • Ledger dan summary diproteksi dari duplikasi melalui idempotency dan konflik unik.

3. Hindari pola “read-modify-write” yang tidak perlu

Jika laporan biaya untuk periode tertentu dapat dihitung ulang secara penuh dari source event, lebih aman menulis hasil final daripada menambah atau mengurangi subtotal lama. Pola ini mengurangi kompleksitas koreksi dan membuat retry lebih aman.

4. Pisahkan event mentah, ledger, dan projection

Praktik yang sehat:

  • Usage events sebagai data mentah yang append-only.
  • Cost ledger sebagai pencatatan biaya yang idempoten dan dapat diaudit.
  • Summary/dashboard projection sebagai turunan yang boleh dibangun ulang.

Dengan pemisahan ini, jika projection rusak, Anda tidak perlu menebak angka yang benar; cukup rebuild dari ledger atau source event yang valid.

Contoh perbaikan skema data

CREATE TABLE cost_ledger (
  id BIGSERIAL PRIMARY KEY,
  tenant_id VARCHAR(64) NOT NULL,
  period_start TIMESTAMP NOT NULL,
  period_end TIMESTAMP NOT NULL,
  amount NUMERIC(18,6) NOT NULL,
  idempotency_key VARCHAR(255) NOT NULL,
  source_type VARCHAR(32) NOT NULL,
  source_ref VARCHAR(128),
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE (idempotency_key)
);

CREATE TABLE tenant_cost_summary (
  tenant_id VARCHAR(64) NOT NULL,
  period_start TIMESTAMP NOT NULL,
  period_end TIMESTAMP NOT NULL,
  total_amount NUMERIC(18,6) NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (tenant_id, period_start, period_end)
);

Skema ini tidak bergantung pada satu framework tertentu, tetapi cukup untuk menunjukkan prinsip dasar: ada batas unik yang jelas untuk ledger dan ada satu baris summary per tenant-periode.

Memperbaiki data yang sudah terlanjur rusak

Sesudah bug diisolasi, Anda masih perlu mengoreksi data produksi dengan hati-hati.

Langkah aman

  1. Freeze sementara job agregasi untuk tenant atau periode terdampak.
  2. Tandai invoice draft yang menggunakan summary salah.
  3. Rekonstruksi biaya dari usage events atau ledger yang sudah didedup.
  4. Rebuild summary untuk window terdampak.
  5. Audit delta sebelum dan sesudah koreksi.

Contoh deduplikasi ledger berbasis idempotency key

Jika idempotency key belum ada saat insiden, proses koreksi biasanya harus berbasis heuristik yang lebih berisiko. Karena itu, sebaiknya pasca-insiden tim segera menambahkan kunci deduplikasi permanen agar koreksi berikutnya tidak manual.

Catatan: jangan langsung menghapus baris ledger produksi tanpa jejak audit. Lebih aman membuat tabel koreksi, snapshot sebelum perbaikan, atau menjalankan rebuild projection dari sumber data mentah.

Uji regresi yang wajib ditambahkan

1. Test retry tidak menggandakan biaya

it('does not double charge when the same job is retried', async () => {
  await runAggregateJob({ tenantId: 't1', period: '2026-05-10T09' });
  await runAggregateJob({ tenantId: 't1', period: '2026-05-10T09' });

  const rows = await db.query('SELECT * FROM cost_ledger WHERE tenant_id = ?', ['t1']);
  expect(rows.length).toBe(1);
});

2. Test paralelisme untuk tenant-periode sama

it('prevents concurrent aggregation for the same tenant and period', async () => {
  await Promise.all([
    runAggregateJob({ tenantId: 't1', period: '2026-05-10T09' }),
    runAggregateJob({ tenantId: 't1', period: '2026-05-10T09' })
  ]);

  const summary = await db.query(
    'SELECT total_amount FROM tenant_cost_summary WHERE tenant_id = ? AND period_start = ?',
    ['t1', '2026-05-10 09:00:00']
  );

  expect(summary[0].total_amount).toBe(/* expected deterministic value */);
});

3. Property test untuk nilai tidak boleh negatif tanpa event kredit valid

Jika domain Anda tidak mengizinkan biaya negatif kecuali ada credit note atau refund resmi, jadikan itu invariant yang diuji.

4. End-to-end test dashboard vs invoice

Bug ini berbahaya karena efeknya lintas subsistem. Uji bukan hanya job agregasi, tetapi juga apakah summary yang dipakai dashboard konsisten dengan data yang dipakai proses invoice.

Debugging tips yang sering menyelamatkan waktu

  • Tambahkan correlation ID dari job scheduler sampai write database.
  • Log unit kerja bisnis, bukan hanya stack trace. Misalnya tenant, periode, batch, retry count, dan idempotency key.
  • Hitung expected cardinality: satu tenant-periode seharusnya menghasilkan berapa summary row dan berapa ledger row.
  • Beri alarm pada anomali domain, misalnya biaya negatif, delta harian ekstrem, atau rasio ledger/event yang tiba-tiba naik.
  • Jangan percaya metrik sukses generik. Untuk pipeline finansial atau billing, “job sukses” belum tentu “hasil benar”.

Kaitannya dengan pola arus kas vs beban kewajiban

Publikasi BIS yang disebutkan di atas berguna sebagai pengingat bahwa angka agregat bisa menipu bila waktu pencatatan dan kewajiban tidak dipisahkan dengan benar. Di sistem backend billing, kesalahan yang sama muncul saat tim mencampur:

  • usage event sebagai aktivitas mentah,
  • cost accrual sebagai pengakuan biaya,
  • invoice state sebagai kewajiban yang siap ditagihkan.

Jika tiga lapisan ini tidak dibedakan secara teknis, dashboard bisa tampak sehat sementara invoice salah, atau sebaliknya biaya summary terlihat negatif meskipun aktivitas mentah tinggi. Jadi pelajarannya bukan soal keuangan, melainkan soal model data yang eksplisit, audit trail, dan kontrol konkurensi.

Checklist pencegahan untuk developer backend

  • Pastikan setiap job billing atau cost memiliki idempotency key yang stabil.
  • Tambahkan unique constraint di database, bukan hanya pengecekan di aplikasi.
  • Gunakan locking untuk unit kerja tenant-periode yang tidak boleh paralel.
  • Hindari incremental summary update jika hasil final bisa dihitung ulang secara deterministik.
  • Pisahkan source events, ledger, dan projection.
  • Simpan audit metadata: job id, retry count, source ref, correlation id.
  • Buat alarm untuk nilai negatif tak valid, lonjakan ekstrem, dan duplikasi posting.
  • Tambahkan test untuk retry, concurrency, dan rebuild projection.
  • Validasi konsistensi antara dashboard internal dan invoice pipeline.
  • Siapkan prosedur rebuild dan backfill sebelum insiden benar-benar terjadi.

Penutup

Debug backend: bug alokasi biaya per tenant akibat race job hampir selalu melibatkan kombinasi tiga hal: konkurensi yang tidak dikendalikan, retry yang tidak idempoten, dan model summary yang terlalu mudah dimutasi. Saat workload AI meledak, kelemahan ini tidak lagi tersembunyi; ia langsung muncul dalam bentuk biaya melonjak, negatif, atau invoice yang bertentangan dengan dashboard.

Solusi yang paling tahan lama bukan sekadar menambah retry atau memperbesar worker, melainkan memperbaiki desain penulisan data: buat ledger yang idempoten, kunci unit kerja yang sama, simpan summary secara deterministik, dan uji skenario paralel sejak awal. Jika itu dilakukan, insiden serupa jauh lebih mudah dicegah dan jauh lebih mudah diaudit ketika tetap terjadi.