Debug approval workflow yang macet sering kali bukan masalah queue atau worker, melainkan perbedaan sumber kebenaran antara UI, backend, dan data keanggotaan. Dalam kasus ini, permintaan sudah terlihat disetujui mayoritas di UI, tetapi status tetap pending dan job lanjutan tidak pernah berjalan karena backend menghitung quorum dari snapshot anggota yang dibuat dari cache lama.

Studi kasus ini membahas sistem persetujuan kolektif yang terinspirasi dari praktik co-determination: keputusan tidak diambil satu orang, tetapi membutuhkan persetujuan mayoritas dari kelompok yang berwenang. Pola ini umum pada approval workflow untuk perubahan konfigurasi, anggaran, akses data, atau deployment berisiko tinggi.

Konteks Sistem: Persetujuan Kolektif dan Quorum

Misalkan sebuah request harus disetujui mayoritas anggota tim. Jika ada 5 approver aktif, quorum adalah 3. Setelah quorum tercapai, backend mengubah status request menjadi approved dan membuat job lanjutan, misalnya mengeksekusi perubahan konfigurasi.

Secara sederhana, alurnya seperti ini:

  1. User membuat approval request untuk suatu tim.
  2. Sistem menentukan daftar approver dan jumlah quorum.
  3. Approver memberikan suara.
  4. Backend menghitung apakah jumlah persetujuan sudah mencapai quorum.
  5. Jika ya, status berubah dari pending ke approved, lalu job dieksekusi.

Bug muncul ketika langkah kedua tidak konsisten. UI memakai struktur tim terbaru, sedangkan backend memakai daftar anggota dari cache lama saat request dibuat atau saat evaluasi quorum.

Gejala Bug: UI Mayoritas, Status Tetap Pending

Gejala yang terlihat oleh pengguna cukup membingungkan:

  • Di UI, request menampilkan 3 dari 5 approval, sehingga terlihat sudah mayoritas.
  • Status request di backend tetap pending.
  • Job lanjutan tidak masuk queue atau tidak pernah dieksekusi.
  • Tidak ada error eksplisit di worker karena worker hanya memproses request yang sudah approved.

Contoh skenario nyata:

  • Sebelumnya tim memiliki 7 approver, sehingga quorum adalah 4.
  • Struktur tim berubah: 2 anggota keluar, tersisa 5 approver aktif.
  • UI membaca data anggota terbaru dan menampilkan quorum 3.
  • Backend masih memakai cache lama berisi 7 anggota, sehingga quorum tetap 4.
  • Ketika 3 orang menyetujui, UI terlihat lengkap, tetapi backend masih menunggu 1 approval lagi.

Petunjuk penting: jika job tidak berjalan tetapi tidak ada error queue, periksa dulu transisi status. Banyak workflow gagal bukan karena queue, melainkan karena status tidak pernah berubah ke kondisi yang memicu job.

Log dan Metric yang Perlu Dicari

Untuk debugging approval workflow seperti ini, log harus menjawab tiga pertanyaan: quorum dihitung dari data versi mana, berapa jumlah approval valid, dan mengapa status tidak berubah.

Field log yang sebaiknya ada

Tambahkan structured log pada titik pembuatan request, pemberian vote, dan evaluasi quorum:

event=approval.quorum_evaluated
request_id=req_123
team_id=team_finance
request_status=pending
membership_version=41
cache_hit=true
eligible_approver_count=7
required_votes=4
yes_votes=3
ui_reported_required_votes=3
result=not_reached

Field membership_version, eligible_approver_count, dan required_votes sangat membantu. Tanpa itu, tim biasanya hanya melihat yes_votes=3 dan status=pending, tetapi tidak tahu angka quorum yang dipakai backend.

Metric yang berguna

  • approval_pending_age_seconds: umur request yang masih pending.
  • approval_quorum_required: distribusi quorum yang dihitung backend.
  • approval_membership_cache_hit_total: rasio cache hit untuk membership.
  • approval_membership_version_mismatch_total: jumlah kasus UI/backend atau request/current membership berbeda versi.
  • approval_pending_with_ui_majority_total: request yang menurut UI sudah mayoritas tetapi backend masih pending.

