@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.
@@ -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
@@ -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 missingItemsCount = freeSlots * this.batchSize;
64
- const itemsCount = missingItemsCount > this.maxFetchSize ? this.maxFetchSize : missingItemsCount;
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
- // Get jobs data
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(messagesToFinalize);
186
+ await this.finalize(messages);
164
187
  }
165
188
  catch (err) {
166
- console.error(`[${this.groupName}] Jobs failed`, err);
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
- // 1. ACK the failed message - removes from stream later (or rather, confirms we processed this specific delivery)
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.retryCount < this.maxRetries) {
179
- // Retry
180
- console.log(`[${this.groupName}] Retrying job ${message.messageUuid} (Attempt ${message.retryCount + 1}/${this.maxRetries})`);
181
- // Refresh TTL
182
- pipeline.expire(this.keys.getJobDataKey(message.messageUuid), 3600);
183
- pipeline.expire(this.keys.getJobStatusKey(message.messageUuid), 3600);
184
- pipeline.xadd(this.streamName, '*', 'id', message.messageUuid, 'target', this.groupName, 'retryCount', message.retryCount + 1);
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
- // DLQ
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, 'payload', message.data ? JSON.stringify(message.data) : 'MISSING', 'failedAt', Date.now());
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
- const dataKey = this.keys.getJobDataKey(msg.messageUuid);
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
- super(redis, groupName, streamName, batchSize, concurrency, maxRetries, blockTimeMs);
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] = data key for job\n-- KEYS[3] = stream key\n-- KEYS[4] = group name\n-- KEYS[5] = metrics key\n-- KEYS[6] = 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[3], KEYS[4], 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[5])\n redis.call('EXPIRE', KEYS[5], 86400)\nend\n\n-- 3.1 Increment Total Metric\nif KEYS[6] then\n redis.call('INCR', KEYS[6])\nend\n\n-- 4. Check for completed routes\nlocal current_fields = redis.call('HLEN', KEYS[1])\n\n-- 5. 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-- 6. 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], KEYS[2])\n redis.call('XDEL', KEYS[3], 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] = data key\n-- KEYS[3] = 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], KEYS[2])\n redis.call('XDEL', KEYS[3], ARGV[3])\n return 1\nend\n\nreturn 0\n";
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] = data key for job
7
- -- KEYS[3] = stream key
8
- -- KEYS[4] = group name
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. Ack the stream message
17
- redis.call('XACK', KEYS[3], KEYS[4], ARGV[3])
15
+ -- 1 Ack the stream message
16
+ redis.call('XACK', KEYS[2], KEYS[3], ARGV[3])
18
17
 
19
- -- 2. in status key mark the current route as done by saving timestamp
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. Increment throughput metric
21
+ -- 3 Increment throughput metric
23
22
  if KEYS[5] then
24
- redis.call('INCR', KEYS[5])
25
- redis.call('EXPIRE', KEYS[5], 86400)
23
+ redis.call('INCR', KEYS[4])
24
+ redis.call('EXPIRE', KEYS[4], 86400)
26
25
  end
27
26
 
28
- -- 3.1 Increment Total Metric
29
- if KEYS[6] then
30
- redis.call('INCR', KEYS[6])
31
- end
27
+ -- 4 Increment Total Metric
28
+ redis.call('INCR', KEYS[5])
32
29
 
33
- -- 4. Check for completed routes
30
+ -- 5 Check for completed routes
34
31
  local current_fields = redis.call('HLEN', KEYS[1])
35
32
 
36
- -- 5. Get the target completed routes
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
- -- 6. If completed routes is status hash length - 1 -> all were done and we can cleanup
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], KEYS[2])
47
- redis.call('XDEL', KEYS[3], ARGV[3])
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] = data key
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], KEYS[2])
76
- redis.call('XDEL', KEYS[3], ARGV[3])
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
  }
@@ -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, 3, blockTimeMs);
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
- set data(data: T);
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
- constructor(redis: Redis, groupName: string, streamName: string, concurrency?: number, MAX_RETRIES?: number, blockTimeMs?: number);
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'); // Wake up fetch loop if it's waiting
46
- // Wait for active jobs to finish
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.redis.xreadgroup('GROUP', this.groupName, this.consumerName(), 'COUNT', freeSlots, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
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
- const dataKey = this.keys.getJobDataKey(streamMessage.messageUuid);
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
- // 1. ACK the failed message - removes from stream later
157
+ async handleFailure(uuid, msgId, currentRetries, errorMsg, payloadData) {
158
+ // Ack
104
159
  await this.redis.xack(this.streamName, this.groupName, msgId);
105
- // If current retries is lower than max retries, enque it back for another run
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
- // Refresh TTL to ensure data persists through retries (e.g., +1 hour)
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
- // If retries is larger than allowed, insert the job with all data to dead letter queue
118
- // 2b. DEAD LETTER QUEUE (DLQ)
119
- console.error(`[${this.groupName}] Job ${uuid} exhausted retries. Moving to DLQ.`);
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, fromError = false) {
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, 6, statusKey, dataKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, msgId);
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koala42/redis-highway",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "High performance redis queue",
5
5
  "license": "MIT",
6
6
  "author": {