ADR-0003. Idempotency phiếu qua partial unique index
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-06 |
| Deciders | finance-team |
| Supersedes | — |
Bối cảnh
- Delivery Kafka là at-least-once; consumer finance commit offset chỉ sau khi handler thành công (
autocommit=false). Một crash sau khi post nhưng trước commit gây redeliver. - Không có dedup, redeliver sẽ post đôi phiếu và làm hỏng số dư tài khoản.
- Các source khác nhau cần dedup key khác nhau: một sale order có thể hợp lệ nhận nhiều thanh toán tender tách (dedup theo từng-event payment), trong khi một purchase order nên book đúng một phiếu PAYMENT (dedup theo từng-source chứng từ).
- Chứng từ đã void không được chặn một re-post hợp lệ sau này của cùng source.
Quyết định
Chúng tôi sẽ thực thi idempotency trong database qua partial unique index trên FinanceVoucher, backed bởi một kiểm tra replay ở mức ứng dụng:
| Key | Điều kiện index | Dùng bởi |
|---|---|---|
(merchantId, type, sourceType, sourceId) | live, không-VOIDED, sourceType NOT IN (MANUAL, POS_SESSION, SALE_ORDER) | dedup theo từng-source (vd PURCHASE_ORDER) |
(sourceType, sourceEventUid) | live, không-VOIDED, sourceEventUid IS NOT NULL | dedup theo từng-event (SALE_ORDER attempt.uid, INVENTORY_ADJUSTMENT inventoryTrackingId) |
Trước khi post, FinanceVoucherService.tryIdempotentReplay tra cứu phiếu hiện có theo key áp dụng và, khi trúng, trả về nó (cùng các dòng ledger) thay vì post lại. Index là backstop cứng; kiểm tra ở app tránh phụ thuộc vào round-trip unique-violation. Mọi điều kiện được scope deletedAt IS NULL AND status <> 'VOIDED'.
Hệ quả
| Ưu | Nhược |
|---|---|
| Redeliver an toàn — không post đôi | Producer phải điền đúng key (sourceEventUid cho source theo từng-event; được validate, throw nếu thiếu) |
| DB là nguồn chân lý ngay cả khi đua giữa các replica consumer | Hai chiến lược dedup phải suy luận (theo từng-source vs từng-event) |
| Dòng void/xóa không bao giờ chặn re-post hợp lệ | Điều kiện index tinh tế — phải đồng bộ với FinanceVoucherSourceTypes.isDedupable* |
| Tender tách post một RECEIPT cho mỗi attempt, đúng | — |
Các phương án đã cân nhắc
| Phương án | Ưu | Nhược | Lý do loại bỏ |
|---|---|---|---|
| Dedup chỉ-app (không index DB) | Schema đơn giản hơn | Đua giữa các replica consumer có thể post đôi | Không an toàn dưới horizontal scaling |
| Một dedup key toàn cục cho mọi source | Một quy tắc | Phá vỡ tender tách (một key cho mỗi đơn) | Sai cho đơn nhiều-thanh-toán |
| Store dedup ngoài (Redis set) | Tách rời | Hạ tầng thêm; cửa sổ nhất quán so với ghi DB | Partial index DB là atomic với quá trình post |
Tham chiếu
packages/core/src/models/schemas/finance/finance-voucher/schema.ts(partial unique index)packages/core/src/models/schemas/finance/finance-voucher/constants.ts(FinanceVoucherSourceTypes.isDedupable,isDedupablePerSourceEvent)packages/core/src/services/finance/finance-voucher.service.ts(tryIdempotentReplay,_lookupExistingForReplay)- API Events — Idempotency & Ordering