atomic-queues 1.2.8 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +363 -636
- package/dist/decorators/decorators.d.ts +123 -4
- package/dist/decorators/decorators.d.ts.map +1 -1
- package/dist/decorators/decorators.js +218 -10
- package/dist/decorators/decorators.js.map +1 -1
- package/dist/domain/interfaces.d.ts +127 -1
- package/dist/domain/interfaces.d.ts.map +1 -1
- package/dist/module/atomic-queues.module.d.ts.map +1 -1
- package/dist/module/atomic-queues.module.js +2 -0
- package/dist/module/atomic-queues.module.js.map +1 -1
- package/dist/services/cron-manager/cron-manager.service.d.ts +23 -1
- package/dist/services/cron-manager/cron-manager.service.d.ts.map +1 -1
- package/dist/services/cron-manager/cron-manager.service.js +150 -41
- package/dist/services/cron-manager/cron-manager.service.js.map +1 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/processor-discovery/processor-discovery.service.d.ts +49 -4
- package/dist/services/processor-discovery/processor-discovery.service.d.ts.map +1 -1
- package/dist/services/processor-discovery/processor-discovery.service.js +262 -14
- package/dist/services/processor-discovery/processor-discovery.service.js.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.d.ts +126 -12
- package/dist/services/queue-bus/queue-bus.service.d.ts.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.js +274 -22
- package/dist/services/queue-bus/queue-bus.service.js.map +1 -1
- package/dist/services/queue-events-manager/index.d.ts +2 -0
- package/dist/services/queue-events-manager/index.d.ts.map +1 -0
- package/dist/services/queue-events-manager/index.js +18 -0
- package/dist/services/queue-events-manager/index.js.map +1 -0
- package/dist/services/queue-events-manager/queue-events-manager.service.d.ts +124 -0
- package/dist/services/queue-events-manager/queue-events-manager.service.d.ts.map +1 -0
- package/dist/services/queue-events-manager/queue-events-manager.service.js +355 -0
- package/dist/services/queue-events-manager/queue-events-manager.service.js.map +1 -0
- package/dist/services/queue-manager/queue-manager.service.d.ts +15 -1
- package/dist/services/queue-manager/queue-manager.service.d.ts.map +1 -1
- package/dist/services/queue-manager/queue-manager.service.js +17 -3
- package/dist/services/queue-manager/queue-manager.service.js.map +1 -1
- package/dist/services/spawn-queue/index.d.ts +2 -0
- package/dist/services/spawn-queue/index.d.ts.map +1 -0
- package/dist/services/spawn-queue/index.js +18 -0
- package/dist/services/spawn-queue/index.js.map +1 -0
- package/dist/services/spawn-queue/spawn-queue.service.d.ts +119 -0
- package/dist/services/spawn-queue/spawn-queue.service.d.ts.map +1 -0
- package/dist/services/spawn-queue/spawn-queue.service.js +273 -0
- package/dist/services/spawn-queue/spawn-queue.service.js.map +1 -0
- package/dist/services/worker-manager/worker-manager.service.d.ts +59 -1
- package/dist/services/worker-manager/worker-manager.service.d.ts.map +1 -1
- package/dist/services/worker-manager/worker-manager.service.js +142 -12
- package/dist/services/worker-manager/worker-manager.service.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,836 +1,563 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://img.shields.io/npm/v/atomic-queues?style=flat-square&color=cb3837" alt="npm version" />
|
|
3
|
+
<img src="https://img.shields.io/badge/NestJS-11-ea2845?style=flat-square&logo=nestjs" alt="NestJS 11" />
|
|
4
|
+
<img src="https://img.shields.io/badge/BullMQ-5-3c873a?style=flat-square" alt="BullMQ 5" />
|
|
5
|
+
<img src="https://img.shields.io/badge/Redis-7-dc382d?style=flat-square&logo=redis&logoColor=white" alt="Redis 7" />
|
|
6
|
+
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License" />
|
|
7
|
+
</p>
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
<h1 align="center">atomic-queues</h1>
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
**atomic-queues** solves the fundamental concurrency problem in distributed systems: ensuring that operations on the same logical entity execute sequentially, even when requests arrive simultaneously across multiple service instances.
|
|
10
|
-
|
|
11
|
-
Rather than relying on distributed locks—which introduce contention, latency degradation, and complex failure modes—this library implements a **per-entity queue architecture** where each entity (user account, game table, order, document) has its own dedicated processing queue and worker.
|
|
11
|
+
<p align="center">
|
|
12
|
+
<strong>Zero-contention, per-entity sequential processing for NestJS.</strong><br/>
|
|
13
|
+
Distributed. Lock-free.
|
|
14
|
+
</p>
|
|
12
15
|
|
|
13
16
|
---
|
|
14
17
|
|
|
15
|
-
##
|
|
18
|
+
## Why atomic-queues?
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
Distributed locks (Redlock, advisory locks, optimistic locking) all share the same fundamental flaw: **contention collapse**. When multiple pods fight for the same lock simultaneously, they spend more time retrying failed acquisitions than doing actual work. The harder you push, the slower they go.
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
Time Request A Request B Database State
|
|
23
|
-
─────────────────────────────────────────────────────────────────────────────────
|
|
24
|
-
T₀ SELECT balance → $100 SELECT balance → $100 balance = $100
|
|
25
|
-
T₁ CHECK: $100 >= $80 ✓ CHECK: $100 >= $80 ✓
|
|
26
|
-
T₂ UPDATE: balance = $20 UPDATE: balance = $20 balance = $20
|
|
27
|
-
T₃ UPDATE: balance = -$60 balance = -$60
|
|
28
|
-
─────────────────────────────────────────────────────────────────────────────────
|
|
29
|
-
Result: Both withdrawals succeed. Balance becomes -$60. Integrity violated.
|
|
30
|
-
```
|
|
22
|
+
**atomic-queues** eliminates contention entirely. Instead of locking, each entity gets its own dedicated BullMQ queue. Operations execute sequentially — back-to-back with zero wasted cycles. There's nothing to contend over.
|
|
31
23
|
|
|
32
|
-
|
|
24
|
+
### atomic-queues vs Redlock
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
|
|
38
|
-
| **
|
|
39
|
-
| **
|
|
40
|
-
| **
|
|
41
|
-
| **
|
|
42
|
-
|
|
43
|
-
**
|
|
26
|
+
| | Redlock | atomic-queues |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Architecture** | Distributed mutex (quorum-based) | Per-entity queue (sequential) |
|
|
29
|
+
| **Under contention** | Degrades — retry storms, backoff delays | **Constant** — jobs queue up, execute instantly |
|
|
30
|
+
| **Per-entity throughput** | ~20-50 ops/s (heavy contention) | **~200-300 ops/s** (queue-bound, no contention) |
|
|
31
|
+
| **Failure mode** | Silent double-execution (clock drift) | Job stuck in queue (visible, retryable) |
|
|
32
|
+
| **Split-brain risk** | Yes (timing assumptions) | **Impossible** (serial queue) |
|
|
33
|
+
| **Warm-path overhead** | 5-7ms per op (acquire + release) | **0ms** (in-memory hot cache) |
|
|
34
|
+
| **Cold-start** | None | ~2-3ms one-time per entity |
|
|
35
|
+
| **Multi-pod scaling** | Contention increases with pods | **Throughput increases with pods** |
|
|
44
36
|
|
|
45
37
|
---
|
|
46
38
|
|
|
47
|
-
##
|
|
48
|
-
|
|
49
|
-
### Design Principle
|
|
50
|
-
|
|
51
|
-
Instead of serializing at execution time, **serialize at ingestion time**:
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
┌─────────────────────────────────────────┐
|
|
55
|
-
Request A ─┐ │ Per-Entity Queue │
|
|
56
|
-
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │
|
|
57
|
-
Request B ─┼──▶ [Entity Router] ─┼─▶│ Op₁ │→│ Op₂ │→│ Op₃ │→ [Worker] ─┐ │
|
|
58
|
-
│ │ └─────┘ └─────┘ └─────┘ │ │
|
|
59
|
-
Request C ─┘ │ │ │
|
|
60
|
-
│ Sequential Processing ◄─────────┘ │
|
|
61
|
-
└─────────────────────────────────────────┘
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Operations targeting the same entity are immediately routed to that entity's queue. A dedicated worker processes operations one at a time, guaranteeing:
|
|
65
|
-
|
|
66
|
-
1. **Serialized Execution**: Operations execute in FIFO order
|
|
67
|
-
2. **Consistent State Visibility**: Each operation sees the result of all prior operations
|
|
68
|
-
3. **Isolation**: No interleaving of concurrent modifications
|
|
69
|
-
|
|
70
|
-
### Correctness Under Load
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
Time Queue State Worker Execution Database State
|
|
74
|
-
───────────────────────────────────────────────────────────────────────────────────
|
|
75
|
-
T₀ [Withdraw $80, Withdraw $80] balance = $100
|
|
76
|
-
T₁ [Withdraw $80] Process Op₁: $100 - $80 balance = $20
|
|
77
|
-
T₂ [] Process Op₂: $20 < $80 → REJECT balance = $20
|
|
78
|
-
───────────────────────────────────────────────────────────────────────────────────
|
|
79
|
-
Result: First withdrawal succeeds. Second is rejected. Integrity preserved.
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
---
|
|
83
|
-
|
|
84
|
-
## Comparative Analysis
|
|
85
|
-
|
|
86
|
-
### Behavioral Characteristics
|
|
87
|
-
|
|
88
|
-
| Characteristic | Distributed Locks | Per-Entity Queues |
|
|
89
|
-
|------------------------- |-------------------------------------- |------------------- |
|
|
90
|
-
| **Request Handling** | Block until lock acquired | Queue immediately, return |
|
|
91
|
-
| **Latency Distribution** | Bimodal (fast if uncontested) | Predictable (queue depth × avg processing time) |
|
|
92
|
-
| **Throughput Ceiling** | Limited by lock contention | Limited only by worker processing rate |
|
|
93
|
-
| **Failure Recovery** | Stuck locks until TTL expiration | Failed jobs retry or move to dead-letter queue |
|
|
94
|
-
| **Ordering Guarantees** | Non-deterministic (race to acquire) | Deterministic FIFO |
|
|
95
|
-
| **Observability** | Lock wait times difficult to measure | Queue depth, throughput directly observable |
|
|
39
|
+
## Table of Contents
|
|
96
40
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
│ ╭───╯ ╭───╯ (contention ceiling)
|
|
109
|
-
│ ╭───╯ ╭───╯
|
|
110
|
-
│ ╭───╯ ╭───────╯
|
|
111
|
-
│╭───╯ ╭───────╯
|
|
112
|
-
├──────╯
|
|
113
|
-
└──────────────────────────────────────────────▶ Concurrent Requests
|
|
114
|
-
|
|
115
|
-
Lock-based systems hit a contention ceiling where adding more
|
|
116
|
-
requests increases wait time faster than throughput.
|
|
117
|
-
|
|
118
|
-
Queue-based systems scale linearly: each entity's queue is
|
|
119
|
-
independent, so Entity A's load doesn't affect Entity B.
|
|
120
|
-
```
|
|
41
|
+
- [Why atomic-queues?](#why-atomic-queues)
|
|
42
|
+
- [How It Works](#how-it-works)
|
|
43
|
+
- [Installation](#installation)
|
|
44
|
+
- [Quick Start](#quick-start)
|
|
45
|
+
- [Commands & Decorators](#commands--decorators)
|
|
46
|
+
- [Configuration](#configuration)
|
|
47
|
+
- [Distributed Worker Lifecycle](#distributed-worker-lifecycle)
|
|
48
|
+
- [Complete Example](#complete-example)
|
|
49
|
+
- [Advanced: Custom Worker Processors](#advanced-custom-worker-processors)
|
|
50
|
+
- [Performance](#performance)
|
|
51
|
+
- [License](#license)
|
|
121
52
|
|
|
122
53
|
---
|
|
123
54
|
|
|
124
|
-
##
|
|
55
|
+
## How It Works
|
|
125
56
|
|
|
126
|
-
###
|
|
57
|
+
### The Problem
|
|
127
58
|
|
|
128
|
-
|
|
59
|
+
Every distributed system eventually hits this:
|
|
129
60
|
|
|
130
61
|
```
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
│ │ │ │ │ │ │
|
|
140
|
-
│ │ ┌────────┴───────────────────┴───────────────────┴────────┐ │ │
|
|
141
|
-
│ │ │ Worker Heartbeat Registry │ │ │
|
|
142
|
-
│ │ │ ACC-001 → node-1 | ACC-002 → node-2 | ACC-003 → node-1 │ │ │
|
|
143
|
-
│ │ └──────────────────────────────────────────────────────────┘ │ │
|
|
144
|
-
│ └────────────────────────────────────────────────────────────────────────────┘ │
|
|
145
|
-
└─────────────────────────────────────────────────────────────────────────────────┘
|
|
146
|
-
│ │ │
|
|
147
|
-
▼ ▼ ▼
|
|
148
|
-
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
|
149
|
-
│ Service Node 1 │ │ Service Node 2 │ │ Service Node 3 │
|
|
150
|
-
│ │ │ │ │ │
|
|
151
|
-
│ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │
|
|
152
|
-
│ │ Worker ACC-001│ │ │ │ Worker ACC-002│ │ │ │ Worker ACC-004│ │
|
|
153
|
-
│ │ Worker ACC-003│ │ │ │ Worker ACC-005│ │ │ │ Worker ACC-006│ │
|
|
154
|
-
│ └───────────────┘ │ │ └───────────────┘ │ │ └───────────────┘ │
|
|
155
|
-
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
|
62
|
+
Time Request A Request B Database
|
|
63
|
+
──────────────────────────────────────────────────────────────────────────
|
|
64
|
+
T₀ SELECT balance → $100 SELECT balance → $100 $100
|
|
65
|
+
T₁ CHECK: $100 ≥ $80 ✓ CHECK: $100 ≥ $80 ✓
|
|
66
|
+
T₂ UPDATE: $100 − $80 = $20 $20
|
|
67
|
+
T₃ UPDATE: $100 − $80 = $20 −$60
|
|
68
|
+
──────────────────────────────────────────────────────────────────────────
|
|
69
|
+
Result: Balance is −$60. Both withdrawals succeed. Integrity violated.
|
|
156
70
|
```
|
|
157
71
|
|
|
158
|
-
|
|
159
|
-
- Each entity has exactly one active worker at any time (enforced via heartbeat TTL)
|
|
160
|
-
- Workers spawn on-demand when jobs arrive for an entity
|
|
161
|
-
- Workers terminate after configurable idle period
|
|
162
|
-
- Node failure → heartbeat expires → worker respawns on healthy node
|
|
72
|
+
### The Solution
|
|
163
73
|
|
|
164
|
-
|
|
74
|
+
atomic-queues routes operations through per-entity queues. Same entity → same queue → sequential execution. Different entities → parallel queues → full throughput.
|
|
165
75
|
|
|
166
76
|
```
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
│ Process jobs until │◄─────── Idle Timeout
|
|
185
|
-
│ queue empty + idle │ (configurable)
|
|
186
|
-
└──────────┬──────────┘
|
|
187
|
-
│
|
|
188
|
-
▼
|
|
189
|
-
┌─────────────────────┐
|
|
190
|
-
│ Worker terminates │
|
|
191
|
-
│ Heartbeat expires │
|
|
192
|
-
└─────────────────────┘
|
|
77
|
+
┌─────────────────────────────────────────────────┐
|
|
78
|
+
Request A ─┐ │ Entity: account-42 │
|
|
79
|
+
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
80
|
+
Request B ─┼─► Route ─┼─►│ Op 1 │─►│ Op 2 │─►│ Op 3 │─► [Worker] ──┐ │
|
|
81
|
+
│ │ └──────┘ └──────┘ └──────┘ │ │
|
|
82
|
+
Request C ─┘ │ Sequential ◄─────────────┘ │
|
|
83
|
+
└─────────────────────────────────────────────────┘
|
|
84
|
+
|
|
85
|
+
┌─────────────────────────────────────────────────┐
|
|
86
|
+
Request D ─┐ │ Entity: account-99 │
|
|
87
|
+
│ │ ┌──────┐ ┌──────┐ │
|
|
88
|
+
Request E ─┼─► Route ─┼─►│ Op 1 │─►│ Op 2 │─────────► [Worker] ──┐ │
|
|
89
|
+
│ │ └──────┘ └──────┘ │ │
|
|
90
|
+
Request F ─┘ │ Sequential ◄───────────┘ │
|
|
91
|
+
└─────────────────────────────────────────────────┘
|
|
92
|
+
|
|
93
|
+
▲ These two queues run in PARALLEL across pods ▲
|
|
193
94
|
```
|
|
194
95
|
|
|
195
|
-
**
|
|
96
|
+
**Key properties:**
|
|
97
|
+
- **One worker per entity** — enforced via Redis heartbeat TTL. No duplicates, ever.
|
|
98
|
+
- **Auto-spawn** — workers materialize when jobs arrive, on the pod that sees them first.
|
|
99
|
+
- **Auto-terminate** — idle workers shut down after a configurable timeout.
|
|
100
|
+
- **Self-healing** — node failure → heartbeat expires → worker respawns on a healthy pod.
|
|
101
|
+
- **Distributed** — workers spread across all pods via atomic `SET NX` claim. No leader election, no single point of failure.
|
|
196
102
|
|
|
197
103
|
---
|
|
198
104
|
|
|
199
|
-
##
|
|
105
|
+
## Installation
|
|
200
106
|
|
|
201
|
-
|
|
107
|
+
```bash
|
|
108
|
+
# npm
|
|
109
|
+
npm install atomic-queues bullmq ioredis
|
|
202
110
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
| **Finance** | Account, Wallet | Deposits, withdrawals, transfers, balance queries |
|
|
206
|
-
| **Gaming** | Game, Match | Player actions, state transitions, bet processing |
|
|
207
|
-
| **E-Commerce** | Order, Cart | Add/remove items, apply discounts, checkout |
|
|
208
|
-
| **Collaboration** | Document | Edits, comments, permission changes |
|
|
209
|
-
| **IoT** | Device | Command dispatch, state synchronization |
|
|
111
|
+
# pnpm
|
|
112
|
+
pnpm add atomic-queues bullmq ioredis
|
|
210
113
|
|
|
211
|
-
|
|
114
|
+
# yarn
|
|
115
|
+
yarn add atomic-queues bullmq ioredis
|
|
116
|
+
```
|
|
212
117
|
|
|
213
|
-
|
|
214
|
-
- **Parallelizable operations**: Use standard job queues (BullMQ, SQS) without entity affinity
|
|
215
|
-
- **Fire-and-forget notifications**: Use pub/sub (Redis Pub/Sub, Kafka) without ordering guarantees
|
|
216
|
-
- **Short critical sections (<10ms)**: Distributed locks may suffice if contention is low
|
|
118
|
+
**Peer dependencies:** NestJS 10+, `@nestjs/cqrs` (optional — for auto-routing commands/queries)
|
|
217
119
|
|
|
218
120
|
---
|
|
219
121
|
|
|
220
|
-
## Installation
|
|
221
|
-
|
|
222
|
-
```bash
|
|
223
|
-
npm install atomic-queues bullmq ioredis
|
|
224
|
-
```
|
|
225
|
-
|
|
226
122
|
## Quick Start
|
|
227
123
|
|
|
228
124
|
### 1. Configure the Module
|
|
229
125
|
|
|
230
126
|
```typescript
|
|
231
127
|
import { Module } from '@nestjs/common';
|
|
128
|
+
import { CqrsModule } from '@nestjs/cqrs';
|
|
232
129
|
import { AtomicQueuesModule } from 'atomic-queues';
|
|
233
130
|
|
|
234
131
|
@Module({
|
|
235
132
|
imports: [
|
|
133
|
+
CqrsModule,
|
|
236
134
|
AtomicQueuesModule.forRoot({
|
|
237
135
|
redis: { host: 'localhost', port: 6379 },
|
|
238
136
|
keyPrefix: 'myapp',
|
|
137
|
+
entities: {
|
|
138
|
+
account: {
|
|
139
|
+
queueName: (id) => `account-${id}-queue`,
|
|
140
|
+
workerName: (id) => `account-${id}-worker`,
|
|
141
|
+
maxWorkersPerEntity: 1,
|
|
142
|
+
idleTimeoutSeconds: 15,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
239
145
|
}),
|
|
240
146
|
],
|
|
241
147
|
})
|
|
242
148
|
export class AppModule {}
|
|
243
149
|
```
|
|
244
150
|
|
|
245
|
-
|
|
151
|
+
> **Tip:** The `entities` config is optional. Without it, default naming applies: `{keyPrefix}:{entityType}:{entityId}:queue`.
|
|
246
152
|
|
|
247
|
-
|
|
153
|
+
### 2. Define Commands
|
|
248
154
|
|
|
249
155
|
```typescript
|
|
250
|
-
|
|
251
|
-
|
|
156
|
+
import { QueueEntity, QueueEntityId } from 'atomic-queues';
|
|
157
|
+
|
|
158
|
+
@QueueEntity('account')
|
|
159
|
+
export class WithdrawCommand {
|
|
252
160
|
constructor(
|
|
253
|
-
public readonly
|
|
254
|
-
public readonly items: string[],
|
|
161
|
+
@QueueEntityId() public readonly accountId: string,
|
|
255
162
|
public readonly amount: number,
|
|
256
163
|
) {}
|
|
257
164
|
}
|
|
258
165
|
|
|
259
|
-
|
|
260
|
-
export class
|
|
166
|
+
@QueueEntity('account')
|
|
167
|
+
export class DepositCommand {
|
|
261
168
|
constructor(
|
|
262
|
-
public readonly
|
|
263
|
-
public readonly
|
|
169
|
+
@QueueEntityId() public readonly accountId: string,
|
|
170
|
+
public readonly amount: number,
|
|
264
171
|
) {}
|
|
265
172
|
}
|
|
266
173
|
```
|
|
267
174
|
|
|
268
|
-
### 3.
|
|
175
|
+
### 3. Write Handlers (standard @nestjs/cqrs)
|
|
269
176
|
|
|
270
177
|
```typescript
|
|
271
|
-
import {
|
|
272
|
-
import { WorkerProcessor } from 'atomic-queues';
|
|
178
|
+
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
273
179
|
|
|
274
|
-
@
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
workerName: (orderId) => `order-${orderId}-worker`,
|
|
278
|
-
})
|
|
279
|
-
@Injectable()
|
|
280
|
-
export class OrderProcessor {}
|
|
281
|
-
```
|
|
180
|
+
@CommandHandler(WithdrawCommand)
|
|
181
|
+
export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
|
|
182
|
+
constructor(private readonly repo: AccountRepository) {}
|
|
282
183
|
|
|
283
|
-
|
|
184
|
+
async execute({ accountId, amount }: WithdrawCommand) {
|
|
185
|
+
// SAFE: No race conditions. Sequential execution per account.
|
|
186
|
+
const account = await this.repo.findById(accountId);
|
|
284
187
|
|
|
285
|
-
|
|
188
|
+
if (account.balance < amount) {
|
|
189
|
+
throw new InsufficientFundsError(accountId, account.balance, amount);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
account.balance -= amount;
|
|
193
|
+
await this.repo.save(account);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 4. Enqueue Jobs
|
|
286
199
|
|
|
287
200
|
```typescript
|
|
288
201
|
import { Injectable } from '@nestjs/common';
|
|
289
202
|
import { QueueBus } from 'atomic-queues';
|
|
290
|
-
import { OrderProcessor } from './order.processor';
|
|
291
|
-
import { ProcessOrderCommand, ShipOrderCommand } from './commands';
|
|
292
203
|
|
|
293
204
|
@Injectable()
|
|
294
|
-
export class
|
|
205
|
+
export class AccountService {
|
|
295
206
|
constructor(private readonly queueBus: QueueBus) {}
|
|
296
207
|
|
|
297
|
-
async
|
|
298
|
-
|
|
299
|
-
await this.queueBus
|
|
300
|
-
.forProcessor(OrderProcessor)
|
|
301
|
-
.enqueue(new ProcessOrderCommand(orderId, items, amount));
|
|
302
|
-
|
|
303
|
-
await this.queueBus
|
|
304
|
-
.forProcessor(OrderProcessor)
|
|
305
|
-
.enqueue(new ShipOrderCommand(orderId, '123 Main St'));
|
|
208
|
+
async withdraw(accountId: string, amount: number) {
|
|
209
|
+
await this.queueBus.enqueue(new WithdrawCommand(accountId, amount));
|
|
306
210
|
}
|
|
307
211
|
}
|
|
308
212
|
```
|
|
309
213
|
|
|
310
|
-
That's it
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
214
|
+
**That's it.** The library automatically:
|
|
215
|
+
1. Creates a queue for each `accountId` when jobs arrive
|
|
216
|
+
2. Spawns a worker (spread across pods) to process jobs sequentially
|
|
217
|
+
3. Routes jobs to the correct `@CommandHandler` via CQRS
|
|
218
|
+
4. Terminates idle workers after the configured timeout
|
|
219
|
+
5. Self-heals if a pod dies (heartbeat expires → respawn elsewhere)
|
|
315
220
|
|
|
316
221
|
---
|
|
317
222
|
|
|
318
|
-
##
|
|
223
|
+
## Commands & Decorators
|
|
319
224
|
|
|
320
|
-
|
|
321
|
-
╔═══════════════════════════════════════════════════════════════════════════════╗
|
|
322
|
-
║ ARCHITECTURE ║
|
|
323
|
-
╚═══════════════════════════════════════════════════════════════════════════════╝
|
|
324
|
-
|
|
325
|
-
┌──────────────────┐
|
|
326
|
-
│ API Request │ POST /accounts/ACC-123/withdraw { amount: 80 }
|
|
327
|
-
└────────┬─────────┘
|
|
328
|
-
│
|
|
329
|
-
▼
|
|
330
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
331
|
-
│ QueueBus.forProcessor(AccountProcessor).enqueue(new WithdrawCommand(...)) │
|
|
332
|
-
└────────┬─────────────────────────────────────────────────────────────────────┘
|
|
333
|
-
│
|
|
334
|
-
│ ① Reads @WorkerProcessor metadata from AccountProcessor
|
|
335
|
-
│ ② Extracts accountId from command.accountId property
|
|
336
|
-
│ ③ Generates queue name: "account-ACC-123-queue"
|
|
337
|
-
│
|
|
338
|
-
▼
|
|
339
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
340
|
-
│ REDIS │
|
|
341
|
-
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
|
342
|
-
│ │ Queue: account-ACC-123-queue │ │
|
|
343
|
-
│ │ ┌─────────────────┬─────────────────┬─────────────────┐ │ │
|
|
344
|
-
│ │ │ Job 1 │ Job 2 │ Job 3 │ ... │ │
|
|
345
|
-
│ │ │ WithdrawCommand │ DepositCommand │ TransferCommand │ │ │
|
|
346
|
-
│ │ │ amount: 80 │ amount: 50 │ amount: 25 │ │ │
|
|
347
|
-
│ │ └─────────────────┴─────────────────┴─────────────────┘ │ │
|
|
348
|
-
│ └────────────────────────────────────────────────────────────────────────┘ │
|
|
349
|
-
│ │
|
|
350
|
-
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
|
351
|
-
│ │ Queue: account-ACC-456-queue (different account = different queue) │ │
|
|
352
|
-
│ │ ┌─────────────────┐ │ │
|
|
353
|
-
│ │ │ Job 1 │ ← Processes in parallel with ACC-123 │ │
|
|
354
|
-
│ │ │ WithdrawCommand │ │ │
|
|
355
|
-
│ │ └─────────────────┘ │ │
|
|
356
|
-
│ └────────────────────────────────────────────────────────────────────────┘ │
|
|
357
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
358
|
-
│
|
|
359
|
-
│ ④ BullMQ Worker pulls Job 1 (only one job at a time per queue)
|
|
360
|
-
│
|
|
361
|
-
▼
|
|
362
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
363
|
-
│ Worker: account-ACC-123-worker │
|
|
364
|
-
│ │
|
|
365
|
-
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
|
|
366
|
-
│ │ ⑤ Lookup "WithdrawCommand" in QueueBus.globalRegistry │ │
|
|
367
|
-
│ │ ⑥ Instantiate: Object.assign(new WithdrawCommand(), job.data) │ │
|
|
368
|
-
│ │ ⑦ Execute: CommandBus.execute(withdrawCommand) │ │
|
|
369
|
-
│ └─────────────────────────────────────────────────────────────────────────┘ │
|
|
370
|
-
└────────┬─────────────────────────────────────────────────────────────────────┘
|
|
371
|
-
│
|
|
372
|
-
▼
|
|
373
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
374
|
-
│ @CommandHandler(WithdrawCommand) │
|
|
375
|
-
│ class WithdrawHandler { │
|
|
376
|
-
│ async execute(cmd: WithdrawCommand) { │
|
|
377
|
-
│ // Safe! No race conditions - guaranteed sequential execution │
|
|
378
|
-
│ const balance = await this.repo.getBalance(cmd.accountId); │
|
|
379
|
-
│ if (balance < cmd.amount) throw new InsufficientFundsError(); │
|
|
380
|
-
│ await this.repo.debit(cmd.accountId, cmd.amount); │
|
|
381
|
-
│ } │
|
|
382
|
-
│ } │
|
|
383
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
384
|
-
```
|
|
225
|
+
### `@QueueEntity(entityType)`
|
|
385
226
|
|
|
386
|
-
|
|
227
|
+
Marks a command/query class for queue routing.
|
|
387
228
|
|
|
388
|
-
|
|
229
|
+
```typescript
|
|
230
|
+
@QueueEntity('account')
|
|
231
|
+
export class TransferCommand { ... }
|
|
232
|
+
```
|
|
389
233
|
|
|
390
|
-
###
|
|
234
|
+
### `@QueueEntityId()`
|
|
391
235
|
|
|
392
|
-
|
|
236
|
+
Marks the property that contains the entity ID. One per class.
|
|
393
237
|
|
|
394
238
|
```typescript
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
.enqueueAndWait(new MyQuery(entityId));
|
|
404
|
-
|
|
405
|
-
// Enqueue multiple commands
|
|
406
|
-
await queueBus
|
|
407
|
-
.forProcessor(MyProcessor)
|
|
408
|
-
.enqueueBulk([
|
|
409
|
-
new CommandA(entityId),
|
|
410
|
-
new CommandB(entityId),
|
|
411
|
-
]);
|
|
412
|
-
|
|
413
|
-
// With job options (delay, priority, etc.)
|
|
414
|
-
await queueBus
|
|
415
|
-
.forProcessor(MyProcessor)
|
|
416
|
-
.enqueue(new MyCommand(entityId), {
|
|
417
|
-
jobOptions: { delay: 5000, priority: 1 }
|
|
418
|
-
});
|
|
239
|
+
@QueueEntity('account')
|
|
240
|
+
export class TransferCommand {
|
|
241
|
+
constructor(
|
|
242
|
+
@QueueEntityId() public readonly accountId: string, // Routes to this account's queue
|
|
243
|
+
public readonly targetAccountId: string,
|
|
244
|
+
public readonly amount: number,
|
|
245
|
+
) {}
|
|
246
|
+
}
|
|
419
247
|
```
|
|
420
248
|
|
|
421
|
-
###
|
|
249
|
+
### `@WorkerProcessor(options)`
|
|
422
250
|
|
|
423
|
-
|
|
251
|
+
Optional. Define a processor class for custom job handling on top of CQRS auto-routing.
|
|
424
252
|
|
|
425
253
|
```typescript
|
|
426
254
|
@WorkerProcessor({
|
|
427
|
-
entityType: '
|
|
428
|
-
queueName: (id) => `
|
|
429
|
-
workerName: (id) => `
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
stalledInterval: 1000, // Check stalled jobs (ms)
|
|
433
|
-
lockDuration: 30000, // Job lock duration (ms)
|
|
434
|
-
},
|
|
255
|
+
entityType: 'account',
|
|
256
|
+
queueName: (id) => `account-${id}-queue`,
|
|
257
|
+
workerName: (id) => `account-${id}-worker`,
|
|
258
|
+
maxWorkersPerEntity: 1,
|
|
259
|
+
idleTimeoutSeconds: 15,
|
|
435
260
|
})
|
|
261
|
+
@Injectable()
|
|
262
|
+
export class AccountProcessor {
|
|
263
|
+
@JobHandler('special-audit')
|
|
264
|
+
async handleAudit(job: Job, entityId: string) { ... }
|
|
265
|
+
}
|
|
436
266
|
```
|
|
437
267
|
|
|
438
|
-
|
|
268
|
+
### `@JobHandler(jobName)` / `@JobHandler('*')`
|
|
439
269
|
|
|
440
|
-
|
|
270
|
+
Custom job handlers on a `@WorkerProcessor`. The wildcard `'*'` catches anything not matched by a specific handler.
|
|
441
271
|
|
|
442
|
-
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Configuration
|
|
443
275
|
|
|
444
276
|
```typescript
|
|
445
|
-
|
|
446
|
-
//
|
|
277
|
+
AtomicQueuesModule.forRoot({
|
|
278
|
+
// ── Redis connection ──────────────────────────────────────
|
|
279
|
+
redis: {
|
|
280
|
+
host: 'redis',
|
|
281
|
+
port: 6379,
|
|
282
|
+
password: 'secret', // optional
|
|
283
|
+
},
|
|
447
284
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
) {}
|
|
453
|
-
}
|
|
285
|
+
// ── Global settings ───────────────────────────────────────
|
|
286
|
+
keyPrefix: 'myapp', // Redis key namespace (default: 'aq')
|
|
287
|
+
enableCronManager: true, // Legacy cron-based scaling (optional)
|
|
288
|
+
cronInterval: 5000, // Cron tick interval in ms
|
|
454
289
|
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
290
|
+
// ── Worker defaults ───────────────────────────────────────
|
|
291
|
+
workerDefaults: {
|
|
292
|
+
concurrency: 1, // Jobs processed concurrently per worker
|
|
293
|
+
stalledInterval: 1000, // ms between stalled-job checks
|
|
294
|
+
lockDuration: 30000, // ms a job is locked during processing
|
|
295
|
+
heartbeatTTL: 3, // Heartbeat key TTL in seconds
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// ── Per-entity configuration (optional) ───────────────────
|
|
299
|
+
entities: {
|
|
300
|
+
account: {
|
|
301
|
+
queueName: (id) => `account-${id}-queue`,
|
|
302
|
+
workerName: (id) => `account-${id}-worker`,
|
|
303
|
+
maxWorkersPerEntity: 1,
|
|
304
|
+
idleTimeoutSeconds: 15,
|
|
305
|
+
defaultEntityId: 'accountId',
|
|
306
|
+
workerConfig: { // Override workerDefaults per entity
|
|
307
|
+
concurrency: 1,
|
|
308
|
+
lockDuration: 60000,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
462
313
|
```
|
|
463
314
|
|
|
464
315
|
---
|
|
465
316
|
|
|
466
|
-
##
|
|
317
|
+
## Distributed Worker Lifecycle
|
|
467
318
|
|
|
468
|
-
|
|
319
|
+
Workers in atomic-queues have a fully automated lifecycle, distributed across all pods with no leader election:
|
|
469
320
|
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
|
|
321
|
+
```
|
|
322
|
+
Job arrives SET NX claim
|
|
323
|
+
on any pod ──────► ┌──────────────────────┐
|
|
324
|
+
│ Pod claims worker? │
|
|
325
|
+
└──────┬───────┬───────┘
|
|
326
|
+
YES │ │ NO (another pod won)
|
|
327
|
+
▼ ▼
|
|
328
|
+
┌────────┐ ┌──────────────┐
|
|
329
|
+
│ Spawn │ │ Wait — other │
|
|
330
|
+
│ worker │ │ pod handles │
|
|
331
|
+
│ locally│ └──────────────┘
|
|
332
|
+
└───┬────┘
|
|
333
|
+
▼
|
|
334
|
+
┌──────────────┐
|
|
335
|
+
│ Processing │◄──── Heartbeat refresh (pipeline)
|
|
336
|
+
│ jobs back- │ every 1s (1 Redis round-trip)
|
|
337
|
+
│ to-back │
|
|
338
|
+
└──────┬───────┘
|
|
339
|
+
│ No jobs for idleTimeoutSeconds
|
|
340
|
+
▼
|
|
341
|
+
┌──────────────┐
|
|
342
|
+
│ Idle sweep │──── Hot cache eviction
|
|
343
|
+
│ closes │ Heartbeat keys cleaned up
|
|
344
|
+
│ worker │
|
|
345
|
+
└──────────────┘
|
|
346
|
+
```
|
|
473
347
|
|
|
474
|
-
|
|
475
|
-
entityType: 'order',
|
|
476
|
-
maxWorkersPerEntity: 1,
|
|
477
|
-
})
|
|
478
|
-
@Injectable()
|
|
479
|
-
export class OrderScaler {
|
|
480
|
-
constructor(private readonly orderRepo: OrderRepository) {}
|
|
348
|
+
### Hot Cache (v1.5.0+)
|
|
481
349
|
|
|
482
|
-
|
|
483
|
-
async getActiveOrders(): Promise<string[]> {
|
|
484
|
-
// Return IDs that need workers
|
|
485
|
-
return this.orderRepo.findPendingOrderIds();
|
|
486
|
-
}
|
|
350
|
+
After a worker is confirmed alive, subsequent job arrivals for that entity hit an **in-memory cache** — zero Redis calls on the warm path. This eliminates the per-job Redis overhead that plagues lock-based approaches.
|
|
487
351
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
352
|
+
| Path | Redis calls | When |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| **Hot** (cache hit) | 0 | Worker known alive |
|
|
355
|
+
| **Warm** (cache miss) | 1 (`EXISTS`) | First time seeing entity |
|
|
356
|
+
| **Cold** (no worker) | 1 (`SET NX`) | Worker needs creation |
|
|
357
|
+
|
|
358
|
+
### SpawnQueueService (v1.4.2+)
|
|
359
|
+
|
|
360
|
+
For multi-pod deployments, the `SpawnQueueService` distributes worker creation across all pods via a shared BullMQ spawn queue. In v1.5.0, the **direct local spawn** path bypasses this queue entirely — the pod that first sees a job for a new entity claims it with an atomic `SET NX` and spawns the worker locally, saving hundreds of milliseconds.
|
|
494
361
|
|
|
495
362
|
---
|
|
496
363
|
|
|
497
364
|
## Complete Example
|
|
498
365
|
|
|
499
|
-
A banking service
|
|
366
|
+
A banking service with withdrawals, deposits, and cross-account transfers:
|
|
500
367
|
|
|
501
368
|
```typescript
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
369
|
+
// ── Module ──────────────────────────────────────────────
|
|
370
|
+
import { Module } from '@nestjs/common';
|
|
371
|
+
import { CqrsModule } from '@nestjs/cqrs';
|
|
372
|
+
import { AtomicQueuesModule } from 'atomic-queues';
|
|
373
|
+
|
|
374
|
+
@Module({
|
|
375
|
+
imports: [
|
|
376
|
+
CqrsModule,
|
|
377
|
+
AtomicQueuesModule.forRoot({
|
|
378
|
+
redis: { host: 'redis', port: 6379 },
|
|
379
|
+
keyPrefix: 'banking',
|
|
380
|
+
entities: {
|
|
381
|
+
account: {
|
|
382
|
+
queueName: (id) => `account-${id}-queue`,
|
|
383
|
+
workerName: (id) => `account-${id}-worker`,
|
|
384
|
+
maxWorkersPerEntity: 1,
|
|
385
|
+
idleTimeoutSeconds: 15,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
}),
|
|
389
|
+
],
|
|
390
|
+
providers: [
|
|
391
|
+
AccountService,
|
|
392
|
+
WithdrawHandler,
|
|
393
|
+
DepositHandler,
|
|
394
|
+
TransferHandler,
|
|
395
|
+
],
|
|
396
|
+
})
|
|
397
|
+
export class BankingModule {}
|
|
398
|
+
|
|
399
|
+
// ── Commands ────────────────────────────────────────────
|
|
400
|
+
import { QueueEntity, QueueEntityId } from 'atomic-queues';
|
|
401
|
+
|
|
402
|
+
@QueueEntity('account')
|
|
505
403
|
export class WithdrawCommand {
|
|
506
404
|
constructor(
|
|
507
|
-
public readonly accountId: string,
|
|
405
|
+
@QueueEntityId() public readonly accountId: string,
|
|
508
406
|
public readonly amount: number,
|
|
509
407
|
public readonly transactionId: string,
|
|
510
|
-
public readonly requestedBy: string,
|
|
511
408
|
) {}
|
|
512
409
|
}
|
|
513
410
|
|
|
514
|
-
|
|
515
|
-
// commands/deposit.command.ts
|
|
516
|
-
// ─────────────────────────────────────────────────────────────────
|
|
411
|
+
@QueueEntity('account')
|
|
517
412
|
export class DepositCommand {
|
|
518
413
|
constructor(
|
|
519
|
-
public readonly accountId: string,
|
|
414
|
+
@QueueEntityId() public readonly accountId: string,
|
|
520
415
|
public readonly amount: number,
|
|
521
|
-
public readonly transactionId: string,
|
|
522
416
|
public readonly source: string,
|
|
523
417
|
) {}
|
|
524
418
|
}
|
|
525
419
|
|
|
526
|
-
|
|
527
|
-
// commands/transfer.command.ts
|
|
528
|
-
// ─────────────────────────────────────────────────────────────────
|
|
420
|
+
@QueueEntity('account')
|
|
529
421
|
export class TransferCommand {
|
|
530
422
|
constructor(
|
|
531
|
-
public readonly accountId: string,
|
|
423
|
+
@QueueEntityId() public readonly accountId: string,
|
|
532
424
|
public readonly toAccountId: string,
|
|
533
425
|
public readonly amount: number,
|
|
534
|
-
public readonly transactionId: string,
|
|
535
426
|
) {}
|
|
536
427
|
}
|
|
537
428
|
|
|
538
|
-
//
|
|
539
|
-
// handlers/withdraw.handler.ts
|
|
540
|
-
// ─────────────────────────────────────────────────────────────────
|
|
429
|
+
// ── Handlers ────────────────────────────────────────────
|
|
541
430
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
542
|
-
import { WithdrawCommand } from '../commands';
|
|
543
431
|
|
|
544
432
|
@CommandHandler(WithdrawCommand)
|
|
545
433
|
export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
|
|
546
|
-
constructor(
|
|
547
|
-
private readonly accountRepo: AccountRepository,
|
|
548
|
-
private readonly ledger: LedgerService,
|
|
549
|
-
) {}
|
|
434
|
+
constructor(private readonly repo: AccountRepository) {}
|
|
550
435
|
|
|
551
|
-
async execute(
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
// SAFE: No race conditions! This handler runs sequentially per account
|
|
555
|
-
// Even if 10 withdrawals arrive simultaneously, they execute one-by-one
|
|
556
|
-
|
|
557
|
-
const account = await this.accountRepo.findById(accountId);
|
|
558
|
-
|
|
559
|
-
if (account.balance < amount) {
|
|
560
|
-
throw new InsufficientFundsError(accountId, account.balance, amount);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (account.status !== 'active') {
|
|
564
|
-
throw new AccountFrozenError(accountId);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Debit the account
|
|
436
|
+
async execute({ accountId, amount }: WithdrawCommand) {
|
|
437
|
+
const account = await this.repo.findById(accountId);
|
|
438
|
+
if (account.balance < amount) throw new InsufficientFundsError();
|
|
568
439
|
account.balance -= amount;
|
|
569
|
-
await this.
|
|
570
|
-
|
|
571
|
-
// Record in ledger
|
|
572
|
-
await this.ledger.record({
|
|
573
|
-
transactionId,
|
|
574
|
-
accountId,
|
|
575
|
-
type: 'DEBIT',
|
|
576
|
-
amount,
|
|
577
|
-
balanceAfter: account.balance,
|
|
578
|
-
timestamp: new Date(),
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
return {
|
|
582
|
-
success: true,
|
|
583
|
-
transactionId,
|
|
584
|
-
newBalance: account.balance
|
|
585
|
-
};
|
|
440
|
+
await this.repo.save(account);
|
|
586
441
|
}
|
|
587
442
|
}
|
|
588
443
|
|
|
589
|
-
// ─────────────────────────────────────────────────────────────────
|
|
590
|
-
// handlers/transfer.handler.ts
|
|
591
|
-
// ─────────────────────────────────────────────────────────────────
|
|
592
|
-
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
593
|
-
import { TransferCommand, DepositCommand } from '../commands';
|
|
594
|
-
import { QueueBus } from 'atomic-queues';
|
|
595
|
-
|
|
596
444
|
@CommandHandler(TransferCommand)
|
|
597
445
|
export class TransferHandler implements ICommandHandler<TransferCommand> {
|
|
598
446
|
constructor(
|
|
599
|
-
private readonly
|
|
447
|
+
private readonly repo: AccountRepository,
|
|
600
448
|
private readonly queueBus: QueueBus,
|
|
601
449
|
) {}
|
|
602
450
|
|
|
603
|
-
async execute(
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
await this.accountRepo.save(sourceAccount);
|
|
615
|
-
|
|
616
|
-
// Step 2: Queue credit to destination account (different queue!)
|
|
617
|
-
// This ensures the destination account also processes atomically
|
|
618
|
-
await this.queueBus
|
|
619
|
-
.forProcessor(AccountProcessor)
|
|
620
|
-
.enqueue(new DepositCommand(
|
|
621
|
-
toAccountId,
|
|
622
|
-
amount,
|
|
623
|
-
transactionId,
|
|
624
|
-
`transfer:${accountId}`,
|
|
625
|
-
));
|
|
626
|
-
|
|
627
|
-
return { success: true, transactionId };
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// ─────────────────────────────────────────────────────────────────
|
|
632
|
-
// account.processor.ts
|
|
633
|
-
// ─────────────────────────────────────────────────────────────────
|
|
634
|
-
import { Injectable } from '@nestjs/common';
|
|
635
|
-
import { WorkerProcessor } from 'atomic-queues';
|
|
636
|
-
|
|
637
|
-
@WorkerProcessor({
|
|
638
|
-
entityType: 'account',
|
|
639
|
-
queueName: (accountId) => `bank-account-${accountId}-queue`,
|
|
640
|
-
workerName: (accountId) => `bank-account-${accountId}-worker`,
|
|
641
|
-
workerConfig: {
|
|
642
|
-
concurrency: 1, // CRITICAL: Must be 1 for financial transactions
|
|
643
|
-
lockDuration: 60000, // 60s lock for long transactions
|
|
644
|
-
stalledInterval: 5000,
|
|
645
|
-
},
|
|
646
|
-
})
|
|
647
|
-
@Injectable()
|
|
648
|
-
export class AccountProcessor {}
|
|
649
|
-
|
|
650
|
-
// ─────────────────────────────────────────────────────────────────
|
|
651
|
-
// account.scaler.ts - Scale workers based on active accounts
|
|
652
|
-
// ─────────────────────────────────────────────────────────────────
|
|
653
|
-
import { Injectable } from '@nestjs/common';
|
|
654
|
-
import { EntityScaler, GetActiveEntities, GetDesiredWorkerCount } from 'atomic-queues';
|
|
655
|
-
|
|
656
|
-
@EntityScaler({
|
|
657
|
-
entityType: 'account',
|
|
658
|
-
maxWorkersPerEntity: 1, // Never more than 1 worker per account
|
|
659
|
-
})
|
|
660
|
-
@Injectable()
|
|
661
|
-
export class AccountScaler {
|
|
662
|
-
constructor(private readonly accountRepo: AccountRepository) {}
|
|
663
|
-
|
|
664
|
-
@GetActiveEntities()
|
|
665
|
-
async getActiveAccounts(): Promise<string[]> {
|
|
666
|
-
// Return accounts with pending transactions
|
|
667
|
-
return this.accountRepo.findAccountsWithPendingTransactions();
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
@GetDesiredWorkerCount()
|
|
671
|
-
async getWorkerCount(accountId: string): Promise<number> {
|
|
672
|
-
// Always 1 worker per account for atomicity
|
|
673
|
-
return 1;
|
|
451
|
+
async execute({ accountId, toAccountId, amount }: TransferCommand) {
|
|
452
|
+
// Debit source (we're in source account's queue — safe)
|
|
453
|
+
const source = await this.repo.findById(accountId);
|
|
454
|
+
if (source.balance < amount) throw new InsufficientFundsError();
|
|
455
|
+
source.balance -= amount;
|
|
456
|
+
await this.repo.save(source);
|
|
457
|
+
|
|
458
|
+
// Credit destination (enqueued to destination's queue — also safe)
|
|
459
|
+
await this.queueBus.enqueue(
|
|
460
|
+
new DepositCommand(toAccountId, amount, `transfer:${accountId}`),
|
|
461
|
+
);
|
|
674
462
|
}
|
|
675
463
|
}
|
|
676
464
|
|
|
677
|
-
//
|
|
678
|
-
// account.module.ts
|
|
679
|
-
// ─────────────────────────────────────────────────────────────────
|
|
680
|
-
import { Module } from '@nestjs/common';
|
|
681
|
-
import { CqrsModule } from '@nestjs/cqrs';
|
|
682
|
-
|
|
683
|
-
@Module({
|
|
684
|
-
imports: [CqrsModule],
|
|
685
|
-
providers: [
|
|
686
|
-
AccountProcessor,
|
|
687
|
-
AccountScaler,
|
|
688
|
-
WithdrawHandler, // Commands auto-discovered!
|
|
689
|
-
DepositHandler,
|
|
690
|
-
TransferHandler,
|
|
691
|
-
],
|
|
692
|
-
controllers: [AccountController],
|
|
693
|
-
})
|
|
694
|
-
export class AccountModule {}
|
|
695
|
-
|
|
696
|
-
// ─────────────────────────────────────────────────────────────────
|
|
697
|
-
// account.controller.ts
|
|
698
|
-
// ─────────────────────────────────────────────────────────────────
|
|
465
|
+
// ── Controller ──────────────────────────────────────────
|
|
699
466
|
import { Controller, Post, Body, Param } from '@nestjs/common';
|
|
700
467
|
import { QueueBus } from 'atomic-queues';
|
|
701
|
-
import { AccountProcessor } from './account.processor';
|
|
702
|
-
import { WithdrawCommand, DepositCommand, TransferCommand } from './commands';
|
|
703
|
-
import { v4 as uuid } from 'uuid';
|
|
704
468
|
|
|
705
469
|
@Controller('accounts')
|
|
706
470
|
export class AccountController {
|
|
707
471
|
constructor(private readonly queueBus: QueueBus) {}
|
|
708
472
|
|
|
709
|
-
@Post(':
|
|
710
|
-
async withdraw(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
) {
|
|
714
|
-
const transactionId = uuid();
|
|
715
|
-
|
|
716
|
-
// Even if user spam-clicks "Withdraw", each request is queued
|
|
717
|
-
// and processed sequentially - no double-withdrawals possible
|
|
718
|
-
await this.queueBus
|
|
719
|
-
.forProcessor(AccountProcessor)
|
|
720
|
-
.enqueue(new WithdrawCommand(
|
|
721
|
-
accountId,
|
|
722
|
-
body.amount,
|
|
723
|
-
transactionId,
|
|
724
|
-
body.requestedBy,
|
|
725
|
-
));
|
|
726
|
-
|
|
727
|
-
return {
|
|
728
|
-
queued: true,
|
|
729
|
-
transactionId,
|
|
730
|
-
message: 'Withdrawal queued for processing',
|
|
731
|
-
};
|
|
473
|
+
@Post(':id/withdraw')
|
|
474
|
+
async withdraw(@Param('id') id: string, @Body() body: { amount: number }) {
|
|
475
|
+
await this.queueBus.enqueue(new WithdrawCommand(id, body.amount, uuid()));
|
|
476
|
+
return { queued: true };
|
|
732
477
|
}
|
|
733
478
|
|
|
734
|
-
@Post(':
|
|
479
|
+
@Post(':id/transfer')
|
|
735
480
|
async transfer(
|
|
736
|
-
@Param('
|
|
737
|
-
@Body() body: {
|
|
481
|
+
@Param('id') id: string,
|
|
482
|
+
@Body() body: { to: string; amount: number },
|
|
738
483
|
) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
await this.queueBus
|
|
742
|
-
.forProcessor(AccountProcessor)
|
|
743
|
-
.enqueue(new TransferCommand(
|
|
744
|
-
accountId,
|
|
745
|
-
body.toAccountId,
|
|
746
|
-
body.amount,
|
|
747
|
-
transactionId,
|
|
748
|
-
));
|
|
749
|
-
|
|
750
|
-
return {
|
|
751
|
-
queued: true,
|
|
752
|
-
transactionId,
|
|
753
|
-
message: 'Transfer queued for processing',
|
|
754
|
-
};
|
|
484
|
+
await this.queueBus.enqueue(new TransferCommand(id, body.to, body.amount));
|
|
485
|
+
return { queued: true };
|
|
755
486
|
}
|
|
756
487
|
}
|
|
757
488
|
```
|
|
758
489
|
|
|
759
490
|
---
|
|
760
491
|
|
|
761
|
-
##
|
|
762
|
-
|
|
763
|
-
```typescript
|
|
764
|
-
AtomicQueuesModule.forRoot({
|
|
765
|
-
redis: {
|
|
766
|
-
host: 'localhost',
|
|
767
|
-
port: 6379,
|
|
768
|
-
password: 'secret',
|
|
769
|
-
},
|
|
770
|
-
|
|
771
|
-
keyPrefix: 'myapp', // Redis key prefix (default: 'aq')
|
|
772
|
-
|
|
773
|
-
enableCronManager: true, // Enable auto-scaling (default: false)
|
|
774
|
-
cronInterval: 5000, // Scaling check interval (default: 5000ms)
|
|
775
|
-
|
|
776
|
-
verbose: false, // Enable verbose logging (default: false)
|
|
777
|
-
// When true, logs service job processing details
|
|
778
|
-
|
|
779
|
-
workerDefaults: {
|
|
780
|
-
concurrency: 1, // Jobs processed simultaneously
|
|
781
|
-
stalledInterval: 1000, // Stalled job check interval
|
|
782
|
-
lockDuration: 30000, // Job lock duration
|
|
783
|
-
heartbeatTTL: 3, // Worker heartbeat TTL (seconds)
|
|
784
|
-
},
|
|
785
|
-
});
|
|
786
|
-
```
|
|
787
|
-
|
|
788
|
-
---
|
|
789
|
-
|
|
790
|
-
## Command Registration
|
|
492
|
+
## Advanced: Custom Worker Processors
|
|
791
493
|
|
|
792
|
-
|
|
494
|
+
For cases where CQRS auto-routing isn't enough, define a `@WorkerProcessor` with explicit `@JobHandler` methods:
|
|
793
495
|
|
|
794
|
-
|
|
496
|
+
```typescript
|
|
497
|
+
import { Injectable } from '@nestjs/common';
|
|
498
|
+
import { WorkerProcessor, JobHandler } from 'atomic-queues';
|
|
499
|
+
import { Job } from 'bullmq';
|
|
795
500
|
|
|
796
|
-
|
|
501
|
+
@WorkerProcessor({
|
|
502
|
+
entityType: 'account',
|
|
503
|
+
queueName: (id) => `account-${id}-queue`,
|
|
504
|
+
workerName: (id) => `account-${id}-worker`,
|
|
505
|
+
maxWorkersPerEntity: 1,
|
|
506
|
+
idleTimeoutSeconds: 15,
|
|
507
|
+
})
|
|
508
|
+
@Injectable()
|
|
509
|
+
export class AccountProcessor {
|
|
510
|
+
@JobHandler('high-priority-audit')
|
|
511
|
+
async handleAudit(job: Job, entityId: string) {
|
|
512
|
+
// Specific handler for this job type
|
|
513
|
+
}
|
|
797
514
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
async execute(command: ProcessOrderCommand) {
|
|
803
|
-
// ProcessOrderCommand is auto-registered with QueueBus
|
|
515
|
+
@JobHandler('*')
|
|
516
|
+
async handleAll(job: Job, entityId: string) {
|
|
517
|
+
// Wildcard — catches everything not explicitly handled
|
|
518
|
+
// Falls back to CQRS routing automatically when not defined
|
|
804
519
|
}
|
|
805
520
|
}
|
|
806
521
|
```
|
|
807
522
|
|
|
808
|
-
|
|
523
|
+
> **Priority order:** Explicit `@JobHandler` → CQRS auto-routing (`@JobCommand`/`@JobQuery`) → Wildcard handler
|
|
809
524
|
|
|
810
|
-
|
|
525
|
+
---
|
|
811
526
|
|
|
812
|
-
|
|
813
|
-
// Disable auto-discovery in config
|
|
814
|
-
AtomicQueuesModule.forRoot({
|
|
815
|
-
redis: { host: 'localhost', port: 6379 },
|
|
816
|
-
autoRegisterCommands: false, // Disable auto-discovery
|
|
817
|
-
});
|
|
527
|
+
## Performance
|
|
818
528
|
|
|
819
|
-
|
|
820
|
-
QueueBus.registerCommands(ProcessOrderCommand, ShipOrderCommand);
|
|
821
|
-
```
|
|
529
|
+
### Throughput (measured — not estimated)
|
|
822
530
|
|
|
823
|
-
|
|
531
|
+
Tested on a 5-pod Kubernetes cluster (OrbStack), 20 concurrent entities, 12,300 orders:
|
|
532
|
+
|
|
533
|
+
| Metric | Result |
|
|
534
|
+
|---|---|
|
|
535
|
+
| **Phase 1** — 10,000 orders (50 waves × 200 concurrent) | 167 orders/sec |
|
|
536
|
+
| **Phase 2** — 1,000 orders (workers still draining) | 140 orders/sec |
|
|
537
|
+
| **Phase 4** — 1,000 orders (cold start from zero workers) | 176 orders/sec |
|
|
538
|
+
| **Total deductions processed** | 104,004 |
|
|
539
|
+
| **Stock drift** | **0** (all 20 entities) |
|
|
540
|
+
| **Pod distribution** | 5/5 pods actively creating workers |
|
|
541
|
+
| **Worker creates** | 120 |
|
|
542
|
+
| **Idle closures** | 180 |
|
|
543
|
+
|
|
544
|
+
### Why it's fast
|
|
545
|
+
|
|
546
|
+
1. **Zero contention** — no locks, no retries, no backoff. Jobs queue and execute.
|
|
547
|
+
2. **Hot cache** — after first check, subsequent job arrivals for an entity incur 0 Redis calls.
|
|
548
|
+
3. **Direct local spawn** — atomic `SET NX` claim, local worker creation. No queue round-trip.
|
|
549
|
+
4. **Pipelined heartbeats** — heartbeat refresh uses a single Redis pipeline (1 round-trip for 2 keys).
|
|
550
|
+
5. **O(1) worker existence check** — global alive key replaces `KEYS` pattern scan.
|
|
824
551
|
|
|
825
|
-
|
|
552
|
+
### When to use what
|
|
826
553
|
|
|
827
|
-
|
|
|
828
|
-
|
|
829
|
-
|
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
|
|
|
833
|
-
|
|
|
554
|
+
| Use case | Recommendation |
|
|
555
|
+
|---|---|
|
|
556
|
+
| High-throughput entity operations (payments, inventory, game state) | **atomic-queues** |
|
|
557
|
+
| Rare, low-frequency mutual exclusion (config updates, migrations) | Redlock / advisory locks |
|
|
558
|
+
| Exactly-once semantics with audit trail | **atomic-queues** (BullMQ job IDs) |
|
|
559
|
+
| Sub-millisecond synchronous response required | Redlock (synchronous acquire) |
|
|
560
|
+
| Multi-pod, many entities, sustained load | **atomic-queues** (contention-free scaling) |
|
|
834
561
|
|
|
835
562
|
---
|
|
836
563
|
|