atomic-queues 2.2.0 → 3.0.0
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 +296 -417
- package/dist/cli/generators/classes.d.ts +1 -1
- package/dist/cli/generators/json-schema.d.ts +1 -1
- package/dist/cli/generators/typescript.d.ts +1 -1
- package/dist/cli/index.js +147 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cluster/cluster-discovery.service.d.ts +91 -0
- package/dist/cluster/cluster-discovery.service.d.ts.map +1 -0
- package/dist/cluster/cluster-discovery.service.js +423 -0
- package/dist/cluster/cluster-discovery.service.js.map +1 -0
- package/dist/cluster/grpc-peer-monitor.service.d.ts +31 -0
- package/dist/cluster/grpc-peer-monitor.service.d.ts.map +1 -0
- package/dist/cluster/grpc-peer-monitor.service.js +192 -0
- package/dist/cluster/grpc-peer-monitor.service.js.map +1 -0
- package/dist/cluster/index.d.ts +7 -0
- package/dist/cluster/index.d.ts.map +1 -0
- package/dist/cluster/index.js +23 -0
- package/dist/cluster/index.js.map +1 -0
- package/dist/cluster/leader-election.service.d.ts +38 -0
- package/dist/cluster/leader-election.service.d.ts.map +1 -0
- package/dist/cluster/leader-election.service.js +184 -0
- package/dist/cluster/leader-election.service.js.map +1 -0
- package/dist/cluster/master-coordinator.d.ts +50 -0
- package/dist/cluster/master-coordinator.d.ts.map +1 -0
- package/dist/cluster/master-coordinator.js +307 -0
- package/dist/cluster/master-coordinator.js.map +1 -0
- package/dist/cluster/redis-health-monitor.service.d.ts +23 -0
- package/dist/cluster/redis-health-monitor.service.d.ts.map +1 -0
- package/dist/cluster/redis-health-monitor.service.js +100 -0
- package/dist/cluster/redis-health-monitor.service.js.map +1 -0
- package/dist/cluster/server-ring.service.d.ts +48 -0
- package/dist/cluster/server-ring.service.d.ts.map +1 -0
- package/dist/cluster/server-ring.service.js +136 -0
- package/dist/cluster/server-ring.service.js.map +1 -0
- package/dist/decorators/constants.d.ts +0 -3
- package/dist/decorators/constants.d.ts.map +1 -1
- package/dist/decorators/constants.js +1 -5
- package/dist/decorators/constants.js.map +1 -1
- package/dist/decorators/entity.decorators.d.ts +16 -24
- package/dist/decorators/entity.decorators.d.ts.map +1 -1
- package/dist/decorators/entity.decorators.js +0 -39
- package/dist/decorators/entity.decorators.js.map +1 -1
- package/dist/decorators/index.d.ts +0 -1
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +0 -1
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/interfaces.d.ts +10 -28
- package/dist/decorators/interfaces.d.ts.map +1 -1
- package/dist/decorators/job.decorators.d.ts +4 -52
- package/dist/decorators/job.decorators.d.ts.map +1 -1
- package/dist/decorators/job.decorators.js +6 -54
- package/dist/decorators/job.decorators.js.map +1 -1
- package/dist/decorators/metadata-readers.d.ts +5 -5
- package/dist/decorators/metadata-readers.d.ts.map +1 -1
- package/dist/decorators/metadata-readers.js +2 -8
- package/dist/decorators/metadata-readers.js.map +1 -1
- package/dist/decorators/schema.decorators.d.ts +1 -1
- package/dist/decorators/schema.decorators.d.ts.map +1 -1
- package/dist/decorators/schema.decorators.js.map +1 -1
- package/dist/decorators/utils.d.ts +1 -1
- package/dist/decorators/utils.d.ts.map +1 -1
- package/dist/decorators/utils.js +5 -1
- package/dist/decorators/utils.js.map +1 -1
- package/dist/domain/interfaces/config.interfaces.d.ts +92 -35
- package/dist/domain/interfaces/config.interfaces.d.ts.map +1 -1
- package/dist/domain/interfaces/index.d.ts +1 -0
- package/dist/domain/interfaces/index.d.ts.map +1 -1
- package/dist/domain/interfaces/index.js +1 -0
- package/dist/domain/interfaces/index.js.map +1 -1
- package/dist/{services/registry → domain/interfaces}/registry.types.d.ts.map +1 -1
- package/dist/domain/interfaces/registry.types.js.map +1 -0
- package/dist/grpc/grpc-client-pool.service.d.ts +71 -0
- package/dist/grpc/grpc-client-pool.service.d.ts.map +1 -0
- package/dist/grpc/grpc-client-pool.service.js +307 -0
- package/dist/grpc/grpc-client-pool.service.js.map +1 -0
- package/dist/grpc/grpc-server.service.d.ts +47 -0
- package/dist/grpc/grpc-server.service.d.ts.map +1 -0
- package/dist/grpc/grpc-server.service.js +494 -0
- package/dist/grpc/grpc-server.service.js.map +1 -0
- package/dist/grpc/index.d.ts +3 -0
- package/dist/grpc/index.d.ts.map +1 -0
- package/dist/{services/gate → grpc}/index.js +2 -1
- package/dist/grpc/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/module/atomic-queues.module.d.ts +1 -0
- package/dist/module/atomic-queues.module.d.ts.map +1 -1
- package/dist/module/atomic-queues.module.js +60 -11
- package/dist/module/atomic-queues.module.js.map +1 -1
- package/dist/services/command-discovery/command-discovery.service.js +2 -2
- package/dist/services/command-discovery/command-discovery.service.js.map +1 -1
- package/dist/services/entity-type-registry/entity-type-registry.service.d.ts +13 -0
- package/dist/services/entity-type-registry/entity-type-registry.service.d.ts.map +1 -0
- package/dist/services/entity-type-registry/entity-type-registry.service.js +75 -0
- package/dist/services/entity-type-registry/entity-type-registry.service.js.map +1 -0
- package/dist/services/entity-type-registry/index.d.ts +2 -0
- package/dist/services/entity-type-registry/index.d.ts.map +1 -0
- package/dist/services/{actor-system → entity-type-registry}/index.js +1 -1
- package/dist/services/entity-type-registry/index.js.map +1 -0
- package/dist/services/handler-executor/handler-executor.service.d.ts +0 -2
- package/dist/services/handler-executor/handler-executor.service.d.ts.map +1 -1
- package/dist/services/handler-executor/handler-executor.service.js +0 -19
- package/dist/services/handler-executor/handler-executor.service.js.map +1 -1
- package/dist/services/index.d.ts +3 -9
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +3 -9
- package/dist/services/index.js.map +1 -1
- package/dist/services/message-router/index.d.ts +2 -0
- package/dist/services/message-router/index.d.ts.map +1 -0
- package/dist/services/{actor-registry → message-router}/index.js +1 -1
- package/dist/services/message-router/index.js.map +1 -0
- package/dist/services/message-router/message-router.service.d.ts +53 -0
- package/dist/services/message-router/message-router.service.d.ts.map +1 -0
- package/dist/services/message-router/message-router.service.js +519 -0
- package/dist/services/message-router/message-router.service.js.map +1 -0
- package/dist/services/queue-bus/cluster-contracts.d.ts +1 -1
- package/dist/services/queue-bus/cluster-contracts.d.ts.map +1 -1
- package/dist/services/queue-bus/cluster-contracts.js.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.d.ts +3 -21
- package/dist/services/queue-bus/queue-bus.service.d.ts.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.js +15 -119
- package/dist/services/queue-bus/queue-bus.service.js.map +1 -1
- package/dist/utils/id.utils.d.ts +3 -0
- package/dist/utils/id.utils.d.ts.map +1 -0
- package/dist/utils/id.utils.js +14 -0
- package/dist/utils/id.utils.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/wal/index.d.ts +4 -0
- package/dist/wal/index.d.ts.map +1 -0
- package/dist/{services/executor-pool → wal}/index.js +3 -1
- package/dist/wal/index.js.map +1 -0
- package/dist/wal/wal.scripts.d.ts +51 -0
- package/dist/wal/wal.scripts.d.ts.map +1 -0
- package/dist/wal/wal.scripts.js +84 -0
- package/dist/wal/wal.scripts.js.map +1 -0
- package/dist/wal/wal.service.d.ts +46 -0
- package/dist/wal/wal.service.d.ts.map +1 -0
- package/dist/wal/wal.service.js +243 -0
- package/dist/wal/wal.service.js.map +1 -0
- package/dist/wal/wal.types.d.ts +23 -0
- package/dist/wal/wal.types.d.ts.map +1 -0
- package/dist/wal/wal.types.js +3 -0
- package/dist/wal/wal.types.js.map +1 -0
- package/dist/workers/consistent-hash.d.ts +97 -0
- package/dist/workers/consistent-hash.d.ts.map +1 -0
- package/dist/workers/consistent-hash.js +231 -0
- package/dist/workers/consistent-hash.js.map +1 -0
- package/dist/workers/entity-worker-manager.d.ts +35 -0
- package/dist/workers/entity-worker-manager.d.ts.map +1 -0
- package/dist/workers/entity-worker-manager.js +237 -0
- package/dist/workers/entity-worker-manager.js.map +1 -0
- package/dist/workers/entity-worker.d.ts +54 -0
- package/dist/workers/entity-worker.d.ts.map +1 -0
- package/dist/workers/entity-worker.js +142 -0
- package/dist/workers/entity-worker.js.map +1 -0
- package/dist/workers/index.d.ts +4 -0
- package/dist/workers/index.d.ts.map +1 -0
- package/dist/workers/index.js +20 -0
- package/dist/workers/index.js.map +1 -0
- package/package.json +17 -4
- package/dist/decorators/actor.decorators.d.ts +0 -4
- package/dist/decorators/actor.decorators.d.ts.map +0 -1
- package/dist/decorators/actor.decorators.js +0 -32
- package/dist/decorators/actor.decorators.js.map +0 -1
- package/dist/services/actor-registry/actor-registry.service.d.ts +0 -32
- package/dist/services/actor-registry/actor-registry.service.d.ts.map +0 -1
- package/dist/services/actor-registry/actor-registry.service.js +0 -220
- package/dist/services/actor-registry/actor-registry.service.js.map +0 -1
- package/dist/services/actor-registry/index.d.ts +0 -2
- package/dist/services/actor-registry/index.d.ts.map +0 -1
- package/dist/services/actor-registry/index.js.map +0 -1
- package/dist/services/actor-system/actor-system.service.d.ts +0 -19
- package/dist/services/actor-system/actor-system.service.d.ts.map +0 -1
- package/dist/services/actor-system/actor-system.service.js +0 -86
- package/dist/services/actor-system/actor-system.service.js.map +0 -1
- package/dist/services/actor-system/index.d.ts +0 -2
- package/dist/services/actor-system/index.d.ts.map +0 -1
- package/dist/services/actor-system/index.js.map +0 -1
- package/dist/services/executor-pool/executor-pool.service.d.ts +0 -38
- package/dist/services/executor-pool/executor-pool.service.d.ts.map +0 -1
- package/dist/services/executor-pool/executor-pool.service.js +0 -180
- package/dist/services/executor-pool/executor-pool.service.js.map +0 -1
- package/dist/services/executor-pool/index.d.ts +0 -2
- package/dist/services/executor-pool/index.d.ts.map +0 -1
- package/dist/services/executor-pool/index.js.map +0 -1
- package/dist/services/gate/gate.service.d.ts +0 -17
- package/dist/services/gate/gate.service.d.ts.map +0 -1
- package/dist/services/gate/gate.service.js +0 -81
- package/dist/services/gate/gate.service.js.map +0 -1
- package/dist/services/gate/index.d.ts +0 -2
- package/dist/services/gate/index.d.ts.map +0 -1
- package/dist/services/gate/index.js.map +0 -1
- package/dist/services/log/index.d.ts +0 -2
- package/dist/services/log/index.d.ts.map +0 -1
- package/dist/services/log/index.js +0 -18
- package/dist/services/log/index.js.map +0 -1
- package/dist/services/log/log.service.d.ts +0 -21
- package/dist/services/log/log.service.d.ts.map +0 -1
- package/dist/services/log/log.service.js +0 -92
- package/dist/services/log/log.service.js.map +0 -1
- package/dist/services/registry/index.d.ts +0 -4
- package/dist/services/registry/index.d.ts.map +0 -1
- package/dist/services/registry/index.js +0 -20
- package/dist/services/registry/index.js.map +0 -1
- package/dist/services/registry/registry.service.d.ts +0 -43
- package/dist/services/registry/registry.service.d.ts.map +0 -1
- package/dist/services/registry/registry.service.js +0 -402
- package/dist/services/registry/registry.service.js.map +0 -1
- package/dist/services/registry/registry.types.js.map +0 -1
- package/dist/services/registry/schema-converter.d.ts +0 -2
- package/dist/services/registry/schema-converter.d.ts.map +0 -1
- package/dist/services/registry/schema-converter.js +0 -27
- package/dist/services/registry/schema-converter.js.map +0 -1
- package/dist/services/result-collector/index.d.ts +0 -2
- package/dist/services/result-collector/index.d.ts.map +0 -1
- package/dist/services/result-collector/index.js +0 -18
- package/dist/services/result-collector/index.js.map +0 -1
- package/dist/services/result-collector/result-collector.service.d.ts +0 -17
- package/dist/services/result-collector/result-collector.service.d.ts.map +0 -1
- package/dist/services/result-collector/result-collector.service.js +0 -92
- package/dist/services/result-collector/result-collector.service.js.map +0 -1
- package/dist/services/scheduler/index.d.ts +0 -2
- package/dist/services/scheduler/index.d.ts.map +0 -1
- package/dist/services/scheduler/index.js +0 -18
- package/dist/services/scheduler/index.js.map +0 -1
- package/dist/services/scheduler/scheduler.service.d.ts +0 -17
- package/dist/services/scheduler/scheduler.service.d.ts.map +0 -1
- package/dist/services/scheduler/scheduler.service.js +0 -140
- package/dist/services/scheduler/scheduler.service.js.map +0 -1
- /package/dist/{services/registry → domain/interfaces}/registry.types.d.ts +0 -0
- /package/dist/{services/registry → domain/interfaces}/registry.types.js +0 -0
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣽⣟⣳⡝⡼⢁⠎⠀⡀⢁⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⡄⠰⣄⠈⠓⢌⠛⢽⣣⡟⢿⠿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿█▀█ █ █▄█ █ ▀ █ █ █▄▄
|
|
11
11
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡿⣽⠳⡼⢁⡞⠀⡜⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢸⢵⠀⠀⠁⠂⠤⣉⠉⠓⠒⠚⠦⠥⡈⠉⣙⢛⡿⣿█▀█ █ █ █▀▀ █ █ █▀▀ █▀▀
|
|
12
12
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡾⣽⣏⢳⢃⣞⠃⡼⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠁⢀⣀⠤⠐⢋⡰⣌⣾⣿⣿▀▀█ █▄█ ██▄ █▄█ ██▄ ▄▄█
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
⣿⢯⡝⠠⠁⠀⠀⠠⠤⠀⠀⠀⠀⡀⠢⣄⣀⡀⠐⠤⡀⠀⠀⠀⢤⣄⣀⠤⣄⣤⢤⣖⡾⠋⢁⡼⠁⣸⡿⣞⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
17
|
-
⣿⣷⣾⣵⣦⣶⣖⣳⣶⣝⣶⣯⣷⣽⣷⣾⣶⣽⣯⣶⠄⠈⠒⣤⣀⠉⠙⠛⠛⠋⠋⢁⣠⠔⠁⠀⢰⣿⣽⣯⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿l o c k
|
|
13
|
+
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣮⢳⣿⠶⠁⠖⠃⠀⠁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠟⠛⠛⠀⠀⠀⠀⢀⡤⠤⠐⠒⣉⠡⣄⠶⣭⣿⣽⣿⣿⣿⣿⣿
|
|
14
|
+
⣿⣿⣿⣿⣿⣿⣿⡿⠿⢉⡢⠝⠁⠀⠃⠀⠀⠀⠀⠀⠿⠃⠿⠿⠿⠛⠋⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⣀⠀⣀⢤⣰⣲⣽⣾⡟⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
15
|
+
⣿⣟⡿⡚⠏⠁⠀⠀⠐⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠂⣠⠀⣯⣗⣮⢿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿v i r t u a l a c t o r s
|
|
16
|
+
⣿⢯⡝⠠⠁⠀⠀⠠⠤⠀⠀⠀⠀⡀⠢⣄⣀⡀⠐⠤⡀⠀⠀⠀⢤⣄⣀⠤⣄⣤⢤⣖⡾⠋⢁⡼⠁⣸⡿⣞⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿s t r i c t l y o n c e
|
|
17
|
+
⣿⣷⣾⣵⣦⣶⣖⣳⣶⣝⣶⣯⣷⣽⣷⣾⣶⣽⣯⣶⠄⠈⠒⣤⣀⠉⠙⠛⠛⠋⠋⢁⣠⠔⠁⠀⢰⣿⣽⣯⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿z e r o l o c k s
|
|
18
18
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡄⡀⡉⠛⠓⠶⠶⠒⠛⠋⠀⠀⢀⣼⣻⢷⣾⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
19
19
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣧⡵⣌⣒⢂⠀⣀⣀⣠⣤⣶⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
20
20
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣾⣷⣯⣿⣧⣿⣷⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
<p align="center">
|
|
27
27
|
<img src="https://img.shields.io/npm/v/atomic-queues?style=flat-square&color=cb3837" alt="npm version" />
|
|
28
28
|
<img src="https://img.shields.io/badge/NestJS-11-ea2845?style=flat-square&logo=nestjs" alt="NestJS 11" />
|
|
29
|
-
<img src="https://img.shields.io/badge/Redis-7-dc382d?style=flat-square&logo=redis&logoColor=white" alt="Redis 7" />
|
|
30
29
|
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License" />
|
|
31
30
|
</p>
|
|
32
31
|
|
|
@@ -34,67 +33,78 @@
|
|
|
34
33
|
|
|
35
34
|
## What is atomic-queues?
|
|
36
35
|
|
|
37
|
-
**
|
|
36
|
+
**Per-entity sequential processing with virtual actors for NestJS.**
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
One worker per entity instance, spawned on demand, destroyed when idle. The worker IS the serialization boundary. If only one worker exists for `account:a-123` across the entire cluster, all operations on that account are serial by construction. No locks. No transactions. No race conditions.
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
**Motto: Strictly once, fail if interrupted.**
|
|
42
41
|
|
|
43
42
|
```
|
|
44
43
|
npm install atomic-queues ioredis
|
|
45
44
|
```
|
|
46
45
|
|
|
46
|
+
**Peer dependencies:** `@nestjs/common`, `@nestjs/core`, `@nestjs/cqrs`, `ioredis`
|
|
47
|
+
|
|
48
|
+
**Optional:** `@grpc/grpc-js`, `@grpc/proto-loader` (cluster mode), `zod` (CLI schema validation)
|
|
49
|
+
|
|
47
50
|
---
|
|
48
51
|
|
|
49
52
|
## The Problem
|
|
50
53
|
|
|
51
|
-
Every distributed system eventually builds toward one of two failure modes: **state corruption** from concurrent mutations on the same entity, or **throughput collapse** from the locking mechanisms used to prevent it.
|
|
52
|
-
|
|
53
54
|
```
|
|
54
55
|
Time Request A Request B Database
|
|
55
56
|
──────────────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
T0 SELECT balance -> $100 SELECT balance -> $100 $100
|
|
58
|
+
T1 CHECK: $100 >= $80 CHECK: $100 >= $80
|
|
59
|
+
T2 UPDATE: $100 - $80 = $20 $20
|
|
60
|
+
T3 UPDATE: $100 - $80 = $20 -$60
|
|
60
61
|
──────────────────────────────────────────────────────────────────────────
|
|
61
|
-
Result: Balance is
|
|
62
|
+
Result: Balance is -$60. Both withdrawals succeed. Integrity violated.
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
Row locks, optimistic locking, Redlock — they all trade throughput for correctness.
|
|
65
66
|
|
|
66
67
|
## The Insight
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
This is the virtual actor model. It's not new — Erlang/OTP has used it since the 1980s, Orleans shipped it in 2014, Akka has been doing it on the JVM for over a decade. What *is* new is implementing it with nothing beyond Redis and making it native to the NestJS ecosystem.
|
|
69
|
+
Don't lock the database. Don't lock the resource. **Route all operations for a given entity through a single worker.** That worker processes messages sequentially. Different entities have their own workers running concurrently.
|
|
71
70
|
|
|
72
71
|
```
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
│ │ └──────┘ └──────┘ └──────┘ │ │
|
|
78
|
-
Request C ─┘ │ Sequential ◄────────────┘ │
|
|
79
|
-
└─────────────────────────────────────────────────┘
|
|
80
|
-
|
|
81
|
-
Meanwhile, account-99, order-7, user-abc — all execute
|
|
82
|
-
in parallel on the same cluster, completely independent.
|
|
72
|
+
account:a-1 ──► [Worker] ──► handler1 → handler2 → handler3 (sequential)
|
|
73
|
+
account:a-2 ──► [Worker] ──► handler1 → handler2 (sequential)
|
|
74
|
+
order:o-5 ──► [Worker] ──► handler1 (sequential)
|
|
75
|
+
(all concurrent across entities)
|
|
83
76
|
```
|
|
84
77
|
|
|
85
|
-
|
|
78
|
+
One worker per entity. Spawned when a message arrives. Destroyed when idle. The worker runs on the event loop — async I/O interleaves naturally across entities. No threads, no separate processes, no extra NestJS contexts.
|
|
86
79
|
|
|
87
80
|
---
|
|
88
81
|
|
|
89
|
-
##
|
|
82
|
+
## Quick Start
|
|
90
83
|
|
|
91
|
-
###
|
|
84
|
+
### 1. Register the module
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
@Module({
|
|
88
|
+
imports: [
|
|
89
|
+
AtomicQueuesModule.forRoot({
|
|
90
|
+
redis: { host: 'localhost', port: 6379 },
|
|
91
|
+
entities: {
|
|
92
|
+
account: {},
|
|
93
|
+
order: { onInterrupt: 'dead-letter' },
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
export class AppModule {}
|
|
99
|
+
```
|
|
92
100
|
|
|
93
|
-
|
|
101
|
+
### 2. Define commands
|
|
94
102
|
|
|
95
103
|
```typescript
|
|
104
|
+
import { EntityType, QueueEntityId, Reply } from 'atomic-queues';
|
|
105
|
+
|
|
96
106
|
@EntityType('account')
|
|
97
|
-
|
|
107
|
+
class DepositCommand implements Reply<{ balance: number }> {
|
|
98
108
|
constructor(
|
|
99
109
|
@QueueEntityId() public readonly accountId: string,
|
|
100
110
|
public readonly amount: number,
|
|
@@ -102,506 +112,375 @@ export class WithdrawCommand {
|
|
|
102
112
|
}
|
|
103
113
|
```
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
### Two levels of abstraction
|
|
108
|
-
|
|
109
|
-
atomic-queues gives you two ways to handle messages, and they're not different systems — they're two levels of abstraction over the same dispatch engine.
|
|
115
|
+
### 3. Handle commands
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
Standard `@nestjs/cqrs` handlers — nothing new to learn:
|
|
112
118
|
|
|
113
119
|
```typescript
|
|
114
|
-
@
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.balance += msg.amount;
|
|
121
|
-
return this.balance;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
@On(WithdrawCommand) // WithdrawCommand has @EntityType('account')
|
|
125
|
-
async withdraw(msg: WithdrawCommand) {
|
|
126
|
-
if (this.balance < msg.amount) throw new InsufficientFunds();
|
|
127
|
-
this.balance -= msg.amount;
|
|
128
|
-
return this.balance;
|
|
120
|
+
@CommandHandler(DepositCommand)
|
|
121
|
+
class DepositHandler implements ICommandHandler<DepositCommand> {
|
|
122
|
+
async execute(cmd: DepositCommand) {
|
|
123
|
+
// Runs sequentially per accountId — no concurrent deposits to the same account
|
|
124
|
+
const balance = await this.accountService.deposit(cmd.accountId, cmd.amount);
|
|
125
|
+
return { balance };
|
|
129
126
|
}
|
|
130
127
|
}
|
|
131
128
|
```
|
|
132
129
|
|
|
133
|
-
|
|
130
|
+
### 4. Dispatch
|
|
134
131
|
|
|
135
132
|
```typescript
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
) {}
|
|
142
|
-
}
|
|
133
|
+
import { QueueBus } from 'atomic-queues';
|
|
134
|
+
|
|
135
|
+
@Injectable()
|
|
136
|
+
class PaymentService {
|
|
137
|
+
constructor(private readonly queueBus: QueueBus) {}
|
|
143
138
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
139
|
+
async deposit(accountId: string, amount: number) {
|
|
140
|
+
// Fire and forget
|
|
141
|
+
await this.queueBus.enqueue(new DepositCommand(accountId, amount));
|
|
142
|
+
|
|
143
|
+
// Wait for typed result (Reply<R> branding)
|
|
144
|
+
const { balance } = await this.queueBus.enqueueAndWait(
|
|
145
|
+
new DepositCommand(accountId, amount),
|
|
146
|
+
);
|
|
149
147
|
}
|
|
150
148
|
}
|
|
151
149
|
```
|
|
152
150
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
### Enqueuing messages
|
|
156
|
-
|
|
157
|
-
```typescript
|
|
158
|
-
// Fire-and-forget
|
|
159
|
-
await queueBus.enqueue(new WithdrawCommand(accountId, 100));
|
|
160
|
-
|
|
161
|
-
// Enqueue and block until result — return type inferred from Reply<T> brand
|
|
162
|
-
const balance = await queueBus.enqueueAndWait(new GetBalanceQuery(accountId));
|
|
163
|
-
|
|
164
|
-
// Scoped to an entity type
|
|
165
|
-
await queueBus.forEntity('account').enqueueBulk([charge1, charge2, charge3]);
|
|
166
|
-
|
|
167
|
-
// Cross-service: string-based API — no class import needed
|
|
168
|
-
await queueBus.enqueue('warehouse', 'ReserveStockCommand', 'SKU-001', { sku: 'SKU-001', quantity: 50 });
|
|
169
|
-
const stock = await queueBus.enqueueAndWait('warehouse', 'GetStockQuery', 'SKU-001', { sku: 'SKU-001' });
|
|
170
|
-
|
|
171
|
-
// Scoped cross-service
|
|
172
|
-
const warehouse = queueBus.forEntity('warehouse');
|
|
173
|
-
await warehouse.enqueue('ReserveStockCommand', 'SKU-001', { sku: 'SKU-001', quantity: 50 });
|
|
174
|
-
|
|
175
|
-
// Actor-style direct send
|
|
176
|
-
await actorSystem.send('account', accountId, new DepositCommand(100));
|
|
177
|
-
const balance = await actorSystem.sendAndWait('account', accountId, new GetBalanceQuery());
|
|
178
|
-
```
|
|
151
|
+
First message for `account:a-123` spawns a worker. All subsequent messages for that account queue behind it. The handler runs on your app's event loop using your existing DI container.
|
|
179
152
|
|
|
180
153
|
---
|
|
181
154
|
|
|
182
|
-
##
|
|
183
|
-
|
|
184
|
-
Under every API call is the same pipeline: **message → Redis log → Lua scheduler → gate → executor → handler**. Understanding this pipeline is key to understanding what atomic-queues actually guarantees and why it can guarantee it without locks.
|
|
185
|
-
|
|
186
|
-
### Per-entity message logs
|
|
187
|
-
|
|
188
|
-
When you call `enqueue()`, the message is serialized to JSON and appended to a Redis list (`LPUSH aq:log:account:a-42`), and the entity key is added to a global ready set (`SADD aq:ready account:a-42`). A pub/sub notification wakes the executor pool. Three Redis commands, pipelined in one round-trip.
|
|
189
|
-
|
|
190
|
-
The log is the source of truth for ordering. Redis lists are FIFO — `LPUSH` appends to the head, `RPOP` consumes from the tail. Messages for the same entity are always processed in enqueue order.
|
|
191
|
-
|
|
192
|
-
### The dispatch gate
|
|
193
|
-
|
|
194
|
-
The core consistency primitive is the **dispatch gate** — a Redis key per entity (`SET aq:gate:account:a-42 <token> EX 30 NX`). The `NX` flag means only one executor can acquire it. The `EX` TTL means a crashed executor releases it automatically. This is not a distributed lock in the Redlock sense — there's no quorum, no retry loop, no backoff. If the gate is held, the scheduler moves on to the next ready entity. Zero contention between entities, zero blocking within the scheduling loop.
|
|
195
|
-
|
|
196
|
-
### Atomic Lua scheduling
|
|
197
|
-
|
|
198
|
-
A single Lua script runs atomically in Redis to perform the entire dispatch cycle:
|
|
199
|
-
|
|
200
|
-
1. Sample entities from the ready set (`SRANDMEMBER` with batch size 32)
|
|
201
|
-
2. Try to acquire the gate for each candidate (`SET NX EX`)
|
|
202
|
-
3. On first successful acquisition, pop the next message from that entity's log (`RPOP`)
|
|
203
|
-
4. Remove the entity from the ready set if its log is now empty
|
|
155
|
+
## Queries
|
|
204
156
|
|
|
205
|
-
|
|
157
|
+
Queries work identically to commands but route through the `QueryBus`. They are sequenced with commands — a query enqueued after a deposit will see the deposit's effect.
|
|
206
158
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
### Gate refresh for long-running handlers
|
|
214
|
-
|
|
215
|
-
If a handler runs longer than the gate TTL, the gate doesn't expire — the executor pool runs a background interval that extends the TTL while the handler is still executing. This prevents false recovery (another node re-dispatching the same message) without requiring an unreasonably large TTL as the safety default.
|
|
216
|
-
|
|
217
|
-
### Multiplexed result collection
|
|
159
|
+
```typescript
|
|
160
|
+
@EntityType('account')
|
|
161
|
+
class GetBalanceQuery implements Reply<{ balance: number }> {
|
|
162
|
+
constructor(@QueueEntityId() public readonly accountId: string) {}
|
|
163
|
+
}
|
|
218
164
|
|
|
219
|
-
|
|
165
|
+
const { balance } = await queueBus.enqueueAndWait(new GetBalanceQuery('acc-123'));
|
|
166
|
+
```
|
|
220
167
|
|
|
221
168
|
---
|
|
222
169
|
|
|
223
|
-
##
|
|
224
|
-
|
|
225
|
-
This is where atomic-queues stops being a "queue library" and becomes a **distributed coordination primitive**.
|
|
226
|
-
|
|
227
|
-
### The problem it solves
|
|
228
|
-
|
|
229
|
-
In a microservices architecture, the standard way for Service A to tell Service B to do something is: define a gRPC/REST contract, deploy an API gateway or service mesh, handle serialization, implement retries, manage circuit breakers, and hope the schema stays in sync across repos. For async communication, add a message broker (RabbitMQ, Kafka, SQS), define topic/queue naming conventions, implement dead-letter handling, and build consumer groups.
|
|
170
|
+
## How It Works
|
|
230
171
|
|
|
231
|
-
|
|
172
|
+
### Virtual Actors (EntityWorker)
|
|
232
173
|
|
|
233
|
-
|
|
174
|
+
Each entity instance (`account:a-123`, `order:o-5`) gets its own virtual actor — a processor callback with a FIFO message queue. The actor:
|
|
234
175
|
|
|
235
|
-
|
|
176
|
+
1. Spawns on first message (no pre-registration needed)
|
|
177
|
+
2. Processes messages sequentially (one at a time, on the event loop)
|
|
178
|
+
3. Yields at `await` points (other entities' actors proceed concurrently)
|
|
179
|
+
4. Tears down after idle timeout (configurable, default 30s)
|
|
236
180
|
|
|
237
|
-
|
|
238
|
-
// warehouse-service: defines and handles the entity
|
|
239
|
-
AtomicQueuesModule.forRoot({
|
|
240
|
-
redis: { url: process.env.REDIS_URL },
|
|
241
|
-
registry: { enabled: true, serviceName: 'warehouse-service' },
|
|
242
|
-
})
|
|
181
|
+
### Write-Ahead Log (WAL)
|
|
243
182
|
|
|
244
|
-
|
|
245
|
-
import { ReserveStockCommand, GetStockQuery } from './generated';
|
|
183
|
+
Every message is dual-written: in-memory queue (speed) + Redis WAL (durability). The WAL is a state machine:
|
|
246
184
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
stock.available; // fully typed — no string API, no explicit timeout, no code dependency on warehouse-service
|
|
185
|
+
```
|
|
186
|
+
enqueued → dispatched → completed | failed | interrupted
|
|
250
187
|
```
|
|
251
188
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
The Lua scheduler ensures each node only dispatches messages for entity types it owns handlers for. Services that don't own any handlers (API gateways, pure producers) participate in the registry without stealing messages from handler-owning nodes.
|
|
255
|
-
|
|
256
|
-
### What this replaces
|
|
257
|
-
|
|
258
|
-
Think about what you no longer need:
|
|
259
|
-
|
|
260
|
-
**No API gateway between services.** Messages go directly into the entity's log via Redis. The "endpoint" is the entity type and message name, not a URL.
|
|
261
|
-
|
|
262
|
-
**No message broker.** Redis is the transport, the ordering guarantee, and the persistence layer. You don't need RabbitMQ, Kafka, or SQS to get async cross-service communication with ordering guarantees.
|
|
263
|
-
|
|
264
|
-
**No schema registry as a separate service.** The entity contracts live in Redis alongside the message logs. Schema validation happens at the call site. Zod schemas on the producer side serialize to JSON Schema in the registry and validate on every enqueue.
|
|
189
|
+
Each transition is an atomic Lua script that checks the current state before moving forward. Recovery runs automatically on startup:
|
|
265
190
|
|
|
266
|
-
|
|
191
|
+
- `enqueued` → re-dispatch (handler never ran — this is the first attempt, not a retry)
|
|
192
|
+
- `dispatched` → **dead-letter** (handler was running when the process crashed — never re-execute)
|
|
193
|
+
- `completed` / `failed` / `interrupted` → cleanup (stale terminal entries)
|
|
267
194
|
|
|
268
|
-
|
|
195
|
+
A background cleanup timer evicts terminal WAL entries on a configurable interval.
|
|
269
196
|
|
|
270
|
-
|
|
197
|
+
### Master Topology (Cluster Mode)
|
|
271
198
|
|
|
272
|
-
|
|
199
|
+
Each replica set has a **deterministic master** — the node with the lowest `serverId` among live nodes in the same `serviceGroup`. No locks, no elections, no Redlock. All nodes read the same Redis-backed heartbeat registry and independently compute who the master is.
|
|
273
200
|
|
|
274
|
-
|
|
201
|
+
The master:
|
|
275
202
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
203
|
+
- Owns the **worker assignment table**: which `entity:entityId` lives on which replica
|
|
204
|
+
- Routes all petitions: replicas forward via gRPC to the master
|
|
205
|
+
- Resolves workers via three tiers: existing assignment → consistent hash ring → least-loaded replica
|
|
206
|
+
- **Epoch fences** every dispatch: replicas reject commands from stale masters
|
|
279
207
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
208
|
+
```
|
|
209
|
+
Replica Set: billing-service
|
|
210
|
+
┌──────────────────────────────────────────────┐
|
|
211
|
+
│ Master (deterministic: lowest serverId) │
|
|
212
|
+
│ ├── Assignment Table │
|
|
213
|
+
│ │ account:a-1 → replica-2 │
|
|
214
|
+
│ │ account:a-2 → replica-1 │
|
|
215
|
+
│ └── Routes petitions, balances load │
|
|
216
|
+
│ │
|
|
217
|
+
│ Replica-1: [worker: account:a-2] │
|
|
218
|
+
│ Replica-2: [worker: account:a-1] │
|
|
219
|
+
│ Replica-3: (master pod, no workers yet) │
|
|
220
|
+
└──────────────────────────────────────────────┘
|
|
289
221
|
```
|
|
290
222
|
|
|
291
|
-
|
|
223
|
+
Masters interconnect across service groups:
|
|
224
|
+
```
|
|
225
|
+
Master (billing) ←── gRPC ──→ Master (warehouse)
|
|
226
|
+
```
|
|
292
227
|
|
|
293
|
-
###
|
|
228
|
+
### Master Failover
|
|
294
229
|
|
|
295
|
-
|
|
230
|
+
1. Master crashes → heartbeat TTL expires
|
|
231
|
+
2. Remaining nodes recompute leader from node list → next-lowest `serverId` becomes master
|
|
232
|
+
3. New master queries all replicas via gRPC `ListWorkers`
|
|
233
|
+
4. Rebuilds assignment table from live cluster state (petitions rejected during rebuild — fail-fast over misrouting)
|
|
234
|
+
5. Old master pushes its worker list to the new master on demotion
|
|
235
|
+
6. Resumes operations
|
|
296
236
|
|
|
297
|
-
|
|
237
|
+
No split-brain: leadership is a pure function of the live node set. Epoch fencing rejects any stale-master commands that arrive during transitions.
|
|
298
238
|
|
|
299
|
-
|
|
239
|
+
### Health Monitoring
|
|
300
240
|
|
|
301
|
-
|
|
302
|
-
const contracts = await queueBus.introspect();
|
|
241
|
+
**Redis health**: Periodic `PING`. Consecutive failures above threshold → degraded mode (reject new messages, leader resigns, discovery steps down). Automatic recovery when Redis responds again.
|
|
303
242
|
|
|
304
|
-
|
|
305
|
-
contracts.hasEntity('warehouse'); // true
|
|
306
|
-
contracts.messagesFor('warehouse'); // ['ReserveStockCommand', 'GetStockQuery']
|
|
307
|
-
contracts.accepts('warehouse', 'ReserveStockCommand'); // true
|
|
308
|
-
contracts.schemaFor('warehouse', 'ReserveStockCommand'); // { properties: { sku: ..., quantity: ... } }
|
|
309
|
-
contracts.replySchemaFor('warehouse', 'GetStockQuery'); // { properties: { sku: ..., available: ... } }
|
|
243
|
+
**gRPC peer connectivity**: Native gRPC channel state watching (`READY` → alive, `TRANSIENT_FAILURE` → suspected dead). Debounce timer prevents flapping on brief disconnects.
|
|
310
244
|
|
|
311
|
-
|
|
312
|
-
console.log(contracts.toString());
|
|
313
|
-
```
|
|
245
|
+
**Per-peer circuit breakers**: gRPC connections track consecutive failures. After threshold → circuit opens (fast-fail, no network calls). After cooldown → half-open (one probe). Success → closed. Failure → re-open.
|
|
314
246
|
|
|
315
|
-
|
|
247
|
+
---
|
|
316
248
|
|
|
317
|
-
|
|
249
|
+
## Enqueuing Messages
|
|
318
250
|
|
|
319
251
|
```typescript
|
|
320
252
|
// Fire-and-forget
|
|
321
|
-
await queueBus.enqueue(
|
|
322
|
-
sku: 'SKU-001',
|
|
323
|
-
quantity: 50,
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// Request-reply
|
|
327
|
-
const stock = await queueBus.enqueueAndWait('warehouse', 'GetStockQuery', 'SKU-001', {
|
|
328
|
-
sku: 'SKU-001',
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
// Scoped to an entity type
|
|
332
|
-
const warehouse = queueBus.forEntity('warehouse');
|
|
333
|
-
await warehouse.enqueue('ReserveStockCommand', 'SKU-001', { sku: 'SKU-001', quantity: 50 });
|
|
334
|
-
const stock = await warehouse.enqueueAndWait('GetStockQuery', 'SKU-001', { sku: 'SKU-001' });
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
This works out of the box — the registry validates entity type and message name at the call site. For production services, class codegen gives you full type safety.
|
|
338
|
-
|
|
339
|
-
### Class codegen (recommended)
|
|
340
|
-
|
|
341
|
-
Generate fully decorated TypeScript classes from the live registry — import them and use them like local CQRS classes with full autocomplete, type safety, and zero string APIs:
|
|
342
|
-
|
|
343
|
-
```bash
|
|
344
|
-
npx atomic-queues generate --classes -o src/generated
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
This produces one file per entity type plus a barrel `index.ts`:
|
|
348
|
-
|
|
349
|
-
```
|
|
350
|
-
src/generated/
|
|
351
|
-
warehouse.ts # ReserveStockCommand, GetStockQuery, data interfaces, reply interfaces
|
|
352
|
-
billing.ts # ChargeCommand, GetInvoiceQuery, ...
|
|
353
|
-
index.ts # export * from './warehouse'; export * from './billing';
|
|
354
|
-
```
|
|
253
|
+
await queueBus.enqueue(new WithdrawCommand(accountId, 100));
|
|
355
254
|
|
|
356
|
-
|
|
255
|
+
// Enqueue and wait for typed result
|
|
256
|
+
const { balance } = await queueBus.enqueueAndWait(new GetBalanceQuery(accountId));
|
|
357
257
|
|
|
358
|
-
|
|
359
|
-
|
|
258
|
+
// Scoped API
|
|
259
|
+
const account = queueBus.forEntity('account', accountId);
|
|
260
|
+
await account.enqueue(new DepositCommand(accountId, 500));
|
|
360
261
|
|
|
361
|
-
//
|
|
362
|
-
await queueBus.enqueue(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const stock = await queueBus.enqueueAndWait(new GetStockQuery({ sku: 'SKU-001' }));
|
|
366
|
-
stock.available; // typed as number — full IDE support
|
|
262
|
+
// Raw string API (cross-service, no class needed)
|
|
263
|
+
await queueBus.enqueue('warehouse', 'ReserveStockCommand', 'SKU-001', {
|
|
264
|
+
sku: 'SKU-001', quantity: 50,
|
|
265
|
+
});
|
|
367
266
|
```
|
|
368
267
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
You can also filter to specific entity types:
|
|
268
|
+
---
|
|
372
269
|
|
|
373
|
-
|
|
374
|
-
npx atomic-queues generate --classes -o src/generated --entities warehouse,billing
|
|
375
|
-
```
|
|
270
|
+
## Backpressure
|
|
376
271
|
|
|
377
|
-
|
|
272
|
+
Three levels, all configurable:
|
|
378
273
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
274
|
+
| Level | Config | Behavior |
|
|
275
|
+
|-------|--------|----------|
|
|
276
|
+
| Per-worker | `workerMaxQueueDepth` | Rejects with `QUEUE_DEPTH_EXCEEDED` |
|
|
277
|
+
| Global workers | `maxTotalWorkers` | Rejects new entities with `WORKER_LIMIT_EXCEEDED` (existing entities still accepted) |
|
|
278
|
+
| Global depth | `maxTotalQueueDepth` | Rejects all enqueues with `QUEUE_DEPTH_EXCEEDED` |
|
|
382
279
|
|
|
383
|
-
|
|
384
|
-
npx atomic-queues generate --json-schema --output ./generated/schema.json
|
|
280
|
+
In cluster mode, the master also enforces `maxConcurrentPetitions` to bound petition processing.
|
|
385
281
|
|
|
386
|
-
|
|
387
|
-
npx atomic-queues generate --snapshot --output ./generated/snapshot.json
|
|
388
|
-
```
|
|
282
|
+
---
|
|
389
283
|
|
|
390
|
-
|
|
284
|
+
## Configuration
|
|
391
285
|
|
|
392
|
-
|
|
286
|
+
### Minimal (single server)
|
|
393
287
|
|
|
394
288
|
```typescript
|
|
395
289
|
AtomicQueuesModule.forRoot({
|
|
396
|
-
|
|
397
|
-
gateTTL: 30,
|
|
398
|
-
defaultReplyTimeout: 15000, // global fallback: 15s
|
|
399
|
-
},
|
|
400
|
-
entities: {
|
|
401
|
-
warehouse: {
|
|
402
|
-
replyTimeout: 5000, // warehouse-specific: 5s
|
|
403
|
-
},
|
|
404
|
-
},
|
|
290
|
+
redis: { host: 'localhost', port: 6379 },
|
|
405
291
|
})
|
|
406
292
|
```
|
|
407
293
|
|
|
408
|
-
|
|
294
|
+
That's it. Everything else has defaults. Add `entities` to customize per-entity behavior, `grpc` to enable cluster mode.
|
|
409
295
|
|
|
410
|
-
|
|
296
|
+
### Full reference
|
|
411
297
|
|
|
412
|
-
|
|
298
|
+
#### `AtomicQueuesModule.forRoot(config)`
|
|
413
299
|
|
|
414
|
-
|
|
300
|
+
| Field | Type | Required | Default | Description |
|
|
301
|
+
|-------|------|----------|---------|-------------|
|
|
302
|
+
| `redis` | `IRedisConfig` | **yes** | — | Redis connection. Accepts `{ host, port, password, db }` or `{ url }` |
|
|
303
|
+
| `entities` | `Record<string, IEntityConfig>` | no | `{}` | Per-entity-type overrides (see below) |
|
|
304
|
+
| `keyPrefix` | `string` | no | `'aq'` | Prefix for all Redis keys |
|
|
305
|
+
| `maxTotalWorkers` | `number` | no | `10000` | Max concurrent entity workers across all types. `0` = unbounded |
|
|
306
|
+
| `maxTotalQueueDepth` | `number` | no | `100000` | Max total pending messages across all workers. `0` = unbounded |
|
|
307
|
+
| `retry` | `IRetryPolicy` | no | `{ maxAttempts: 1 }` | Default retry policy (strictly-once by default) |
|
|
308
|
+
| `wal` | `IWalConfig` | no | `{ enabled: true }` | Write-ahead log settings |
|
|
309
|
+
| `grpc` | `IGrpcConfig` | no | `{ enabled: false }` | Cluster mode — omit entirely for single-server |
|
|
310
|
+
| `verbose` | `boolean` | no | `false` | Enable verbose logging |
|
|
415
311
|
|
|
416
|
-
|
|
312
|
+
#### `IEntityConfig` — per entity type
|
|
417
313
|
|
|
314
|
+
```typescript
|
|
315
|
+
entities: {
|
|
316
|
+
account: { /* all fields optional */ },
|
|
317
|
+
order: { onInterrupt: 'dead-letter', workerIdleTimeout: 60_000 },
|
|
318
|
+
}
|
|
418
319
|
```
|
|
419
|
-
LPUSH aq:log:account:a-1 '<message JSON>'
|
|
420
|
-
SADD aq:ready account:a-1
|
|
421
|
-
PUBLISH aq:tickle 1
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
**Any language with a Redis client is a first-class citizen.** A Python data pipeline can enqueue commands to a NestJS-hosted actor. A Go microservice can fire events at entities defined in TypeScript. A Rust executor can run the same Lua scheduling script and compete for gates on equal terms with the Node.js executor pool. A Bash script can trigger a workflow.
|
|
425
|
-
|
|
426
|
-
This is not a feature of any existing mainstream actor framework. Orleans requires the Orleans silo. Akka requires the JVM. Temporal requires the Temporal server with its own database. All of them are monoglot execution environments — actors must be written in the framework's language.
|
|
427
320
|
|
|
428
|
-
|
|
321
|
+
| Field | Type | Default | Description |
|
|
322
|
+
|-------|------|---------|-------------|
|
|
323
|
+
| `defaultEntityId` | `string` | — | Property name used as entity ID when `@QueueEntityId` is not present |
|
|
324
|
+
| `onInterrupt` | `'dead-letter' \| 'retry'` | `'dead-letter'` | What to do when a message is found mid-execution on recovery |
|
|
325
|
+
| `workerIdleTimeout` | `number` (ms) | `30000` | How long an idle worker lives before teardown |
|
|
326
|
+
| `workerMaxQueueDepth` | `number` | `0` (unbounded) | Max pending messages per worker. Rejects with `QUEUE_DEPTH_EXCEEDED` |
|
|
327
|
+
| `replyTimeout` | `number` (ms) | `5000` | Default timeout for `enqueueAndWait` on this entity type |
|
|
328
|
+
| `retry` | `IRetryPolicy` | inherits root | Per-entity retry policy override |
|
|
429
329
|
|
|
430
|
-
|
|
330
|
+
#### `IRetryPolicy`
|
|
431
331
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
332
|
+
| Field | Type | Default | Description |
|
|
333
|
+
|-------|------|---------|-------------|
|
|
334
|
+
| `maxAttempts` | `number` | `1` | Total attempts. `1` = strictly once, no retries |
|
|
335
|
+
| `backoff` | `'fixed' \| 'exponential'` | `'exponential'` | Backoff strategy between retries |
|
|
336
|
+
| `backoffDelay` | `number` (ms) | `1000` | Base delay between retries |
|
|
337
|
+
| `maxDelay` | `number` (ms) | `30000` | Maximum delay cap for exponential backoff |
|
|
436
338
|
|
|
437
|
-
|
|
339
|
+
#### `IWalConfig` — write-ahead log
|
|
438
340
|
|
|
439
|
-
|
|
341
|
+
| Field | Type | Default | Description |
|
|
342
|
+
|-------|------|---------|-------------|
|
|
343
|
+
| `enabled` | `boolean` | `true` | Disable WAL for testing only — **never disable in production** |
|
|
344
|
+
| `cleanupInterval` | `number` (ms) | `5000` | How often to evict completed/failed WAL entries |
|
|
345
|
+
| `entryTTL` | `number` (seconds) | `86400` (24h) | Safety TTL for WAL entries in Redis |
|
|
440
346
|
|
|
441
|
-
|
|
442
|
-
import { Module } from '@nestjs/common';
|
|
443
|
-
import { AtomicQueuesModule } from 'atomic-queues';
|
|
444
|
-
|
|
445
|
-
@Module({
|
|
446
|
-
imports: [
|
|
447
|
-
AtomicQueuesModule.forRoot({
|
|
448
|
-
redis: { host: 'localhost', port: 6379 },
|
|
449
|
-
}),
|
|
450
|
-
],
|
|
451
|
-
})
|
|
452
|
-
export class AppModule {}
|
|
453
|
-
```
|
|
347
|
+
#### `IGrpcConfig` — cluster mode
|
|
454
348
|
|
|
455
|
-
|
|
349
|
+
Omit entirely for single-server. Set `enabled: true` to activate.
|
|
456
350
|
|
|
457
351
|
```typescript
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
@Injectable()
|
|
467
|
-
export class PaymentService {
|
|
468
|
-
constructor(private readonly queueBus: QueueBus) {}
|
|
469
|
-
|
|
470
|
-
async withdraw(accountId: string, amount: number) {
|
|
471
|
-
await this.queueBus.enqueue(new WithdrawCommand(accountId, amount));
|
|
472
|
-
}
|
|
352
|
+
grpc: {
|
|
353
|
+
enabled: true,
|
|
354
|
+
listenAddress: '0.0.0.0:50051',
|
|
355
|
+
advertisedAddress: '10.0.1.5:50051',
|
|
356
|
+
serverId: 'billing-1',
|
|
357
|
+
serviceGroup: 'billing',
|
|
473
358
|
}
|
|
474
359
|
```
|
|
475
360
|
|
|
476
|
-
|
|
361
|
+
| Field | Type | Default | Description |
|
|
362
|
+
|-------|------|---------|-------------|
|
|
363
|
+
| `enabled` | `boolean` | `false` | Enable gRPC cluster transport |
|
|
364
|
+
| `listenAddress` | `string` | `'0.0.0.0:50051'` | Address the gRPC server binds to |
|
|
365
|
+
| `advertisedAddress` | `string` | `os.hostname() + ':50051'` | Address other nodes use to reach this one |
|
|
366
|
+
| `serverId` | `string` | auto-generated UUID | Unique node ID. Must be stable across restarts for predictable leader election |
|
|
367
|
+
| `serviceGroup` | `string` | `'default'` | Logical grouping — nodes in the same group form a replica set |
|
|
368
|
+
| `maxForwardHops` | `number` | `3` | Max cross-service forwarding hops to prevent loops |
|
|
369
|
+
| `maxConcurrentPetitions` | `number` | `50` | Max in-flight petitions the master processes. `0` = unbounded |
|
|
370
|
+
|
|
371
|
+
**Timing (ms)**
|
|
372
|
+
|
|
373
|
+
| Field | Default | Description |
|
|
374
|
+
|-------|---------|-------------|
|
|
375
|
+
| `heartbeatMs` | `400` | How often this node heartbeats to Redis |
|
|
376
|
+
| `nodeTTLMs` | `1500` | Node considered dead after this long without heartbeat |
|
|
377
|
+
| `reconcileIntervalMs` | `2000` | How often to scan Redis for membership changes |
|
|
378
|
+
| `leaderTTLMs` | `2000` | Leader lock TTL |
|
|
379
|
+
| `leaderRenewalMs` | `400` | Leader lock renewal interval |
|
|
380
|
+
| `leaderDebounceMs` | `800` | Debounce window before recomputing leader after ring changes |
|
|
381
|
+
|
|
382
|
+
**Health monitoring**
|
|
383
|
+
|
|
384
|
+
| Field | Default | Description |
|
|
385
|
+
|-------|---------|-------------|
|
|
386
|
+
| `peerMonitorEnabled` | `true` | Watch gRPC channel state for fast failure detection |
|
|
387
|
+
| `peerSuspectDebounceMs` | `500` | Debounce before declaring a peer suspected-dead |
|
|
388
|
+
| `redisHealthCheckMs` | `500` | Redis PING interval |
|
|
389
|
+
| `redisHealthFailureThreshold` | `3` | Consecutive PING failures before degraded mode |
|
|
390
|
+
|
|
391
|
+
**Circuit breaker (per-peer gRPC connections)**
|
|
392
|
+
|
|
393
|
+
| Field | Default | Description |
|
|
394
|
+
|-------|---------|-------------|
|
|
395
|
+
| `circuitBreakerFailureThreshold` | `3` | Consecutive failures before opening the circuit |
|
|
396
|
+
| `circuitBreakerCooldownMs` | `2000` | Time before a half-open probe is allowed |
|
|
397
|
+
|
|
398
|
+
**gRPC keepalive**
|
|
399
|
+
|
|
400
|
+
| Field | Default | Description |
|
|
401
|
+
|-------|---------|-------------|
|
|
402
|
+
| `keepaliveTimeMs` | `10000` | Keepalive ping interval (minimum enforced by grpc-js) |
|
|
403
|
+
| `keepaliveTimeoutMs` | `5000` | Connection dead if no keepalive response |
|
|
404
|
+
|
|
405
|
+
**RPC deadlines** (`deadlines` sub-object)
|
|
406
|
+
|
|
407
|
+
| Field | Default | Description |
|
|
408
|
+
|-------|---------|-------------|
|
|
409
|
+
| `deadlines.forwardMs` | `1500` | Deadline for fire-and-forget RPCs (forward, petition, enqueueToWorker) |
|
|
410
|
+
| `deadlines.pingMs` | `1000` | Deadline for health ping |
|
|
411
|
+
| `deadlines.andWaitMs` | `60000` | Default deadline for `*AndWait` RPCs when no `replyTimeout` is set |
|
|
412
|
+
| `deadlines.syncMs` | `1000` | Deadline for `listWorkers` during master table rebuild |
|
|
413
|
+
| `deadlines.connectivityWatchMs` | `30000` | Timeout for peer connectivity watch loop re-arm |
|
|
477
414
|
|
|
478
415
|
---
|
|
479
416
|
|
|
480
|
-
##
|
|
481
|
-
|
|
482
|
-
```typescript
|
|
483
|
-
AtomicQueuesModule.forRoot({
|
|
484
|
-
redis: { host: 'localhost', port: 6379 },
|
|
485
|
-
|
|
486
|
-
executor: {
|
|
487
|
-
poolSize: 1, // concurrent executors per node
|
|
488
|
-
gateTTL: 30, // seconds before gate expires (safety net)
|
|
489
|
-
defaultReplyTimeout: 15000, // global default for enqueueAndWait (ms)
|
|
490
|
-
},
|
|
491
|
-
|
|
492
|
-
entities: {
|
|
493
|
-
account: {
|
|
494
|
-
defaultEntityId: 'accountId',
|
|
495
|
-
gateTTL: 60,
|
|
496
|
-
retry: { maxAttempts: 5, backoff: 'exponential', backoffDelay: 2000 },
|
|
497
|
-
actorIdleTimeout: 120000,
|
|
498
|
-
statePersistence: true,
|
|
499
|
-
replyTimeout: 5000, // per-entity enqueueAndWait timeout (ms)
|
|
500
|
-
},
|
|
501
|
-
},
|
|
502
|
-
|
|
503
|
-
registry: {
|
|
504
|
-
enabled: false,
|
|
505
|
-
serviceName: 'my-service',
|
|
506
|
-
schemaValidation: false,
|
|
507
|
-
heartbeatInterval: 10000,
|
|
508
|
-
registrationTTL: 30,
|
|
509
|
-
},
|
|
510
|
-
|
|
511
|
-
keyPrefix: 'aq',
|
|
512
|
-
verbose: false,
|
|
513
|
-
})
|
|
514
|
-
```
|
|
417
|
+
## Dead Letter Queue
|
|
515
418
|
|
|
516
|
-
|
|
419
|
+
Messages found in `dispatched` state on recovery, or that exhaust all retry attempts, are moved to a Redis-backed dead letter queue.
|
|
517
420
|
|
|
518
421
|
```bash
|
|
519
|
-
|
|
520
|
-
|
|
422
|
+
npx atomic-queues dlq list
|
|
423
|
+
npx atomic-queues dlq replay --id <message-id>
|
|
424
|
+
npx atomic-queues dlq purge
|
|
521
425
|
```
|
|
522
426
|
|
|
523
427
|
---
|
|
524
428
|
|
|
525
|
-
##
|
|
526
|
-
|
|
527
|
-
| Guarantee | Scope | Mechanism |
|
|
528
|
-
|---|---|---|
|
|
529
|
-
| FIFO per entity | Cluster-wide | Redis list (`LPUSH`/`RPOP`) |
|
|
530
|
-
| Single-writer per entity | Cluster-wide | Gate key (`SET NX EX`) |
|
|
531
|
-
| At-least-once delivery | Per message | Retry on gate TTL expiry |
|
|
532
|
-
| Parallel across entities | Per node | Executor pool concurrency |
|
|
533
|
-
| Durability | Per message | Redis persistence (AOF/RDB) |
|
|
534
|
-
|
|
535
|
-
### What this does NOT guarantee
|
|
429
|
+
## CLI
|
|
536
430
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
## How It Compares
|
|
431
|
+
```bash
|
|
432
|
+
# Inspect live entity/command/query registry from Redis
|
|
433
|
+
npx atomic-queues introspect
|
|
542
434
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
| Cross-service messaging | Shared queue names | gRPC | Redis registry + codegen |
|
|
549
|
-
| Polyglot clients | JS/TS only | SDK per language | Any Redis client (3 commands) |
|
|
550
|
-
| Infrastructure required | Redis | Temporal server + DB | Redis only |
|
|
551
|
-
| Distributed locks needed | Yes, for ordering | Internal | None — gates are non-contending |
|
|
552
|
-
| Service discovery | External | Built-in | Built-in (registry) |
|
|
553
|
-
| Schema validation | No | Protobuf | Zod → JSON Schema |
|
|
435
|
+
# Generate TypeScript from the live registry
|
|
436
|
+
npx atomic-queues generate --classes -o ./src/generated # decorated class files
|
|
437
|
+
npx atomic-queues generate --ts -o ./src/generated # namespace interfaces + DispatchMap
|
|
438
|
+
npx atomic-queues generate --json-schema -o ./src/generated
|
|
439
|
+
```
|
|
554
440
|
|
|
555
441
|
---
|
|
556
442
|
|
|
557
|
-
##
|
|
443
|
+
## Guarantees
|
|
558
444
|
|
|
559
|
-
|
|
|
445
|
+
| Guarantee | Mechanism |
|
|
560
446
|
|---|---|
|
|
561
|
-
|
|
|
562
|
-
|
|
|
563
|
-
|
|
|
564
|
-
|
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
447
|
+
| FIFO per entity | One worker per entity:entityId with FIFO queue |
|
|
448
|
+
| Single-writer per entity | Only one worker exists across the cluster |
|
|
449
|
+
| At-most-once delivery | WAL: enqueued → dispatched → completed. Never re-executed after dispatch. |
|
|
450
|
+
| Fail if interrupted | Dispatched on crash → dead-lettered, source notified |
|
|
451
|
+
| Concurrent across entities | Event loop interleaves at await points |
|
|
452
|
+
| Durability | Redis WAL (dual-write: in-memory + Redis) |
|
|
453
|
+
| Auto-recovery | WAL recovery + cleanup run automatically on startup |
|
|
454
|
+
| Cluster coordination | Deterministic master topology with gRPC |
|
|
455
|
+
| Master failover | Heartbeat expiry → deterministic re-election + assignment table rebuild |
|
|
456
|
+
| Epoch fencing | Replicas reject commands from stale masters |
|
|
457
|
+
| No distributed locks | The worker IS the serialization — not a lock, not Redlock, not SET NX |
|
|
568
458
|
|
|
569
459
|
---
|
|
570
460
|
|
|
571
|
-
##
|
|
572
|
-
|
|
573
|
-
### Redis as a Single Point of Failure
|
|
461
|
+
## Design Philosophy
|
|
574
462
|
|
|
575
|
-
|
|
463
|
+
AtomicQueues is pessimistic by design. At every decision point, it chooses safety over liveness:
|
|
576
464
|
|
|
577
|
-
**
|
|
465
|
+
- **Interrupted?** Dead-letter, don't retry.
|
|
466
|
+
- **Redis down?** Reject new work, don't buffer.
|
|
467
|
+
- **Stale epoch?** Reject, don't process.
|
|
468
|
+
- **Master rebuilding?** Reject petitions, don't guess.
|
|
469
|
+
- **Unknown assignment?** Bounce and retry through the master, don't deliver speculatively.
|
|
578
470
|
|
|
579
|
-
|
|
580
|
-
- **Redis Cluster** — horizontal scaling. Requires all keys for a given entity to land on the same shard. Use Redis hash tags (e.g. `{account:a-1}`) in your `keyPrefix` config to ensure co-location.
|
|
581
|
-
- **Persistence** — enable AOF (`appendonly yes`) with `appendfsync everysec` at minimum. RDB snapshots alone risk losing the last seconds of enqueued messages on crash.
|
|
582
|
-
- **Monitoring** — watch `connected_clients`, `used_memory`, and `instantaneous_ops_per_sec`. Set alerts on replication lag if using Sentinel.
|
|
583
|
-
|
|
584
|
-
### Retry Ordering
|
|
585
|
-
|
|
586
|
-
Failed messages are re-enqueued with `RPUSH`, placing them at the back of the entity's log. This means other pending messages for the same entity are processed before the retry. If you need head-of-line retry (failed message retried immediately), implement a custom retry strategy.
|
|
587
|
-
|
|
588
|
-
### Actor State
|
|
589
|
-
|
|
590
|
-
Actor state is serialized to Redis as JSON after each message. `Map`, `Set`, `Date`, and circular references are silently skipped during serialization. Keep actor state plain and serializable. State TTL defaults to 86400 seconds (24 hours) and is configurable per entity type via `stateTTL` in the module config.
|
|
471
|
+
The system refuses to operate under uncertainty rather than risk executing a message twice.
|
|
591
472
|
|
|
592
473
|
---
|
|
593
474
|
|
|
594
|
-
## Migrating from
|
|
595
|
-
|
|
596
|
-
V2 is a full rewrite of the internals. BullMQ is removed. Workers are removed. The public API is largely preserved.
|
|
475
|
+
## Migrating from v2
|
|
597
476
|
|
|
598
|
-
**
|
|
477
|
+
**Removed**: `executor`, `registry`, `gateTTL`, `ActorSystem`, `LogService`, `GateService`, `SchedulerService`, `ExecutorPoolService`, `ResultCollector`, `RegistryService`, `workers` config, `WorkerModule`.
|
|
599
478
|
|
|
600
|
-
**
|
|
479
|
+
**Added**: `EntityWorker`, `EntityWorkerManager`, `MasterCoordinator`, `workerIdleTimeout` in entity config.
|
|
601
480
|
|
|
602
|
-
**
|
|
481
|
+
**Unchanged**: All decorators, `QueueBus` public API, CLI generators.
|
|
603
482
|
|
|
604
|
-
**Migration
|
|
483
|
+
**Migration**: Remove `executor`/`registry`/`workers` from config. That's it. Workers are now internal.
|
|
605
484
|
|
|
606
485
|
---
|
|
607
486
|
|