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.
Files changed (51) hide show
  1. package/README.md +363 -636
  2. package/dist/decorators/decorators.d.ts +123 -4
  3. package/dist/decorators/decorators.d.ts.map +1 -1
  4. package/dist/decorators/decorators.js +218 -10
  5. package/dist/decorators/decorators.js.map +1 -1
  6. package/dist/domain/interfaces.d.ts +127 -1
  7. package/dist/domain/interfaces.d.ts.map +1 -1
  8. package/dist/module/atomic-queues.module.d.ts.map +1 -1
  9. package/dist/module/atomic-queues.module.js +2 -0
  10. package/dist/module/atomic-queues.module.js.map +1 -1
  11. package/dist/services/cron-manager/cron-manager.service.d.ts +23 -1
  12. package/dist/services/cron-manager/cron-manager.service.d.ts.map +1 -1
  13. package/dist/services/cron-manager/cron-manager.service.js +150 -41
  14. package/dist/services/cron-manager/cron-manager.service.js.map +1 -1
  15. package/dist/services/index.d.ts +2 -0
  16. package/dist/services/index.d.ts.map +1 -1
  17. package/dist/services/index.js +2 -0
  18. package/dist/services/index.js.map +1 -1
  19. package/dist/services/processor-discovery/processor-discovery.service.d.ts +49 -4
  20. package/dist/services/processor-discovery/processor-discovery.service.d.ts.map +1 -1
  21. package/dist/services/processor-discovery/processor-discovery.service.js +262 -14
  22. package/dist/services/processor-discovery/processor-discovery.service.js.map +1 -1
  23. package/dist/services/queue-bus/queue-bus.service.d.ts +126 -12
  24. package/dist/services/queue-bus/queue-bus.service.d.ts.map +1 -1
  25. package/dist/services/queue-bus/queue-bus.service.js +274 -22
  26. package/dist/services/queue-bus/queue-bus.service.js.map +1 -1
  27. package/dist/services/queue-events-manager/index.d.ts +2 -0
  28. package/dist/services/queue-events-manager/index.d.ts.map +1 -0
  29. package/dist/services/queue-events-manager/index.js +18 -0
  30. package/dist/services/queue-events-manager/index.js.map +1 -0
  31. package/dist/services/queue-events-manager/queue-events-manager.service.d.ts +124 -0
  32. package/dist/services/queue-events-manager/queue-events-manager.service.d.ts.map +1 -0
  33. package/dist/services/queue-events-manager/queue-events-manager.service.js +355 -0
  34. package/dist/services/queue-events-manager/queue-events-manager.service.js.map +1 -0
  35. package/dist/services/queue-manager/queue-manager.service.d.ts +15 -1
  36. package/dist/services/queue-manager/queue-manager.service.d.ts.map +1 -1
  37. package/dist/services/queue-manager/queue-manager.service.js +17 -3
  38. package/dist/services/queue-manager/queue-manager.service.js.map +1 -1
  39. package/dist/services/spawn-queue/index.d.ts +2 -0
  40. package/dist/services/spawn-queue/index.d.ts.map +1 -0
  41. package/dist/services/spawn-queue/index.js +18 -0
  42. package/dist/services/spawn-queue/index.js.map +1 -0
  43. package/dist/services/spawn-queue/spawn-queue.service.d.ts +119 -0
  44. package/dist/services/spawn-queue/spawn-queue.service.d.ts.map +1 -0
  45. package/dist/services/spawn-queue/spawn-queue.service.js +273 -0
  46. package/dist/services/spawn-queue/spawn-queue.service.js.map +1 -0
  47. package/dist/services/worker-manager/worker-manager.service.d.ts +59 -1
  48. package/dist/services/worker-manager/worker-manager.service.d.ts.map +1 -1
  49. package/dist/services/worker-manager/worker-manager.service.js +142 -12
  50. package/dist/services/worker-manager/worker-manager.service.js.map +1 -1
  51. package/package.json +1 -1
package/README.md CHANGED
@@ -1,836 +1,563 @@
1
- # atomic-queues
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
- A NestJS library for atomic, sequential job processing per entity using BullMQ and Redis.
9
+ <h1 align="center">atomic-queues</h1>
4
10
 
