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 80000Implementasi 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
Afteragar tidak menimbulkan false positive pada test berikutnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!