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.
- package/README.md +295 -103
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
│
|
|
16
|
-
|
|
17
|
-
│
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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/
|
|
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
|
|
341
|
+
export class DepositCommand {
|
|
289
342
|
constructor(
|
|
290
|
-
public readonly
|
|
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/
|
|
351
|
+
// commands/transfer.command.ts
|
|
298
352
|
// ─────────────────────────────────────────────────────────────────
|
|
299
|
-
export class
|
|
353
|
+
export class TransferCommand {
|
|
300
354
|
constructor(
|
|
301
|
-
public readonly
|
|
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/
|
|
363
|
+
// handlers/withdraw.handler.ts
|
|
307
364
|
// ─────────────────────────────────────────────────────────────────
|
|
308
365
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
309
|
-
import {
|
|
366
|
+
import { WithdrawCommand } from '../commands';
|
|
310
367
|
|
|
311
|
-
@CommandHandler(
|
|
312
|
-
export class
|
|
313
|
-
|
|
314
|
-
|
|
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/
|
|
414
|
+
// handlers/transfer.handler.ts
|
|
320
415
|
// ─────────────────────────────────────────────────────────────────
|
|
321
416
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
322
|
-
import {
|
|
417
|
+
import { TransferCommand, DepositCommand } from '../commands';
|
|
418
|
+
import { QueueBus } from 'atomic-queues';
|
|
323
419
|
|
|
324
|
-
@CommandHandler(
|
|
325
|
-
export class
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
//
|
|
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: '
|
|
339
|
-
queueName: (
|
|
340
|
-
workerName: (
|
|
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
|
|
472
|
+
export class AccountProcessor {}
|
|
344
473
|
|
|
345
474
|
// ─────────────────────────────────────────────────────────────────
|
|
346
|
-
//
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
510
|
+
AccountProcessor,
|
|
511
|
+
AccountScaler,
|
|
512
|
+
WithdrawHandler, // Commands auto-discovered!
|
|
513
|
+
DepositHandler,
|
|
514
|
+
TransferHandler,
|
|
361
515
|
],
|
|
516
|
+
controllers: [AccountController],
|
|
362
517
|
})
|
|
363
|
-
export class
|
|
518
|
+
export class AccountModule {}
|
|
364
519
|
|
|
365
520
|
// ─────────────────────────────────────────────────────────────────
|
|
366
|
-
//
|
|
521
|
+
// account.controller.ts
|
|
367
522
|
// ─────────────────────────────────────────────────────────────────
|
|
368
|
-
import {
|
|
523
|
+
import { Controller, Post, Body, Param } from '@nestjs/common';
|
|
369
524
|
import { QueueBus } from 'atomic-queues';
|
|
370
|
-
import {
|
|
371
|
-
import {
|
|
525
|
+
import { AccountProcessor } from './account.processor';
|
|
526
|
+
import { WithdrawCommand, DepositCommand, TransferCommand } from './commands';
|
|
527
|
+
import { v4 as uuid } from 'uuid';
|
|
372
528
|
|
|
373
|
-
@
|
|
374
|
-
export class
|
|
529
|
+
@Controller('accounts')
|
|
530
|
+
export class AccountController {
|
|
375
531
|
constructor(private readonly queueBus: QueueBus) {}
|
|
376
532
|
|
|
377
|
-
|
|
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(
|
|
380
|
-
.enqueue(new
|
|
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
|
-
|
|
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(
|
|
386
|
-
.enqueue(new
|
|
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
|
+
"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",
|