Metric terakhir biasanya tidak ada sejak awal, tetapi sangat berguna sebagai detektor regresi setelah bug ditemukan.

Reproduksi Minimal

Reproduksi yang baik harus kecil, deterministik, dan langsung menargetkan inkonsistensi membership. Berikut skenario minimal:

  1. Buat tim dengan 7 approver aktif.
  2. Panaskan cache membership agar backend menyimpan daftar 7 approver.
  3. Ubah struktur tim menjadi 5 approver aktif.
  4. Buat approval request atau evaluasi quorum sebelum cache lama invalid.
  5. Berikan 3 approval.
  6. Pastikan UI menampilkan 3/5, tetapi backend masih menghitung 3/4 dan status tetap pending.

Contoh pseudo-test:

test('request uses stale cached membership and stays pending incorrectly', async () => {
  await team.createApprovers('team-a', ['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7']);

  // Cache terisi dengan 7 approver.
  await membershipService.getApprovers('team-a');

  // Struktur tim berubah menjadi 5 approver, tetapi cache belum invalid.
  await team.removeApprovers('team-a', ['u6', 'u7']);

  const request = await approval.createRequest({ teamId: 'team-a' });

  await approval.approve(request.id, 'u1');
  await approval.approve(request.id, 'u2');
  await approval.approve(request.id, 'u3');

  const stored = await approval.getRequest(request.id);

  expect(stored.status).toBe('pending');
  expect(stored.requiredVotes).toBe(4); // Inilah bug: seharusnya 3 untuk 5 anggota aktif.
});

Test di atas sengaja menggambarkan perilaku rusak. Setelah perbaikan, ekspektasinya harus dibalik: request memakai membership version yang benar, quorum 3, dan status menjadi approved.

Root Cause: Snapshot Anggota Dibuat dari Cache Lama

Akar masalahnya bukan sekadar cache basi. Masalah utamanya adalah sistem mencampur dua konsep yang berbeda:

  • Current membership: daftar anggota aktif saat ini.
  • Request membership snapshot: daftar anggota dan quorum yang berlaku untuk request tertentu.

Approval workflow membutuhkan aturan yang eksplisit: apakah request mengikuti struktur tim saat request dibuat, atau selalu mengikuti struktur tim terbaru? Keduanya valid, tetapi harus konsisten di UI, backend, database, dan audit log.

Bug terjadi karena backend melakukan sesuatu seperti ini:

async function createApprovalRequest(teamId, payload) {
  const members = await membershipCache.getOrLoad(teamId);
  const requiredVotes = Math.floor(members.length / 2) + 1;

  return db.approvalRequests.insert({
    team_id: teamId,
    status: 'pending',
    required_votes: requiredVotes,
    payload
  });
}

Kode tersebut terlihat wajar, tetapi berbahaya jika membershipCache tidak dijamin invalid setelah perubahan tim. Request baru bisa menyimpan quorum dari data lama. Lebih buruk lagi, jika UI membaca langsung dari database terbaru, UI dan backend akan menampilkan kebenaran yang berbeda.

Perbaikan Desain

Perbaikan yang kuat biasanya membutuhkan beberapa lapisan, bukan hanya menurunkan TTL cache. TTL mengurangi durasi bug, tetapi tidak menghilangkan inkonsistensi pada waktu kritis.

1. Invalidasi cache setelah perubahan membership

Setiap perubahan struktur tim harus menghapus atau memperbarui cache membership. Lakukan invalidasi setelah transaksi perubahan membership berhasil commit, bukan sebelum commit.

async function updateTeamMembership(teamId, changes) {
  await db.transaction(async tx => {
    await membershipRepository.applyChanges(tx, teamId, changes);
    await membershipRepository.incrementVersion(tx, teamId);
  });

  // Setelah commit: cache lama tidak boleh dipakai untuk request baru.
  await cache.delete(`team:${teamId}:approvers`);
  await eventBus.publish('team.membership_changed', { teamId });
}

