@koala42/redis-highway 0.1.7 → 0.1.9

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,13 +5,19 @@ 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;
10
11
  private isRunning;
11
12
  private activeCount;
12
- private readonly events;
13
13
  private keys;
14
- constructor(redis: Redis, groupName: string, streamName: string, batchSize?: number, concurrency?: number, maxRetries?: number, blockTimeMs?: number);
14
+ private blockingRedis;
15
+ private readonly events;
16
+ private readonly consumerId;
17
+ constructor(redis: Redis, groupName: string, streamName: string, batchSize?: number, // How many jobs are passed to the process function (max)
18
+ concurrency?: number, // How many concurrent loops should run
19
+ maxFetchSize?: number, // How many jobs are fetched at once from redis stream
20
+ maxRetries?: number, blockTimeMs?: number);
15
21
  start(): Promise<void>;
16
22
  stop(): Promise<void>;
17
23
  private fetchLoop;
@@ -5,18 +5,25 @@ const events_1 = require("events");
5
5
  const keys_1 = require("./keys");
6
6
  const stream_message_entity_1 = require("./stream-message-entity");
7
7
  const lua_1 = require("./lua");
8
+ const uuid_1 = require("uuid");
8
9
  class BatchWorker {
9
- 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) {
10
14
  this.redis = redis;
11
15
  this.groupName = groupName;
12
16
  this.streamName = streamName;
13
17
  this.batchSize = batchSize;
14
18
  this.concurrency = concurrency;
19
+ this.maxFetchSize = maxFetchSize;
15
20
  this.maxRetries = maxRetries;
16
21
  this.blockTimeMs = blockTimeMs;
17
22
  this.isRunning = false;
18
23
  this.activeCount = 0;
24
+ this.blockingRedis = null;
19
25
  this.events = new events_1.EventEmitter();
26
+ this.consumerId = (0, uuid_1.v7)();
20
27
  if (batchSize < 1) {
21
28
  throw new Error('Batch size cannot be less then 0');
22
29
  }
@@ -36,14 +43,15 @@ class BatchWorker {
36
43
  throw e;
37
44
  }
38
45
  }
46
+ this.blockingRedis = this.redis.duplicate();
39
47
  this.fetchLoop();
40
48
  }
41
- // TODO: implement waiting for runnnig jobs
42
49
  async stop() {
43
- return new Promise((resolve) => {
44
- this.isRunning = false;
45
- resolve();
46
- });
50
+ this.isRunning = false;
51
+ this.events.emit('job_finished');
52
+ while (this.activeCount > 0) {
53
+ await new Promise((resolve) => setTimeout(resolve, 50));
54
+ }
47
55
  }
48
56
  async fetchLoop() {
49
57
  while (this.isRunning) {
@@ -52,9 +60,13 @@ class BatchWorker {
52
60
  await new Promise((resolve) => this.events.once('job_finished', resolve));
53
61
  continue;
54
62
  }
55
- const itemsCount = freeSlots * this.batchSize;
63
+ const missingItemsCount = freeSlots * this.batchSize;
64
+ const itemsCount = missingItemsCount > this.maxFetchSize ? this.maxFetchSize : missingItemsCount;
56
65
  try {
57
- const results = await this.redis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
66
+ if (!this.blockingRedis) {
67
+ throw new Error('Blocking Redis connection missing');
68
+ }
69
+ const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
58
70
  if (!results) {
59
71
  continue;
60
72
  }
@@ -65,8 +77,10 @@ class BatchWorker {
65
77
  }
66
78
  }
67
79
  catch (err) {
68
- console.error(`[${this.groupName}] Fetch Error: `, err);
69
- await new Promise((resolve) => setTimeout(resolve, 1000));
80
+ if (this.isRunning) { // Quicker grace shutdown
81
+ console.error(`[${this.groupName}] Fetch Error: `, err);
82
+ await new Promise((resolve) => setTimeout(resolve, 1000));
83
+ }
70
84
  }
71
85
  }
72
86
  }
@@ -132,19 +146,21 @@ class BatchWorker {
132
146
  }
133
147
  message.data = JSON.parse(data);
134
148
  });
135
- const messagesData = messages.reduce((acc, current) => {
136
- if (current.data) {
137
- acc.push(current.data);
149
+ const messagesData = [];
150
+ const messagesToFinalize = [];
151
+ messages.forEach((message) => {
152
+ messagesToFinalize.push(message);
153
+ if (message.data) {
154
+ messagesData.push(message.data);
138
155
  }
139
- return acc;
140
- }, []);
156
+ });
141
157
  // TODO improve error handling
142
158
  if (!messagesData.length) {
143
159
  return;
144
160
  }
145
161
  try {
146
162
  await this.process(messagesData);
147
- await this.finalize(messages);
163
+ await this.finalize(messagesToFinalize);
148
164
  }
149
165
  catch (err) {
150
166
  console.error(`[${this.groupName}] Jobs failed`, err);
@@ -180,19 +196,29 @@ class BatchWorker {
180
196
  }
181
197
  }
182
198
  async finalize(messages) {
199
+ if (messages.length === 0)
200
+ return;
183
201
  const pipeline = this.redis.pipeline();
184
- for (const message of messages) {
185
- const timestamp = Date.now();
186
- const statusKey = this.keys.getJobStatusKey(message.messageUuid);
187
- const dataKey = this.keys.getJobDataKey(message.messageUuid);
188
- const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
189
- const totalKey = this.keys.getTotalKey(this.groupName);
190
- pipeline.eval(lua_1.LUA_MARK_DONE, 6, statusKey, dataKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, message.streamMessageId);
202
+ const timestamp = Date.now();
203
+ const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
204
+ const totalKey = this.keys.getTotalKey(this.groupName);
205
+ // 1. Batch xacks
206
+ const ids = messages.map(m => m.streamMessageId);
207
+ pipeline.xack(this.streamName, this.groupName, ...ids);
208
+ // 2. Batch metrics
209
+ pipeline.incrby(throughputKey, ids.length);
210
+ pipeline.expire(throughputKey, 86400);
211
+ pipeline.incrby(totalKey, ids.length);
212
+ // Lua scripts to only check if data should be deleted
213
+ for (const msg of messages) {
214
+ 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);
191
217
  }
192
218
  await pipeline.exec();
193
219
  }
194
220
  getConsumerName() {
195
- return `${this.groupName}-${process.pid}`;
221
+ return `${this.groupName}-${process.pid}-${this.consumerId}`;
196
222
  }
197
223
  }
