Menguji sistem antrean (queue) terdistribusi menuntut verifikasi konkurensi nyata. Unit test dengan isolasi mock kerap gagal mendeteksi masalah konkurensi seperti double spending, kondisi balapan (race condition), atau stale cache. Pendekatan Behavior-Driven Development (BDD) menggunakan Gherkin memungkinkan tim merekayasa skenario deterministik untuk memverifikasi mekanisme distributed lock, konsistensi cache, dan idempotensi.

Tantangan State Terdistribusi pada Queue Worker

Saat dua atau lebih instance worker memproses pesan terkait entitas yang sama secara paralel, tiga risiko utama muncul:

  • Race condition pada state mutasi: Dua proses membaca state awal yang sama dan saling menimpa perubahan (lost update).
  • Duplikasi eksekusi (at-least-once delivery): Broker pesan seperti RabbitMQ atau AWS SQS dapat mengirimkan pesan yang sama lebih dari sekali akibat network partition atau timeout ACK.
  • Stale cache reads: Worker membaca data usang sebelum proses sebelumnya menyelesaikan invalidasi Redis/Memcached.

Pengujian harus mengeksekusi integrasi nyata terhadap instance Redis dan database pengujian guna memvalidasi locking dan serialisasi transaksi secara akurat.

Menulis Skenario Gherkin untuk Skenario Konkurensi

Kunci pengujian konkurensi di Gherkin adalah mendefinisikan eksekusi paralel secara eksplisit pada langkah When dan memvalidasi efek samping akhir pada Then.

Feature: Pemrosesan Pembayaran Worker Terdistribusi

  Background:
    Given database dompet bersih
    And instance Redis lock manager aktif
    And akun "ACC-101" memiliki saldo 100000

  Scenario: Menghindari race condition saldo via Distributed Lock
    When 2 worker memproses debet sebesar 70000 secara paralel untuk akun "ACC-101":
      | message_id | request_id | amount |
      | msg-001    | req-abc-1  | 70000  |
      | msg-002    | req-abc-2  | 70000  |
    Then 1 pesan berhasil diproses
    And 1 pesan gagal karena lock contention atau saldo tidak cukup
    And saldo akhir akun "ACC-101" di database adalah 30000
    And key lock Redis "lock:account:ACC-101" telah dilepas

  Scenario: Idempotensi saat broker mengirim pesan duplikat
    When worker menerima pesan debet berulang dengan idempotency key "req-dup-99":
      | delivery_attempt | amount |
      | 1                | 20000  |
      | 2                | 20000  |
    Then saldo akhir akun "ACC-101" di database adalah 80000
    And entri audit log transaksi "req-dup-99" tepat 1 baris
    And cache Redis "cache:account:ACC-101" bernilai 80000

Implementasi Step Definition dengan Concurrency Barrier

Gunakan barrier sinkronisasi (seperti Promise.all di Node.js atau CountDownLatch / sync.WaitGroup di platform lain) pada step definition agar worker mengeksekusi pesan tepat pada waktu yang sama.

const { Given, When, Then } = require('@cucumber/cucumber');
const assert = require('assert');
const { WalletWorker } = require('../src/worker');
const { db, redisClient } = require('../src/infrastructure');

let executionResults = [];

Given('akun {string} memiliki saldo {int}', async function (accountId, initialBalance) {
  await db('wallets').insert({ id: accountId, balance: initialBalance })
    .onConflict('id').merge();
  await redisClient.set(`cache:account:${accountId}`, initialBalance);
});

When('{int} worker memproses debet sebesar {int} secara paralel untuk akun {string}:',
  async function (workerCount, amount, accountId, dataTable) {
    const messages = dataTable.hashes();
    executionResults = [];

    // Eksekusi paralel via Promise.all tanpa delay artifisial
    executionResults = await Promise.allSettled(
      messages.map(msg => {
        const worker = new WalletWorker(db, redisClient);
        return worker.handle({
          messageId: msg.message_id,
          requestId: msg.request_id,
          accountId: accountId,
          amount: parseInt(msg.amount, 10)
        });
      })
    );
  }
);

Then('{int} pesan berhasil diproses', function (successCount) {
  const fulfilled = executionResults.filter(r => r.status === 'fulfilled' && r.value.success);
  assert.strictEqual(fulfilled.length, successCount);
});

Then('saldo akhir akun {string} di database adalah {int}', async function (accountId, expectedBalance) {
  const row = await db('wallets').where({ id: accountId }).first();
  assert.strictEqual(row.balance, expectedBalance);
});

Then('key lock Redis {string} telah dilepas', async function (lockKey) {
  const exists = await redisClient.exists(lockKey);
  assert.strictEqual(exists, 0, `Lock key ${lockKey} masih tertahan`);
});

Pola Logika Worker: Redis Redlock dan Invalidation

Di balik step definition tersebut, worker wajib menerapkan strategi penanganan locking, transaksi data, dan invalidasi cache secara tepat:

class WalletWorker {
  constructor(db, redis) {
    this.db = db;
    this.redis = redis;
  }

  async handle(payload) {
    const { requestId, accountId, amount } = payload;
    const lockKey = `lock:account:${accountId}`;
    const lockTtlMs = 3000;

    // 1. Akuisisi lock via Redis SET NX PX
    const acquired = await this.redis.set(lockKey, requestId, 'NX', 'PX', lockTtlMs);
    if (!acquired) {
      throw new Error('Lock contention: Gagal memperoleh lock');
    }

    try {
      // 2. Cek idempotensi pada database storage
      return await this.db.transaction(async (trx) => {
        const processed = await trx('processed_requests').where({ request_id: requestId }).first();
        if (processed) {
          return { success: true, duplicate: true };
        }

        const wallet = await trx('wallets').where({ id: accountId }).first();
        if (wallet.balance < amount) {
          throw new Error('Insufficient balance');
        }

        await trx('wallets').where({ id: accountId }).decrement('balance', amount);
        await trx('processed_requests').insert({ request_id: requestId });

        // 3. Cache Invalidation di dalam siklus transaksi atau segera pasca commit
        await this.redis.del(`cache:account:${accountId}`);
        return { success: true, duplicate: false };
      });
    } finally {
      // 4. Pastikan lock dilepas hanya jika ID lock cocok
      const releaseLua = `
        if redis.call("get", KEYS[1]) == ARGV[1] then
          return redis.call("del", KEYS[1])
        else
          return 0
        end`;
      await this.redis.eval(releaseLua, 1, lockKey, requestId);
    }
  }
}

Trade-Off dan Rekomendasi Eksekusi

Menguji skenario konkurensi di level BDD membawa trade-off teknis yang perlu diperhitungkan:

  • Test Execution Time: Integrasi langsung dengan service Redis dan Database (misal via Testcontainers) lebih lambat dibanding unit test mock murni, namun memberikan jaminan keandalan state.
  • Deterministic vs Flaky: Gunakan sinkronisasi Promise.all atau barrier thread. Hindari pemakaian sleep() acak di dalam skenario pengujian.
  • Clean Teardown: Pastikan setiap skenario membersihkan keyspace Redis dan skema database secara menyeluruh melalui hook After agar tidak menimbulkan false positive pada test berikutnya.