ADR-0003. Phát hành bất đồng bộ trên BullMQ 3 phân vùng với hashing order tất định
| Trường | Giá trị |
|---|---|
| Status | Accepted |
| Date | 2026-04-15 |
| Deciders | invoice-team |
| Supersedes | — |
Bối cảnh
- Phát hành qua provider là I/O-bound (HTTP tới VNIS/VNPAY/T-VAN) và có thể lỗi tạm thời — không được chặn Kafka consumer hay request REST.
- Cùng một order không bao giờ được phát hành đồng thời trên nhiều worker (rủi ro phát hành kép).
- Throughput phải scale ngang trong khi giữ serialization theo từng order.
- Retry cần backoff có giới hạn; lỗi vĩnh viễn (4xx) không được retry.
Quyết định
Chúng ta sẽ phát hành hoá đơn bất đồng bộ qua BullMQ với 3 phân vùng mỗi loại queue (issuance, claim-expiry). Một order được định tuyến tới phân vùng bằng getPartitionByKey(orderId) — Java-hashCode mod 3 tất định — nên cùng order luôn rơi vào cùng phân vùng. Job phát hành dùng jobId = orderId để idempotency.
Chính sách retry đến từ InvoiceProviderConfig.retryMetadata (mặc định maxRetryCount = 3, retryDelayMinutes = [5, 15, 60]). Lỗi 4xx vĩnh viễn (≠429) short-circuit thành FAILED; job cạn lượt/DLQ lật hoá đơn sang FAILED và ghi một hàng audit. Concurrency worker phát hành là APP_ENV_INVOICE_ISSUANCE_WORKER_CONCURRENCY (mặc định 10); claim-expiry cố định 3.
Hệ quả
| Ưu | Nhược |
|---|---|
| Serialization theo từng order không cần lock toàn cục | Số phân vùng (3) là hằng cố định |
| Scale ngang qua núm concurrency | Tái cân bằng phân vùng sau này đổi ánh xạ order→partition |
| Backoff có giới hạn; lỗi vĩnh viễn fail nhanh | Trạng thái retry nằm trên hàng hoá đơn (retryCount, metadata) |
| Claim-expiry là job delay (không polling) | Xử lý DLQ là riêng biệt theo từng loại worker |
Phương án đã cân nhắc
| Phương án | Ưu | Nhược | Vì sao loại |
|---|---|---|---|
| Phát hành đồng bộ trong handler Kafka | Đơn giản nhất | Chặn consumer; không cô lập retry | Lỗi provider tạm thời làm nghẽn pipeline |
| Một queue (không phân vùng) | Định tuyến đơn giản | Không có affinity theo order; concurrency rủi ro phát hành kép | Mất bảo đảm serialization |
| Distributed lock mỗi order | Loại trừ tương hỗ rõ ràng | Tranh chấp lock + rủi ro rò khi lỗi | Hashing phân vùng đạt được điều này miễn phí |
| Cron-poll chỉ cho hoá đơn pending | Không cần hạ tầng queue | Latency cao cho mode REAL_TIME | Chỉ giữ cho mode SCHEDULED |
Tham chiếu
src/common/queues.ts(InvoiceQueuePartitions,getPartitionByKey, definitions)src/components/invoice-queue/component.ts(queue/worker phân vùng, DLQ)src/services/invoice-issuance-queue.service.ts(enqueueIssuance,_handleIssuanceFailure)- Xem thêm: API Events §3