atomic-queues 1.2.3 → 1.2.5

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 (2) hide show
  1. package/README.md +295 -103
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -5,30 +5,40 @@ A NestJS library for atomic, sequential job processing per entity with BullMQ an
5
5
  ## What It Does
6
6
 
7
7
  ```
8
- ┌─────────────────────────────────────────────────────────────────┐
9
- THE PROBLEM
10
- ├─────────────────────────────────────────────────────────────────┤
11
- │ │
12
- │ Multiple requests for the same entity arrive simultaneously:
13
- │ │
14
- │ Request A ───┐ │
15
- Request B ───┼──► Entity 123 ──► 💥 RACE CONDITION! │
16
- │ Request C ───┘
17
-
18
- └─────────────────────────────────────────────────────────────────┘
19
-
20
- ┌─────────────────────────────────────────────────────────────────┐
21
- │ THE SOLUTION │
22
- ├─────────────────────────────────────────────────────────────────┤
23
- │ │
24
- │ atomic-queues ensures sequential processing per entity: │
25
- │ │
26
- │ Request A ───┐ ┌─────────┐ │
27
- │ Request B ───┼──► │ Queue │ ──► Worker ──► Entity 123 │
28
- │ Request C ───┘ │ A, B, C │ (1 at a time) │
29
- │ └─────────┘ │
30
- │ │
31
- └─────────────────────────────────────────────────────────────────┘
8
+ ╔═══════════════════════════════════════════════════════════════════════════════╗
9
+ THE PROBLEM
10
+ ╠═══════════════════════════════════════════════════════════════════════════════╣
11
+ ║ ║
12
+ ║ User has $100 balance. Two $80 withdrawals arrive at the same time:
13
+ ║ ║
14
+ ║ Withdraw $80 ──┐ ║
15
+ ║ (API 1) ┌────────────────────┐ ║
16
+ ║ ├───▶│ Balance: $100
17
+ ║ Withdraw $80 ──┘ Both read $100
18
+ ║ (API 2) │ Both approve │ ║
19
+ ║ │ Final: -$60 💥 │ ║
20
+ ║ └────────────────────┘ ║
21
+ ║ ║
22
+ ║ Race condition: Both transactions see $100, both succeed, balance goes -$60 ║
23
+ ║ ║
24
+ ╚═══════════════════════════════════════════════════════════════════════════════╝
25
+
26
+ ╔═══════════════════════════════════════════════════════════════════════════════╗
27
+ ║ THE SOLUTION ║
28
+ ╠═══════════════════════════════════════════════════════════════════════════════╣
29
+ ║ ║
30
+ ║ atomic-queues processes one transaction at a time per account: ║
31
+ ║ ║
32
+ ║ Withdraw $80 ──┐ ┌─────────────┐ ┌─────────────────────────────┐ ║
33
+ ║ (API 1) │ │ │ │ Worker processes queue: │ ║
34
+ ║ ├────▶│ Redis Queue │────▶│ │ ║
35
+ ║ Withdraw $80 ──┘ │ [W1] [W2] │ │ W1: $100 - $80 = $20 ✓ │ ║
36
+ ║ (API 2) │ │ │ W2: $20 < $80 = REJECTED ✓ │ ║
37
+ ║ └─────────────┘ └─────────────────────────────┘ ║
38
+ ║ ║
39
+ ║ Sequential processing: W1 completes first, W2 sees updated balance ║
40
+ ║ ║
41
+ ╚═══════════════════════════════════════════════════════════════════════════════╝
32
42
  ```
33
43
 
34
44
  ## Installation
@@ -132,40 +142,69 @@ That's it! The library automatically:
132
142
  ## How It Works
133
143
 
