@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.
- package/dist/batch-worker.d.ts +8 -2
- package/dist/batch-worker.js +50 -24
- package/dist/lua.d.ts +1 -0
- package/dist/lua.js +30 -1
- package/dist/queue.spec.js +2 -2
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +9 -2
- package/package.json +1 -1
package/dist/batch-worker.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/batch-worker.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
63
|
+
const missingItemsCount = freeSlots * this.batchSize;
|
|
64
|
+
const itemsCount = missingItemsCount > this.maxFetchSize ? this.maxFetchSize : missingItemsCount;
|
|
56
65
|
try {
|
|
57
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
`;
|
package/dist/queue.spec.js
CHANGED
|
@@ -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;
|