@mayurbhusare/resilient-queue 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mayur Bhusare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @mayurbhusare/resilient-queue
2
+
3
+ Minimal resilient Redis-backed job queue for Node.js.
4
+
5
+ Provides exponential retry, dead-letter queue (DLQ) handling, idempotency guarantees, and graceful shutdown — without heavy frameworks.
6
+
7
+ ---
8
+
9
+ ## ✨ Features
10
+
11
+ - Redis-backed job queue
12
+ - Exponential backoff retry
13
+ - Dead Letter Queue (DLQ) support
14
+ - Idempotency protection
15
+ - Graceful shutdown support
16
+ - Lightweight and minimal design
17
+ - Fully tested with Jest
18
+
19
+ ---
20
+
21
+ ## 📦 Installation
22
+
23
+ ```bash
24
+ npm install @mayurbhusare/resilient-queue
25
+ ```
26
+
27
+ ## 🚀 Quick Example
28
+ ```bash
29
+
30
+ const {
31
+ ResilientQueue,
32
+ RetryableError,
33
+ FatalError
34
+ } = require("@mayurbhusare/resilient-queue");
35
+
36
+ const queue = new ResilientQueue({
37
+ redisUrl: "redis://127.0.0.1:6379",
38
+ maxRetries: 2,
39
+ baseDelay: 500
40
+ });
41
+
42
+ queue.process(async (job) => {
43
+ console.log("Processing:", job.data);
44
+
45
+ if (job.data.retry) {
46
+ throw new RetryableError("Temporary failure");
47
+ }
48
+
49
+ if (job.data.fatal) {
50
+ throw new FatalError("Permanent failure");
51
+ }
52
+
53
+ console.log("Completed");
54
+ });
55
+
56
+ queue.enqueue({ message: "Hello World" });
57
+
58
+ ```
59
+ ## 🧠 How It Works
60
+ Main Queue
61
+
62
+ Jobs are pushed into:
63
+ ```bash
64
+ rq:main
65
+ ```
66
+ Workers consume jobs using blocking Redis BLPOP.
67
+
68
+ ## Retry Strategy
69
+
70
+ Retryable errors trigger exponential backoff:
71
+ ```bash
72
+ delay = baseDelay * 2^attempt
73
+ ```
74
+
75
+ After exceeding maxRetries, the job is moved to the DLQ.
76
+
77
+ ## Dead Letter Queue (DLQ)
78
+
79
+ Failed jobs are stored in:
80
+ ```bash
81
+ rq:dlq
82
+ ```
83
+
84
+ ### DLQ entries contain:
85
+
86
+ - failure reason
87
+ - attempt count
88
+ - timestamps
89
+
90
+ ## Idempotency
91
+
92
+ If an `idempotencyKey` is provided:
93
+
94
+ - First execution succeeds
95
+ - Duplicate submissions are ignored
96
+ - Retries are not blocked
97
+
98
+ ## Graceful Shutdown
99
+ ```bash
100
+ await queue.close();
101
+ ```
102
+ Safely stops worker and closes Redis connections.
103
+
104
+ ## 🎯 Use Cases
105
+ - Email processing
106
+ - Webhook consumers
107
+ - Payment confirmation retries
108
+ - Background jobs
109
+ - Distributed microservices tasks
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mayurbhusare/resilient-queue",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight Redis-backed job queue with exponential retry, dead-letter queue support, and idempotency guarantees.",
5
+ "main": "src/index.js",
6
+ "type": "commonjs",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "redis",
10
+ "queue",
11
+ "job-queue",
12
+ "retry",
13
+ "dead-letter-queue",
14
+ "dlq",
15
+ "idempotency",
16
+ "distributed-systems",
17
+ "nodejs",
18
+ "backend"
19
+ ],
20
+ "author": "Mayur Bhusare",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/mayurbhusare/resilient-queue.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/mayurbhusare/resilient-queue/issues"
27
+ },
28
+ "homepage": "https://github.com/mayurbhusare/resilient-queue#readme",
29
+ "scripts": {
30
+ "test": "jest --runInBand"
31
+ },
32
+ "dependencies": {
33
+ "ioredis": "^5.9.3",
34
+ "uuid": "^8.3.2"
35
+ },
36
+ "devDependencies": {
37
+ "jest": "^30.2.0"
38
+ }
39
+ }
@@ -0,0 +1,157 @@
1
+ const { v4: uuidv4 } = require("uuid");
2
+
3
+ const RedisClient = require("../redis/RedisClient");
4
+ const IdempotencyManager = require("../managers/IdempotencyManager");
5
+ const DLQManager = require("../managers/DLQManager");
6
+ const RetryManager = require("../managers/RetryManager");
7
+
8
+ /**
9
+ * Main queue class.
10
+ */
11
+ class ResilientQueue {
12
+ constructor(options = {}) {
13
+ const {
14
+ redisUrl,
15
+ maxRetries = 3,
16
+ baseDelay = 500,
17
+ keyPrefix = "rq"
18
+ } = options;
19
+
20
+ if (!redisUrl) {
21
+ throw new Error("redisUrl is required to initialize ResilientQueue");
22
+ }
23
+
24
+ this.keyPrefix = keyPrefix;
25
+ this.mainQueueKey = `${keyPrefix}:main`;
26
+ this.isRunning = false;
27
+
28
+ const redisClientWrapper = new RedisClient(redisUrl);
29
+
30
+ this.producer = redisClientWrapper.getProducer();
31
+ this.consumer = redisClientWrapper.getConsumer();
32
+
33
+ this.idempotencyManager = new IdempotencyManager(
34
+ this.producer,
35
+ keyPrefix
36
+ );
37
+
38
+ this.dlqManager = new DLQManager(
39
+ this.producer,
40
+ keyPrefix
41
+ );
42
+
43
+ this.retryManager = new RetryManager(
44
+ this.producer,
45
+ this.dlqManager,
46
+ { maxRetries, baseDelay, keyPrefix }
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Enqueue a new job.
52
+ */
53
+ async enqueue(data, options = {}) {
54
+ const { idempotencyKey, ttlSeconds } = options;
55
+
56
+ const job = {
57
+ id: uuidv4(),
58
+ data,
59
+ attempt: 0,
60
+ idempotencyKey: idempotencyKey || null,
61
+ ttlSeconds: ttlSeconds || null,
62
+ createdAt: Date.now()
63
+ };
64
+
65
+ await this.producer.rpush(
66
+ this.mainQueueKey,
67
+ JSON.stringify(job)
68
+ );
69
+
70
+ return job.id;
71
+ }
72
+
73
+ /**
74
+ * Start processing jobs.
75
+ */
76
+ async process(handler) {
77
+ if (typeof handler !== "function") {
78
+ throw new Error("A handler function must be provided to process()");
79
+ }
80
+
81
+ this.isRunning = true;
82
+
83
+ while (this.isRunning) {
84
+ try {
85
+ // IMPORTANT: timeout = 1 (not 0)
86
+ const result = await this.consumer.blpop(
87
+ this.mainQueueKey,
88
+ 1
89
+ );
90
+
91
+ if (!result || result.length < 2) {
92
+ continue;
93
+ }
94
+
95
+ const rawJob = result[1];
96
+ let job;
97
+
98
+ try {
99
+ job = JSON.parse(rawJob);
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ let shouldProceed = true;
105
+
106
+ // Apply idempotency only on first attempt
107
+ if (job.attempt === 0) {
108
+ shouldProceed =
109
+ await this.idempotencyManager.shouldProcess(
110
+ job.idempotencyKey,
111
+ job.ttlSeconds
112
+ );
113
+ }
114
+
115
+ if (!shouldProceed) {
116
+ continue;
117
+ }
118
+
119
+ try {
120
+ await handler(job);
121
+ } catch (error) {
122
+ // Prevent retry during shutdown
123
+ if (this.isRunning) {
124
+ await this.retryManager.handleFailure(job, error);
125
+ }
126
+ }
127
+
128
+ } catch {
129
+ // Silent safety net
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Stop the worker loop.
136
+ */
137
+ async stop() {
138
+ this.isRunning = false;
139
+ }
140
+
141
+ /**
142
+ * Stop worker and close Redis connections safely.
143
+ */
144
+ async close() {
145
+ this.isRunning = false;
146
+
147
+ // Wait for BLPOP (timeout 1s) to exit naturally
148
+ await new Promise(resolve => setTimeout(resolve, 1100));
149
+
150
+ await Promise.all([
151
+ this.producer.quit(),
152
+ this.consumer.quit()
153
+ ]);
154
+ }
155
+ }
156
+
157
+ module.exports = ResilientQueue;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Error used to indicate a job can be retried.
3
+ */
4
+ class RetryableError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "RetryableError";
8
+ Error.captureStackTrace(this, this.constructor);
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Error used to indicate a job should not be retried.
14
+ */
15
+ class FatalError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "FatalError";
19
+ Error.captureStackTrace(this, this.constructor);
20
+ }
21
+ }
22
+
23
+ module.exports = {
24
+ RetryableError,
25
+ FatalError
26
+ };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ const ResilientQueue = require("./core/ResilientQueue");
2
+ const { RetryableError, FatalError } = require("./errors/Errors");
3
+
4
+ module.exports = {
5
+ ResilientQueue,
6
+ RetryableError,
7
+ FatalError
8
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Handles Dead Letter Queue (DLQ) operations.
3
+ */
4
+ class DLQManager {
5
+ constructor(redisClient, keyPrefix = "rq") {
6
+ if (!redisClient) {
7
+ throw new Error("Redis client is required for DLQManager");
8
+ }
9
+
10
+ this.redis = redisClient;
11
+ this.keyPrefix = keyPrefix;
12
+ }
13
+
14
+ /**
15
+ * Moves a failed job to DLQ.
16
+ */
17
+ async moveToDLQ(job, reason) {
18
+ const dlqKey = `${this.keyPrefix}:dlq`;
19
+
20
+ const failedJob = {
21
+ ...job,
22
+ failedAt: Date.now(),
23
+ failureReason: reason?.message || "Unknown error"
24
+ };
25
+
26
+ await this.redis.rpush(dlqKey, JSON.stringify(failedJob));
27
+ }
28
+ }
29
+
30
+ module.exports = DLQManager;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Handles idempotency checks using Redis.
3
+ */
4
+ class IdempotencyManager {
5
+ constructor(redisClient, keyPrefix = "rq") {
6
+ if (!redisClient) {
7
+ throw new Error("Redis client is required for IdempotencyManager");
8
+ }
9
+
10
+ this.redis = redisClient;
11
+ this.keyPrefix = keyPrefix;
12
+ }
13
+
14
+ /**
15
+ * Returns true if job should proceed.
16
+ * Returns false if duplicate.
17
+ */
18
+ async shouldProcess(idempotencyKey, ttlSeconds = null) {
19
+ if (!idempotencyKey) {
20
+ return true;
21
+ }
22
+
23
+ const redisKey = `${this.keyPrefix}:idempotency:${idempotencyKey}`;
24
+
25
+ let result;
26
+
27
+ if (ttlSeconds) {
28
+ result = await this.redis.set(
29
+ redisKey,
30
+ "1",
31
+ "EX",
32
+ ttlSeconds,
33
+ "NX"
34
+ );
35
+ } else {
36
+ result = await this.redis.set(
37
+ redisKey,
38
+ "1",
39
+ "NX"
40
+ );
41
+ }
42
+
43
+ return result === "OK";
44
+ }
45
+ }
46
+
47
+ module.exports = IdempotencyManager;
@@ -0,0 +1,68 @@
1
+ const { RetryableError } = require("../errors/Errors");
2
+
3
+ /**
4
+ * Handles retry logic with exponential backoff.
5
+ */
6
+ class RetryManager {
7
+ constructor(redisClient, dlqManager, options = {}) {
8
+ if (!redisClient) {
9
+ throw new Error("Redis client is required for RetryManager");
10
+ }
11
+
12
+ if (!dlqManager) {
13
+ throw new Error("DLQManager is required for RetryManager");
14
+ }
15
+
16
+ this.redis = redisClient;
17
+ this.dlqManager = dlqManager;
18
+
19
+ this.maxRetries = options.maxRetries ?? 3;
20
+ this.baseDelay = options.baseDelay ?? 500;
21
+ this.keyPrefix = options.keyPrefix ?? "rq";
22
+ }
23
+
24
+ /**
25
+ * Handle job failure and decide whether to retry or send to DLQ.
26
+ */
27
+ async handleFailure(job, error) {
28
+ const mainQueueKey = `${this.keyPrefix}:main`;
29
+
30
+ // If error is not retryable → send to DLQ immediately
31
+ if (!(error instanceof RetryableError)) {
32
+ await this.dlqManager.moveToDLQ(job, error);
33
+ return;
34
+ }
35
+
36
+ const nextAttempt = (job.attempt || 0) + 1;
37
+
38
+ // If max retries exceeded → send to DLQ
39
+ if (nextAttempt > this.maxRetries) {
40
+ await this.dlqManager.moveToDLQ(
41
+ { ...job, attempt: nextAttempt },
42
+ error
43
+ );
44
+ return;
45
+ }
46
+
47
+ const delay = this.baseDelay * Math.pow(2, nextAttempt);
48
+
49
+ const retryJob = {
50
+ ...job,
51
+ attempt: nextAttempt,
52
+ lastError: error.message,
53
+ retriedAt: Date.now()
54
+ };
55
+
56
+ // Requeue job after exponential delay
57
+ setTimeout(() => {
58
+ this.redis
59
+ .rpush(mainQueueKey, JSON.stringify(retryJob))
60
+ .catch(err => {
61
+ // If requeue fails, move job to DLQ
62
+ this.dlqManager.moveToDLQ(retryJob, err);
63
+ });
64
+ }, delay);
65
+ }
66
+ }
67
+
68
+ module.exports = RetryManager;
@@ -0,0 +1,67 @@
1
+ const Redis = require("ioredis");
2
+
3
+ /**
4
+ * RedisClient
5
+ *
6
+ * Creates separate Redis connections for:
7
+ * - Producer (writes: RPUSH, SET, etc.)
8
+ * - Consumer (blocking reads: BLPOP)
9
+ *
10
+ * This separation is critical because a connection
11
+ * blocked by BLPOP cannot execute other commands.
12
+ */
13
+ class RedisClient {
14
+ constructor(redisUrl) {
15
+ if (!redisUrl) {
16
+ throw new Error("redisUrl is required to initialize RedisClient");
17
+ }
18
+
19
+ // Producer connection (writes)
20
+ this.producer = new Redis(redisUrl, {
21
+ maxRetriesPerRequest: null
22
+ });
23
+
24
+ // Consumer connection (blocking reads)
25
+ this.consumer = new Redis(redisUrl, {
26
+ maxRetriesPerRequest: null
27
+ });
28
+
29
+ this._attachEventHandlers();
30
+ }
31
+
32
+ /**
33
+ * Attach safe error handlers.
34
+ * Library should not log by default.
35
+ * Host application can manage Redis errors.
36
+ */
37
+ _attachEventHandlers() {
38
+ this.producer.on("error", () => {});
39
+ this.consumer.on("error", () => {});
40
+ }
41
+
42
+ /**
43
+ * Returns producer connection.
44
+ */
45
+ getProducer() {
46
+ return this.producer;
47
+ }
48
+
49
+ /**
50
+ * Returns consumer connection.
51
+ */
52
+ getConsumer() {
53
+ return this.consumer;
54
+ }
55
+
56
+ /**
57
+ * Gracefully close connections.
58
+ */
59
+ async disconnect() {
60
+ await Promise.all([
61
+ this.producer.quit(),
62
+ this.consumer.quit()
63
+ ]);
64
+ }
65
+ }
66
+
67
+ module.exports = RedisClient;