atomic-queues 1.6.2 → 2.0.1
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 +273 -413
- package/dist/cli/generators/json-schema.d.ts +3 -0
- package/dist/cli/generators/json-schema.d.ts.map +1 -0
- package/dist/cli/generators/json-schema.js +31 -0
- package/dist/cli/generators/json-schema.js.map +1 -0
- package/dist/cli/generators/typescript.d.ts +3 -0
- package/dist/cli/generators/typescript.d.ts.map +1 -0
- package/dist/cli/generators/typescript.js +62 -0
- package/dist/cli/generators/typescript.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/decorators/actor.decorators.d.ts +4 -0
- package/dist/decorators/actor.decorators.d.ts.map +1 -0
- package/dist/decorators/actor.decorators.js +32 -0
- package/dist/decorators/actor.decorators.js.map +1 -0
- package/dist/decorators/constants.d.ts +5 -12
- package/dist/decorators/constants.d.ts.map +1 -1
- package/dist/decorators/constants.js +10 -16
- package/dist/decorators/constants.js.map +1 -1
- package/dist/decorators/index.d.ts +4 -4
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +4 -4
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/interfaces.d.ts +18 -78
- package/dist/decorators/interfaces.d.ts.map +1 -1
- package/dist/decorators/metadata-readers.d.ts +5 -26
- package/dist/decorators/metadata-readers.d.ts.map +1 -1
- package/dist/decorators/metadata-readers.js +16 -33
- package/dist/decorators/metadata-readers.js.map +1 -1
- package/dist/decorators/schema.decorators.d.ts +2 -0
- package/dist/decorators/schema.decorators.d.ts.map +1 -0
- package/dist/decorators/schema.decorators.js +13 -0
- package/dist/decorators/schema.decorators.js.map +1 -0
- package/dist/domain/interfaces/config.interfaces.d.ts +52 -153
- package/dist/domain/interfaces/config.interfaces.d.ts.map +1 -1
- package/dist/domain/interfaces/index.d.ts +0 -7
- package/dist/domain/interfaces/index.d.ts.map +1 -1
- package/dist/domain/interfaces/index.js +0 -7
- package/dist/domain/interfaces/index.js.map +1 -1
- package/dist/domain/interfaces/job.interfaces.d.ts +32 -65
- package/dist/domain/interfaces/job.interfaces.d.ts.map +1 -1
- package/dist/index.d.ts +0 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -39
- package/dist/index.js.map +1 -1
- package/dist/module/atomic-queues.module.d.ts +0 -83
- package/dist/module/atomic-queues.module.d.ts.map +1 -1
- package/dist/module/atomic-queues.module.js +35 -134
- package/dist/module/atomic-queues.module.js.map +1 -1
- package/dist/services/actor-registry/actor-registry.service.d.ts +30 -0
- package/dist/services/actor-registry/actor-registry.service.d.ts.map +1 -0
- package/dist/services/actor-registry/actor-registry.service.js +186 -0
- package/dist/services/actor-registry/actor-registry.service.js.map +1 -0
- package/dist/services/actor-registry/index.d.ts +2 -0
- package/dist/services/actor-registry/index.d.ts.map +1 -0
- package/dist/services/actor-registry/index.js +18 -0
- package/dist/services/actor-registry/index.js.map +1 -0
- package/dist/services/actor-system/actor-system.service.d.ts +19 -0
- package/dist/services/actor-system/actor-system.service.d.ts.map +1 -0
- package/dist/services/actor-system/actor-system.service.js +86 -0
- package/dist/services/actor-system/actor-system.service.js.map +1 -0
- package/dist/services/actor-system/index.d.ts +2 -0
- package/dist/services/actor-system/index.d.ts.map +1 -0
- package/dist/services/{cron-manager → actor-system}/index.js +1 -1
- package/dist/services/actor-system/index.js.map +1 -0
- package/dist/services/command-discovery/command-discovery.service.d.ts +6 -53
- package/dist/services/command-discovery/command-discovery.service.d.ts.map +1 -1
- package/dist/services/command-discovery/command-discovery.service.js +0 -59
- package/dist/services/command-discovery/command-discovery.service.js.map +1 -1
- package/dist/services/constants.d.ts +2 -9
- package/dist/services/constants.d.ts.map +1 -1
- package/dist/services/constants.js +3 -10
- package/dist/services/constants.js.map +1 -1
- package/dist/services/executor-pool/executor-pool.service.d.ts +31 -0
- package/dist/services/executor-pool/executor-pool.service.d.ts.map +1 -0
- package/dist/services/executor-pool/executor-pool.service.js +147 -0
- package/dist/services/executor-pool/executor-pool.service.js.map +1 -0
- package/dist/services/executor-pool/index.d.ts +2 -0
- package/dist/services/executor-pool/index.d.ts.map +1 -0
- package/dist/services/{queue-manager → executor-pool}/index.js +1 -1
- package/dist/services/executor-pool/index.js.map +1 -0
- package/dist/services/gate/gate.service.d.ts +17 -0
- package/dist/services/gate/gate.service.d.ts.map +1 -0
- package/dist/services/gate/gate.service.js +66 -0
- package/dist/services/gate/gate.service.js.map +1 -0
- package/dist/services/gate/index.d.ts +2 -0
- package/dist/services/gate/index.d.ts.map +1 -0
- package/dist/services/{spawn-queue → gate}/index.js +1 -1
- package/dist/services/gate/index.js.map +1 -0
- package/dist/services/handler-executor/handler-executor.service.d.ts +32 -0
- package/dist/services/handler-executor/handler-executor.service.d.ts.map +1 -0
- package/dist/services/handler-executor/handler-executor.service.js +186 -0
- package/dist/services/handler-executor/handler-executor.service.js.map +1 -0
- package/dist/services/handler-executor/index.d.ts +2 -0
- package/dist/services/handler-executor/index.d.ts.map +1 -0
- package/dist/services/handler-executor/index.js +18 -0
- package/dist/services/handler-executor/index.js.map +1 -0
- package/dist/services/index.d.ts +11 -12
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +11 -12
- package/dist/services/index.js.map +1 -1
- package/dist/services/log/index.d.ts +2 -0
- package/dist/services/log/index.d.ts.map +1 -0
- package/dist/services/{index-manager → log}/index.js +1 -1
- package/dist/services/log/index.js.map +1 -0
- package/dist/services/log/log.service.d.ts +21 -0
- package/dist/services/log/log.service.d.ts.map +1 -0
- package/dist/services/log/log.service.js +92 -0
- package/dist/services/log/log.service.js.map +1 -0
- package/dist/services/queue-bus/index.d.ts +0 -4
- package/dist/services/queue-bus/index.d.ts.map +1 -1
- package/dist/services/queue-bus/index.js +0 -4
- package/dist/services/queue-bus/index.js.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.d.ts +44 -198
- package/dist/services/queue-bus/queue-bus.service.d.ts.map +1 -1
- package/dist/services/queue-bus/queue-bus.service.js +103 -259
- package/dist/services/queue-bus/queue-bus.service.js.map +1 -1
- package/dist/services/queue-bus/queue-bus.utils.d.ts +0 -28
- package/dist/services/queue-bus/queue-bus.utils.d.ts.map +1 -1
- package/dist/services/queue-bus/queue-bus.utils.js +1 -41
- package/dist/services/queue-bus/queue-bus.utils.js.map +1 -1
- package/dist/services/registry/index.d.ts +4 -0
- package/dist/services/registry/index.d.ts.map +1 -0
- package/dist/services/{queue-events-manager → registry}/index.js +3 -1
- package/dist/services/registry/index.js.map +1 -0
- package/dist/services/registry/registry.service.d.ts +43 -0
- package/dist/services/registry/registry.service.d.ts.map +1 -0
- package/dist/services/registry/registry.service.js +379 -0
- package/dist/services/registry/registry.service.js.map +1 -0
- package/dist/services/registry/registry.types.d.ts +24 -0
- package/dist/services/registry/registry.types.d.ts.map +1 -0
- package/dist/{domain/interfaces/lock.interfaces.js → services/registry/registry.types.js} +1 -1
- package/dist/services/registry/registry.types.js.map +1 -0
- package/dist/services/registry/schema-converter.d.ts +2 -0
- package/dist/services/registry/schema-converter.d.ts.map +1 -0
- package/dist/services/registry/schema-converter.js +27 -0
- package/dist/services/registry/schema-converter.js.map +1 -0
- package/dist/services/result-collector/index.d.ts +2 -0
- package/dist/services/result-collector/index.d.ts.map +1 -0
- package/dist/services/result-collector/index.js +18 -0
- package/dist/services/result-collector/index.js.map +1 -0
- package/dist/services/result-collector/result-collector.service.d.ts +17 -0
- package/dist/services/result-collector/result-collector.service.d.ts.map +1 -0
- package/dist/services/result-collector/result-collector.service.js +92 -0
- package/dist/services/result-collector/result-collector.service.js.map +1 -0
- package/dist/services/scheduler/index.d.ts +2 -0
- package/dist/services/scheduler/index.d.ts.map +1 -0
- package/dist/services/{job-processor → scheduler}/index.js +1 -1
- package/dist/services/scheduler/index.js.map +1 -0
- package/dist/services/scheduler/scheduler.service.d.ts +17 -0
- package/dist/services/scheduler/scheduler.service.d.ts.map +1 -0
- package/dist/services/scheduler/scheduler.service.js +116 -0
- package/dist/services/scheduler/scheduler.service.js.map +1 -0
- package/dist/services/shutdown/index.d.ts +2 -0
- package/dist/services/shutdown/index.d.ts.map +1 -0
- package/dist/services/shutdown/index.js +18 -0
- package/dist/services/shutdown/index.js.map +1 -0
- package/dist/services/shutdown/shutdown.service.d.ts +8 -0
- package/dist/services/shutdown/shutdown.service.d.ts.map +1 -0
- package/dist/services/shutdown/shutdown.service.js +29 -0
- package/dist/services/shutdown/shutdown.service.js.map +1 -0
- package/dist/utils/index.d.ts +3 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/naming.utils.d.ts +0 -16
- package/dist/utils/naming.utils.d.ts.map +1 -1
- package/dist/utils/naming.utils.js +0 -29
- package/dist/utils/naming.utils.js.map +1 -1
- package/package.json +19 -11
- package/dist/decorators/legacy.decorators.d.ts +0 -36
- package/dist/decorators/legacy.decorators.d.ts.map +0 -1
- package/dist/decorators/legacy.decorators.js +0 -61
- package/dist/decorators/legacy.decorators.js.map +0 -1
- package/dist/decorators/scaler.decorators.d.ts +0 -65
- package/dist/decorators/scaler.decorators.d.ts.map +0 -1
- package/dist/decorators/scaler.decorators.js +0 -103
- package/dist/decorators/scaler.decorators.js.map +0 -1
- package/dist/decorators/type-guards.d.ts +0 -18
- package/dist/decorators/type-guards.d.ts.map +0 -1
- package/dist/decorators/type-guards.js +0 -32
- package/dist/decorators/type-guards.js.map +0 -1
- package/dist/decorators/worker.decorators.d.ts +0 -58
- package/dist/decorators/worker.decorators.d.ts.map +0 -1
- package/dist/decorators/worker.decorators.js +0 -92
- package/dist/decorators/worker.decorators.js.map +0 -1
- package/dist/domain/interfaces/event.interfaces.d.ts +0 -71
- package/dist/domain/interfaces/event.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/event.interfaces.js +0 -3
- package/dist/domain/interfaces/event.interfaces.js.map +0 -1
- package/dist/domain/interfaces/index-tracking.interfaces.d.ts +0 -69
- package/dist/domain/interfaces/index-tracking.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/index-tracking.interfaces.js +0 -3
- package/dist/domain/interfaces/index-tracking.interfaces.js.map +0 -1
- package/dist/domain/interfaces/lock.interfaces.d.ts +0 -54
- package/dist/domain/interfaces/lock.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/lock.interfaces.js.map +0 -1
- package/dist/domain/interfaces/process.interfaces.d.ts +0 -44
- package/dist/domain/interfaces/process.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/process.interfaces.js +0 -3
- package/dist/domain/interfaces/process.interfaces.js.map +0 -1
- package/dist/domain/interfaces/queue.interfaces.d.ts +0 -46
- package/dist/domain/interfaces/queue.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/queue.interfaces.js +0 -3
- package/dist/domain/interfaces/queue.interfaces.js.map +0 -1
- package/dist/domain/interfaces/scaling.interfaces.d.ts +0 -62
- package/dist/domain/interfaces/scaling.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/scaling.interfaces.js +0 -3
- package/dist/domain/interfaces/scaling.interfaces.js.map +0 -1
- package/dist/domain/interfaces/worker.interfaces.d.ts +0 -120
- package/dist/domain/interfaces/worker.interfaces.d.ts.map +0 -1
- package/dist/domain/interfaces/worker.interfaces.js +0 -3
- package/dist/domain/interfaces/worker.interfaces.js.map +0 -1
- package/dist/services/cron-manager/cron-manager.service.d.ts +0 -199
- package/dist/services/cron-manager/cron-manager.service.d.ts.map +0 -1
- package/dist/services/cron-manager/cron-manager.service.js +0 -583
- package/dist/services/cron-manager/cron-manager.service.js.map +0 -1
- package/dist/services/cron-manager/index.d.ts +0 -2
- package/dist/services/cron-manager/index.d.ts.map +0 -1
- package/dist/services/cron-manager/index.js.map +0 -1
- package/dist/services/index-manager/index-manager.service.d.ts +0 -142
- package/dist/services/index-manager/index-manager.service.d.ts.map +0 -1
- package/dist/services/index-manager/index-manager.service.js +0 -325
- package/dist/services/index-manager/index-manager.service.js.map +0 -1
- package/dist/services/index-manager/index.d.ts +0 -2
- package/dist/services/index-manager/index.d.ts.map +0 -1
- package/dist/services/index-manager/index.js.map +0 -1
- package/dist/services/job-processor/index.d.ts +0 -2
- package/dist/services/job-processor/index.d.ts.map +0 -1
- package/dist/services/job-processor/index.js.map +0 -1
- package/dist/services/job-processor/job-processor.service.d.ts +0 -156
- package/dist/services/job-processor/job-processor.service.d.ts.map +0 -1
- package/dist/services/job-processor/job-processor.service.js +0 -331
- package/dist/services/job-processor/job-processor.service.js.map +0 -1
- package/dist/services/processor-discovery/decorator-discovery.service.d.ts +0 -40
- package/dist/services/processor-discovery/decorator-discovery.service.d.ts.map +0 -1
- package/dist/services/processor-discovery/decorator-discovery.service.js +0 -191
- package/dist/services/processor-discovery/decorator-discovery.service.js.map +0 -1
- package/dist/services/processor-discovery/index.d.ts +0 -6
- package/dist/services/processor-discovery/index.d.ts.map +0 -1
- package/dist/services/processor-discovery/index.js +0 -22
- package/dist/services/processor-discovery/index.js.map +0 -1
- package/dist/services/processor-discovery/processor-discovery.service.d.ts +0 -98
- package/dist/services/processor-discovery/processor-discovery.service.d.ts.map +0 -1
- package/dist/services/processor-discovery/processor-discovery.service.js +0 -258
- package/dist/services/processor-discovery/processor-discovery.service.js.map +0 -1
- package/dist/services/processor-discovery/processor-registry.d.ts +0 -58
- package/dist/services/processor-discovery/processor-registry.d.ts.map +0 -1
- package/dist/services/processor-discovery/processor-registry.js +0 -74
- package/dist/services/processor-discovery/processor-registry.js.map +0 -1
- package/dist/services/processor-discovery/scaling-registration.service.d.ts +0 -60
- package/dist/services/processor-discovery/scaling-registration.service.d.ts.map +0 -1
- package/dist/services/processor-discovery/scaling-registration.service.js +0 -261
- package/dist/services/processor-discovery/scaling-registration.service.js.map +0 -1
- package/dist/services/processor-discovery/worker-factory.service.d.ts +0 -54
- package/dist/services/processor-discovery/worker-factory.service.d.ts.map +0 -1
- package/dist/services/processor-discovery/worker-factory.service.js +0 -185
- package/dist/services/processor-discovery/worker-factory.service.js.map +0 -1
- package/dist/services/queue-bus/entity-target.d.ts +0 -58
- package/dist/services/queue-bus/entity-target.d.ts.map +0 -1
- package/dist/services/queue-bus/entity-target.js +0 -109
- package/dist/services/queue-bus/entity-target.js.map +0 -1
- package/dist/services/queue-bus/queue-bus.types.d.ts +0 -40
- package/dist/services/queue-bus/queue-bus.types.d.ts.map +0 -1
- package/dist/services/queue-bus/queue-bus.types.js +0 -3
- package/dist/services/queue-bus/queue-bus.types.js.map +0 -1
- package/dist/services/queue-bus/queue-target.d.ts +0 -61
- package/dist/services/queue-bus/queue-target.d.ts.map +0 -1
- package/dist/services/queue-bus/queue-target.js +0 -123
- package/dist/services/queue-bus/queue-target.js.map +0 -1
- package/dist/services/queue-events-manager/index.d.ts +0 -2
- package/dist/services/queue-events-manager/index.d.ts.map +0 -1
- package/dist/services/queue-events-manager/index.js.map +0 -1
- package/dist/services/queue-events-manager/queue-events-manager.service.d.ts +0 -120
- package/dist/services/queue-events-manager/queue-events-manager.service.d.ts.map +0 -1
- package/dist/services/queue-events-manager/queue-events-manager.service.js +0 -343
- package/dist/services/queue-events-manager/queue-events-manager.service.js.map +0 -1
- package/dist/services/queue-manager/index.d.ts +0 -2
- package/dist/services/queue-manager/index.d.ts.map +0 -1
- package/dist/services/queue-manager/index.js.map +0 -1
- package/dist/services/queue-manager/queue-manager.service.d.ts +0 -148
- package/dist/services/queue-manager/queue-manager.service.d.ts.map +0 -1
- package/dist/services/queue-manager/queue-manager.service.js +0 -348
- package/dist/services/queue-manager/queue-manager.service.js.map +0 -1
- package/dist/services/resource-lock/index.d.ts +0 -2
- package/dist/services/resource-lock/index.d.ts.map +0 -1
- package/dist/services/resource-lock/index.js +0 -18
- package/dist/services/resource-lock/index.js.map +0 -1
- package/dist/services/resource-lock/resource-lock.service.d.ts +0 -120
- package/dist/services/resource-lock/resource-lock.service.d.ts.map +0 -1
- package/dist/services/resource-lock/resource-lock.service.js +0 -367
- package/dist/services/resource-lock/resource-lock.service.js.map +0 -1
- package/dist/services/service-queue/index.d.ts +0 -3
- package/dist/services/service-queue/index.d.ts.map +0 -1
- package/dist/services/service-queue/index.js +0 -19
- package/dist/services/service-queue/index.js.map +0 -1
- package/dist/services/service-queue/service-queue.service.d.ts +0 -199
- package/dist/services/service-queue/service-queue.service.d.ts.map +0 -1
- package/dist/services/service-queue/service-queue.service.js +0 -617
- package/dist/services/service-queue/service-queue.service.js.map +0 -1
- package/dist/services/service-queue/service-queue.types.d.ts +0 -32
- package/dist/services/service-queue/service-queue.types.d.ts.map +0 -1
- package/dist/services/service-queue/service-queue.types.js +0 -27
- package/dist/services/service-queue/service-queue.types.js.map +0 -1
- package/dist/services/shutdown-state/index.d.ts +0 -2
- package/dist/services/shutdown-state/index.d.ts.map +0 -1
- package/dist/services/shutdown-state/index.js +0 -18
- package/dist/services/shutdown-state/index.js.map +0 -1
- package/dist/services/shutdown-state/shutdown-state.service.d.ts +0 -69
- package/dist/services/shutdown-state/shutdown-state.service.d.ts.map +0 -1
- package/dist/services/shutdown-state/shutdown-state.service.js +0 -127
- package/dist/services/shutdown-state/shutdown-state.service.js.map +0 -1
- package/dist/services/spawn-queue/index.d.ts +0 -2
- package/dist/services/spawn-queue/index.d.ts.map +0 -1
- package/dist/services/spawn-queue/index.js.map +0 -1
- package/dist/services/spawn-queue/spawn-queue.service.d.ts +0 -119
- package/dist/services/spawn-queue/spawn-queue.service.d.ts.map +0 -1
- package/dist/services/spawn-queue/spawn-queue.service.js +0 -273
- package/dist/services/spawn-queue/spawn-queue.service.js.map +0 -1
- package/dist/services/worker-manager/index.d.ts +0 -2
- package/dist/services/worker-manager/index.d.ts.map +0 -1
- package/dist/services/worker-manager/index.js +0 -18
- package/dist/services/worker-manager/index.js.map +0 -1
- package/dist/services/worker-manager/worker-manager.service.d.ts +0 -221
- package/dist/services/worker-manager/worker-manager.service.d.ts.map +0 -1
- package/dist/services/worker-manager/worker-manager.service.js +0 -591
- package/dist/services/worker-manager/worker-manager.service.js.map +0 -1
- package/dist/utils/helpers.d.ts +0 -5
- package/dist/utils/helpers.d.ts.map +0 -1
- package/dist/utils/helpers.js +0 -21
- package/dist/utils/helpers.js.map +0 -1
- package/dist/utils/job.utils.d.ts +0 -50
- package/dist/utils/job.utils.d.ts.map +0 -1
- package/dist/utils/job.utils.js +0 -89
- package/dist/utils/job.utils.js.map +0 -1
package/README.md
CHANGED
|
@@ -26,54 +26,29 @@
|
|
|
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/BullMQ-5-3c873a?style=flat-square" alt="BullMQ 5" />
|
|
30
29
|
<img src="https://img.shields.io/badge/Redis-7-dc382d?style=flat-square&logo=redis&logoColor=white" alt="Redis 7" />
|
|
31
30
|
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License" />
|
|
32
31
|
</p>
|
|
33
32
|
|
|
34
33
|
---
|
|
35
34
|
|
|
36
|
-
##
|
|
35
|
+
## What is atomic-queues?
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
**A distributed virtual actor runtime for Node.js, built entirely on Redis primitives.**
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
Think [Microsoft Orleans](https://learn.microsoft.com/en-us/dotnet/orleans/) or [Akka](https://akka.io/) — but for the NestJS ecosystem, requiring nothing beyond a Redis instance you probably already have.
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
Messages addressed to the same entity execute sequentially. Messages addressed to different entities execute in parallel. No distributed locks. No worker processes. No message broker. No BullMQ.
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
| **Under contention** | Degrades — retry storms, backoff delays | **Constant** — jobs queue up, execute instantly |
|
|
48
|
-
| **Failure mode** | Silent double-execution (clock drift) | Job stuck in queue (visible, retryable) |
|
|
49
|
-
| **Split-brain risk** | Yes (timing assumptions) | **Impossible** (serial queue) |
|
|
50
|
-
| **Warm-path overhead** | Acquire + release per op | **0 Redis calls** (in-memory hot cache) |
|
|
51
|
-
| **Cold-start** | None | One-time per entity |
|
|
52
|
-
| **Multi-pod scaling** | Contention increases with pods | **Throughput increases with pods** |
|
|
43
|
+
```
|
|
44
|
+
npm install atomic-queues ioredis
|
|
45
|
+
```
|
|
53
46
|
|
|
54
47
|
---
|
|
55
48
|
|
|
56
|
-
##
|
|
57
|
-
|
|
58
|
-
- [Why atomic-queues?](#why-atomic-queues)
|
|
59
|
-
- [How It Works](#how-it-works)
|
|
60
|
-
- [Installation](#installation)
|
|
61
|
-
- [Quick Start](#quick-start)
|
|
62
|
-
- [Commands & Decorators](#commands--decorators)
|
|
63
|
-
- [Configuration](#configuration)
|
|
64
|
-
- [Distributed Worker Lifecycle](#distributed-worker-lifecycle)
|
|
65
|
-
- [Complete Example](#complete-example)
|
|
66
|
-
- [Advanced: Custom Worker Processors](#advanced-custom-worker-processors)
|
|
67
|
-
- [Performance](#performance)
|
|
68
|
-
- [License](#license)
|
|
49
|
+
## The Problem
|
|
69
50
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
## How It Works
|
|
73
|
-
|
|
74
|
-
### The Problem
|
|
75
|
-
|
|
76
|
-
Every distributed system eventually hits this:
|
|
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.
|
|
77
52
|
|
|
78
53
|
```
|
|
79
54
|
Time Request A Request B Database
|
|
@@ -86,110 +61,80 @@ T₃ UPDATE: $100 − $80 = $20 −$60
|
|
|
86
61
|
Result: Balance is −$60. Both withdrawals succeed. Integrity violated.
|
|
87
62
|
```
|
|
88
63
|
|
|
89
|
-
|
|
64
|
+
The standard answers — `SELECT ... FOR UPDATE`, optimistic locking with retries, distributed locks via Redlock or ZooKeeper, serializable transactions — all trade throughput for correctness. Under load, they become bottlenecks. Across services, they become nightmares. And every team ends up inventing some ad-hoc combination of them, poorly, under production pressure.
|
|
65
|
+
|
|
66
|
+
## The Insight
|
|
90
67
|
|
|
91
|
-
|
|
68
|
+
The problem disappears if you change *when* serialization happens. Instead of serializing at the database level (row locks, transaction isolation), serialize at the **message level**: route all operations for a given entity through a single ordered log, and process that log sequentially. Different entities maintain independent logs with zero coordination between them.
|
|
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.
|
|
92
71
|
|
|
93
72
|
```
|
|
94
73
|
┌─────────────────────────────────────────────────┐
|
|
95
74
|
Request A ─┐ │ Entity: account-42 │
|
|
96
75
|
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
97
|
-
Request B ─┼─► Route ─┼─►│
|
|
98
|
-
│ │ └──────┘ └──────┘ └──────┘ │
|
|
99
|
-
Request C ─┘ │
|
|
76
|
+
Request B ─┼─► Route ─┼─►│ Msg1 │─►│ Msg2 │─►│ Msg3 │─► [Executor] ─┐ │
|
|
77
|
+
│ │ └──────┘ └──────┘ └──────┘ │ │
|
|
78
|
+
Request C ─┘ │ Sequential ◄────────────┘ │
|
|
100
79
|
└─────────────────────────────────────────────────┘
|
|
101
80
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
│ │ ┌──────┐ ┌──────┐ │
|
|
105
|
-
Request E ─┼─► Route ─┼─►│ Op 1 │─►│ Op 2 │─────────► [Worker] ──┐ │
|
|
106
|
-
│ │ └──────┘ └──────┘ │ │
|
|
107
|
-
Request F ─┘ │ Sequential ◄───────────┘ │
|
|
108
|
-
└─────────────────────────────────────────────────┘
|
|
109
|
-
|
|
110
|
-
▲ These two queues run in PARALLEL across pods ▲
|
|
81
|
+
Meanwhile, account-99, order-7, user-abc — all execute
|
|
82
|
+
in parallel on the same cluster, completely independent.
|
|
111
83
|
```
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
- **One worker per entity** — enforced via Redis heartbeat TTL. No duplicates, ever.
|
|
115
|
-
- **Auto-spawn** — workers materialize when jobs arrive, on the pod that sees them first.
|
|
116
|
-
- **Auto-terminate** — idle workers shut down after a configurable timeout.
|
|
117
|
-
- **Self-healing** — node failure → heartbeat expires → worker respawns on a healthy pod.
|
|
118
|
-
- **Distributed** — workers spread across all pods via atomic `SET NX` claim. No leader election, no single point of failure.
|
|
85
|
+
This eliminates an entire class of bugs — lost updates, dirty reads, write skew, phantom reads on hot entities — without pessimistic locks, without optimistic retries, and without the `SELECT ... FOR UPDATE` that your DBA tells you not to use under load. The entity itself becomes the consistency boundary, and the consistency is structural rather than transactional.
|
|
119
86
|
|
|
120
87
|
---
|
|
121
88
|
|
|
122
|
-
##
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
npm install atomic-queues
|
|
126
|
-
```
|
|
89
|
+
## How It Works
|
|
127
90
|
|
|
128
|
-
|
|
91
|
+
### Entities and messages
|
|
129
92
|
|
|
130
|
-
**
|
|
93
|
+
Everything in atomic-queues is an **entity** that receives **messages**. An entity is identified by a type and an ID — `account:a-42`, `order:o-17`, `user:u-abc`. A message is a command or query addressed to a specific entity instance. You define this relationship with two decorators:
|
|
131
94
|
|
|
132
|
-
|
|
95
|
+
```typescript
|
|
96
|
+
@EntityType('account')
|
|
97
|
+
export class WithdrawCommand {
|
|
98
|
+
constructor(
|
|
99
|
+
@QueueEntityId() public readonly accountId: string,
|
|
100
|
+
public readonly amount: number,
|
|
101
|
+
) {}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
133
104
|
|
|
134
|
-
|
|
105
|
+
That's the entire contract. `@EntityType` says "this message targets the `account` entity type." `@QueueEntityId()` says "the value of `accountId` is the entity instance ID." When you enqueue this command, the runtime routes it to the log for `account:{accountId}` and guarantees sequential execution against that specific entity instance, cluster-wide.
|
|
135
106
|
|
|
136
|
-
###
|
|
107
|
+
### Two levels of abstraction
|
|
137
108
|
|
|
138
|
-
|
|
139
|
-
import { Module } from '@nestjs/common';
|
|
140
|
-
import { CqrsModule } from '@nestjs/cqrs';
|
|
141
|
-
import { AtomicQueuesModule } from 'atomic-queues';
|
|
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.
|
|
142
110
|
|
|
143
|
-
|
|
144
|
-
imports: [
|
|
145
|
-
CqrsModule,
|
|
146
|
-
AtomicQueuesModule.forRoot({
|
|
147
|
-
redis: { host: 'localhost', port: 6379 },
|
|
148
|
-
keyPrefix: 'myapp',
|
|
149
|
-
entities: {
|
|
150
|
-
account: {
|
|
151
|
-
queueName: (id) => `account-${id}-queue`,
|
|
152
|
-
workerName: (id) => `account-${id}-worker`,
|
|
153
|
-
maxWorkersPerEntity: 1,
|
|
154
|
-
idleTimeoutSeconds: 15,
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
}),
|
|
158
|
-
],
|
|
159
|
-
})
|
|
160
|
-
export class AppModule {}
|
|
161
|
-
```
|
|
111
|
+
**Actors** are the foundational primitive. An actor class *is* an entity — its fields are the state, its methods are message handlers. The runtime manages its lifecycle: activate on first message, evict from memory on idle, persist state to Redis automatically, restore on reactivation.
|
|
162
112
|
|
|
163
|
-
|
|
113
|
+
```typescript
|
|
114
|
+
@Actor('account')
|
|
115
|
+
@Injectable()
|
|
116
|
+
export class AccountActor {
|
|
117
|
+
private balance = 0;
|
|
164
118
|
|
|
165
|
-
|
|
166
|
-
|
|
119
|
+
@On(DepositCommand)
|
|
120
|
+
async deposit(msg: DepositCommand) {
|
|
121
|
+
this.balance += msg.amount;
|
|
122
|
+
return this.balance;
|
|
123
|
+
}
|
|
167
124
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
account: {
|
|
176
|
-
queueName: (id) => `account-${id}-queue`,
|
|
177
|
-
workerName: (id) => `account-${id}-worker`,
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
}),
|
|
181
|
-
inject: [ConfigService],
|
|
182
|
-
}),
|
|
125
|
+
@On(WithdrawCommand)
|
|
126
|
+
async withdraw(msg: WithdrawCommand) {
|
|
127
|
+
if (this.balance < msg.amount) throw new InsufficientFunds();
|
|
128
|
+
this.balance -= msg.amount;
|
|
129
|
+
return this.balance;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
183
132
|
```
|
|
184
133
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
### 2. Define Commands
|
|
134
|
+
**CQRS handlers** are the convenience layer for teams using `@nestjs/cqrs`. You don't write actor classes — you write standard `@CommandHandler` and `@QueryHandler` classes exactly as NestJS CQRS prescribes, and atomic-queues intercepts the dispatch to route them through the same per-entity log and gate system. The handler code doesn't change. The guarantee changes — instead of executing inline on whatever request thread happens to call `commandBus.execute()`, your handler now executes sequentially per entity, cluster-wide.
|
|
188
135
|
|
|
189
136
|
```typescript
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
@QueueEntity('account')
|
|
137
|
+
@EntityType('account')
|
|
193
138
|
export class WithdrawCommand {
|
|
194
139
|
constructor(
|
|
195
140
|
@QueueEntityId() public readonly accountId: string,
|
|
@@ -197,409 +142,324 @@ export class WithdrawCommand {
|
|
|
197
142
|
) {}
|
|
198
143
|
}
|
|
199
144
|
|
|
200
|
-
@
|
|
201
|
-
export class
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
145
|
+
@CommandHandler(WithdrawCommand)
|
|
146
|
+
export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
|
|
147
|
+
async execute(cmd: WithdrawCommand) {
|
|
148
|
+
// This runs sequentially per account — cluster-wide.
|
|
149
|
+
// No locks. No transactions. The dispatch engine guarantees it.
|
|
150
|
+
}
|
|
206
151
|
}
|
|
207
152
|
```
|
|
208
153
|
|
|
209
|
-
|
|
154
|
+
The library auto-discovers `@CommandHandler` and `@QueryHandler` classes at boot and wires them into the dispatch pipeline. Your existing CQRS architecture gets per-entity sequential guarantees without changing a single handler. The CQRS surface *calls into the actor runtime* — it's not a separate execution path.
|
|
210
155
|
|
|
211
|
-
|
|
212
|
-
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
156
|
+
### Enqueuing messages
|
|
213
157
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
158
|
+
```typescript
|
|
159
|
+
// Fire-and-forget
|
|
160
|
+
await queueBus.enqueue(new WithdrawCommand(accountId, 100));
|
|
217
161
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const account = await this.repo.findById(accountId);
|
|
162
|
+
// Enqueue and block until result
|
|
163
|
+
const balance = await queueBus.enqueueAndWait(new GetBalanceQuery(accountId));
|
|
221
164
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
165
|
+
// Scoped to an entity type
|
|
166
|
+
await queueBus.forEntity('account').enqueueBulk([charge1, charge2, charge3]);
|
|
225
167
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
168
|
+
// Actor-style direct send
|
|
169
|
+
await actorSystem.send('account', accountId, new DepositCommand(100));
|
|
170
|
+
const balance = await actorSystem.sendAndWait('account', accountId, new GetBalanceQuery());
|
|
230
171
|
```
|
|
231
172
|
|
|
232
|
-
|
|
173
|
+
---
|
|
233
174
|
|
|
234
|
-
|
|
235
|
-
import { Injectable } from '@nestjs/common';
|
|
236
|
-
import { QueueBus } from 'atomic-queues';
|
|
175
|
+
## The Dispatch Engine
|
|
237
176
|
|
|
238
|
-
|
|
239
|
-
export class AccountService {
|
|
240
|
-
constructor(private readonly queueBus: QueueBus) {}
|
|
177
|
+
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.
|
|
241
178
|
|
|
242
|
-
|
|
243
|
-
await this.queueBus.enqueue(new WithdrawCommand(accountId, amount));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
```
|
|
179
|
+
### Per-entity message logs
|
|
247
180
|
|
|
248
|
-
|
|
249
|
-
1. Creates a queue for each `accountId` when jobs arrive
|
|
250
|
-
2. Spawns a worker (spread across pods) to process jobs sequentially
|
|
251
|
-
3. Routes jobs to the correct `@CommandHandler` via CQRS
|
|
252
|
-
4. Terminates idle workers after the configured timeout
|
|
253
|
-
5. Self-heals if a pod dies (heartbeat expires → respawn elsewhere)
|
|
181
|
+
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.
|
|
254
182
|
|
|
255
|
-
|
|
183
|
+
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.
|
|
256
184
|
|
|
257
|
-
|
|
185
|
+
### The dispatch gate
|
|
258
186
|
|
|
259
|
-
|
|
187
|
+
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.
|
|
260
188
|
|
|
261
|
-
|
|
189
|
+
### Atomic Lua scheduling
|
|
262
190
|
|
|
263
|
-
|
|
264
|
-
// Option 1: Explicit property name (no @QueueEntityId needed)
|
|
265
|
-
@QueueEntity('account', 'accountId')
|
|
266
|
-
export class TransferCommand {
|
|
267
|
-
constructor(
|
|
268
|
-
public readonly accountId: string,
|
|
269
|
-
public readonly toAccountId: string,
|
|
270
|
-
public readonly amount: number,
|
|
271
|
-
) {}
|
|
272
|
-
}
|
|
191
|
+
A single Lua script runs atomically in Redis to perform the entire dispatch cycle:
|
|
273
192
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
public readonly accountId: string, // Matched by entities.account.defaultEntityId
|
|
279
|
-
public readonly amount: number,
|
|
280
|
-
) {}
|
|
281
|
-
}
|
|
282
|
-
```
|
|
193
|
+
1. Sample entities from the ready set (`SRANDMEMBER` with batch size 32)
|
|
194
|
+
2. Try to acquire the gate for each candidate (`SET NX EX`)
|
|
195
|
+
3. On first successful acquisition, pop the next message from that entity's log (`RPOP`)
|
|
196
|
+
4. Remove the entity from the ready set if its log is now empty
|
|
283
197
|
|
|
284
|
-
|
|
198
|
+
Because Lua scripts execute atomically in Redis, the pick → gate acquisition → message pop sequence cannot be interleaved by another executor on another node. This is what eliminates race conditions — not locks, but atomicity at the Redis command level.
|
|
285
199
|
|
|
286
|
-
|
|
200
|
+
### Shared executor pool
|
|
287
201
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
202
|
+
Traditional queue systems spawn a worker per queue or per entity type. With thousands of entities, that means thousands of blocking Redis connections, thousands of event loops, and a scaling problem that grows linearly with your domain model.
|
|
203
|
+
|
|
204
|
+
atomic-queues uses a **shared executor pool** — a configurable number of concurrent executors per node that dispatch messages from *any* ready entity. One pool can service millions of distinct entities. The pool self-regulates: it drains the ready set until empty or until the concurrency limit is hit, then sleeps until the next pub/sub tickle wakes it. There are no workers to spawn, monitor, or auto-scale.
|
|
205
|
+
|
|
206
|
+
### Gate refresh for long-running handlers
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
### Multiplexed result collection
|
|
211
|
+
|
|
212
|
+
Request-reply (`enqueueAndWait` / `sendAndWait`) uses a single `PSUBSCRIBE` connection per node for all concurrent result waits. Hundreds or thousands of pending results share one TCP connection to Redis, routed to the correct promise via correlation ID. No connection-per-call, no connection pool exhaustion, no subscriber amplification.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Cross-Service Communication
|
|
217
|
+
|
|
218
|
+
This is where atomic-queues stops being a "queue library" and becomes a **distributed coordination primitive**.
|
|
219
|
+
|
|
220
|
+
### The problem it solves
|
|
221
|
+
|
|
222
|
+
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.
|
|
298
223
|
|
|
299
|
-
|
|
224
|
+
atomic-queues replaces all of that with Redis.
|
|
300
225
|
|
|
301
|
-
###
|
|
226
|
+
### How it works
|
|
302
227
|
|
|
303
|
-
|
|
228
|
+
Enable the distributed registry and any service connected to the same Redis instance can send typed messages to any entity — regardless of which service owns the handler.
|
|
304
229
|
|
|
305
230
|
```typescript
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
maxWorkersPerEntity: 1,
|
|
311
|
-
idleTimeoutSeconds: 15,
|
|
231
|
+
// billing-service: defines and handles the entity
|
|
232
|
+
AtomicQueuesModule.forRoot({
|
|
233
|
+
redis: { url: process.env.REDIS_URL },
|
|
234
|
+
registry: { enabled: true, serviceName: 'billing-service' },
|
|
312
235
|
})
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
async handleAudit(job: Job, entityId: string) { ... }
|
|
317
|
-
}
|
|
236
|
+
|
|
237
|
+
// payments-service: sends to it (shared Redis, no code dependency on billing)
|
|
238
|
+
await queueBus.enqueue(new WithdrawCommand(accountId, 100));
|
|
318
239
|
```
|
|
319
240
|
|
|
320
|
-
|
|
241
|
+
When `billing-service` starts, it scans its own `@Actor`, `@CommandHandler`, and `@QueryHandler` classes and publishes **entity contracts** to Redis — a JSON document listing the entity type, accepted messages, and optional JSON schemas, refreshed via heartbeat TTL. When `payments-service` enqueues a message, the registry validates it at the call site *before* it enters the log: entity type exists, message name is accepted, payload matches schema. Errors are immediate and descriptive — not silent dead letters discovered hours later in a DLQ dashboard.
|
|
321
242
|
|
|
322
|
-
|
|
243
|
+
### What this replaces
|
|
323
244
|
|
|
324
|
-
|
|
245
|
+
Think about what you no longer need:
|
|
325
246
|
|
|
326
|
-
|
|
247
|
+
**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.
|
|
248
|
+
|
|
249
|
+
**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.
|
|
250
|
+
|
|
251
|
+
**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.
|
|
252
|
+
|
|
253
|
+
**No service discovery.** The registry *is* service discovery. When a service starts, it publishes what it handles. When a service stops, its registrations TTL out. Other services discover capabilities by reading the registry.
|
|
254
|
+
|
|
255
|
+
**No serialization framework.** Messages are JSON. The wire protocol is three Redis commands. No Protobuf compilation step, no `.proto` files, no code generation from IDL. (Though atomic-queues does offer codegen from the live registry — it generates TypeScript interfaces so Service A gets compile-time type safety for messages destined to Service B, without importing Service B's code.)
|
|
256
|
+
|
|
257
|
+
**No separate dead-letter infrastructure.** Failed messages are dead-lettered per entity type in Redis, queryable via the same connection.
|
|
258
|
+
|
|
259
|
+
### Schema validation
|
|
260
|
+
|
|
261
|
+
Attach Zod schemas to message classes for runtime safety across service boundaries:
|
|
327
262
|
|
|
328
263
|
```typescript
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
264
|
+
import { Schema } from 'atomic-queues';
|
|
265
|
+
import { z } from 'zod';
|
|
266
|
+
|
|
267
|
+
@Schema(z.object({
|
|
268
|
+
accountId: z.string().uuid(),
|
|
269
|
+
amount: z.number().positive(),
|
|
270
|
+
}))
|
|
271
|
+
@EntityType('account')
|
|
272
|
+
export class WithdrawCommand {
|
|
273
|
+
@QueueEntityId() public readonly accountId: string;
|
|
274
|
+
public readonly amount: number;
|
|
275
|
+
}
|
|
276
|
+
```
|
|
336
277
|
|
|
337
|
-
|
|
338
|
-
keyPrefix: 'myapp', // Redis key namespace (default: 'aq')
|
|
339
|
-
enableCronManager: true, // Legacy cron-based scaling (optional)
|
|
340
|
-
cronInterval: 5000, // Cron tick interval in ms
|
|
341
|
-
|
|
342
|
-
// ── Worker defaults ───────────────────────────────────────
|
|
343
|
-
workerDefaults: {
|
|
344
|
-
concurrency: 1, // Jobs processed concurrently per worker
|
|
345
|
-
stalledInterval: 1000, // ms between stalled-job checks
|
|
346
|
-
lockDuration: 30000, // ms a job is locked during processing
|
|
347
|
-
heartbeatTTL: 3, // Heartbeat key TTL in seconds
|
|
348
|
-
},
|
|
278
|
+
The Zod schema serializes to JSON Schema and stores in the registry. Every service validates payloads against it — even services that don't import your code, even services written in a different language that read the registry directly from Redis.
|
|
349
279
|
|
|
350
|
-
|
|
351
|
-
entities: {
|
|
352
|
-
account: {
|
|
353
|
-
queueName: (id) => `account-${id}-queue`,
|
|
354
|
-
workerName: (id) => `account-${id}-worker`,
|
|
355
|
-
maxWorkersPerEntity: 1,
|
|
356
|
-
idleTimeoutSeconds: 15,
|
|
357
|
-
|
|
358
|
-
// Fallback property name for entity ID extraction.
|
|
359
|
-
// Used when a command has no @QueueEntityId() decorator
|
|
360
|
-
// and no second argument to @QueueEntity().
|
|
361
|
-
defaultEntityId: 'accountId',
|
|
280
|
+
### Entity co-ownership
|
|
362
281
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
282
|
+
Multiple services can handle different message types on the same entity. Service A handles `DepositCommand` and `WithdrawCommand` on the `account` entity type. Service B handles `FreezeAccountCommand` on the same entity type. The registry merges their contracts automatically. The dispatch gate still ensures single-writer semantics per entity instance, regardless of which service's executor picks up the message.
|
|
283
|
+
|
|
284
|
+
### Contract codegen
|
|
285
|
+
|
|
286
|
+
Generate typed interfaces from the live registry:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
REDIS_URL=redis://localhost:6379 npx atomic-queues generate --ts --output ./generated/contracts.ts
|
|
370
290
|
```
|
|
371
291
|
|
|
292
|
+
Also supports `--json-schema` for language-agnostic schema export and `--snapshot` for full registry dumps.
|
|
293
|
+
|
|
372
294
|
---
|
|
373
295
|
|
|
374
|
-
##
|
|
296
|
+
## Redis *is* the Protocol
|
|
297
|
+
|
|
298
|
+
This is the most important architectural decision in the project, and it has implications that go far beyond NestJS.
|
|
375
299
|
|
|
376
|
-
|
|
300
|
+
The wire protocol is [fully documented](./WIRE-PROTOCOL.md), intentionally simple, and versioned with breaking-change semantics. Enqueuing a message is three Redis commands:
|
|
377
301
|
|
|
378
302
|
```
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
└──────┬───────┬───────┘
|
|
383
|
-
YES │ │ NO (another pod won)
|
|
384
|
-
▼ ▼
|
|
385
|
-
┌────────┐ ┌──────────────┐
|
|
386
|
-
│ Spawn │ │ Wait — other │
|
|
387
|
-
│ worker │ │ pod handles │
|
|
388
|
-
│ locally│ └──────────────┘
|
|
389
|
-
└───┬────┘
|
|
390
|
-
▼
|
|
391
|
-
┌──────────────┐
|
|
392
|
-
│ Processing │◄──── Heartbeat refresh (pipeline)
|
|
393
|
-
│ jobs back- │ every 1s (1 Redis round-trip)
|
|
394
|
-
│ to-back │
|
|
395
|
-
└──────┬───────┘
|
|
396
|
-
│ No jobs for idleTimeoutSeconds
|
|
397
|
-
▼
|
|
398
|
-
┌──────────────┐
|
|
399
|
-
│ Idle sweep │──── Hot cache eviction
|
|
400
|
-
│ closes │ Heartbeat keys cleaned up
|
|
401
|
-
│ worker │
|
|
402
|
-
└──────────────┘
|
|
303
|
+
LPUSH aq:log:account:a-1 '<message JSON>'
|
|
304
|
+
SADD aq:ready account:a-1
|
|
305
|
+
PUBLISH aq:tickle 1
|
|
403
306
|
```
|
|
404
307
|
|
|
405
|
-
|
|
308
|
+
**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.
|
|
406
309
|
|
|
407
|
-
|
|
310
|
+
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.
|
|
408
311
|
|
|
409
|
-
|
|
410
|
-
|---|---|---|
|
|
411
|
-
| **Hot** (cache hit) | 0 | Worker known alive |
|
|
412
|
-
| **Warm** (cache miss) | 1 (`EXISTS`) | First time seeing entity |
|
|
413
|
-
| **Cold** (no worker) | 1 (`SET NX`) | Worker needs creation |
|
|
312
|
+
atomic-queues is **polyglot by construction**. The coordination happens in Redis, not in the runtime. Any process that speaks the wire protocol participates on equal terms, and the [WIRE-PROTOCOL.md](./WIRE-PROTOCOL.md) includes a complete Python reference client to prove it.
|
|
414
313
|
|
|
415
|
-
|
|
314
|
+
This opens architectures that are genuinely difficult to build otherwise:
|
|
416
315
|
|
|
417
|
-
|
|
316
|
+
- **Ingest in Go, process in Node.js, analyze in Python.** Each layer speaks Redis. The entity logs are the integration boundary.
|
|
317
|
+
- **Rust executors for CPU-hot-path actors.** The same Lua scheduler, the same gates, the same entity logs. The Rust process is just another executor that happens to be faster. The Node.js side doesn't know or care.
|
|
318
|
+
- **Gradual migration.** Move one entity type's handlers to a different service, a different language, or a different infrastructure — without touching any other service's code. The entity contract in the registry is the interface, not the import statement.
|
|
319
|
+
- **Edge coordination.** An IoT device with a Redis client and 3 commands of knowledge can participate in the same entity model as your cloud services.
|
|
418
320
|
|
|
419
321
|
---
|
|
420
322
|
|
|
421
|
-
##
|
|
422
|
-
|
|
423
|
-
A banking service with withdrawals, deposits, and cross-account transfers:
|
|
323
|
+
## Quick Start
|
|
424
324
|
|
|
425
325
|
```typescript
|
|
426
|
-
// ── Module ──────────────────────────────────────────────
|
|
427
326
|
import { Module } from '@nestjs/common';
|
|
428
|
-
import { CqrsModule } from '@nestjs/cqrs';
|
|
429
327
|
import { AtomicQueuesModule } from 'atomic-queues';
|
|
430
328
|
|
|
431
329
|
@Module({
|
|
432
330
|
imports: [
|
|
433
|
-
CqrsModule,
|
|
434
331
|
AtomicQueuesModule.forRoot({
|
|
435
|
-
redis: { host: '
|
|
436
|
-
keyPrefix: 'banking',
|
|
437
|
-
entities: {
|
|
438
|
-
account: {
|
|
439
|
-
queueName: (id) => `account-${id}-queue`,
|
|
440
|
-
workerName: (id) => `account-${id}-worker`,
|
|
441
|
-
maxWorkersPerEntity: 1,
|
|
442
|
-
idleTimeoutSeconds: 15,
|
|
443
|
-
},
|
|
444
|
-
},
|
|
332
|
+
redis: { host: 'localhost', port: 6379 },
|
|
445
333
|
}),
|
|
446
334
|
],
|
|
447
|
-
providers: [
|
|
448
|
-
AccountService,
|
|
449
|
-
WithdrawHandler,
|
|
450
|
-
DepositHandler,
|
|
451
|
-
TransferHandler,
|
|
452
|
-
],
|
|
453
335
|
})
|
|
454
|
-
export class
|
|
336
|
+
export class AppModule {}
|
|
337
|
+
```
|
|
455
338
|
|
|
456
|
-
|
|
457
|
-
import { QueueEntity, QueueEntityId } from 'atomic-queues';
|
|
339
|
+
Define a command and enqueue it:
|
|
458
340
|
|
|
459
|
-
|
|
341
|
+
```typescript
|
|
342
|
+
@EntityType('account')
|
|
460
343
|
export class WithdrawCommand {
|
|
461
344
|
constructor(
|
|
462
345
|
@QueueEntityId() public readonly accountId: string,
|
|
463
346
|
public readonly amount: number,
|
|
464
|
-
public readonly transactionId: string,
|
|
465
347
|
) {}
|
|
466
348
|
}
|
|
467
349
|
|
|
468
|
-
@
|
|
469
|
-
export class
|
|
470
|
-
constructor(
|
|
471
|
-
@QueueEntityId() public readonly accountId: string,
|
|
472
|
-
public readonly amount: number,
|
|
473
|
-
public readonly source: string,
|
|
474
|
-
) {}
|
|
475
|
-
}
|
|
350
|
+
@Injectable()
|
|
351
|
+
export class PaymentService {
|
|
352
|
+
constructor(private readonly queueBus: QueueBus) {}
|
|
476
353
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
@QueueEntityId() public readonly accountId: string,
|
|
481
|
-
public readonly toAccountId: string,
|
|
482
|
-
public readonly amount: number,
|
|
483
|
-
) {}
|
|
354
|
+
async withdraw(accountId: string, amount: number) {
|
|
355
|
+
await this.queueBus.enqueue(new WithdrawCommand(accountId, amount));
|
|
356
|
+
}
|
|
484
357
|
}
|
|
358
|
+
```
|
|
485
359
|
|
|
486
|
-
|
|
487
|
-
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
360
|
+
The command is appended to `account:{accountId}`'s message log and executed sequentially by the shared executor pool. No handler registration, no worker setup, no queue configuration.
|
|
488
361
|
|
|
489
|
-
|
|
490
|
-
export class WithdrawHandler implements ICommandHandler<WithdrawCommand> {
|
|
491
|
-
constructor(private readonly repo: AccountRepository) {}
|
|
362
|
+
---
|
|
492
363
|
|
|
493
|
-
|
|
494
|
-
const account = await this.repo.findById(accountId);
|
|
495
|
-
if (account.balance < amount) throw new InsufficientFundsError();
|
|
496
|
-
account.balance -= amount;
|
|
497
|
-
await this.repo.save(account);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
364
|
+
## Configuration
|
|
500
365
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
private readonly repo: AccountRepository,
|
|
505
|
-
private readonly queueBus: QueueBus,
|
|
506
|
-
) {}
|
|
366
|
+
```typescript
|
|
367
|
+
AtomicQueuesModule.forRoot({
|
|
368
|
+
redis: { host: 'localhost', port: 6379 },
|
|
507
369
|
|
|
508
|
-
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
source.balance -= amount;
|
|
513
|
-
await this.repo.save(source);
|
|
514
|
-
|
|
515
|
-
// Credit destination (enqueued to destination's queue — also safe)
|
|
516
|
-
await this.queueBus.enqueue(
|
|
517
|
-
new DepositCommand(toAccountId, amount, `transfer:${accountId}`),
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
370
|
+
executor: {
|
|
371
|
+
poolSize: 1, // concurrent executors per node
|
|
372
|
+
gateTTL: 30, // seconds before gate expires (safety net)
|
|
373
|
+
},
|
|
521
374
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
375
|
+
entities: {
|
|
376
|
+
account: {
|
|
377
|
+
defaultEntityId: 'accountId',
|
|
378
|
+
gateTTL: 60,
|
|
379
|
+
retry: { maxAttempts: 5, backoff: 'exponential', backoffDelay: 2000 },
|
|
380
|
+
actorIdleTimeout: 120000,
|
|
381
|
+
statePersistence: true,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
525
384
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
385
|
+
registry: {
|
|
386
|
+
enabled: false,
|
|
387
|
+
serviceName: 'my-service',
|
|
388
|
+
schemaValidation: false,
|
|
389
|
+
heartbeatInterval: 10000,
|
|
390
|
+
registrationTTL: 30,
|
|
391
|
+
},
|
|
529
392
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
393
|
+
keyPrefix: 'aq',
|
|
394
|
+
verbose: false,
|
|
395
|
+
})
|
|
396
|
+
```
|
|
535
397
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
await this.queueBus.enqueue(new TransferCommand(id, body.to, body.amount));
|
|
542
|
-
return { queued: true };
|
|
543
|
-
}
|
|
544
|
-
}
|
|
398
|
+
Optional peer dependencies:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
npm install @nestjs/cqrs # for CQRS handler auto-wiring
|
|
402
|
+
npm install zod zod-to-json-schema # for schema validation in the registry
|
|
545
403
|
```
|
|
546
404
|
|
|
547
405
|
---
|
|
548
406
|
|
|
549
|
-
##
|
|
407
|
+
## Guarantees
|
|
550
408
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
@WorkerProcessor({
|
|
559
|
-
entityType: 'account',
|
|
560
|
-
queueName: (id) => `account-${id}-queue`,
|
|
561
|
-
workerName: (id) => `account-${id}-worker`,
|
|
562
|
-
maxWorkersPerEntity: 1,
|
|
563
|
-
idleTimeoutSeconds: 15,
|
|
564
|
-
})
|
|
565
|
-
@Injectable()
|
|
566
|
-
export class AccountProcessor {
|
|
567
|
-
@JobHandler('high-priority-audit')
|
|
568
|
-
async handleAudit(job: Job, entityId: string) {
|
|
569
|
-
// Specific handler for this job type
|
|
570
|
-
}
|
|
409
|
+
| Guarantee | Scope | Mechanism |
|
|
410
|
+
|---|---|---|
|
|
411
|
+
| FIFO per entity | Cluster-wide | Redis list (`LPUSH`/`RPOP`) |
|
|
412
|
+
| Single-writer per entity | Cluster-wide | Gate key (`SET NX EX`) |
|
|
413
|
+
| At-least-once delivery | Per message | Retry on gate TTL expiry |
|
|
414
|
+
| Parallel across entities | Per node | Executor pool concurrency |
|
|
415
|
+
| Durability | Per message | Redis persistence (AOF/RDB) |
|
|
571
416
|
|
|
572
|
-
|
|
573
|
-
async handleAll(job: Job, entityId: string) {
|
|
574
|
-
// Wildcard — catches everything not explicitly handled
|
|
575
|
-
// Falls back to CQRS routing automatically when not defined
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
```
|
|
417
|
+
### What this does NOT guarantee
|
|
579
418
|
|
|
580
|
-
|
|
419
|
+
**Exactly-once processing.** Like every distributed message system — Orleans, Akka, Temporal, Kafka — handlers must be idempotent. If an executor crashes mid-processing, the gate TTL expires and the message retries on another node. This is a fundamental constraint of distributed systems, not a limitation of the library.
|
|
581
420
|
|
|
582
421
|
---
|
|
583
422
|
|
|
584
|
-
##
|
|
423
|
+
## How It Compares
|
|
585
424
|
|
|
586
|
-
|
|
425
|
+
| Capability | BullMQ | Temporal | atomic-queues |
|
|
426
|
+
|---|---|---|---|
|
|
427
|
+
| Per-entity ordering | Manual (named queues) | Workflow-scoped | Built-in, zero config |
|
|
428
|
+
| Cross-entity parallelism | Worker pools | Worker pools | Shared executor pool |
|
|
429
|
+
| Stateful entities | No | Workflow state | Virtual actors |
|
|
430
|
+
| Cross-service messaging | Shared queue names | gRPC | Redis registry + codegen |
|
|
431
|
+
| Polyglot clients | JS/TS only | SDK per language | Any Redis client (3 commands) |
|
|
432
|
+
| Infrastructure required | Redis | Temporal server + DB | Redis only |
|
|
433
|
+
| Distributed locks needed | Yes, for ordering | Internal | None — gates are non-contending |
|
|
434
|
+
| Service discovery | External | Built-in | Built-in (registry) |
|
|
435
|
+
| Schema validation | No | Protobuf | Zod → JSON Schema |
|
|
587
436
|
|
|
588
|
-
|
|
589
|
-
2. **Hot cache** — after first check, subsequent job arrivals for an entity incur 0 Redis calls.
|
|
590
|
-
3. **Direct local spawn** — atomic `SET NX` claim, local worker creation. No queue round-trip.
|
|
591
|
-
4. **Pipelined heartbeats** — heartbeat refresh uses a single Redis pipeline (1 round-trip for 2 keys).
|
|
592
|
-
5. **O(1) worker existence check** — global alive key replaces `KEYS` pattern scan.
|
|
437
|
+
---
|
|
593
438
|
|
|
594
|
-
|
|
439
|
+
## Decorator Reference
|
|
595
440
|
|
|
596
|
-
|
|
|
441
|
+
| Decorator | Purpose |
|
|
597
442
|
|---|---|
|
|
598
|
-
|
|
|
599
|
-
|
|
|
600
|
-
|
|
|
601
|
-
|
|
|
602
|
-
|
|
|
443
|
+
| `@EntityType('type')` | Route a message to an entity type |
|
|
444
|
+
| `@QueueEntityId()` | Mark the property holding the entity ID |
|
|
445
|
+
| `@QueueEntity('type', 'prop')` | Combined entity type + ID |
|
|
446
|
+
| `@Actor('type')` | Declare a virtual actor class |
|
|
447
|
+
| `@On(MessageClass)` | Handle a message type on an actor |
|
|
448
|
+
| `@Schema(zodSchema)` | Attach a Zod schema for registry validation |
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Migrating from V1
|
|
453
|
+
|
|
454
|
+
V2 is a full rewrite of the internals. BullMQ is removed. Workers are removed. The public API is largely preserved.
|
|
455
|
+
|
|
456
|
+
**What stays the same**: `@EntityType`, `@QueueEntityId`, `@QueueEntity`, `queueBus.enqueue()`, `queueBus.forEntity()`, `queueBus.enqueueAndWait()`.
|
|
457
|
+
|
|
458
|
+
**What's removed**: `@WorkerProcessor`, `@JobHandler`, `@EntityScaler`, `@OnSpawnWorker`, `@OnTerminateWorker`, `@GetActiveEntities`, `@GetDesiredWorkerCount`, `.forProcessor()`. All worker and scaling concepts are gone.
|
|
459
|
+
|
|
460
|
+
**What's new**: `@Actor`, `@On`, `@Schema`, `ActorSystem`, `RegistryService`, distributed registry, codegen CLI.
|
|
461
|
+
|
|
462
|
+
**Migration steps**: (1) remove all `@WorkerProcessor` classes — replace with `@Actor` or configure entity defaults in module config; (2) remove all scaling decorators; (3) run the data migration script to drain in-flight BullMQ jobs to the new log format; (4) remove `bullmq` and `@nestjs/bullmq` from your dependencies.
|
|
603
463
|
|
|
604
464
|
---
|
|
605
465
|
|