134
144
  ```
135
- ┌─────────────────────────────────────────────────────────────────┐
136
- │ FLOW DIAGRAM
137
- └─────────────────────────────────────────────────────────────────┘
138
-
139
- YOUR CODE ATOMIC-QUEUES WORKER
140
- ───────── ───────────── ──────
141
-
142
- queueBus
143
- .forProcessor(OrderProcessor)
144
- .enqueue(new ProcessOrderCommand(...))
145
-
146
- │ 1. Extract queue config from @WorkerProcessor
147
- 2. Extract orderId from command.orderId
148
- 3. Build queue name: order-{orderId}-queue
149
-
150
- ┌─────────────┐
151
- Redis │
152
- │ Queue │ ◄─── Job: { name: "ProcessOrderCommand", data: {...} }
153
- └──────┬──────┘
154
-
155
- 4. Worker pulls job from queue
156
-
157
- ┌─────────────┐
158
- Worker
159
- (1 per ID)
160
- └──────┬──────┘
161
-
162
- 5. Lookup ProcessOrderCommand in registry
163
- 6. Instantiate command from job.data
164
- 7. Execute via CommandBus
165
-
166
- ┌─────────────┐
167
- CommandBus──► ProcessOrderCommandHandler.execute()
168
- └─────────────┘
145
+ ╔═══════════════════════════════════════════════════════════════════════════════╗
146
+ ARCHITECTURE ║
147
+ ╚═══════════════════════════════════════════════════════════════════════════════╝
148
+
149
+ ┌──────────────────┐
150
+ │ API Request │ POST /accounts/ACC-123/withdraw { amount: 80 }
151
+ └────────┬─────────┘
152
+
153
+
154
+ ┌──────────────────────────────────────────────────────────────────────────────┐
155
+ QueueBus.forProcessor(AccountProcessor).enqueue(new WithdrawCommand(...)) │
156
+ └────────┬─────────────────────────────────────────────────────────────────────┘
157
+
158
+ Reads @WorkerProcessor metadata from AccountProcessor
159
+ │ ② Extracts accountId from command.accountId property
160
+ │ ③ Generates queue name: "account-ACC-123-queue"
161
+
162
+
163
+ ┌──────────────────────────────────────────────────────────────────────────────┐
164
+ REDIS │
165
+ ┌────────────────────────────────────────────────────────────────────────┐ │
166
+ │ │ Queue: account-ACC-123-queue │ │
167
+ │ │ ┌─────────────────┬─────────────────┬─────────────────┐ │ │
168
+ │ Job 1 │ Job 2 │ Job 3 │ ... │ │
169
+ │ │ WithdrawCommand DepositCommand TransferCommand │ │ │
170
+ │ │ │ amount: 80 │ amount: 50 │ amount: 25 │ │ │
171
+ │ └─────────────────┴─────────────────┴─────────────────┘ │ │
172
+ └────────────────────────────────────────────────────────────────────────┘ │
173
+
174
+ ┌────────────────────────────────────────────────────────────────────────┐ │
175
+ │ │ Queue: account-ACC-456-queue (different account = different queue) │ │
176
+ │ │ ┌─────────────────┐ │ │
177
+ │ │ Job 1 │ ← Processes in parallel with ACC-123
178
+ │ │ │ WithdrawCommand │ │ │
179
+ │ │ └─────────────────┘ │ │
180
+ │ └────────────────────────────────────────────────────────────────────────┘ │
181
+ └──────────────────────────────────────────────────────────────────────────────┘
182
+
183
+ │ ④ BullMQ Worker pulls Job 1 (only one job at a time per queue)
184
+
185
+
186
+ ┌──────────────────────────────────────────────────────────────────────────────┐
187
+ │ Worker: account-ACC-123-worker │
188
+ │ │
189
+ │ ┌─────────────────────────────────────────────────────────────────────────┐ │
190
+ │ │ ⑤ Lookup "WithdrawCommand" in QueueBus.globalRegistry │ │
191
+ │ │ ⑥ Instantiate: Object.assign(new WithdrawCommand(), job.data) │ │
192
+ │ │ ⑦ Execute: CommandBus.execute(withdrawCommand) │ │
193
+ │ └─────────────────────────────────────────────────────────────────────────┘ │
194
+ └────────┬─────────────────────────────────────────────────────────────────────┘
195
+
196
+
197
+ ┌──────────────────────────────────────────────────────────────────────────────┐
198
+ │ @CommandHandler(WithdrawCommand) │
199
+ │ class WithdrawHandler { │
200
+ │ async execute(cmd: WithdrawCommand) { │
201
+ │ // Safe! No race conditions - guaranteed sequential execution │
202
+ │ const balance = await this.repo.getBalance(cmd.accountId); │
203
+ │ if (balance < cmd.amount) throw new InsufficientFundsError(); │
204
+ │ await this.repo.debit(cmd.accountId, cmd.amount); │
205
+ │ } │
206
+ │ } │
207
+ └──────────────────────────────────────────────────────────────────────────────┘
169
208
  ```