Trade-off-nya: invalidasi berbasis event bisa gagal jika event bus bermasalah. Karena itu, invalidasi sebaiknya dikombinasikan dengan versioned membership, sehingga sistem dapat mendeteksi cache lama, bukan sekadar berharap cache terhapus.

2. Gunakan versioned membership

Simpan versi membership pada tim, lalu tempelkan versi itu ke setiap approval request. Dengan begitu, request memiliki sumber kebenaran yang jelas.

approval_requests
- id
- team_id
- membership_version
- required_votes
- status
- created_at

approval_votes
- request_id
- approver_id
- decision
- membership_version
- created_at

team_memberships
- team_id
- user_id
- membership_version
- active
- created_at

Saat membuat request, ambil membership aktif dan versinya dalam transaksi. Jika database mendukung row-level locking, kunci baris tim atau metadata membership agar versi yang dibaca stabil selama request dibuat.

async function createApprovalRequest(teamId, payload) {
  return db.transaction(async tx => {
    const membership = await membershipRepository.getCurrentSnapshotForUpdate(tx, teamId);
    const requiredVotes = Math.floor(membership.approvers.length / 2) + 1;

    return approvalRepository.insert(tx, {
      teamId,
      membershipVersion: membership.version,
      requiredVotes,
      status: 'pending',
      payload
    });
  });
}

Dengan cara ini, UI tidak perlu menebak quorum dari current membership. UI cukup membaca required_votes dan membership_version dari request. Jika ingin menampilkan perubahan struktur tim terbaru, tampilkan sebagai informasi tambahan, bukan sebagai dasar quorum request lama.

3. Evaluasi quorum secara idempotent dalam transaksi

Pemberian approval dapat terjadi bersamaan. Dua approver bisa menekan tombol approve pada waktu hampir sama, atau client bisa mengirim ulang request karena timeout. Karena itu, operasi vote dan transisi status harus idempotent.

async function approve(requestId, approverId) {
  return db.transaction(async tx => {
    const request = await tx.one(
      `select id, status, membership_version, required_votes
       from approval_requests
       where id = $1
       for update`,
      [requestId]
    );

    if (request.status !== 'pending') {
      return request;
    }

    const eligible = await tx.exists(
      `select 1
       from team_memberships
       where team_id = (select team_id from approval_requests where id = $1)
         and user_id = $2
         and membership_version = $3
         and active = true`,
      [requestId, approverId, request.membership_version]
    );

    if (!eligible) {
      throw new Error('Approver is not eligible for this membership version');
    }

    await tx.none(
      `insert into approval_votes (request_id, approver_id, decision, membership_version)
       values ($1, $2, 'approved', $3)
       on conflict (request_id, approver_id) do nothing`,
      [requestId, approverId, request.membership_version]
    );

    const yes = await tx.one(
      `select count(*)::int as count
       from approval_votes
       where request_id = $1
         and decision = 'approved'
         and membership_version = $2`,
      [requestId, request.membership_version]
    );

    if (yes.count >= request.required_votes) {
      await tx.none(
        `update approval_requests
         set status = 'approved', approved_at = now()
         where id = $1 and status = 'pending'`,
        [requestId]
      );

      await tx.none(
        `insert into approval_outbox (request_id, event_type)
         values ($1, 'approval.approved')
         on conflict (request_id, event_type) do nothing`,
        [requestId]
      );
    }
  });
}

Beberapa hal penting dari contoh di atas:

  • for update mencegah dua transaksi mengubah status request yang sama secara tidak terkendali.
  • on conflict do nothing membuat retry dari client aman.
  • Eligibility dicek terhadap membership_version request, bukan current membership.
  • Outbox memakai constraint unik agar event job tidak terbit dua kali.

4. Migrasi data pending yang sudah terdampak

Setelah bug diperbaiki, data lama masih bisa tersangkut. Anda perlu migration atau repair job untuk request pending yang quorum-nya dihitung dari cache lama.