5
- ---
6
-
7
- ## Overview
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
- ## The Concurrency Problem
18
+ ## Why atomic-queues?
16
19
 
17
- ### Race Condition Scenario
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
- Consider a financial system where a user with a $100 balance submits two concurrent $80 withdrawal requests:
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
- This occurs because both transactions read the balance before either writes, a classic **lost update anomaly**.
24
+ ### atomic-queues vs Redlock
33
25
 
34
- ### Traditional Solutions and Their Limitations
35
-
36
- | Approach | Mechanism | Failure Mode |
37
- |----------|-----------|--------------|
38
- | **Distributed Locks (Redlock)** | Acquire lock before operation, release after | Lock contention storms under high throughput; exponential latency degradation; lock holder failure requires TTL expiration |
39
- | **Database Row Locks** | `SELECT ... FOR UPDATE` | Connection pool exhaustion; deadlock risk in multi-entity transactions; database becomes bottleneck |
40
- | **Optimistic Concurrency Control** | Version numbers with conditional updates | Retry storms under contention; unbounded retries on hot entities; wasted compute cycles |
41
- | **Application Semaphores** | In-memory mutex/semaphore | Single-process only; ineffective in horizontally scaled deployments |
42
-
43
- **Fundamental limitation**: These approaches attempt to serialize access at the *moment of execution*. Under high contention, this creates a thundering herd where N requests compete for the same resource simultaneously.
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
- ## The Per-Entity Queue Architecture
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
- ### Scalability Profile
98
-
99
- ```
100
- Throughput
101
-
102
- │ ╭──── Per-Entity Queues
103
- │ ╭───╯ (linear scaling)
104
- │ ╭───╯
105
- │ ╭───╯
106
- │ ╭───╯
107
- │ ╭───╯ ╭────── Distributed Locks
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
- ## Architecture
55
+ ## How It Works
125
56
 
126
- ### Horizontal Scaling Model
57
+ ### The Problem
127
58
 
128
- Workers are distributed across service instances via Redis-based coordination:
59
+ Every distributed system eventually hits this:
129
60
 
130
61
  ```
131
- ┌─────────────────────────────────────────────────────────────────────────────────┐
132
- │ REDIS CLUSTER │
133
- │ ┌────────────────────────────────────────────────────────────────────────────┐
134
- │ │ Entity Queues │
135
- │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
136
- │ │ │ account:ACC-001 account:ACC-002 │ account:ACC-003 │ ... │ │
137
- │ │ │ [op₁][op₂][op₃] │ │ [op₁] │ │ [op₁][op₂] │ │ │
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
- **Properties:**
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
- ### Dynamic Worker Lifecycle
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
- Job Arrives for Entity X
168
-
169
-
170
- ┌─────────────────────┐
171
- Worker exists for X?
172
- └──────────┬──────────┘
173
-
174
- ┌────────────────┴────────────────┐
175
- │ NO │ YES
176
- ▼ ▼
177
- ┌─────────────────────┐ ┌─────────────────────┐
178
- Spawn worker for X │ │ Job added to queue
179
- Register heartbeat Worker will process │
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
- **Resource Efficiency**: A system with 1 million registered accounts but 10,000 active accounts maintains only 10,000 workers.
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
- ## Use Cases
105
+ ## Installation
200
106
 
201
- ### Recommended Applications
107
+ ```bash
108
+ # npm
109
+ npm install atomic-queues bullmq ioredis
202
110
 
203
- | Domain | Entity Type | Operations |
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
- ### When to Use Alternative Approaches
114
+ # yarn
115
+ yarn add atomic-queues bullmq ioredis
116
+ ```
212
117
 
213
- - **Read-heavy workloads**: Use caching layers (Redis, Memcached) or read replicas
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
- ### 2. Create Your Commands
151
+ > **Tip:** The `entities` config is optional. Without it, default naming applies: `{keyPrefix}:{entityType}:{entityId}:queue`.
246
152
 
247
- Plain classes - no decorators needed:
153
+ ### 2. Define Commands
248
154
 
