@koala42/redis-highway 0.1.11 → 0.2.0

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.
@@ -1,246 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BatchWorker = void 0;
4
- const events_1 = require("events");
5
- const keys_1 = require("./keys");
6
- const stream_message_entity_1 = require("./stream-message-entity");
7
- const lua_1 = require("./lua");
8
- const uuid_1 = require("uuid");
9
- class BatchWorker {
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) {
16
- this.redis = redis;
17
- this.groupName = groupName;
18
- this.streamName = streamName;
19
- this.batchSize = batchSize;
20
- this.concurrency = concurrency;
21
- this.maxFetchSize = maxFetchSize;
22
- this.maxRetries = maxRetries;
23
- this.blockTimeMs = blockTimeMs;
24
- this.maxFetchCount = maxFetchCount;
25
- this.claimIntervalMs = claimIntervalMs;
26
- this.minIdleTimeMs = minIdleTimeMs;
27
- this.isRunning = false;
28
- this.activeCount = 0;
29
- this.events = new events_1.EventEmitter();
30
- this.consumerId = (0, uuid_1.v7)();
31
- if (batchSize < 1) {
32
- throw new Error('Batch size cannot be less then 0');
33
- }
34
- this.events.setMaxListeners(100);
35
- this.keys = new keys_1.KeyManager(streamName);
36
- this.blockingRedis = this.redis.duplicate();
37
- }
38
- async start() {
39
- if (this.isRunning) {
40
- return;
41
- }
42
- this.isRunning = true;
43
- try {
44
- await this.redis.xgroup('CREATE', this.streamName, this.groupName, '0', 'MKSTREAM');
45
- }
46
- catch (e) {
47
- if (!e.message.includes('BUSYGROUP')) {
48
- throw e;
49
- }
50
- }
51
- this.fetchLoop();
52
- this.autoClaimLoop();
53
- }
54
- async stop() {
55
- this.isRunning = false;
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
- }
65
- while (this.activeCount > 0) {
66
- await new Promise((resolve) => setTimeout(resolve, 50));
67
- }
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
- }
121
- async fetchLoop() {
122
- while (this.isRunning) {
123
- const freeSlots = this.concurrency - this.activeCount;
124
- if (freeSlots <= 0) {
125
- await new Promise((resolve) => this.events.once('job_finished', resolve));
126
- continue;
127
- }
128
- const calculatedCount = freeSlots * this.batchSize;
129
- const itemsCount = Math.min(calculatedCount, this.maxFetchCount);
130
- try {
131
- const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.getConsumerName(), 'COUNT', itemsCount, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
132
- if (!results) {
133
- continue;
134
- }
135
- const messages = results[0][1];
136
- for (let i = 0; i < messages.length; i += this.batchSize) {
137
- const chunk = messages.slice(i, i + this.batchSize);
138
- this.spawnWorker(chunk);
139
- }
140
- }
141
- catch (err) {
142
- if (this.isRunning) { // Quicker grace shutdown
143
- console.error(`[${this.groupName}] Fetch Error: `, err);
144
- await new Promise((resolve) => setTimeout(resolve, 1000));
145
- }
146
- }
147
- }
148
- }
149
- /**
150
- * Spawn worker for current processing
151
- * @param messages
152
- */
153
- spawnWorker(messages) {
154
- this.activeCount++;
155
- this.processInternal(messages).finally(() => {
156
- this.activeCount--;
157
- this.events.emit('job_finished');
158
- });
159
- }
160
- async processInternal(rawMessages) {
161
- const allMessages = rawMessages.map((msg) => new stream_message_entity_1.StreamMessageEntity(msg));
162
- const messages = []; // Messages to process
163
- const ignoredMessages = []; // Messages to ignore
164
- for (const message of allMessages) {
165
- if (message.routes.includes(this.groupName)) {
166
- messages.push(message);
167
- }
168
- else {
169
- ignoredMessages.push(message);
170
- }
171
- }
172
- // ACK ignored messages
173
- if (ignoredMessages.length) {
174
- const pipeline = this.redis.pipeline();
175
- for (const ignoredMessage of ignoredMessages) {
176
- pipeline.xack(this.streamName, this.groupName, ignoredMessage.streamMessageId);
177
- }
178
- await pipeline.exec();
179
- }
180
- if (!messages.length) {
181
- return;
182
- }
183
- const messagesData = messages.map((msg) => msg.data);
184
- try {
185
- await this.process(messagesData);
186
- await this.finalize(messages);
187
- }
188
- catch (err) {
189
- console.error(`[${this.groupName}] Processing failed`, err);
190
- await this.handleFailure(messages, err.message);
191
- }
192
- }
193
- async handleFailure(messages, errorMessage) {
194
- const pipeline = this.redis.pipeline();
195
- // ack
196
- for (const message of messages) {
197
- pipeline.xack(this.streamName, this.groupName, message.streamMessageId);
198
- }
199
- const messagesToDlq = [];
200
- for (const message of messages) {
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
- }
211
- }
212
- else {
213
- console.error(`[${this.groupName}] Job ${message.messageUuid} failed but not routed to this group. Moving to DLQ.`);
214
- messagesToDlq.push(message);
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());
216
- }
217
- }
218
- await pipeline.exec();
219
- if (messagesToDlq.length > 0) {
220
- await this.finalize(messagesToDlq);
221
- }
222
- }
223
- async finalize(messages) {
224
- if (messages.length === 0) {
225
- return;
226
- }
227
- const pipeline = this.redis.pipeline();
228
- const timestamp = Date.now();
229
- const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
230
- const totalKey = this.keys.getTotalKey(this.groupName);
231
- const ids = messages.map(m => m.streamMessageId);
232
- pipeline.xack(this.streamName, this.groupName, ...ids);
233
- pipeline.incrby(throughputKey, ids.length);
234
- pipeline.expire(throughputKey, 86400);
235
- pipeline.incrby(totalKey, ids.length);
236
- for (const msg of messages) {
237
- const statusKey = this.keys.getJobStatusKey(msg.messageUuid);
238
- pipeline.eval(lua_1.LUA_FINALIZE_COMPLEX, 2, statusKey, this.streamName, this.groupName, timestamp, msg.streamMessageId);
239
- }
240
- await pipeline.exec();
241
- }
242
- getConsumerName() {
243
- return `${this.groupName}-${process.pid}-${this.consumerId}`;
244
- }
245
- }
246
- exports.BatchWorker = BatchWorker;
@@ -1 +0,0 @@
1
- export {};
@@ -1,124 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const vitest_1 = require("vitest");
7
- const ioredis_1 = __importDefault(require("ioredis"));
8
- const producer_1 = require("./producer");
9
- const batch_worker_1 = require("./batch-worker");
10
- const uuid_1 = require("uuid");
11
- const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
12
- class TestBatchWorker extends batch_worker_1.BatchWorker {
13
- constructor(redis, groupName, streamName, batchSize = 10, concurrency = 1, maxRetries = 3, blockTimeMs = 100) {
14
- // Fix argument order: batchSize, concurrency, maxFetchSize, maxRetries, blockTimeMs
15
- super(redis, groupName, streamName, batchSize, concurrency, 20, maxRetries, blockTimeMs);
16
- this.processedBatches = [];
17
- this.shouldFail = false;
18
- this.failCount = 0;
19
- this.maxFails = 0;
20
- }
21
- async process(data) {
22
- if (this.shouldFail) {
23
- this.failCount++;
24
- if (this.maxFails > 0 && this.failCount > this.maxFails) {
25
- // Stop failing
26
- }
27
- else {
28
- throw new Error("Simulated Batch Failure");
29
- }
30
- }
31
- this.processedBatches.push(data);
32
- }
33
- }
34
- (0, vitest_1.describe)('Batch Worker Integration', () => {
35
- let redisProducer;
36
- let redisWorker;
37
- let producer;
38
- let streamName;
39
- let worker;
40
- (0, vitest_1.beforeEach)(() => {
41
- // Use separate connections to avoid XREAD BLOCK blocking the producer
42
- redisProducer = new ioredis_1.default(REDIS_URL);
43
- redisWorker = new ioredis_1.default(REDIS_URL);
44
- streamName = `test-batch-queue-${(0, uuid_1.v7)()}`;
45
- producer = new producer_1.Producer(redisProducer, streamName);
46
- });
47
- (0, vitest_1.afterEach)(async () => {
48
- if (worker)
49
- await worker.stop();
50
- // Wait a bit
51
- await new Promise(r => setTimeout(r, 100));
52
- // Cleanup
53
- if (redisProducer.status === 'ready') {
54
- const keys = await redisProducer.keys(`${streamName}*`);
55
- if (keys.length)
56
- await redisProducer.del(...keys);
57
- }
58
- redisProducer.disconnect();
59
- redisWorker.disconnect();
60
- });
61
- const waitFor = async (condition, timeout = 8000) => {
62
- const start = Date.now();
63
- while (Date.now() - start < timeout) {
64
- if (await condition())
65
- return true;
66
- await new Promise(r => setTimeout(r, 100));
67
- }
68
- return false;
69
- };
70
- (0, vitest_1.it)('Should process a batch of messages', async () => {
71
- worker = new TestBatchWorker(redisWorker, 'group-Batch', streamName, 5, 1);
72
- // Push messages BEFORE starting worker to ensure a full batch is available
73
- // BatchWorker uses '0' pointer for group creation, so it will pick up existing messages
74
- for (let i = 0; i < 5; i++) {
75
- await producer.push({ id: `msg-${i}` }, ['group-Batch']);
76
- }
77
- await worker.start();
78
- await waitFor(() => worker.processedBatches.length > 0);
79
- (0, vitest_1.expect)(worker.processedBatches.length).toBeGreaterThanOrEqual(1);
80
- // It might still split it if redis pagination behaves weirdly, but usually it grabs Count
81
- const allProcessed = worker.processedBatches.flat();
82
- (0, vitest_1.expect)(allProcessed.length).toBe(5);
83
- (0, vitest_1.expect)(worker.processedBatches[0].length).toBe(5);
84
- (0, vitest_1.expect)(worker.processedBatches[0].map(j => j.id)).toEqual(['msg-0', 'msg-1', 'msg-2', 'msg-3', 'msg-4']);
85
- });
86
- (0, vitest_1.it)('Should process multiple batches', async () => {
87
- worker = new TestBatchWorker(redisWorker, 'group-Batch-Multi', streamName, 2, 1);
88
- for (let i = 0; i < 4; i++) {
89
- await producer.push({ id: `msg-${i}` }, ['group-Batch-Multi']);
90
- }
91
- await worker.start();
92
- await waitFor(() => worker.processedBatches.flat().length === 4);
93
- (0, vitest_1.expect)(worker.processedBatches.length).toBe(2);
94
- });
95
- (0, vitest_1.it)('Should retry failed batches', async () => {
96
- worker = new TestBatchWorker(redisWorker, 'group-Batch-Retry', streamName, 5, 1, 3);
97
- worker.shouldFail = true;
98
- worker.maxFails = 1;
99
- await worker.start();
100
- await producer.push({ id: 'retry-1' }, ['group-Batch-Retry']);
101
- await waitFor(() => worker.processedBatches.length === 1);
102
- (0, vitest_1.expect)(worker.failCount).toBeGreaterThanOrEqual(1);
103
- (0, vitest_1.expect)(worker.processedBatches.length).toBe(1);
104
- (0, vitest_1.expect)(worker.processedBatches[0][0].id).toBe('retry-1');
105
- });
106
- (0, vitest_1.it)('Should move to DLQ after max retries', async () => {
107
- worker = new TestBatchWorker(redisWorker, 'group-Batch-DLQ', streamName, 1, 1, 2);
108
- worker.shouldFail = true;
109
- worker.maxFails = 10;
110
- await worker.start();
111
- const id = await producer.push({ id: 'dlq-1' }, ['group-Batch-DLQ']);
112
- await waitFor(async () => {
113
- const len = await redisProducer.xlen(`${streamName}:dlq`);
114
- return len > 0;
115
- }, 10000);
116
- const dlqLen = await redisProducer.xlen(`${streamName}:dlq`);
117
- (0, vitest_1.expect)(dlqLen).toBe(1);
118
- const dlqMsgs = await redisProducer.xrange(`${streamName}:dlq`, '-', '+');
119
- const body = dlqMsgs[0][1];
120
- const payloadIdx = body.indexOf('payload');
121
- const payload = body[payloadIdx + 1];
122
- (0, vitest_1.expect)(JSON.parse(payload)).toEqual({ id: 'dlq-1' });
123
- });
124
- });
@@ -1,2 +0,0 @@
1
- export type StreamMessage = [string, string[]];
2
- export type XReadGroupResponse = [string, StreamMessage[]][];
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/dist/lua.d.ts DELETED
@@ -1,2 +0,0 @@
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 DELETED
@@ -1,77 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LUA_FINALIZE_COMPLEX = exports.LUA_MARK_DONE = void 0;
4
- exports.LUA_MARK_DONE = `
5
- -- KEYS[1] = status key status key for jog
6
- -- KEYS[2] = stream key
7
- -- KEYS[3] = group name
8
- -- KEYS[4] = metrics key
9
- -- KEYS[5] = total metrics key(persistent)
10
-
11
- -- ARGV[1] = route name
12
- -- ARGV[2] = timestamp
13
- -- ARGV[3] = msgId - redis stream item ID
14
-
15
- -- 1 Ack the stream message
16
- redis.call('XACK', KEYS[2], KEYS[3], ARGV[3])
17
-
18
- -- 2 in status key mark the current route as done by saving timestamp
19
- redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
20
-
21
- -- 3 Increment throughput metric
22
- if KEYS[5] then
23
- redis.call('INCR', KEYS[4])
24
- redis.call('EXPIRE', KEYS[4], 86400)
25
- end
26
-
27
- -- 4 Increment Total Metric
28
- redis.call('INCR', KEYS[5])
29
-
30
- -- 5 Check for completed routes
31
- local current_fields = redis.call('HLEN', KEYS[1])
32
-
33
- -- 6 Get the target completed routes
34
- local target_str = redis.call('HGET', KEYS[1], '__target')
35
- local target = tonumber(target_str)
36
-
37
- if not target then
38
- return 0
39
- end
40
-
41
- -- 7 If completed routes is status hash length - 1 -> all were done and we can cleanup
42
- if current_fields >= (target + 1) then
43
- redis.call('DEL', KEYS[1]) -- Only delete status key
44
- redis.call('XDEL', KEYS[2], ARGV[3])
45
- return 1 -- Cleanup, DONE
46
- end
47
-
48
- return 0 -- Some routes are not done yet
49
- `;
50
- exports.LUA_FINALIZE_COMPLEX = `
51
- -- KEYS[1] = status key
52
- -- KEYS[2] = stream key
53
- -- ARGV[1] = group name
54
- -- ARGV[2] = timestamp
55
- -- ARGV[3] = msgId
56
-
57
- -- 1. Update status
58
- redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
59
-
60
- -- 2. Check completions
61
- local current_fields = redis.call('HLEN', KEYS[1])
62
- local target_str = redis.call('HGET', KEYS[1], '__target')
63
- local target = tonumber(target_str)
64
-
65
- if not target then
66
- return 0
67
- end
68
-
69
- -- 3. Cleanup if done
70
- if current_fields >= (target + 1) then
71
- redis.call('DEL', KEYS[1])
72
- redis.call('XDEL', KEYS[2], ARGV[3])
73
- return 1
74
- end
75
-
76
- return 0
77
- `;
package/dist/worker.d.ts DELETED
@@ -1,32 +0,0 @@
1
- import Redis from "ioredis";
2
- export declare abstract class Worker<T extends Record<string, unknown>> {
3
- protected redis: Redis;
4
- protected groupName: string;
5
- protected streamName: string;
6
- protected concurrency: number;
7
- protected MAX_RETRIES: number;
8
- protected blockTimeMs: number;
9
- protected claimIntervalMs: number;
10
- protected minIdleTimeMs: number;
11
- private isRunning;
12
- private activeCount;
13
- private readonly events;
14
- private keys;
15
- private consumerId;
16
- private blockingRedis;
17
- constructor(redis: Redis, groupName: string, streamName: string, concurrency?: number, MAX_RETRIES?: number, blockTimeMs?: number, claimIntervalMs?: number, minIdleTimeMs?: number);
18
- /**
19
- * Start worker
20
- * @returns
21
- */
22
- start(): Promise<void>;
23
- stop(): Promise<void>;
24
- private autoClaimLoop;
25
- private fetchLoop;
26
- private spawnWorker;
27
- private processInternal;
28
- private handleFailure;
29
- private finalize;
30
- private consumerName;
31
- abstract process(data: T): Promise<void>;
32
- }
package/dist/worker.js DELETED
@@ -1,183 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Worker = void 0;
4
- const events_1 = require("events");
5
- const lua_1 = require("./lua");
6
- const keys_1 = require("./keys");
7
- const stream_message_entity_1 = require("./stream-message-entity");
8
- const uuid_1 = require("uuid");
9
- class Worker {
10
- constructor(redis, groupName, streamName, concurrency = 1, MAX_RETRIES = 3, blockTimeMs = 2000, claimIntervalMs = 60000, minIdleTimeMs = 300000) {
11
- this.redis = redis;
12
- this.groupName = groupName;
13
- this.streamName = streamName;
14
- this.concurrency = concurrency;
15
- this.MAX_RETRIES = MAX_RETRIES;
16
- this.blockTimeMs = blockTimeMs;
17
- this.claimIntervalMs = claimIntervalMs;
18
- this.minIdleTimeMs = minIdleTimeMs;
19
- this.isRunning = false;
20
- this.activeCount = 0;
21
- this.events = new events_1.EventEmitter();
22
- this.consumerId = (0, uuid_1.v7)();
23
- this.events.setMaxListeners(100);
24
- this.keys = new keys_1.KeyManager(streamName);
25
- this.blockingRedis = this.redis.duplicate();
26
- }
27
- /**
28
- * Start worker
29
- * @returns
30
- */
31
- async start() {
32
- if (this.isRunning) {
33
- return;
34
- }
35
- this.isRunning = true;
36
- try {
37
- await this.redis.xgroup('CREATE', this.streamName, this.groupName, '0', 'MKSTREAM');
38
- }
39
- catch (e) {
40
- if (!e.message.includes('BUSYGROUP')) {
41
- throw e;
42
- }
43
- }
44
- this.fetchLoop();
45
- this.autoClaimLoop();
46
- }
47
- async stop() {
48
- this.isRunning = false;
49
- this.events.emit('job_finished');
50
- if (this.blockingRedis) {
51
- try {
52
- await this.blockingRedis.quit();
53
- }
54
- catch (e) { }
55
- }
56
- while (this.activeCount > 0) {
57
- await new Promise(resolve => setTimeout(resolve, 50));
58
- }
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
- }
113
- async fetchLoop() {
114
- while (this.isRunning) {
115
- const freeSlots = this.concurrency - this.activeCount;
116
- if (freeSlots <= 0) {
117
- await new Promise((resolve) => this.events.once('job_finished', resolve));
118
- continue;
119
- }
120
- try {
121
- const results = await this.blockingRedis.xreadgroup('GROUP', this.groupName, this.consumerName(), 'COUNT', freeSlots, 'BLOCK', this.blockTimeMs, 'STREAMS', this.streamName, '>');
122
- if (results) {
123
- const messages = results[0][1];
124
- for (const msg of messages) {
125
- this.spawnWorker(msg);
126
- }
127
- }
128
- }
129
- catch (err) {
130
- console.error(`[${this.groupName}] Fetch Error:`, err);
131
- await new Promise((resolve) => setTimeout(resolve, 1000));
132
- }
133
- }
134
- }
135
- spawnWorker(msg) {
136
- this.activeCount++;
137
- this.processInternal(msg).finally(() => {
138
- this.activeCount--;
139
- this.events.emit('job_finished');
140
- });
141
- }
142
- async processInternal(msg) {
143
- const streamMessage = new stream_message_entity_1.StreamMessageEntity(msg);
144
- if (!streamMessage.routes.includes(this.groupName)) {
145
- await this.redis.xack(this.streamName, this.groupName, streamMessage.streamMessageId);
146
- return;
147
- }
148
- try {
149
- await this.process(streamMessage.data);
150
- await this.finalize(streamMessage.messageUuid, streamMessage.streamMessageId);
151
- }
152
- catch (err) {
153
- console.error(`[${this.groupName}] Job failed ${streamMessage.messageUuid}`, err);
154
- await this.handleFailure(streamMessage.messageUuid, streamMessage.streamMessageId, streamMessage.retryCount, err.message, streamMessage.data);
155
- }
156
- }
157
- async handleFailure(uuid, msgId, currentRetries, errorMsg, payloadData) {
158
- // Ack
159
- await this.redis.xack(this.streamName, this.groupName, msgId);
160
- const payloadString = payloadData ? JSON.stringify(payloadData) : '';
161
- if (currentRetries < this.MAX_RETRIES && payloadData) {
162
- const pipeline = this.redis.pipeline();
163
- pipeline.xadd(this.streamName, '*', 'id', uuid, 'target', this.groupName, 'retryCount', currentRetries + 1, 'data', payloadString);
164
- await pipeline.exec();
165
- }
166
- else {
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);
170
- }
171
- }
172
- async finalize(messageUuid, msgId) {
173
- const timestamp = Date.now();
174
- const statusKey = this.keys.getJobStatusKey(messageUuid);
175
- const throughputKey = this.keys.getThroughputKey(this.groupName, timestamp);
176
- const totalKey = this.keys.getTotalKey(this.groupName);
177
- await this.redis.eval(lua_1.LUA_MARK_DONE, 5, statusKey, this.streamName, this.groupName, throughputKey, totalKey, this.groupName, timestamp, msgId);
178
- }
179
- consumerName() {
180
- return `${this.groupName}-${process.pid}-${this.consumerId}`;
181
- }
182
- }
183
- exports.Worker = Worker;