Strategi migrasi harus disesuaikan dengan kebijakan bisnis. Ada dua opsi umum:

  • Freeze snapshot lama: request tetap mengikuti membership saat dibuat. Cocok untuk audit ketat, tetapi sulit jika snapshot lama tidak tersimpan lengkap.
  • Rebase ke membership terbaru: request pending dievaluasi ulang menggunakan struktur tim terbaru. Cocok jika organisasi menganggap approval yang belum selesai harus mengikuti struktur saat ini.

Jika memilih rebase, lakukan secara eksplisit dan audit-able. Jangan diam-diam mengubah quorum tanpa catatan.

-- Contoh konseptual, sesuaikan dengan skema produksi.
update approval_requests ar
set membership_version = tm.current_version,
    required_votes = tm.required_votes,
    updated_at = now()
from team_membership_summary tm
where ar.team_id = tm.team_id
  and ar.status = 'pending'
  and ar.membership_version <> tm.current_version;

Setelah itu, jalankan ulang evaluator quorum untuk request pending. Jika jumlah approval valid sudah memenuhi required_votes, ubah status ke approved dan tulis event outbox secara idempotent.

5. Test regresi

Bug ini layak memiliki test regresi di level service atau integration test karena melibatkan database, cache, dan transaksi. Unit test murni sering tidak cukup.

test('approval request uses latest membership version after team structure changes', async () => {
  await team.createApprovers('team-a', ['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7']);
  await membershipService.getApprovers('team-a'); // cache warm

  await team.removeApprovers('team-a', ['u6', 'u7']); // harus invalidasi cache dan increment version

  const request = await approval.createRequest({ teamId: 'team-a' });

  expect(request.requiredVotes).toBe(3);

  await approval.approve(request.id, 'u1');
  await approval.approve(request.id, 'u2');
  await approval.approve(request.id, 'u3');

  const stored = await approval.getRequest(request.id);
  expect(stored.status).toBe('approved');

  const outbox = await approvalOutbox.findByRequest(request.id);
  expect(outbox).toHaveLength(1);
});

Tambahkan juga test untuk retry vote, concurrent approval, approver yang sudah keluar tim, dan request lama yang sengaja memakai snapshot lama.

Kesalahan Umum Saat Memperbaiki Bug Ini

  • Hanya menurunkan TTL cache. TTL lebih pendek tetap memungkinkan cache basi pada periode kritis.
  • UI menghitung quorum sendiri. UI sebaiknya menampilkan quorum dari backend untuk request tertentu.
  • Tidak menyimpan membership version. Tanpa versi, sulit menjelaskan keputusan lama dan sulit melakukan audit.
  • Transisi status tidak idempotent. Retry dan race condition bisa membuat event job ganda.
  • Migrasi data tanpa audit trail. Perubahan quorum pada request pending harus tercatat karena berdampak pada keputusan bisnis.

Checklist Debugging Cepat

  1. Ambil satu request_id yang terlihat mayoritas di UI tetapi pending di backend.
  2. Bandingkan required_votes di database dengan quorum yang ditampilkan UI.
  3. Periksa membership_version request dan versi membership tim saat ini.
  4. Cek apakah cache membership masih berisi anggota lama.
  5. Hitung ulang approval valid berdasarkan snapshot request, bukan current membership.
  6. Pastikan event/job hanya dibuat setelah status benar-benar berubah ke approved.
  7. Tambahkan log quorum evaluation sebelum memperbaiki agar dampak bug bisa diukur.

Kesimpulan

Quorum yang macet pada approval workflow biasanya terjadi karena sistem tidak memiliki batas yang jelas antara current membership dan snapshot membership request. Dalam kasus ini, cache anggota lama membuat backend menyimpan quorum 4, sementara UI menampilkan mayoritas 3 dari 5 berdasarkan struktur tim terbaru.

Perbaikan yang tahan lama adalah kombinasi dari invalidasi cache, versioned membership, transaksi idempotent, migrasi data pending, dan test regresi. Dengan desain ini, UI, backend, job worker, dan audit log memakai sumber kebenaran yang sama untuk setiap approval request.