atomic-queues 1.2.4 → 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 +264 -127
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -9,17 +9,17 @@ A NestJS library for atomic, sequential job processing per entity with BullMQ an
9
9
  ║ THE PROBLEM ║
10
10
  ╠═══════════════════════════════════════════════════════════════════════════════╣
11
11
  ║ ║
12
- Multiple requests for the same entity arrive simultaneously:
12
+ User has $100 balance. Two $80 withdrawals arrive at the same time:
13
13
  ║ ║
14
- ┌──────────┐
15
- Request A │──┐
16
- └──────────┘
17
- ┌──────────┐ ┌─────────────┐
18
- Request B │──┼───▶│ Entity 123 │───▶ 💥 RACE CONDITION!
19
- └──────────┘ └─────────────┘
20
- ┌──────────┐ │
21
- │ Request C │──┘
22
- └──────────┘
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
23
  ║ ║
24
24
  ╚═══════════════════════════════════════════════════════════════════════════════╝
25
25
 
@@ -27,17 +27,16 @@ A NestJS library for atomic, sequential job processing per entity with BullMQ an
27
27
  ║ THE SOLUTION ║
28
28
  ╠═══════════════════════════════════════════════════════════════════════════════╣
29
29
  ║ ║
30
- ║ atomic-queues ensures sequential processing per entity:
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
+ ║ └─────────────┘ └─────────────────────────────┘ ║
31
38
  ║ ║