170
209
 
171
210
  ---
@@ -281,109 +320,262 @@ export class OrderScaler {
281
320
 
282
321
  ## Complete Example
283
322
 
323
+ A banking service handling critical financial transactions where race conditions could cause overdrafts or double-spending:
324
+
284
325
  ```typescript
285
326
  // ─────────────────────────────────────────────────────────────────
286
- // commands/place-bet.command.ts
327
+ // commands/withdraw.command.ts
328
+ // ─────────────────────────────────────────────────────────────────
329
+ export class WithdrawCommand {
330
+ constructor(
331
+ public readonly accountId: string,
332
+ public readonly amount: number,
333
+ public readonly transactionId: string,
334
+ public readonly requestedBy: string,
335
+ ) {}
336
+ }
337
+
338
+ // ─────────────────────────────────────────────────────────────────
339
+ // commands/deposit.command.ts
287
340
  // ─────────────────────────────────────────────────────────────────
288
- export class PlaceBetCommand {
341
+ export class DepositCommand {
289
342
  constructor(
290
- public readonly tableId: string,
291
- public readonly playerId: string,
343
+ public readonly accountId: string,
292
344
  public readonly amount: number,
345
+ public readonly transactionId: string,
346
+ public readonly source: string,
293
347
  ) {}
294
348
  }
295
349
 
296
350
  // ─────────────────────────────────────────────────────────────────
297
- // commands/deal-cards.command.ts
351
+ // commands/transfer.command.ts
298
352
  // ─────────────────────────────────────────────────────────────────
299
- export class DealCardsCommand {
353
+ export class TransferCommand {
300
354
  constructor(
301
- public readonly tableId: string,
355
+ public readonly accountId: string, // Source account (for queue routing)
356
+ public readonly toAccountId: string,
357
+ public readonly amount: number,
358
+ public readonly transactionId: string,
302
359
  ) {}
303
360
  }
304
361
 
305
362
  // ─────────────────────────────────────────────────────────────────
306
- // handlers/place-bet.handler.ts (auto-registers PlaceBetCommand)
363
+ // handlers/withdraw.handler.ts
307
364
  // ─────────────────────────────────────────────────────────────────
308
365
  import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
309
- import { PlaceBetCommand } from '../commands/place-bet.command';
366
+ import { WithdrawCommand } from '../commands';
310
367
 
311
- @CommandHandler(PlaceBetCommand)
312
- export class PlaceBetHandler implements ICommandHandler<PlaceBetCommand> {
313
- async execute(command: PlaceBetCommand) {
314
- console.log(`Placing bet of ${command.amount} for player ${command.playerId}`);
368
+ @CommandHandler(WithdrawCommand)
369
+ export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
370
+ constructor(
371
+ private readonly accountRepo: AccountRepository,
372
+ private readonly ledger: LedgerService,
373
+ ) {}
374
+
375
+ async execute(command: WithdrawCommand) {
376
+ const { accountId, amount, transactionId } = command;
377
+
378
+ // SAFE: No race conditions! This handler runs sequentially per account
379
+ // Even if 10 withdrawals arrive simultaneously, they execute one-by-one
380
+
381
+ const account = await this.accountRepo.findById(accountId);
382
+
383
+ if (account.balance < amount) {
384
+ throw new InsufficientFundsError(accountId, account.balance, amount);
385
+ }
386
+
387
+ if (account.status !== 'active') {
388
+ throw new AccountFrozenError(accountId);
389
+ }
390
+
391
+ // Debit the account
392
+ account.balance -= amount;
393
+ await this.accountRepo.save(account);
394
+
395
+ // Record in ledger
396
+ await this.ledger.record({
397
+ transactionId,
398
+ accountId,
399
+ type: 'DEBIT',
400
+ amount,
401
+ balanceAfter: account.balance,
402
+ timestamp: new Date(),
403
+ });
404
+
405
+ return {
406
+ success: true,
407
+ transactionId,
408
+ newBalance: account.balance
409
+ };
315
410
  }
316
411
  }
317
412
 
318
413
  // ─────────────────────────────────────────────────────────────────
319
- // handlers/deal-cards.handler.ts (auto-registers DealCardsCommand)
414
+ // handlers/transfer.handler.ts
320
415
  // ─────────────────────────────────────────────────────────────────
321
416
  import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
322
- import { DealCardsCommand } from '../commands/deal-cards.command';
417
+ import { TransferCommand, DepositCommand } from '../commands';
418
+ import { QueueBus } from 'atomic-queues';
323
419
 
324
- @CommandHandler(DealCardsCommand)
325
- export class DealCardsHandler implements ICommandHandler<DealCardsCommand> {
326
- async execute(command: DealCardsCommand) {
327
- console.log(`Dealing cards for table ${command.tableId}`);
420
+ @CommandHandler(TransferCommand)
421
+ export class TransferHandler implements ICommandHandler<TransferCommand> {
422
+ constructor(
423
+ private readonly accountRepo: AccountRepository,
424
+ private readonly queueBus: QueueBus,
425
+ ) {}
426
+
427
+ async execute(command: TransferCommand) {
428
+ const { accountId, toAccountId, amount, transactionId } = command;
429
+
430
+ // Step 1: Debit source account (already in source account's queue)
431
+ const sourceAccount = await this.accountRepo.findById(accountId);
432
+
433
+ if (sourceAccount.balance < amount) {
434
+ throw new InsufficientFundsError(accountId, sourceAccount.balance, amount);
435
+ }
436
+
437
+ sourceAccount.balance -= amount;
438
+ await this.accountRepo.save(sourceAccount);
439
+
440
+ // Step 2: Queue credit to destination account (different queue!)
441
+ // This ensures the destination account also processes atomically
442
+ await this.queueBus
443
+ .forProcessor(AccountProcessor)
444
+ .enqueue(new DepositCommand(
445
+ toAccountId,
446
+ amount,
447
+ transactionId,
448
+ `transfer:${accountId}`,
449
+ ));
450
+
451
+ return { success: true, transactionId };
328
452
  }
329
453
  }
330
454
 
331
455
  // ─────────────────────────────────────────────────────────────────
332
- // table.processor.ts
456
+ // account.processor.ts
333
457
  // ─────────────────────────────────────────────────────────────────
334
458
  import { Injectable } from '@nestjs/common';
335
459
  import { WorkerProcessor } from 'atomic-queues';
336
460
 
337
461
  @WorkerProcessor({
338
- entityType: 'table',
339
- queueName: (tableId) => `table-${tableId}-queue`,
340
- workerName: (tableId) => `table-${tableId}-worker`,
462
+ entityType: 'account',
463
+ queueName: (accountId) => `bank-account-${accountId}-queue`,
464
+ workerName: (accountId) => `bank-account-${accountId}-worker`,
465
+ workerConfig: {
466
+ concurrency: 1, // CRITICAL: Must be 1 for financial transactions
467
+ lockDuration: 60000, // 60s lock for long transactions
468
+ stalledInterval: 5000,
469
+ },
341
470
  })
342
471
  @Injectable()
343
- export class TableProcessor {}
472
+ export class AccountProcessor {}
344
473
 
345
474
  // ─────────────────────────────────────────────────────────────────
346
- // table.module.ts - No manual registration needed!
475
+ // account.scaler.ts - Scale workers based on active accounts
476
+ // ─────────────────────────────────────────────────────────────────
477
+ import { Injectable } from '@nestjs/common';
478
+ import { EntityScaler, GetActiveEntities, GetDesiredWorkerCount } from 'atomic-queues';
479
+
480
+ @EntityScaler({
481
+ entityType: 'account',
482
+ maxWorkersPerEntity: 1, // Never more than 1 worker per account
483
+ })
484
+ @Injectable()
485
+ export class AccountScaler {
486
+ constructor(private readonly accountRepo: AccountRepository) {}
487
+
488
+ @GetActiveEntities()
489
+ async getActiveAccounts(): Promise<string[]> {
490
+ // Return accounts with pending transactions
491
+ return this.accountRepo.findAccountsWithPendingTransactions();
492
+ }
493
+
494
+ @GetDesiredWorkerCount()
495
+ async getWorkerCount(accountId: string): Promise<number> {
496
+ // Always 1 worker per account for atomicity
497
+ return 1;
498
+ }
499
+ }
500
+
501
+ // ─────────────────────────────────────────────────────────────────
502
+ // account.module.ts
347
503
  // ─────────────────────────────────────────────────────────────────
348
504
  import { Module } from '@nestjs/common';
349
505
  import { CqrsModule } from '@nestjs/cqrs';
350
- import { TableProcessor } from './table.processor';
351
- import { TableGateway } from './table.gateway';
352
- import { PlaceBetHandler, DealCardsHandler } from './handlers';
353
506
 
354
507
  @Module({
355
508
  imports: [CqrsModule],
356
509
  providers: [
357
- TableProcessor,
358
- TableGateway,
359
- PlaceBetHandler, // Commands auto-discovered from handlers!
360
- DealCardsHandler,
510
+ AccountProcessor,
511
+ AccountScaler,
512
+ WithdrawHandler, // Commands auto-discovered!
513
+ DepositHandler,
514
+ TransferHandler,
361
515
  ],
516
+ controllers: [AccountController],
362
517
  })
363
- export class TableModule {}
518
+ export class AccountModule {}
364
519
 
365
520
  // ─────────────────────────────────────────────────────────────────
366
- // table.gateway.ts (WebSocket example)
521
+ // account.controller.ts
367
522
  // ─────────────────────────────────────────────────────────────────
368
- import { Injectable } from '@nestjs/common';
523
+ import { Controller, Post, Body, Param } from '@nestjs/common';
369
524
  import { QueueBus } from 'atomic-queues';
370
- import { TableProcessor } from './table.processor';
371
- import { PlaceBetCommand, DealCardsCommand } from './commands';
525
+ import { AccountProcessor } from './account.processor';
526
+ import { WithdrawCommand, DepositCommand, TransferCommand } from './commands';
527
+ import { v4 as uuid } from 'uuid';
372
528
 
373
- @Injectable()
374
- export class TableGateway {
529
+ @Controller('accounts')
530
+ export class AccountController {
375
531
  constructor(private readonly queueBus: QueueBus) {}
376
532
 
377
- async onPlaceBet(tableId: string, playerId: string, amount: number) {
533
+ @Post(':accountId/withdraw')
534
+ async withdraw(
535
+ @Param('accountId') accountId: string,
536
+ @Body() body: { amount: number; requestedBy: string },
537
+ ) {
538
+ const transactionId = uuid();
539
+
540
+ // Even if user spam-clicks "Withdraw", each request is queued
541
+ // and processed sequentially - no double-withdrawals possible
378
542
  await this.queueBus
379
- .forProcessor(TableProcessor)
380
- .enqueue(new PlaceBetCommand(tableId, playerId, amount));
543
+ .forProcessor(AccountProcessor)
544
+ .enqueue(new WithdrawCommand(
545
+ accountId,
546
+ body.amount,
547
+ transactionId,
548
+ body.requestedBy,
549
+ ));
550
+
551
+ return {
552
+ queued: true,
553
+ transactionId,
554
+ message: 'Withdrawal queued for processing',
555
+ };
381
556
  }
382
557
 
383
- async onDealCards(tableId: string) {
558
+ @Post(':accountId/transfer')
559
+ async transfer(
560
+ @Param('accountId') accountId: string,
561
+ @Body() body: { toAccountId: string; amount: number },
562
+ ) {
563
+ const transactionId = uuid();
564
+
384
565
  await this.queueBus
385
- .forProcessor(TableProcessor)
386
- .enqueue(new DealCardsCommand(tableId));
566
+ .forProcessor(AccountProcessor)
567
+ .enqueue(new TransferCommand(
568
+ accountId,
569
+ body.toAccountId,
570
+ body.amount,
571
+ transactionId,
572
+ ));
573
+
574
+ return {
575
+ queued: true,
576
+ transactionId,
577
+ message: 'Transfer queued for processing',
578
+ };
387
579
  }
388
580
  }
389
581
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atomic-queues",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "A plug-and-play NestJS library for atomic process handling per entity with BullMQ, Redis distributed locking, and dynamic worker management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",