198
224
  exports.BatchWorker = BatchWorker;
package/dist/lua.d.ts CHANGED
@@ -1 +1,2 @@
1
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";
package/dist/lua.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LUA_MARK_DONE = void 0;
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
6
  -- KEYS[2] = data key for job
@@ -50,3 +50,32 @@ end
50
50
 
51
51
  return 0 -- Some routes are not done yet
52
52
  `;
53
+ exports.LUA_FINALIZE_COMPLEX = `
54
+ -- KEYS[1] = status key
55
+ -- KEYS[2] = data key
56
+ -- KEYS[3] = stream key
57
+ -- ARGV[1] = group name
58
+ -- ARGV[2] = timestamp
59
+ -- ARGV[3] = msgId
60
+
61
+ -- 1. Update status
62
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
63
+
64
+ -- 2. Check completions
65
+ local current_fields = redis.call('HLEN', KEYS[1])
66
+ local target_str = redis.call('HGET', KEYS[1], '__target')
67
+ local target = tonumber(target_str)
68
+
69
+ if not target then
70
+ return 0
71
+ end
72
+
73
+ -- 3. Cleanup if done
74
+ if current_fields >= (target + 1) then
75
+ redis.call('DEL', KEYS[1], KEYS[2])
76
+ redis.call('XDEL', KEYS[3], ARGV[3])
77
+ return 1
78
+ end
79
+
80
+ return 0
81
+ `;
@@ -12,7 +12,7 @@ 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
14
  constructor(redis, groupName, streamName, concurrency = 1, blockTimeMs = 100) {
15
- super(redis, groupName, streamName, concurrency, blockTimeMs);
15
+ super(redis, groupName, streamName, concurrency, 3, blockTimeMs);
16
16
  this.processedCount = 0;
17
17
  this.lastProcessedId = null;
18
18
  this.shouldFail = false;
@@ -48,7 +48,7 @@ class TestWorker extends worker_1.Worker {
48
48
  });
49
49
  (0, vitest_1.afterEach)(async () => {
50
50
  for (const w of workers) {
51
- w.stop();
51
+ await w.stop();
52
52
  }
53
53
  await new Promise(r => setTimeout(r, 500));
54
54
  // Cleanup Redis keys using the existing connection before closing
package/dist/worker.d.ts CHANGED
@@ -10,13 +10,14 @@ export declare abstract class Worker<T extends Record<string, unknown>> {
10
10
  private activeCount;
11
11
  private readonly events;
12
12
  private keys;
13
+ private consumerId;
13
14
  constructor(redis: Redis, groupName: string, streamName: string, concurrency?: number, MAX_RETRIES?: number, blockTimeMs?: number);
14
15
  /**
15
16
  * Start worker
16
17
  * @returns
17
18
  */
18
19
  start(): Promise<void>;
19
- stop(): void;
20
+ stop(): Promise<void>;
20
21
  private fetchLoop;
21
22
  private spawnWorker;
22
23
  private processInternal;
package/dist/worker.js CHANGED
@@ -5,6 +5,7 @@ const events_1 = require("events");
5
5
  const lua_1 = require("./lua");
6
6
  const keys_1 = require("./keys");
7
7
  const stream_message_entity_1 = require("./stream-message-entity");
8
+ const uuid_1 = require("uuid");
8
9
  class Worker {
9
10
  constructor(redis, groupName, streamName, concurrency = 1, MAX_RETRIES = 3, blockTimeMs = 2000) {
10
11
  this.redis = redis;
@@ -16,6 +17,7 @@ class Worker {
16
17
  this.isRunning = false;
17
18
  this.activeCount = 0;
18
19
  this.events = new events_1.EventEmitter();
20
+ this.consumerId = (0, uuid_1.v7)();
19
21
  this.events.setMaxListeners(100);
20
22
  this.keys = new keys_1.KeyManager(streamName);
21
23
  }
@@ -38,8 +40,13 @@ class Worker {
38
40
  }
39
41
  this.fetchLoop();
40
42
  }
41
- stop() {
43
+ async stop() {
42
44
  this.isRunning = false;
45
+ this.events.emit('job_finished'); // Wake up fetch loop if it's waiting
46
+ // Wait for active jobs to finish
47
+ while (this.activeCount > 0) {
48
+ await new Promise(resolve => setTimeout(resolve, 50));
49
+ }
43
50
  }
44
51
  async fetchLoop() {
45
52
  while (this.isRunning) {
@@ -125,7 +132,7 @@ class Worker {
125
132
  await this.redis.eval(lua_1.LUA_MARK_DONE, 6, statusKey, dataKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, msgId);
126
133
  }
127
134
  consumerName() {
128
- return `${this.groupName}-${process.pid}`;
135
+ return `${this.groupName}-${process.pid}-${this.consumerId}`;
129
136
  }
130
137
  }
131
138
  exports.Worker = Worker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koala42/redis-highway",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "High performance redis queue",
5
5
  "license": "MIT",
6
6
  "author": {