32
- ┌──────────┐ ┌─────────────────┐ ┌──────────┐
33
- ║ │ Request A │──┐ │ │ │ │ ║
34
- ║ └──────────┘ │ │ Redis Queue │ │ Worker │ ┌───────────┐ ║
35
- ║ ┌──────────┐ │ │ ┌───┬───┬───┐ │ │ │ │ │ ║
36
- ║ │ Request B │──┼──▶│ │ A │ B │ C │ │─────▶│ (1 job │─▶│Entity 123 │ ║
37
- ║ └──────────┘ │ │ └───┴───┴───┘ │ │ at a time│ │ │ ║
38
- ║ ┌──────────┐ │ │ │ │ │ └───────────┘ ║
39
- ║ │ Request C │──┘ └─────────────────┘ └──────────┘ ║
40
- ║ └──────────┘ ║
39
+ Sequential processing: W1 completes first, W2 sees updated balance
41
40
  ║ ║
42
41
  ╚═══════════════════════════════════════════════════════════════════════════════╝
43
42
  ```
@@ -147,44 +146,65 @@ That's it! The library automatically:
147
146
  ║ ARCHITECTURE ║
148
147
  ╚═══════════════════════════════════════════════════════════════════════════════╝
149
148
 
150
- YOUR CODE ATOMIC-QUEUES EXECUTION
151
- ───────── ───────────── ─────────
152
-
153
- ┌─────────────────────────┐
154
- │ queueBus │
155
- │ .forProcessor(...) │
156
- .enqueue(command)
157
- └───────────┬─────────────┘
158
-
159
- │ ① Extract queue config from @WorkerProcessor
160
- │ ② Extract entityId from command properties
161
- │ ③ Build queue name: {prefix}-{entityId}-queue
162
-
163
- ┌───────────────────┐
164
- │ │
165
- Redis Queue │◀─── Job { name: "MyCommand", data: {...} }
166
- (per entity)
167
-
168
- └─────────┬─────────┘
169
-
170
- Worker pulls job (one at a time)
171
-
172
- ┌───────────────────┐
173
-
174
- BullMQ Worker
175
- (1 per entity)
176
-
177
- └─────────┬─────────┘
178
-
179
- Lookup command class in registry
180
- ⑥ Instantiate from job.data
181
- ⑦ Execute via CQRS CommandBus
182
-
183
- ┌───────────────────┐ ┌─────────────────────────┐
184
- │ │ │
185
- CommandBus │─────▶│ MyCommandHandler │
186
- │ │ │ .execute(command) │
187
- └───────────────────┘ └─────────────────────────┘
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
+ └──────────────────────────────────────────────────────────────────────────────┘
188
208
  ```
189
209
 
190
210
  ---
@@ -300,145 +320,262 @@ export class OrderScaler {
300
320
 
301
321
  ## Complete Example
302
322
 
303
- A document processing service where multiple users can edit the same document:
323
+ A banking service handling critical financial transactions where race conditions could cause overdrafts or double-spending:
304
324
 
305
325
  ```typescript
306
326
  // ─────────────────────────────────────────────────────────────────
307
- // commands/update-document.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
308
340
  // ─────────────────────────────────────────────────────────────────
309
- export class UpdateDocumentCommand {
341
+ export class DepositCommand {
310
342
  constructor(
311
- public readonly documentId: string,
312
- public readonly userId: string,
313
- public readonly content: string,
314
- public readonly version: number,
343
+ public readonly accountId: string,
344
+ public readonly amount: number,
345
+ public readonly transactionId: string,
346
+ public readonly source: string,
315
347
  ) {}
316
348
  }
317
349
 
318
350
  // ─────────────────────────────────────────────────────────────────
319
- // commands/publish-document.command.ts
351
+ // commands/transfer.command.ts
320
352
  // ─────────────────────────────────────────────────────────────────
321
- export class PublishDocumentCommand {
353
+ export class TransferCommand {
322
354
  constructor(
323
- public readonly documentId: string,
324
- public readonly publishedBy: 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,
325
359
  ) {}
326
360
  }
327
361
 
328
362
  // ─────────────────────────────────────────────────────────────────
329
- // handlers/update-document.handler.ts
363
+ // handlers/withdraw.handler.ts
330
364
  // ─────────────────────────────────────────────────────────────────
331
365
  import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
332
- import { UpdateDocumentCommand } from '../commands';
366
+ import { WithdrawCommand } from '../commands';
333
367
 
334
- @CommandHandler(UpdateDocumentCommand)
335
- export class UpdateDocumentHandler implements ICommandHandler<UpdateDocumentCommand> {
336
- constructor(private readonly documentRepo: DocumentRepository) {}
368
+ @CommandHandler(WithdrawCommand)
369
+ export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
370
+ constructor(
371
+ private readonly accountRepo: AccountRepository,
372
+ private readonly ledger: LedgerService,
373
+ ) {}
337
374
 
338
- async execute(command: UpdateDocumentCommand) {
339
- const { documentId, userId, content, version } = command;
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);
340
394
 
341
- // Safe! No race conditions - one update at a time per document
342
- await this.documentRepo.update(documentId, { content, version, lastEditedBy: userId });
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
+ });
343
404
 
344
- return { success: true, documentId, version };
405
+ return {
406
+ success: true,
407
+ transactionId,
408
+ newBalance: account.balance
409
+ };
345
410
  }
346
411
  }
347
412
 
348
413
  // ─────────────────────────────────────────────────────────────────
349
- // handlers/publish-document.handler.ts
414
+ // handlers/transfer.handler.ts
350
415
  // ─────────────────────────────────────────────────────────────────
351
416
  import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
352
- import { PublishDocumentCommand } from '../commands';
417
+ import { TransferCommand, DepositCommand } from '../commands';
418
+ import { QueueBus } from 'atomic-queues';
353
419
 
354
- @CommandHandler(PublishDocumentCommand)
355
- export class PublishDocumentHandler implements ICommandHandler<PublishDocumentCommand> {
356
- constructor(private readonly documentRepo: DocumentRepository) {}
420
+ @CommandHandler(TransferCommand)
421
+ export class TransferHandler implements ICommandHandler<TransferCommand> {
422
+ constructor(
423
+ private readonly accountRepo: AccountRepository,
424
+ private readonly queueBus: QueueBus,
425
+ ) {}
357
426
 
358
- async execute(command: PublishDocumentCommand) {
359
- const { documentId, publishedBy } = command;
427
+ async execute(command: TransferCommand) {
428
+ const { accountId, toAccountId, amount, transactionId } = command;
360
429
 
361
- await this.documentRepo.publish(documentId, publishedBy);
430
+ // Step 1: Debit source account (already in source account's queue)
431
+ const sourceAccount = await this.accountRepo.findById(accountId);
362
432
 
363
- return { success: true, documentId, publishedAt: new Date() };
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 };
364
452
  }
365
453
  }
366
454
 
367
455
  // ─────────────────────────────────────────────────────────────────
368
- // document.processor.ts
456
+ // account.processor.ts
369
457
  // ─────────────────────────────────────────────────────────────────
370
458
  import { Injectable } from '@nestjs/common';
371
459
  import { WorkerProcessor } from 'atomic-queues';
372
460
 
373
461
  @WorkerProcessor({
374
- entityType: 'document',
375
- queueName: (documentId) => `doc-${documentId}-queue`,
376
- workerName: (documentId) => `doc-${documentId}-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
+ },
377
470
  })
378
471
  @Injectable()