249
155
  ```typescript
250
- // commands/process-order.command.ts
251
- export class ProcessOrderCommand {
156
+ import { QueueEntity, QueueEntityId } from 'atomic-queues';
157
+
158
+ @QueueEntity('account')
159
+ export class WithdrawCommand {
252
160
  constructor(
253
- public readonly orderId: string,
254
- public readonly items: string[],
161
+ @QueueEntityId() public readonly accountId: string,
255
162
  public readonly amount: number,
256
163
  ) {}
257
164
  }
258
165
 
259
- // commands/ship-order.command.ts
260
- export class ShipOrderCommand {
166
+ @QueueEntity('account')
167
+ export class DepositCommand {
261
168
  constructor(
262
- public readonly orderId: string,
263
- public readonly address: string,
169
+ @QueueEntityId() public readonly accountId: string,
170
+ public readonly amount: number,
264
171
  ) {}
265
172
  }
266
173
  ```
267
174
 
268
- ### 3. Create a Worker Processor
175
+ ### 3. Write Handlers (standard @nestjs/cqrs)
269
176
 
270
177
  ```typescript
271
- import { Injectable } from '@nestjs/common';
272
- import { WorkerProcessor } from 'atomic-queues';
178
+ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
273
179
 
274
- @WorkerProcessor({
275
- entityType: 'order',
276
- queueName: (orderId) => `order-${orderId}-queue`,
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
- ### 4. Queue Jobs with the Fluent API
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
- Commands are **automatically registered** from your `@CommandHandler` classes - no manual registration needed!
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 OrderService {
205
+ export class AccountService {
295
206
  constructor(private readonly queueBus: QueueBus) {}
296
207
 
297
- async createOrder(orderId: string, items: string[], amount: number) {
298
- // Jobs are queued and processed sequentially per orderId
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! The library automatically:
311
- - Discovers commands from `@CommandHandler` decorators
312
- - Creates a queue for each `orderId`
313
- - Spawns a worker to process jobs sequentially
314
- - Routes jobs to the correct command handlers
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
- ## How It Works
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
- ## API Reference
229
+ ```typescript
230
+ @QueueEntity('account')
231
+ export class TransferCommand { ... }
232
+ ```
389
233
 
390
- ### QueueBus
234
+ ### `@QueueEntityId()`
391
235
 
392
- The main way to add jobs to queues:
236
+ Marks the property that contains the entity ID. One per class.
393
237
 
394
238
  ```typescript
395
- // Enqueue a single command
396
- await queueBus
397
- .forProcessor(MyProcessor)
398
- .enqueue(new MyCommand(entityId, data));
399
-
400
- // Enqueue and wait for result
401
- const result = await queueBus
402
- .forProcessor(MyProcessor)
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
- ### @WorkerProcessor
249
+ ### `@WorkerProcessor(options)`
422
250
 
