@koala42/redis-highway 0.1.8 → 0.1.10

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.
@@ -5,16 +5,27 @@ export declare abstract class BatchWorker<T extends Record<string, unknown>> {
5
5
  protected streamName: string;
6
6
  protected batchSize: number;
7
7
  protected concurrency: number;
8
+ protected maxFetchSize: number;
8
9
  protected maxRetries: number;
9
10
  protected blockTimeMs: number;
11
+ protected maxFetchCount: number;
12
+ protected claimIntervalMs: number;
13
+ protected minIdleTimeMs: number;
10
14
  private isRunning;
11
15
  private activeCount;
12
- private readonly events;
13
16
  private keys;
17
+ private blockingRedis;
18
+ private readonly events;
14
19
  private readonly consumerId;
15
- constructor(redis: Redis, groupName: string, streamName: string, batchSize?: number, concurrency?: number, maxRetries?: number, blockTimeMs?: number);
20
+ constructor(redis: Redis, groupName: string, streamName: string, batchSize?: number, // How many jobs are passed to the process function (max)
21
+ concurrency?: number, // How many concurrent loops should run
22
+ maxFetchSize?: number, // How many jobs are fetched at once from redis stream
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);
16
26
  start(): Promise<void>;
17
27
  stop(): Promise<void>;
28
+ private autoClaimLoop;
18
29
  private fetchLoop;
19
30
  /**
20
31
  * Spawn worker for current processing
@@ -7,14 +7,23 @@ const stream_message_entity_1 = require("./stream-message-entity");
7
7
  const lua_1 = require("./lua");
8
8
  const uuid_1 = require("uuid");
9
9
  class BatchWorker {
10
- constructor(redis, groupName, streamName, batchSize = 10, concurrency = 1, maxRetries = 3, blockTimeMs = 2000) {
10
+ constructor(redis, groupName, streamName, batchSize = 10, // How many jobs are passed to the process function (max)
11
+ concurrency = 1, // How many concurrent loops should run
12
+ maxFetchSize = 20, // How many jobs are fetched at once from redis stream
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) {
11
16
  this.redis = redis;
12
17
  this.groupName = groupName;
13
18
  this.streamName = streamName;
14
19
  this.batchSize = batchSize;
15
20
  this.concurrency = concurrency;
21
+ this.maxFetchSize = maxFetchSize;
16
22
  this.maxRetries = maxRetries;
17
23
  this.blockTimeMs = blockTimeMs;
24
+ this.maxFetchCount = maxFetchCount;
25
+ this.claimIntervalMs = claimIntervalMs;
26
+ this.minIdleTimeMs = minIdleTimeMs;
18
27
  this.isRunning = false;
19
28
  this.activeCount = 0;
20
29
  this.events = new events_1.EventEmitter();
@@ -24,6 +33,7 @@ class BatchWorker {
24
33
  }
25
34
  this.events.setMaxListeners(100);
26
35
  this.keys = new keys_1.KeyManager(streamName);
36
+ this.blockingRedis = this.redis.duplicate();
27
37
  }
28
38
  async start() {
29
39
  if (this.isRunning) {
@@ -39,14 +49,62 @@ class BatchWorker {
39
49
  }
40
50
  }
41
51
  this.fetchLoop();
52
+ this.autoClaimLoop();
42
53
  }
43
54
  async stop() {
44
55
  this.isRunning = false;
45
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
+ }
46
65
  while (this.activeCount > 0) {
47
66
  await new Promise((resolve) => setTimeout(resolve, 50));
48
67
  }
49
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
+ console.log(`[${this.groupName}] Recovered ${messages.length} stuck messages`);
88
+ if (this.activeCount < this.concurrency) {
89
+ continueClaiming = false;
90
+ }
91
+ this.spawnWorker(messages);
92
+ }
93
+ else {
94
+ continueClaiming = false;
95
+ }
96
+ if (nextCursor === '0-0') {
97
+ continueClaiming = false;
98
+ }
99
+ }
100
+ }
101
+ catch (e) {
102
+ if (this.isRunning) {
103
+ console.error(`[${this.groupName}] Auto claim err:`, e.message);
104
+ }
105
+ }
106
+ }
107
+ }
50
108
  async fetchLoop() {
51
109
  while (this.isRunning) {
52
110
  const freeSlots = this.concurrency - this.activeCount;
@@ -54,9 +112,10 @@ class BatchWorker {
54
112
  await new Promise((resolve) => this.events.once('job_finished', resolve));
55
113
  continue;
56
114
  }
57
- const itemsCount = freeSlots * this.batchSize;
115
+ const calculatedCount = freeSlots * this.batchSize;
116
+ const itemsCount = Math.min(calculatedCount, this.maxFetchCount);
58
117
  try {
59
- const results = await this.redis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
118
+ const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
60
119
  if (!results) {
61
120
  continue;
62
121
  }
@@ -67,8 +126,10 @@ class BatchWorker {
67
126
  }
68
127
  }
69
128
  catch (err) {
70
- console.error(`[${this.groupName}] Fetch Error: `, err);
71
- await new Promise((resolve) => setTimeout(resolve, 1000));
129
+ if (this.isRunning) { // Quicker grace shutdown
130
+ console.error(`[${this.groupName}] Fetch Error: `, err);
131
+ await new Promise((resolve) => setTimeout(resolve, 1000));
132
+ }
72
133
  }
73
134
  }
74
135
  }
@@ -103,79 +164,43 @@ class BatchWorker {
103
164
  }
104
165
  await pipeline.exec();
105
166
  }
106
- // If no messsages need to be process, return. Fires job finished event for another loop to pickup next logs
107
167
  if (!messages.length) {
108
168
  return;
109
169
  }
110
- // Get jobs data
111
- const pipeline = this.redis.pipeline();
112
- for (const message of messages) {
113
- pipeline.get(this.keys.getJobDataKey(message.messageUuid));
114
- }
115
- const response = await pipeline.exec();
116
- // TODO: Add error handling
117
- if (!response) {
118
- return;
119
- }
120
- // Parse job data into message entities (lol, titties)
121
- messages.forEach((message, index) => {
122
- const foundData = response[index] || null;
123
- if (!foundData) {
124
- return;
125
- }
126
- const [error, data] = foundData;
127
- if (error) {
128
- console.error(`[${this.groupName}] Failed getting job data err: `, error);
129
- return;
130
- }
131
- if (!data) {
132
- console.error(`[${this.groupName}] Data not found for job`);
133
- return;
134
- }
135
- message.data = JSON.parse(data);
136
- });
137
- const messagesData = [];
138
- const messagesToFinalize = [];
139
- messages.forEach((message) => {
140
- messagesToFinalize.push(message);
141
- if (message.data) {
142
- messagesData.push(message.data);
143
- }
144
- });
145
- // TODO improve error handling
146
- if (!messagesData.length) {
147
- return;
148
- }
170
+ const messagesData = messages.map((msg) => msg.data);
149
171
  try {
150
172
  await this.process(messagesData);
151
- await this.finalize(messagesToFinalize);
173
+ await this.finalize(messages);
152
174
  }
153
175
  catch (err) {
154
- console.error(`[${this.groupName}] Jobs failed`, err);
176
+ console.error(`[${this.groupName}] Processing failed`, err);
155
177
  await this.handleFailure(messages, err.message);
156
178
  }
157
179
  }
158
180
  async handleFailure(messages, errorMessage) {
159
181
  const pipeline = this.redis.pipeline();
160
- // 1. ACK the failed message - removes from stream later (or rather, confirms we processed this specific delivery)
182
+ // ack
161
183
  for (const message of messages) {
162
184
  pipeline.xack(this.streamName, this.groupName, message.streamMessageId);
163
185
  }
164
186
  const messagesToDlq = [];
165
187
  for (const message of messages) {
166
- if (message.retryCount < this.maxRetries) {
167
- // Retry
168
- console.log(`[${this.groupName}] Retrying job ${message.messageUuid} (Attempt ${message.retryCount + 1}/${this.maxRetries})`);
169
- // Refresh TTL
170
- pipeline.expire(this.keys.getJobDataKey(message.messageUuid), 3600);
171
- pipeline.expire(this.keys.getJobStatusKey(message.messageUuid), 3600);
172
- pipeline.xadd(this.streamName, '*', 'id', message.messageUuid, 'target', this.groupName, 'retryCount', message.retryCount + 1);
188
+ if (message.routes.includes(this.groupName)) {
189
+ if (message.retryCount < this.maxRetries && message.data) {
190
+ console.log(`[${this.groupName}] Retrying job ${message.messageUuid} attempt ${message.retryCount + 1}/${this.maxRetries}`);
191
+ const payloadString = JSON.stringify(message.data);
192
+ pipeline.xadd(this.streamName, '*', 'id', message.messageUuid, 'target', this.groupName, 'retryCount', message.retryCount + 1, 'data', payloadString);
193
+ }
194
+ else {
195
+ console.error(`[${this.groupName}] Job ${message.messageUuid} run out of retries. Moving to DLQ`);
196
+ messagesToDlq.push(message);
197
+ pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', message.messageUuid, 'group', this.groupName, 'error', errorMessage, 'payload', message.data ? JSON.stringify(message.data) : 'MISSING', 'failedAt', Date.now());
198
+ }
173
199
  }
174
200
  else {
175
- // DLQ
176
- console.error(`[${this.groupName}] Job ${message.messageUuid} exhausted retries. Moving to DLQ.`);
201
+ console.error(`[${this.groupName}] Job ${message.messageUuid} failed but not routed to this group. Moving to DLQ.`);
177
202
  messagesToDlq.push(message);
178
- pipeline.xadd(this.keys.getDlqStreamKey(), '*', 'id', message.messageUuid, 'group', this.groupName, 'error', errorMessage, 'payload', message.data ? JSON.stringify(message.data) : 'MISSING', 'failedAt', Date.now());
203
+ 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());
179
204
  }
180
205
  }
181
206
  await pipeline.exec();
@@ -184,26 +209,21 @@ class BatchWorker {
184
209
  }
185
210
  }
186
211
  async finalize(messages) {
187
- if (messages.length === 0)
212
+ if (messages.length === 0) {
188
213
  return;
214
+ }
189
215
  const pipeline = this.redis.pipeline();
190
216
  const timestamp = Date.now();
191
217
  const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
192
218
  const totalKey = this.keys.getTotalKey(this.groupName);
193
- // 1. Batch xacks
194
219
  const ids = messages.map(m => m.streamMessageId);
195
220
  pipeline.xack(this.streamName, this.groupName, ...ids);
196
- // 2. Batch metrics
197
221
  pipeline.incrby(throughputKey, ids.length);
198
222
  pipeline.expire(throughputKey, 86400);
199
223
  pipeline.incrby(totalKey, ids.length);
200
- // Lua scripts to only check if data should be deleted
201
224
  for (const msg of messages) {
202
225
  const statusKey = this.keys.getJobStatusKey(msg.messageUuid);
203
- const dataKey = this.keys.getJobDataKey(msg.messageUuid);
204
- pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 3, statusKey, dataKey, this.streamName, // Keys
205
- this.groupName, timestamp, msg.streamMessageId // args
206
- );
226
+ pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 2, statusKey, this.streamName, this.groupName, timestamp, msg.streamMessageId);
207
227
  }
208
228
  await pipeline.exec();
209
229
  }
@@ -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/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,33 @@ 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
+ const len = await redis.xlen(streamName);
233
+ (0, vitest_1.expect)(len).toBe(0);
234
+ });
206
235
  });
@@ -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,17 @@ 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
+ try {
18
+ this._data = JSON.parse(this._fields['data']);
19
+ }
20
+ catch (e) {
21
+ // Handle corrupt or missing data gracefully
22
+ // We can set it to null (need to update type to T | null) or a dummy.
23
+ // Since strict T is expected, we might have to cast or throw controlled error.
24
+ // For now, let's assume T can be null-ish or cast. But getter says T.
25
+ // Let's coerce to {} as any to avoid crash, let validation downstream handle it.
26
+ this._data = {};
27
+ }
21
28
  }
22
29
  get data() {
23
30
  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,59 @@ 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
+ console.log(`[${this.groupName}] Recovered ${messages.length} stuck messages`);
79
+ for (const msg of messages) {
80
+ this.spawnWorker(msg);
81
+ }
82
+ }
83
+ else {
84
+ continueClaiming = false;
85
+ }
86
+ if (nextCursor === '0-0') {
87
+ continueClaiming = false;
88
+ }
89
+ }
90
+ }
91
+ catch (e) {
92
+ if (this.isRunning) {
93
+ console.error(`[${this.groupName}] auto claim err:`, e.message);
94
+ }
95
+ }
96
+ }
97
+ }
51
98
  async fetchLoop() {
52
99
  while (this.isRunning) {
53
100
  const freeSlots = this.concurrency - this.activeCount;
@@ -56,7 +103,7 @@ class Worker {
56
103
  continue;
57
104
  }
58
105
  try {
59
- const results = await this.redis.xreadgroup('GROUP', this.groupName, this.consumerName(), 'COUNT', freeSlots, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
106
+ const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.consumerName(), 'COUNT', freeSlots, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
60
107
  if (results) {
61
108
  const messages = results[0][1];
62
109
  for (const msg of messages) {
@@ -84,52 +131,37 @@ class Worker {
84
131
  return;
85
132
  }
86
133
  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));
134
+ await this.process(streamMessage.data);
95
135
  await this.finalize(streamMessage.messageUuid, streamMessage.streamMessageId);
96
136
  }
97
137
  catch (err) {
98
138
  console.error(`[${this.groupName}] Job failed ${streamMessage.messageUuid}`, err);
99
- await this.handleFailure(streamMessage.messageUuid, streamMessage.streamMessageId, streamMessage.retryCount, err.message);
139
+ await this.handleFailure(streamMessage.messageUuid, streamMessage.streamMessageId, streamMessage.retryCount, err.message, streamMessage.data);
100
140
  }
101
141
  }
102
- async handleFailure(uuid, msgId, currentRetries, errorMsg) {
103
- // 1. ACK the failed message - removes from stream later
142
+ async handleFailure(uuid, msgId, currentRetries, errorMsg, payloadData) {
143
+ // Ack
104
144
  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) {
145
+ const payloadString = payloadData ? JSON.stringify(payloadData) : '';
146
+ if (currentRetries < this.MAX_RETRIES && payloadData) {
107
147
  console.log(`[${this.groupName}] Retrying job ${uuid} (Attempt ${currentRetries + 1}/${this.MAX_RETRIES})`);
108
148
  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);
149
+ pipeline.xadd(this.streamName, '*', 'id', uuid, 'target', this.groupName, 'retryCount', currentRetries + 1, 'data', payloadString);
114
150
  await pipeline.exec();
115
151
  }
116
152
  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);
153
+ console.error(`[${this.groupName}] Job ${uuid} run outof retries. Moving to DLQ`);
154
+ await this.redis.xadd(this.keys.getDlqStreamKey(), '*', 'id', uuid, 'group', this.groupName, 'error', errorMsg, 'payload', payloadString, 'failedAt', Date.now());
155
+ await this.finalize(uuid, msgId);
124
156
  }
125
157
  }
126
- async finalize(messageUuid, msgId, fromError = false) {
158
+ async finalize(messageUuid, msgId) {
127
159
  const timestamp = Date.now();
128
160
  const statusKey = this.keys.getJobStatusKey(messageUuid);
129
161
  const dataKey = this.keys.getJobDataKey(messageUuid);
130
162
  const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
131
163
  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);
164
+ await this.redis.eval(lua_1.LUA_MARK_DONE, 5, statusKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, msgId);
133
165
  }
134
166
  consumerName() {
135
167
  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.8",
3
+ "version": "0.1.10",
4
4
  "description": "High performance redis queue",
5
5
  "license": "MIT",
6
6
  "author": {