379
- export class DocumentProcessor {}
472
+ export class AccountProcessor {}
380
473
 
381
474
  // ─────────────────────────────────────────────────────────────────
382
- // document.module.ts
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
383
503
  // ─────────────────────────────────────────────────────────────────
384
504
  import { Module } from '@nestjs/common';
385
505
  import { CqrsModule } from '@nestjs/cqrs';
386
- import { DocumentProcessor } from './document.processor';
387
- import { DocumentController } from './document.controller';
388
- import { UpdateDocumentHandler, PublishDocumentHandler } from './handlers';
389
506
 
390
507
  @Module({
391
508
  imports: [CqrsModule],
392
509
  providers: [
393
- DocumentProcessor,
394
- UpdateDocumentHandler, // Commands auto-discovered!
395
- PublishDocumentHandler,
510
+ AccountProcessor,
511
+ AccountScaler,
512
+ WithdrawHandler, // Commands auto-discovered!
513
+ DepositHandler,
514
+ TransferHandler,
396
515
  ],
397
- controllers: [DocumentController],
516
+ controllers: [AccountController],
398
517
  })
399
- export class DocumentModule {}
518
+ export class AccountModule {}
400
519
 
401
520
  // ─────────────────────────────────────────────────────────────────
402
- // document.controller.ts
521
+ // account.controller.ts
403
522
  // ─────────────────────────────────────────────────────────────────
404
523
  import { Controller, Post, Body, Param } from '@nestjs/common';
405
524
  import { QueueBus } from 'atomic-queues';
406
- import { DocumentProcessor } from './document.processor';
407
- import { UpdateDocumentCommand, PublishDocumentCommand } from './commands';
525
+ import { AccountProcessor } from './account.processor';
526
+ import { WithdrawCommand, DepositCommand, TransferCommand } from './commands';
527
+ import { v4 as uuid } from 'uuid';
408
528
 
409
- @Controller('documents')
410
- export class DocumentController {
529
+ @Controller('accounts')
530
+ export class AccountController {
411
531
  constructor(private readonly queueBus: QueueBus) {}
412
532
 
413
- @Post(':id/update')
414
- async updateDocument(
415
- @Param('id') documentId: string,
416
- @Body() body: { userId: string; content: string; version: number },
533
+ @Post(':accountId/withdraw')
534
+ async withdraw(
535
+ @Param('accountId') accountId: string,
536
+ @Body() body: { amount: number; requestedBy: string },
417
537
  ) {
418
- // Multiple users editing same doc? No problem!
419
- // Updates are queued and processed one at a time
538
+ const transactionId = uuid();
539
+
540
+ // Even if user spam-clicks "Withdraw", each request is queued
541
+ // and processed sequentially - no double-withdrawals possible
420
542
  await this.queueBus
421
- .forProcessor(DocumentProcessor)
422
- .enqueue(new UpdateDocumentCommand(
423
- documentId,
424
- body.userId,
425
- body.content,
426
- body.version,
543
+ .forProcessor(AccountProcessor)
544
+ .enqueue(new WithdrawCommand(
545
+ accountId,
546
+ body.amount,
547
+ transactionId,
548
+ body.requestedBy,
427
549
  ));
428
550
 
429
- return { queued: true, documentId };
551
+ return {
552
+ queued: true,
553
+ transactionId,
554
+ message: 'Withdrawal queued for processing',
555
+ };
430
556
  }
431
557
 
432
- @Post(':id/publish')
433
- async publishDocument(
434
- @Param('id') documentId: string,
435
- @Body() body: { publishedBy: string },
558
+ @Post(':accountId/transfer')
559
+ async transfer(
560
+ @Param('accountId') accountId: string,
561
+ @Body() body: { toAccountId: string; amount: number },
436
562
  ) {
563
+ const transactionId = uuid();
564
+
437
565
  await this.queueBus
438
- .forProcessor(DocumentProcessor)
439
- .enqueue(new PublishDocumentCommand(documentId, body.publishedBy));
566
+ .forProcessor(AccountProcessor)
567
+ .enqueue(new TransferCommand(
568
+ accountId,
569
+ body.toAccountId,
570
+ body.amount,
571
+ transactionId,
572
+ ));
440
573
 
441
- return { queued: true, documentId };
574
+ return {
575
+ queued: true,
576
+ transactionId,
577
+ message: 'Transfer queued for processing',
578
+ };
442
579
  }
443
580
  }
444
581
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atomic-queues",
3
- "version": "1.2.4",
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",