423
- Defines how workers are created for an entity type:
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: 'order', // Required
428
- queueName: (id) => `order-${id}-queue`, // Optional
429
- workerName: (id) => `order-${id}-worker`, // Optional
430
- workerConfig: {
431
- concurrency: 1, // Jobs per worker (default: 1)
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
- ## Entity ID Extraction
270
+ Custom job handlers on a `@WorkerProcessor`. The wildcard `'*'` catches anything not matched by a specific handler.
441
271
 
442
- The `entityId` is automatically extracted from your command's properties:
272
+ ---
273
+
274
+ ## Configuration
443
275
 
444
276
  ```typescript
445
- // These property names are checked in order:
446
- // entityId, tableId, userId, id, gameId, playerId
277
+ AtomicQueuesModule.forRoot({
278
+ // ── Redis connection ──────────────────────────────────────
279
+ redis: {
280
+ host: 'redis',
281
+ port: 6379,
282
+ password: 'secret', // optional
283
+ },
447
284
 
448
- export class ProcessOrderCommand {
449
- constructor(
450
- public readonly orderId: string, // 'orderId' contains 'Id' → entityId
451
- public readonly items: string[],
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
- // Or use standard names
456
- export class UpdateUserCommand {
457
- constructor(
458
- public readonly userId: string, // Matches 'userId' → entityId
459
- public readonly name: string,
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
- ## Scaling with Entity Scalers
317
+ ## Distributed Worker Lifecycle
467
318
 
468
- For dynamic worker management based on demand:
319
+ Workers in atomic-queues have a fully automated lifecycle, distributed across all pods with no leader election:
469
320
 
470
- ```typescript
471
- import { Injectable } from '@nestjs/common';
472
- import { EntityScaler, GetActiveEntities, GetDesiredWorkerCount } from 'atomic-queues';
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
- @EntityScaler({
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
- @GetActiveEntities()
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
- @GetDesiredWorkerCount()
489
- async getWorkerCount(orderId: string): Promise<number> {
490
- return 1; // One worker per order
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 handling critical financial transactions where race conditions could cause overdrafts or double-spending:
366
+ A banking service with withdrawals, deposits, and cross-account transfers:
500
367
 
501
368
  ```typescript
502
- // ─────────────────────────────────────────────────────────────────
503
- // commands/withdraw.command.ts
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, // Source account (for queue routing)
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(command: WithdrawCommand) {
552
- const { accountId, amount, transactionId } = command;
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.accountRepo.save(account);
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 accountRepo: AccountRepository,
447
+ private readonly repo: AccountRepository,
600
448
  private readonly queueBus: QueueBus,
601
449
  ) {}
602
450
 
603
- async execute(command: TransferCommand) {
604
- const { accountId, toAccountId, amount, transactionId } = command;
605
-
606
- // Step 1: Debit source account (already in source account's queue)
607
- const sourceAccount = await this.accountRepo.findById(accountId);
608
-
609
- if (sourceAccount.balance < amount) {
610
- throw new InsufficientFundsError(accountId, sourceAccount.balance, amount);
611
- }
612
-
613
- sourceAccount.balance -= amount;
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(':accountId/withdraw')
710
- async withdraw(
711
- @Param('accountId') accountId: string,
712
- @Body() body: { amount: number; requestedBy: string },
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(':accountId/transfer')
479
+ @Post(':id/transfer')
735
480
  async transfer(
736
- @Param('accountId') accountId: string,
737
- @Body() body: { toAccountId: string; amount: number },
481
+ @Param('id') id: string,
482
+ @Body() body: { to: string; amount: number },
738
483
  ) {
739
- const transactionId = uuid();
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
- ## Configuration
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
- By default, atomic-queues **auto-discovers** all commands from your `@CommandHandler` and `@QueryHandler` decorators. No manual registration needed!
494
+ For cases where CQRS auto-routing isn't enough, define a `@WorkerProcessor` with explicit `@JobHandler` methods:
793
495
 
794
- ### Auto-Discovery (Default)
496
+ ```typescript
497
+ import { Injectable } from '@nestjs/common';
498
+ import { WorkerProcessor, JobHandler } from 'atomic-queues';
499
+ import { Job } from 'bullmq';
795
500
 
796
- Commands are automatically discovered when you have CQRS handlers:
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
- ```typescript
799
- // Your handler - that's all you need!
800
- @CommandHandler(ProcessOrderCommand)
801
- export class ProcessOrderHandler implements ICommandHandler<ProcessOrderCommand> {
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
- ### Manual Registration (Optional)
523
+ > **Priority order:** Explicit `@JobHandler` → CQRS auto-routing (`@JobCommand`/`@JobQuery`) → Wildcard handler
809
524
 
810
- If you need to register commands without handlers, or disable auto-discovery:
525
+ ---
811
526
 
812
- ```typescript
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
- // Then manually register
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
- ## Why Use atomic-queues?
552
+ ### When to use what
826
553
 
827
- | Feature | Without | With atomic-queues |
828
- |---------|---------|-------------------|
829
- | Sequential per-entity | Manual locking | Automatic via queues |
830
- | Race conditions | Possible | Prevented |
831
- | Worker management | Manual | Automatic |
832
- | Horizontal scaling | Complex | Built-in |
833
- | Code organization | Scattered | Clean decorators |
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