ADR-0004. Worker idempotency via InventoryTracking lookup
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-12 |
| Deciders | inventory-team |
| Supersedes | — |
Context
- All Kafka topics are at-least-once. Same message can be redelivered on consumer crash, rebalance, or replay.
InventoryStockRepository.adjustStockis not idempotent — re-applying the same delta double-counts.- We need a per-
(saleOrderId, stockId)and per-(purchaseOrderId, stockId)deduplication that survives restarts.
Decision
Before any stock mutation, the worker queries InventoryTracking by (referenceType, referenceId, inventoryStockId):
- If a row exists → handler short-circuits, commits Kafka offset, no stock change.
- If no row exists → proceed with
adjustStock+ insert tracking row in the same flow.
InventoryTracking is the deduplication ledger. The bulk of inventory's idempotency is reading from it before any write.
Consequences
| Pros | Cons |
|---|---|
| Single mechanism for all worker handlers | Adds 1 query per stock mutation |
| Tracking row doubles as both audit log and dedup key | If tracking insert fails after adjustStock succeeds, on retry we double-deduct (rare; mitigated by transactions) |
| No external state store (Redis, DB lock table) | Tracking lookup must be indexed for performance |
| Replay tooling works against any past timeframe | Tracking grows unbounded — needs partitioning at scale |
Idempotency keys per topic
| Topic | Key |
|---|---|
PAYMENT_SUCCESS | (SALE_ORDER, saleOrderId, stockId) |
KITCHEN_TICKET_ITEM_STATUS_CHANGED | (KITCHEN_TICKET_ITEM, kitchenTicketItemId, materialStockId) |
MATERIAL_TRANSFERRED | (transferId, fromStockId) + (transferId, toStockId) |
| Merchant CDC | ensureDefaultLocation(merchantId) self-idempotent |
| ProductVariant CDC | ensureInventoryItem(...) self-idempotent |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
Separate IdempotencyKey table | Cleaner conceptual separation | Yet another table; same data already in tracking | Redundant |
| Redis SETEX with TTL | Fast lookup | Lost on Redis flush; no audit trail | Fragile; tracking is needed anyway |
| Kafka's transactional / exactly-once mode | DB-Kafka coordination | Significantly more complex; broker version constraints | Overkill for our workload |
References
inventory/src/services/inventory-worker.service.ts(idempotency check pattern)inventory/src/services/material-worker.service.ts(same pattern for materials)core/src/models/schemas/inventory/inventory-tracking/schema.ts(deduplication index)