@koala42/redis-highway 0.1.9 → 0.1.11
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/dist/batch-worker.d.ts +7 -1
- package/dist/batch-worker.js +89 -67
- package/dist/batch-worker.spec.js +2 -1
- package/dist/keys.d.ts +0 -4
- package/dist/keys.js +0 -6
- package/dist/lua.d.ts +2 -2
- package/dist/lua.js +20 -24
- package/dist/producer.js +1 -9
- package/dist/queue.spec.js +48 -14
- package/dist/stream-message-entity.d.ts +2 -3
- package/dist/stream-message-entity.js +1 -4
- package/dist/worker.d.ts +5 -1
- package/dist/worker.js +78 -33
- package/package.json +1 -1
package/dist/batch-worker.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export declare abstract class BatchWorker<T extends Record<string, unknown>> {
|
|
|
8
8
|
protected maxFetchSize: number;
|
|
9
9
|
protected maxRetries: number;
|
|
10
10
|
protected blockTimeMs: number;
|
|
11
|
+
protected maxFetchCount: number;
|
|
12
|
+
protected claimIntervalMs: number;
|
|
13
|
+
protected minIdleTimeMs: number;
|
|
11
14
|
private isRunning;
|
|
12
15
|
private activeCount;
|
|
13
16
|
private keys;
|
|
@@ -17,9 +20,12 @@ export declare abstract class BatchWorker<T extends Record<string, unknown>> {
|
|
|
17
20
|
constructor(redis: Redis, groupName: string, streamName: string, batchSize?: number, // How many jobs are passed to the process function (max)
|
|
18
21
|
concurrency?: number, // How many concurrent loops should run
|
|
19
22
|
maxFetchSize?: number, // How many jobs are fetched at once from redis stream
|
|
20
|
-
maxRetries?: number, blockTimeMs?: number
|
|
23
|
+
maxRetries?: number, blockTimeMs?: number, // How long should the blocking redis wait for logs from stream
|
|
24
|
+
maxFetchCount?: number, claimIntervalMs?: number, // Check for stuck jobs every minute
|
|
25
|
+
minIdleTimeMs?: number);
|
|
21
26
|
start(): Promise<void>;
|
|
22
27
|
stop(): Promise<void>;
|
|
28
|
+
private autoClaimLoop;
|
|
23
29
|
private fetchLoop;
|
|
24
30
|
/**
|
|
25
31
|
* Spawn worker for current processing
|
package/dist/batch-worker.js
CHANGED
|
@@ -10,7 +10,9 @@ class BatchWorker {
|
|
|
10
10
|
constructor(redis, groupName, streamName, batchSize = 10, // How many jobs are passed to the process function (max)
|
|
11
11
|
concurrency = 1, // How many concurrent loops should run
|
|
12
12
|
maxFetchSize = 20, // How many jobs are fetched at once from redis stream
|
|
13
|
-
maxRetries = 3, blockTimeMs = 2000
|
|
13
|
+
maxRetries = 3, blockTimeMs = 2000, // How long should the blocking redis wait for logs from stream
|
|
14
|
+
maxFetchCount = 5000, claimIntervalMs = 60000, // Check for stuck jobs every minute
|
|
15
|
+
minIdleTimeMs = 120000) {
|
|
14
16
|
this.redis = redis;
|
|
15
17
|
this.groupName = groupName;
|
|
16
18
|
this.streamName = streamName;
|
|
@@ -19,9 +21,11 @@ class BatchWorker {
|
|
|
19
21
|
this.maxFetchSize = maxFetchSize;
|
|
20
22
|
this.maxRetries = maxRetries;
|
|
21
23
|
this.blockTimeMs = blockTimeMs;
|
|
24
|
+
this.maxFetchCount = maxFetchCount;
|
|
25
|
+
this.claimIntervalMs = claimIntervalMs;
|
|
26
|
+
this.minIdleTimeMs = minIdleTimeMs;
|
|
22
27
|
this.isRunning = false;
|
|
23
28
|
this.activeCount = 0;
|
|
24
|
-
this.blockingRedis = null;
|
|
25
29
|
this.events = new events_1.EventEmitter();
|
|
26
30
|
this.consumerId = (0, uuid_1.v7)();
|
|
27
31
|
if (batchSize < 1) {
|
|
@@ -29,6 +33,7 @@ class BatchWorker {
|
|
|
29
33
|
}
|
|
30
34
|
this.events.setMaxListeners(100);
|
|
31
35
|
this.keys = new keys_1.KeyManager(streamName);
|
|
36
|
+
this.blockingRedis = this.redis.duplicate();
|
|
32
37
|
}
|
|
33
38
|
async start() {
|
|
34
39
|
if (this.isRunning) {
|
|
@@ -43,16 +48,76 @@ class BatchWorker {
|
|
|
43
48
|
throw e;
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
|
-
this.blockingRedis = this.redis.duplicate();
|
|
47
51
|
this.fetchLoop();
|
|
52
|
+
this.autoClaimLoop();
|
|
48
53
|
}
|
|
49
54
|
async stop() {
|
|
50
55
|
this.isRunning = false;
|
|
51
56
|
this.events.emit('job_finished');
|
|
57
|
+
if (this.blockingRedis) {
|
|
58
|
+
try {
|
|
59
|
+
await this.blockingRedis.quit();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// whatever
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
while (this.activeCount > 0) {
|
|
53
66
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
54
67
|
}
|
|
55
68
|
}
|
|
69
|
+
async autoClaimLoop() {
|
|
70
|
+
while (this.isRunning) {
|
|
71
|
+
try {
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, this.claimIntervalMs));
|
|
73
|
+
if (!this.isRunning) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
let cursor = '0-0';
|
|
77
|
+
let continueClaiming = true;
|
|
78
|
+
while (continueClaiming && this.isRunning) {
|
|
79
|
+
const result = await this.redis.xautoclaim(this.streamName, this.groupName, this.getConsumerName(), this.minIdleTimeMs, cursor, 'COUNT', this.batchSize);
|
|
80
|
+
if (!result || !result.length) {
|
|
81
|
+
continueClaiming = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const [nextCursor, messages] = result;
|
|
85
|
+
cursor = nextCursor;
|
|
86
|
+
if (messages && messages.length > 0) {
|
|
87
|
+
const pipeline = this.redis.pipeline();
|
|
88
|
+
for (const msg of messages) {
|
|
89
|
+
const entity = new stream_message_entity_1.StreamMessageEntity(msg);
|
|
90
|
+
pipeline.xack(this.streamName, this.groupName, entity.streamMessageId);
|
|
91
|
+
const statusKey = this.keys.getJobStatusKey(entity.messageUuid);
|
|
92
|
+
const timestamp = Date.now();
|
|
93
|
+
pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 2, statusKey, this.streamName, this.groupName, timestamp, entity.streamMessageId);
|
|
94
|
+
if (entity.retryCount < this.maxRetries) {
|
|
95
|
+
pipeline.xadd(this.streamName, '*', 'id', entity.messageUuid, 'target', entity.routes.join(','), 'retryCount', entity.retryCount + 1, 'data', entity.data ? JSON.stringify(entity.data) : '');
|
|
96
|
+
pipeline.hset(statusKey, '__target', entity.routes.length);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.error(`[${this.groupName}] Job ${entity.messageUuid} run out of retries (stuck). Moving to DLQ`);
|
|
100
|
+
pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', entity.messageUuid, 'group', this.groupName, 'error', 'Stuck message recovered max retries', 'payload', entity.data ? JSON.stringify(entity.data) : 'MISSING', 'failedAt', Date.now());
|
|
101
|
+
pipeline.del(statusKey);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await pipeline.exec();
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
continueClaiming = false;
|
|
108
|
+
}
|
|
109
|
+
if (nextCursor === '0-0') {
|
|
110
|
+
continueClaiming = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (this.isRunning) {
|
|
116
|
+
console.error(`[${this.groupName}] Auto claim err:`, e.message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
56
121
|
async fetchLoop() {
|
|
57
122
|
while (this.isRunning) {
|
|
58
123
|
const freeSlots = this.concurrency - this.activeCount;
|
|
@@ -60,12 +125,9 @@ class BatchWorker {
|
|
|
60
125
|
await new Promise((resolve) => this.events.once('job_finished', resolve));
|
|
61
126
|
continue;
|
|
62
127
|
}
|
|
63
|
-
const
|
|
64
|
-
const itemsCount =
|
|
128
|
+
const calculatedCount = freeSlots * this.batchSize;
|
|
129
|
+
const itemsCount = Math.min(calculatedCount, this.maxFetchCount);
|
|
65
130
|
try {
|
|
66
|
-
if (!this.blockingRedis) {
|
|
67
|
-
throw new Error('Blocking Redis connection missing');
|
|
68
|
-
}
|
|
69
131
|
const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
|
|
70
132
|
if (!results) {
|
|
71
133
|
continue;
|
|
@@ -115,79 +177,42 @@ class BatchWorker {
|
|
|
115
177
|
}
|
|
116
178
|
await pipeline.exec();
|
|
117
179
|
}
|
|
118
|
-
// If no messsages need to be process, return. Fires job finished event for another loop to pickup next logs
|
|
119
180
|
if (!messages.length) {
|
|
120
181
|
return;
|
|
121
182
|
}
|
|
122
|
-
|
|
123
|
-
const pipeline = this.redis.pipeline();
|
|
124
|
-
for (const message of messages) {
|
|
125
|
-
pipeline.get(this.keys.getJobDataKey(message.messageUuid));
|
|
126
|
-
}
|
|
127
|
-
const response = await pipeline.exec();
|
|
128
|
-
// TODO: Add error handling
|
|
129
|
-
if (!response) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
// Parse job data into message entities (lol, titties)
|
|
133
|
-
messages.forEach((message, index) => {
|
|
134
|
-
const foundData = response[index] || null;
|
|
135
|
-
if (!foundData) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const [error, data] = foundData;
|
|
139
|
-
if (error) {
|
|
140
|
-
console.error(`[${this.groupName}] Failed getting job data err: `, error);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (!data) {
|
|
144
|
-
console.error(`[${this.groupName}] Data not found for job`);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
message.data = JSON.parse(data);
|
|
148
|
-
});
|
|
149
|
-
const messagesData = [];
|
|
150
|
-
const messagesToFinalize = [];
|
|
151
|
-
messages.forEach((message) => {
|
|
152
|
-
messagesToFinalize.push(message);
|
|
153
|
-
if (message.data) {
|
|
154
|
-
messagesData.push(message.data);
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
// TODO improve error handling
|
|
158
|
-
if (!messagesData.length) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
183
|
+
const messagesData = messages.map((msg) => msg.data);
|
|
161
184
|
try {
|
|
162
185
|
await this.process(messagesData);
|
|
163
|
-
await this.finalize(
|
|
186
|
+
await this.finalize(messages);
|
|
164
187
|
}
|
|
165
188
|
catch (err) {
|
|
166
|
-
console.error(`[${this.groupName}]
|
|
189
|
+
console.error(`[${this.groupName}] Processing failed`, err);
|
|
167
190
|
await this.handleFailure(messages, err.message);
|
|
168
191
|
}
|
|
169
192
|
}
|
|
170
193
|
async handleFailure(messages, errorMessage) {
|
|
171
194
|
const pipeline = this.redis.pipeline();
|
|
172
|
-
//
|
|
195
|
+
// ack
|
|
173
196
|
for (const message of messages) {
|
|
174
197
|
pipeline.xack(this.streamName, this.groupName, message.streamMessageId);
|
|
175
198
|
}
|
|
176
199
|
const messagesToDlq = [];
|
|
177
200
|
for (const message of messages) {
|
|
178
|
-
if (message.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
201
|
+
if (message.routes.includes(this.groupName)) {
|
|
202
|
+
if (message.retryCount < this.maxRetries && message.data) {
|
|
203
|
+
const payloadString = JSON.stringify(message.data);
|
|
204
|
+
pipeline.xadd(this.streamName, '*', 'id', message.messageUuid, 'target', this.groupName, 'retryCount', message.retryCount + 1, 'data', payloadString);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.error(`[${this.groupName}] Job ${message.messageUuid} run out of retries. Moving to DLQ`);
|
|
208
|
+
messagesToDlq.push(message);
|
|
209
|
+
pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', message.messageUuid, 'group', this.groupName, 'error', errorMessage, 'payload', message.data ? JSON.stringify(message.data) : 'MISSING', 'failedAt', Date.now());
|
|
210
|
+
}
|
|
185
211
|
}
|
|
186
212
|
else {
|
|
187
|
-
|
|
188
|
-
console.error(`[${this.groupName}] Job ${message.messageUuid} exhausted retries. Moving to DLQ.`);
|
|
213
|
+
console.error(`[${this.groupName}] Job ${message.messageUuid} failed but not routed to this group. Moving to DLQ.`);
|
|
189
214
|
messagesToDlq.push(message);
|
|
190
|
-
pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', message.messageUuid, 'group', this.groupName, 'error', errorMessage
|
|
215
|
+
pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', message.messageUuid, 'group', this.groupName, 'error', `Failed but not routed to ${this.groupName}: ${errorMessage}`, 'payload', JSON.stringify(message.data), 'failedAt', Date.now());
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
218
|
await pipeline.exec();
|
|
@@ -196,24 +221,21 @@ class BatchWorker {
|
|
|
196
221
|
}
|
|
197
222
|
}
|
|
198
223
|
async finalize(messages) {
|
|
199
|
-
if (messages.length === 0)
|
|
224
|
+
if (messages.length === 0) {
|
|
200
225
|
return;
|
|
226
|
+
}
|
|
201
227
|
const pipeline = this.redis.pipeline();
|
|
202
228
|
const timestamp = Date.now();
|
|
203
229
|
const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
|
|
204
230
|
const totalKey = this.keys.getTotalKey(this.groupName);
|
|
205
|
-
// 1. Batch xacks
|
|
206
231
|
const ids = messages.map(m => m.streamMessageId);
|
|
207
232
|
pipeline.xack(this.streamName, this.groupName, ...ids);
|
|
208
|
-
// 2. Batch metrics
|
|
209
233
|
pipeline.incrby(throughputKey, ids.length);
|
|
210
234
|
pipeline.expire(throughputKey, 86400);
|
|
211
235
|
pipeline.incrby(totalKey, ids.length);
|
|
212
|
-
// Lua scripts to only check if data should be deleted
|
|
213
236
|
for (const msg of messages) {
|
|
214
237
|
const statusKey = this.keys.getJobStatusKey(msg.messageUuid);
|
|
215
|
-
|
|
216
|
-
pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 3, statusKey, dataKey, this.streamName, this.groupName, timestamp, msg.streamMessageId);
|
|
238
|
+
pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 2, statusKey, this.streamName, this.groupName, timestamp, msg.streamMessageId);
|
|
217
239
|
}
|
|
218
240
|
await pipeline.exec();
|
|
219
241
|
}
|
|
@@ -11,7 +11,8 @@ const uuid_1 = require("uuid");
|
|
|
11
11
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
12
12
|
class TestBatchWorker extends batch_worker_1.BatchWorker {
|
|
13
13
|
constructor(redis, groupName, streamName, batchSize = 10, concurrency = 1, maxRetries = 3, blockTimeMs = 100) {
|
|
14
|
-
|
|
14
|
+
// Fix argument order: batchSize, concurrency, maxFetchSize, maxRetries, blockTimeMs
|
|
15
|
+
super(redis, groupName, streamName, batchSize, concurrency, 20, maxRetries, blockTimeMs);
|
|
15
16
|
this.processedBatches = [];
|
|
16
17
|
this.shouldFail = false;
|
|
17
18
|
this.failCount = 0;
|
package/dist/keys.d.ts
CHANGED
|
@@ -8,10 +8,6 @@ export declare class KeyManager {
|
|
|
8
8
|
* And targets add their completed timestamps there
|
|
9
9
|
*/
|
|
10
10
|
getJobStatusKey(id: string): string;
|
|
11
|
-
/**
|
|
12
|
-
* Job data contains the job payload
|
|
13
|
-
*/
|
|
14
|
-
getJobDataKey(id: string): string;
|
|
15
11
|
/**
|
|
16
12
|
* Dead letter queue stream name
|
|
17
13
|
*/
|
package/dist/keys.js
CHANGED
|
@@ -16,12 +16,6 @@ class KeyManager {
|
|
|
16
16
|
getJobStatusKey(id) {
|
|
17
17
|
return `${this.streamName}:status:${id}`;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Job data contains the job payload
|
|
21
|
-
*/
|
|
22
|
-
getJobDataKey(id) {
|
|
23
|
-
return `${this.streamName}:data:${id}`;
|
|
24
|
-
}
|
|
25
19
|
/**
|
|
26
20
|
* Dead letter queue stream name
|
|
27
21
|
*/
|
package/dist/lua.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const LUA_MARK_DONE = "\n-- KEYS[1] = status key status key for jog\n-- KEYS[2] =
|
|
2
|
-
export declare const LUA_FINALIZE_COMPLEX = "\n-- KEYS[1] = status key\n-- KEYS[2] =
|
|
1
|
+
export declare const LUA_MARK_DONE = "\n-- KEYS[1] = status key status key for jog\n-- KEYS[2] = stream key\n-- KEYS[3] = group name\n-- KEYS[4] = metrics key\n-- KEYS[5] = total metrics key(persistent)\n\n-- ARGV[1] = route name\n-- ARGV[2] = timestamp\n-- ARGV[3] = msgId - redis stream item ID\n\n-- 1 Ack the stream message\nredis.call('XACK', KEYS[2], KEYS[3], ARGV[3])\n\n-- 2 in status key mark the current route as done by saving timestamp\nredis.call('HSET', KEYS[1], ARGV[1], ARGV[2])\n\n-- 3 Increment throughput metric\nif KEYS[5] then\n redis.call('INCR', KEYS[4])\n redis.call('EXPIRE', KEYS[4], 86400)\nend\n\n-- 4 Increment Total Metric\nredis.call('INCR', KEYS[5])\n\n-- 5 Check for completed routes\nlocal current_fields = redis.call('HLEN', KEYS[1])\n\n-- 6 Get the target completed routes\nlocal target_str = redis.call('HGET', KEYS[1], '__target')\nlocal target = tonumber(target_str)\n\nif not target then\n return 0\nend\n\n-- 7 If completed routes is status hash length - 1 -> all were done and we can cleanup\nif current_fields >= (target + 1) then\n redis.call('DEL', KEYS[1]) -- Only delete status key\n redis.call('XDEL', KEYS[2], ARGV[3])\n return 1 -- Cleanup, DONE\nend\n\nreturn 0 -- Some routes are not done yet\n";
|
|
2
|
+
export declare const LUA_FINALIZE_COMPLEX = "\n-- KEYS[1] = status key\n-- KEYS[2] = stream key\n-- ARGV[1] = group name\n-- ARGV[2] = timestamp\n-- ARGV[3] = msgId\n\n-- 1. Update status\nredis.call('HSET', KEYS[1], ARGV[1], ARGV[2])\n\n-- 2. Check completions\nlocal current_fields = redis.call('HLEN', KEYS[1])\nlocal target_str = redis.call('HGET', KEYS[1], '__target')\nlocal target = tonumber(target_str)\n\nif not target then\n return 0\nend\n\n-- 3. Cleanup if done\nif current_fields >= (target + 1) then\n redis.call('DEL', KEYS[1])\n redis.call('XDEL', KEYS[2], ARGV[3])\n return 1\nend\n\nreturn 0\n";
|
package/dist/lua.js
CHANGED
|
@@ -3,37 +3,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.LUA_FINALIZE_COMPLEX = exports.LUA_MARK_DONE = void 0;
|
|
4
4
|
exports.LUA_MARK_DONE = `
|
|
5
5
|
-- KEYS[1] = status key status key for jog
|
|
6
|
-
-- KEYS[2] =
|
|
7
|
-
-- KEYS[3] =
|
|
8
|
-
-- KEYS[4] =
|
|
9
|
-
-- KEYS[5] = metrics key
|
|
10
|
-
-- KEYS[6] = total metrics key(persistent)
|
|
6
|
+
-- KEYS[2] = stream key
|
|
7
|
+
-- KEYS[3] = group name
|
|
8
|
+
-- KEYS[4] = metrics key
|
|
9
|
+
-- KEYS[5] = total metrics key(persistent)
|
|
11
10
|
|
|
12
11
|
-- ARGV[1] = route name
|
|
13
12
|
-- ARGV[2] = timestamp
|
|
14
13
|
-- ARGV[3] = msgId - redis stream item ID
|
|
15
14
|
|
|
16
|
-
-- 1
|
|
17
|
-
redis.call('XACK', KEYS[
|
|
15
|
+
-- 1 Ack the stream message
|
|
16
|
+
redis.call('XACK', KEYS[2], KEYS[3], ARGV[3])
|
|
18
17
|
|
|
19
|
-
-- 2
|
|
18
|
+
-- 2 in status key mark the current route as done by saving timestamp
|
|
20
19
|
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
21
20
|
|
|
22
|
-
-- 3
|
|
21
|
+
-- 3 Increment throughput metric
|
|
23
22
|
if KEYS[5] then
|
|
24
|
-
redis.call('INCR', KEYS[
|
|
25
|
-
redis.call('EXPIRE', KEYS[
|
|
23
|
+
redis.call('INCR', KEYS[4])
|
|
24
|
+
redis.call('EXPIRE', KEYS[4], 86400)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
--
|
|
29
|
-
|
|
30
|
-
redis.call('INCR', KEYS[6])
|
|
31
|
-
end
|
|
27
|
+
-- 4 Increment Total Metric
|
|
28
|
+
redis.call('INCR', KEYS[5])
|
|
32
29
|
|
|
33
|
-
--
|
|
30
|
+
-- 5 Check for completed routes
|
|
34
31
|
local current_fields = redis.call('HLEN', KEYS[1])
|
|
35
32
|
|
|
36
|
-
--
|
|
33
|
+
-- 6 Get the target completed routes
|
|
37
34
|
local target_str = redis.call('HGET', KEYS[1], '__target')
|
|
38
35
|
local target = tonumber(target_str)
|
|
39
36
|
|
|
@@ -41,10 +38,10 @@ if not target then
|
|
|
41
38
|
return 0
|
|
42
39
|
end
|
|
43
40
|
|
|
44
|
-
--
|
|
41
|
+
-- 7 If completed routes is status hash length - 1 -> all were done and we can cleanup
|
|
45
42
|
if current_fields >= (target + 1) then
|
|
46
|
-
redis.call('DEL', KEYS[1]
|
|
47
|
-
redis.call('XDEL', KEYS[
|
|
43
|
+
redis.call('DEL', KEYS[1]) -- Only delete status key
|
|
44
|
+
redis.call('XDEL', KEYS[2], ARGV[3])
|
|
48
45
|
return 1 -- Cleanup, DONE
|
|
49
46
|
end
|
|
50
47
|
|
|
@@ -52,8 +49,7 @@ return 0 -- Some routes are not done yet
|
|
|
52
49
|
`;
|
|
53
50
|
exports.LUA_FINALIZE_COMPLEX = `
|
|
54
51
|
-- KEYS[1] = status key
|
|
55
|
-
-- KEYS[2] =
|
|
56
|
-
-- KEYS[3] = stream key
|
|
52
|
+
-- KEYS[2] = stream key
|
|
57
53
|
-- ARGV[1] = group name
|
|
58
54
|
-- ARGV[2] = timestamp
|
|
59
55
|
-- ARGV[3] = msgId
|
|
@@ -72,8 +68,8 @@ end
|
|
|
72
68
|
|
|
73
69
|
-- 3. Cleanup if done
|
|
74
70
|
if current_fields >= (target + 1) then
|
|
75
|
-
redis.call('DEL', KEYS[1]
|
|
76
|
-
redis.call('XDEL', KEYS[
|
|
71
|
+
redis.call('DEL', KEYS[1])
|
|
72
|
+
redis.call('XDEL', KEYS[2], ARGV[3])
|
|
77
73
|
return 1
|
|
78
74
|
end
|
|
79
75
|
|
package/dist/producer.js
CHANGED
|
@@ -21,15 +21,7 @@ class Producer {
|
|
|
21
21
|
const id = (0, uuid_1.v7)();
|
|
22
22
|
const ttl = opts?.ttl || null; // 24 hours in seconds
|
|
23
23
|
const pipeline = this.redis.pipeline();
|
|
24
|
-
const dataKey = this.keys.getJobDataKey(id);
|
|
25
24
|
const statusKey = this.keys.getJobStatusKey(id);
|
|
26
|
-
// Create job data
|
|
27
|
-
if (ttl) {
|
|
28
|
-
pipeline.set(dataKey, serializedPayload, 'EX', ttl);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
pipeline.set(dataKey, serializedPayload);
|
|
32
|
-
}
|
|
33
25
|
// Initialize job metadata - status
|
|
34
26
|
// TODO: improve target groups use groups join by "," instead of groups length
|
|
35
27
|
pipeline.hset(statusKey, '__target', targetGroups.length);
|
|
@@ -37,7 +29,7 @@ class Producer {
|
|
|
37
29
|
pipeline.expire(statusKey, ttl);
|
|
38
30
|
}
|
|
39
31
|
// Push message to stream
|
|
40
|
-
pipeline.xadd(this.streamName, '*', 'id', id, 'target', targetGroups.join(','));
|
|
32
|
+
pipeline.xadd(this.streamName, '*', 'id', id, 'target', targetGroups.join(','), 'data', serializedPayload);
|
|
41
33
|
await pipeline.exec();
|
|
42
34
|
return id;
|
|
43
35
|
}
|
package/dist/queue.spec.js
CHANGED
|
@@ -11,8 +11,8 @@ const metrics_1 = require("./metrics");
|
|
|
11
11
|
const uuid_1 = require("uuid");
|
|
12
12
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
13
13
|
class TestWorker extends worker_1.Worker {
|
|
14
|
-
constructor(redis, groupName, streamName, concurrency = 1, blockTimeMs = 100) {
|
|
15
|
-
super(redis, groupName, streamName, concurrency,
|
|
14
|
+
constructor(redis, groupName, streamName, concurrency = 1, maxRetries = 3, blockTimeMs = 100, claimIntervalMs = 60000, minIdleTimeMs = 300000) {
|
|
15
|
+
super(redis, groupName, streamName, concurrency, maxRetries, blockTimeMs, claimIntervalMs, minIdleTimeMs);
|
|
16
16
|
this.processedCount = 0;
|
|
17
17
|
this.lastProcessedId = null;
|
|
18
18
|
this.shouldFail = false;
|
|
@@ -70,8 +70,8 @@ class TestWorker extends worker_1.Worker {
|
|
|
70
70
|
};
|
|
71
71
|
(0, vitest_1.describe)('Core Functionality', () => {
|
|
72
72
|
(0, vitest_1.it)('Should deliver message to all target groups', async () => {
|
|
73
|
-
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 100);
|
|
74
|
-
const w2 = new TestWorker(redis, 'group-B', streamName, 1, 100);
|
|
73
|
+
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 3, 100);
|
|
74
|
+
const w2 = new TestWorker(redis, 'group-B', streamName, 1, 3, 100);
|
|
75
75
|
workers.push(w1, w2);
|
|
76
76
|
await w1.start();
|
|
77
77
|
await w2.start();
|
|
@@ -86,8 +86,8 @@ class TestWorker extends worker_1.Worker {
|
|
|
86
86
|
(0, vitest_1.expect)(await redis.exists(dataKey)).toBe(0);
|
|
87
87
|
});
|
|
88
88
|
(0, vitest_1.it)('Should only deliver to targeted groups', async () => {
|
|
89
|
-
const wA = new TestWorker(redis, 'group-A', streamName, 1, 100);
|
|
90
|
-
const wB = new TestWorker(redis, 'group-B', streamName, 1, 100);
|
|
89
|
+
const wA = new TestWorker(redis, 'group-A', streamName, 1, 3, 100);
|
|
90
|
+
const wB = new TestWorker(redis, 'group-B', streamName, 1, 3, 100);
|
|
91
91
|
workers.push(wA, wB);
|
|
92
92
|
await wA.start();
|
|
93
93
|
await wB.start();
|
|
@@ -97,8 +97,8 @@ class TestWorker extends worker_1.Worker {
|
|
|
97
97
|
(0, vitest_1.expect)(wB.processedCount).toBe(0);
|
|
98
98
|
});
|
|
99
99
|
(0, vitest_1.it)('Should retry only the failed group', async () => {
|
|
100
|
-
const wOk = new TestWorker(redis, 'group-Ok', streamName, 1, 100);
|
|
101
|
-
const wFail = new TestWorker(redis, 'group-Fail', streamName, 1, 100);
|
|
100
|
+
const wOk = new TestWorker(redis, 'group-Ok', streamName, 1, 3, 100);
|
|
101
|
+
const wFail = new TestWorker(redis, 'group-Fail', streamName, 1, 3, 100);
|
|
102
102
|
wFail.shouldFail = true;
|
|
103
103
|
wFail.maxFails = 1; // Fail once, then succeed
|
|
104
104
|
workers.push(wOk, wFail);
|
|
@@ -113,7 +113,7 @@ class TestWorker extends worker_1.Worker {
|
|
|
113
113
|
(0, vitest_1.expect)(wOk.processedCount).toBe(1); // wOk should NOT process the retry
|
|
114
114
|
});
|
|
115
115
|
(0, vitest_1.it)('Should move to DLQ after max retries', async () => {
|
|
116
|
-
const wDead = new TestWorker(redis, 'group-Dead', streamName, 1, 100);
|
|
116
|
+
const wDead = new TestWorker(redis, 'group-Dead', streamName, 1, 3, 100);
|
|
117
117
|
wDead.shouldFail = true;
|
|
118
118
|
wDead.maxFails = 10; // Fail forever (more than max retries which is 3)
|
|
119
119
|
workers.push(wDead);
|
|
@@ -130,7 +130,7 @@ class TestWorker extends worker_1.Worker {
|
|
|
130
130
|
});
|
|
131
131
|
(0, vitest_1.describe)('Metrics & Monitoring', () => {
|
|
132
132
|
(0, vitest_1.it)('Should track throughput and queue size', async () => {
|
|
133
|
-
const w = new TestWorker(redis, 'group-Metrics', streamName, 1, 100);
|
|
133
|
+
const w = new TestWorker(redis, 'group-Metrics', streamName, 1, 3, 100);
|
|
134
134
|
const metricsService = new metrics_1.Metrics(redis, streamName);
|
|
135
135
|
workers.push(w);
|
|
136
136
|
await w.start();
|
|
@@ -148,7 +148,7 @@ class TestWorker extends worker_1.Worker {
|
|
|
148
148
|
(0, vitest_1.expect)(metrics.dlqLength).toBe(1);
|
|
149
149
|
});
|
|
150
150
|
(0, vitest_1.it)('Should export Prometheus metrics', async () => {
|
|
151
|
-
const w = new TestWorker(redis, 'group-Prom', streamName, 1, 100);
|
|
151
|
+
const w = new TestWorker(redis, 'group-Prom', streamName, 1, 3, 100);
|
|
152
152
|
const metricsService = new metrics_1.Metrics(redis, streamName);
|
|
153
153
|
workers.push(w);
|
|
154
154
|
await w.start();
|
|
@@ -165,7 +165,7 @@ class TestWorker extends worker_1.Worker {
|
|
|
165
165
|
});
|
|
166
166
|
(0, vitest_1.describe)('Stream Cleanup', () => {
|
|
167
167
|
(0, vitest_1.it)('Should delete message from stream after processing', async () => {
|
|
168
|
-
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 100);
|
|
168
|
+
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 3, 100);
|
|
169
169
|
workers.push(w1);
|
|
170
170
|
await w1.start();
|
|
171
171
|
const id = await producer.push({ id: 'msg-cleanup' }, ['group-A']);
|
|
@@ -182,8 +182,8 @@ class TestWorker extends worker_1.Worker {
|
|
|
182
182
|
(0, vitest_1.expect)(messages.length).toBe(0);
|
|
183
183
|
});
|
|
184
184
|
(0, vitest_1.it)('Should delete message from stream only after ALL groups processed it', async () => {
|
|
185
|
-
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 100);
|
|
186
|
-
const w2 = new TestWorker(redis, 'group-B', streamName, 1, 100);
|
|
185
|
+
const w1 = new TestWorker(redis, 'group-A', streamName, 1, 3, 100);
|
|
186
|
+
const w2 = new TestWorker(redis, 'group-B', streamName, 1, 3, 100);
|
|
187
187
|
workers.push(w1, w2);
|
|
188
188
|
await w1.start(); // Only start w1
|
|
189
189
|
const id = await producer.push({ id: 'msg-multi' }, ['group-A', 'group-B']);
|
|
@@ -203,4 +203,38 @@ class TestWorker extends worker_1.Worker {
|
|
|
203
203
|
(0, vitest_1.expect)(success).toBe(true);
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
(0, vitest_1.it)('Should recover stuck messages via Auto-Claim', async () => {
|
|
207
|
+
const groupName = 'group-Recover';
|
|
208
|
+
// Start worker with short minIdleTime (e.g., 1000ms) to trigger claim quickly
|
|
209
|
+
// minIdleTimeMs = 1000. claimIntervalMs = 500 (check frequently)
|
|
210
|
+
const w = new TestWorker(redis, groupName, streamName, 1, 3, 100, 500, 1000);
|
|
211
|
+
workers.push(w);
|
|
212
|
+
// 1. Setup group manually
|
|
213
|
+
await redis.xgroup('CREATE', streamName, groupName, '0', 'MKSTREAM');
|
|
214
|
+
// 2. Push message
|
|
215
|
+
const id = await producer.push({ id: 'stuck-msg' }, [groupName]);
|
|
216
|
+
// 3. Simulate a consumer reading but crashing (no ACK)
|
|
217
|
+
// consumer name 'bad-consumer'
|
|
218
|
+
await redis.xreadgroup('GROUP', groupName, 'bad-consumer', 'COUNT', 1, 'STREAMS', streamName, '>');
|
|
219
|
+
// 4. Wait for minIdleTime (1000ms) + buffer
|
|
220
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
221
|
+
// 5. Start our worker
|
|
222
|
+
await w.start();
|
|
223
|
+
// 6. Verify worker picks it up
|
|
224
|
+
await waitFor(() => w.processedCount === 1, 5000);
|
|
225
|
+
(0, vitest_1.expect)(w.processedCount).toBe(1);
|
|
226
|
+
(0, vitest_1.expect)(w.lastProcessedId).toBe('stuck-msg');
|
|
227
|
+
// Verify it was claimed (delivered to new consumer)
|
|
228
|
+
// We can check PEL or just trust processedCount
|
|
229
|
+
const pending = await redis.xpending(streamName, groupName);
|
|
230
|
+
// After processing, it should be ACKed, so pending count => 0 (if deleted)
|
|
231
|
+
// or if finalize runs, it deletes the message entirely.
|
|
232
|
+
// Wait for cleanup (finalize runs after process)
|
|
233
|
+
await waitFor(async () => {
|
|
234
|
+
const len = await redis.xlen(streamName);
|
|
235
|
+
return len === 0;
|
|
236
|
+
}, 2000);
|
|
237
|
+
const len = await redis.xlen(streamName);
|
|
238
|
+
(0, vitest_1.expect)(len).toBe(0);
|
|
239
|
+
});
|
|
206
240
|
});
|
|
@@ -6,10 +6,9 @@ export declare class StreamMessageEntity<T extends Record<string, unknown>> {
|
|
|
6
6
|
private readonly _routes;
|
|
7
7
|
private readonly _messageUuid;
|
|
8
8
|
private readonly _retryCount;
|
|
9
|
-
private _data;
|
|
9
|
+
private readonly _data;
|
|
10
10
|
constructor(message: StreamMessage);
|
|
11
|
-
|
|
12
|
-
get data(): T | null;
|
|
11
|
+
get data(): T;
|
|
13
12
|
get streamMessageId(): string;
|
|
14
13
|
get messageUuid(): string;
|
|
15
14
|
get routes(): string[];
|
|
@@ -6,7 +6,6 @@ class StreamMessageEntity {
|
|
|
6
6
|
this._rawFields = [];
|
|
7
7
|
this._fields = {};
|
|
8
8
|
this._routes = [];
|
|
9
|
-
this._data = null;
|
|
10
9
|
this._streamMessageId = message[0];
|
|
11
10
|
this._rawFields = message[1];
|
|
12
11
|
for (let i = 0; i < this._rawFields.length; i += 2) {
|
|
@@ -15,9 +14,7 @@ class StreamMessageEntity {
|
|
|
15
14
|
this._messageUuid = this._fields['id'];
|
|
16
15
|
this._routes = this._fields['target'].split(',');
|
|
17
16
|
this._retryCount = parseInt(this._fields['retryCount'] || '0', 10);
|
|
18
|
-
|
|
19
|
-
set data(data) {
|
|
20
|
-
this._data = data;
|
|
17
|
+
this._data = JSON.parse(this._fields['data']);
|
|
21
18
|
}
|
|
22
19
|
get data() {
|
|
23
20
|
return this._data;
|
package/dist/worker.d.ts
CHANGED
|
@@ -6,18 +6,22 @@ export declare abstract class Worker<T extends Record<string, unknown>> {
|
|
|
6
6
|
protected concurrency: number;
|
|
7
7
|
protected MAX_RETRIES: number;
|
|
8
8
|
protected blockTimeMs: number;
|
|
9
|
+
protected claimIntervalMs: number;
|
|
10
|
+
protected minIdleTimeMs: number;
|
|
9
11
|
private isRunning;
|
|
10
12
|
private activeCount;
|
|
11
13
|
private readonly events;
|
|
12
14
|
private keys;
|
|
13
15
|
private consumerId;
|
|
14
|
-
|
|
16
|
+
private blockingRedis;
|
|
17
|
+
constructor(redis: Redis, groupName: string, streamName: string, concurrency?: number, MAX_RETRIES?: number, blockTimeMs?: number, claimIntervalMs?: number, minIdleTimeMs?: number);
|
|
15
18
|
/**
|
|
16
19
|
* Start worker
|
|
17
20
|
* @returns
|
|
18
21
|
*/
|
|
19
22
|
start(): Promise<void>;
|
|
20
23
|
stop(): Promise<void>;
|
|
24
|
+
private autoClaimLoop;
|
|
21
25
|
private fetchLoop;
|
|
22
26
|
private spawnWorker;
|
|
23
27
|
private processInternal;
|
package/dist/worker.js
CHANGED
|
@@ -7,19 +7,22 @@ const keys_1 = require("./keys");
|
|
|
7
7
|
const stream_message_entity_1 = require("./stream-message-entity");
|
|
8
8
|
const uuid_1 = require("uuid");
|
|
9
9
|
class Worker {
|
|
10
|
-
constructor(redis, groupName, streamName, concurrency = 1, MAX_RETRIES = 3, blockTimeMs = 2000) {
|
|
10
|
+
constructor(redis, groupName, streamName, concurrency = 1, MAX_RETRIES = 3, blockTimeMs = 2000, claimIntervalMs = 60000, minIdleTimeMs = 300000) {
|
|
11
11
|
this.redis = redis;
|
|
12
12
|
this.groupName = groupName;
|
|
13
13
|
this.streamName = streamName;
|
|
14
14
|
this.concurrency = concurrency;
|
|
15
15
|
this.MAX_RETRIES = MAX_RETRIES;
|
|
16
16
|
this.blockTimeMs = blockTimeMs;
|
|
17
|
+
this.claimIntervalMs = claimIntervalMs;
|
|
18
|
+
this.minIdleTimeMs = minIdleTimeMs;
|
|
17
19
|
this.isRunning = false;
|
|
18
20
|
this.activeCount = 0;
|
|
19
21
|
this.events = new events_1.EventEmitter();
|
|
20
22
|
this.consumerId = (0, uuid_1.v7)();
|
|
21
23
|
this.events.setMaxListeners(100);
|
|
22
24
|
this.keys = new keys_1.KeyManager(streamName);
|
|
25
|
+
this.blockingRedis = this.redis.duplicate();
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* Start worker
|
|
@@ -39,15 +42,74 @@ class Worker {
|
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
this.fetchLoop();
|
|
45
|
+
this.autoClaimLoop();
|
|
42
46
|
}
|
|
43
47
|
async stop() {
|
|
44
48
|
this.isRunning = false;
|
|
45
|
-
this.events.emit('job_finished');
|
|
46
|
-
|
|
49
|
+
this.events.emit('job_finished');
|
|
50
|
+
if (this.blockingRedis) {
|
|
51
|
+
try {
|
|
52
|
+
await this.blockingRedis.quit();
|
|
53
|
+
}
|
|
54
|
+
catch (e) { }
|
|
55
|
+
}
|
|
47
56
|
while (this.activeCount > 0) {
|
|
48
57
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
49
58
|
}
|
|
50
59
|
}
|
|
60
|
+
async autoClaimLoop() {
|
|
61
|
+
while (this.isRunning) {
|
|
62
|
+
try {
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, this.claimIntervalMs));
|
|
64
|
+
if (!this.isRunning) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
let cursor = '0-0';
|
|
68
|
+
let continueClaiming = true;
|
|
69
|
+
while (continueClaiming && this.isRunning) {
|
|
70
|
+
const result = await this.redis.xautoclaim(this.streamName, this.groupName, this.consumerName(), this.minIdleTimeMs, cursor, 'COUNT', this.concurrency);
|
|
71
|
+
if (!result) {
|
|
72
|
+
continueClaiming = false;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const [nextCursor, messages] = result;
|
|
76
|
+
cursor = nextCursor;
|
|
77
|
+
if (messages && messages.length > 0) {
|
|
78
|
+
const pipeline = this.redis.pipeline();
|
|
79
|
+
for (const msg of messages) {
|
|
80
|
+
const entity = new stream_message_entity_1.StreamMessageEntity(msg);
|
|
81
|
+
pipeline.xack(this.streamName, this.groupName, entity.streamMessageId);
|
|
82
|
+
const statusKey = this.keys.getJobStatusKey(entity.messageUuid);
|
|
83
|
+
const timestamp = Date.now();
|
|
84
|
+
pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 2, statusKey, this.streamName, this.groupName, timestamp, entity.streamMessageId);
|
|
85
|
+
if (entity.retryCount < this.MAX_RETRIES) {
|
|
86
|
+
pipeline.xadd(this.streamName, '*', 'id', entity.messageUuid, 'target', entity.routes.join(','), 'retryCount', entity.retryCount + 1, 'data', entity.data ? JSON.stringify(entity.data) : '');
|
|
87
|
+
pipeline.hset(statusKey, '__target', entity.routes.length);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.error(`[${this.groupName}] Job ${entity.messageUuid} run outof retries (stuck). Moving to DLQ`);
|
|
91
|
+
// DLQ
|
|
92
|
+
pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', entity.messageUuid, 'group', this.groupName, 'error', 'Stuck message recovered max retries', 'payload', entity.data ? JSON.stringify(entity.data) : '', 'failedAt', Date.now());
|
|
93
|
+
pipeline.del(statusKey);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await pipeline.exec();
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
continueClaiming = false;
|
|
100
|
+
}
|
|
101
|
+
if (nextCursor === '0-0') {
|
|
102
|
+
continueClaiming = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (this.isRunning) {
|
|
108
|
+
console.error(`[${this.groupName}] auto claim err:`, e.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
51
113
|
async fetchLoop() {
|
|
52
114
|
while (this.isRunning) {
|
|
53
115
|
const freeSlots = this.concurrency - this.activeCount;
|
|
@@ -56,7 +118,7 @@ class Worker {
|
|
|
56
118
|
continue;
|
|
57
119
|
}
|
|
58
120
|
try {
|
|
59
|
-
const results = await this.
|
|
121
|
+
const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.consumerName(), 'COUNT', freeSlots, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
|
|
60
122
|
if (results) {
|
|
61
123
|
const messages = results[0][1];
|
|
62
124
|
for (const msg of messages) {
|
|
@@ -84,52 +146,35 @@ class Worker {
|
|
|
84
146
|
return;
|
|
85
147
|
}
|
|
86
148
|
try {
|
|
87
|
-
|
|
88
|
-
const payload = await this.redis.get(dataKey);
|
|
89
|
-
if (!payload) {
|
|
90
|
-
// Data missing or expired
|
|
91
|
-
await this.finalize(streamMessage.messageUuid, streamMessage.streamMessageId);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
await this.process(JSON.parse(payload));
|
|
149
|
+
await this.process(streamMessage.data);
|
|
95
150
|
await this.finalize(streamMessage.messageUuid, streamMessage.streamMessageId);
|
|
96
151
|
}
|
|
97
152
|
catch (err) {
|
|
98
153
|
console.error(`[${this.groupName}] Job failed ${streamMessage.messageUuid}`, err);
|
|
99
|
-
await this.handleFailure(streamMessage.messageUuid, streamMessage.streamMessageId, streamMessage.retryCount, err.message);
|
|
154
|
+
await this.handleFailure(streamMessage.messageUuid, streamMessage.streamMessageId, streamMessage.retryCount, err.message, streamMessage.data);
|
|
100
155
|
}
|
|
101
156
|
}
|
|
102
|
-
async handleFailure(uuid, msgId, currentRetries, errorMsg) {
|
|
103
|
-
//
|
|
157
|
+
async handleFailure(uuid, msgId, currentRetries, errorMsg, payloadData) {
|
|
158
|
+
// Ack
|
|
104
159
|
await this.redis.xack(this.streamName, this.groupName, msgId);
|
|
105
|
-
|
|
106
|
-
if (currentRetries < this.MAX_RETRIES) {
|
|
107
|
-
console.log(`[${this.groupName}] Retrying job ${uuid} (Attempt ${currentRetries + 1}/${this.MAX_RETRIES})`);
|
|
160
|
+
const payloadString = payloadData ? JSON.stringify(payloadData) : '';
|
|
161
|
+
if (currentRetries < this.MAX_RETRIES && payloadData) {
|
|
108
162
|
const pipeline = this.redis.pipeline();
|
|
109
|
-
|
|
110
|
-
pipeline.expire(this.keys.getJobDataKey(uuid), 3600);
|
|
111
|
-
pipeline.expire(this.keys.getJobStatusKey(uuid), 3600);
|
|
112
|
-
pipeline.xadd(this.streamName, '*', 'id', uuid, 'target', this.groupName, // Instead of all groups, target the failed one
|
|
113
|
-
'retryCount', currentRetries + 1);
|
|
163
|
+
pipeline.xadd(this.streamName, '*', 'id', uuid, 'target', this.groupName, 'retryCount', currentRetries + 1, 'data', payloadString);
|
|
114
164
|
await pipeline.exec();
|
|
115
165
|
}
|
|
116
166
|
else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const payload = await this.redis.get(this.keys.getJobDataKey(uuid));
|
|
121
|
-
await this.redis.xadd(this.keys.getDlqStreamKey(), '*', 'id', uuid, 'group', this.groupName, 'error', errorMsg, 'payload', payload || 'MISSING', 'failedAt', Date.now());
|
|
122
|
-
// Delete job from stream and mark it as "done"
|
|
123
|
-
await this.finalize(uuid, msgId, true);
|
|
167
|
+
console.error(`[${this.groupName}] Job ${uuid} run outof retries. Moving to DLQ`);
|
|
168
|
+
await this.redis.xadd(this.keys.getDlqStreamKey(), '*', 'id', uuid, 'group', this.groupName, 'error', errorMsg, 'payload', payloadString, 'failedAt', Date.now());
|
|
169
|
+
await this.finalize(uuid, msgId);
|
|
124
170
|
}
|
|
125
171
|
}
|
|
126
|
-
async finalize(messageUuid, msgId
|
|
172
|
+
async finalize(messageUuid, msgId) {
|
|
127
173
|
const timestamp = Date.now();
|
|
128
174
|
const statusKey = this.keys.getJobStatusKey(messageUuid);
|
|
129
|
-
const dataKey = this.keys.getJobDataKey(messageUuid);
|
|
130
175
|
const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
|
|
131
176
|
const totalKey = this.keys.getTotalKey(this.groupName);
|
|
132
|
-
await this.redis.eval(lua_1.LUA_MARK_DONE,
|
|
177
|
+
await this.redis.eval(lua_1.LUA_MARK_DONE, 5, statusKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, msgId);
|
|
133
178
|
}
|
|
134
179
|
consumerName() {
|
|
135
180
|
return `${this.groupName}-${process.pid}-${this.consumerId}`;
|