power-queues 2.0.3 → 2.0.5

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/index.js CHANGED
@@ -1,765 +1,767 @@
1
- // src/PowerQueue.ts
2
- import { v4 as uuid } from "uuid";
3
- import {
4
- isStrFilled,
5
- isStr,
6
- isArrFilled,
7
- isArr,
8
- isObjFilled,
9
- isNumPZ,
10
- isNumP,
11
- isFunc,
12
- wait
13
- } from "full-utils";
1
+ // src/PowerQueues.ts
14
2
  import { PowerRedis } from "power-redis";
15
- var PowerQueue = class extends PowerRedis {
16
- constructor() {
17
- super(...arguments);
18
- this.iterationTimeout = 1e3;
19
- this.portionLength = 1e3;
20
- this.expireStatusSec = 300;
21
- this.maxAttempts = 1;
22
- this.concurrency = 32;
23
- this.visibilityTimeoutSec = 60;
24
- this.retryBaseSec = 1;
25
- this.retryMaxSec = 3600;
26
- this.runners = /* @__PURE__ */ new Map();
27
- this.processingRaw = /* @__PURE__ */ new Map();
28
- this.heartbeatTimers = /* @__PURE__ */ new Map();
29
- }
30
- nowSec() {
31
- return Math.floor(Date.now() / 1e3);
32
- }
33
- readyKey(queueName) {
34
- return this.toKeyString(queueName);
35
- }
36
- processingKey(queueName) {
37
- return this.toKeyString(queueName, "processing");
38
- }
39
- processingVtKey(queueName) {
40
- return this.toKeyString(queueName, "processing", "vt");
41
- }
42
- delayedKey(queueName) {
43
- return this.toKeyString(queueName, "delayed");
44
- }
45
- toKeyString(...parts) {
46
- return super.toKeyString("queue", ...parts);
47
- }
48
- getReserveScriptLMOVE() {
49
- return `
50
- local source = KEYS[1]
51
- local processing = KEYS[2]
52
- local vtkey = KEYS[3]
53
- local limit = tonumber(ARGV[1])
54
- local deadline = tonumber(ARGV[2])
55
- local moved = {}
56
- for i = 1, limit do
57
- local v = redis.call('LMOVE', source, processing, 'LEFT', 'RIGHT')
58
- if not v then break end
59
- table.insert(moved, v)
60
- end
61
- if #moved > 0 then
62
- for i = 1, #moved do
63
- redis.call('ZADD', vtkey, deadline, moved[i])
3
+ import { wait } from "full-utils";
4
+ import { v4 as uuid } from "uuid";
5
+
6
+ // src/scripts.ts
7
+ var XAddBulk = `
8
+ local UNPACK = table and table.unpack or unpack
9
+
10
+ local stream = KEYS[1]
11
+ local maxlen = tonumber(ARGV[1])
12
+ local approxFlag = tonumber(ARGV[2]) == 1
13
+ local n = tonumber(ARGV[3])
14
+ local exactFlag = tonumber(ARGV[4]) == 1
15
+ local nomkstream = tonumber(ARGV[5]) == 1
16
+ local trimLimit = tonumber(ARGV[6])
17
+ local minidWindowMs = tonumber(ARGV[7]) or 0
18
+ local minidExact = tonumber(ARGV[8]) == 1
19
+ local idx = 9
20
+ local out = {}
21
+
22
+ local common_opts = {}
23
+ local co_len = 0
24
+
25
+ if nomkstream then
26
+ co_len = co_len + 1; common_opts[co_len] = 'NOMKSTREAM'
27
+ end
28
+
29
+ if minidWindowMs > 0 then
30
+ local tm = redis.call('TIME')
31
+ local now_ms = (tonumber(tm[1]) * 1000) + math.floor(tonumber(tm[2]) / 1000)
32
+ local cutoff_ms = now_ms - minidWindowMs
33
+ if cutoff_ms < 0 then cutoff_ms = 0 end
34
+ local cutoff_id = tostring(cutoff_ms) .. '-0'
35
+
36
+ co_len = co_len + 1; common_opts[co_len] = 'MINID'
37
+ co_len = co_len + 1; common_opts[co_len] = (minidExact and '=' or '~')
38
+ co_len = co_len + 1; common_opts[co_len] = cutoff_id
39
+ if trimLimit and trimLimit > 0 then
40
+ co_len = co_len + 1; common_opts[co_len] = 'LIMIT'
41
+ co_len = co_len + 1; common_opts[co_len] = trimLimit
42
+ end
43
+ elseif maxlen and maxlen > 0 then
44
+ co_len = co_len + 1; common_opts[co_len] = 'MAXLEN'
45
+ if exactFlag then
46
+ co_len = co_len + 1; common_opts[co_len] = '='
47
+ elseif approxFlag then
48
+ co_len = co_len + 1; common_opts[co_len] = '~'
49
+ end
50
+ co_len = co_len + 1; common_opts[co_len] = maxlen
51
+ if trimLimit and trimLimit > 0 then
52
+ co_len = co_len + 1; common_opts[co_len] = 'LIMIT'
53
+ co_len = co_len + 1; common_opts[co_len] = trimLimit
54
+ end
55
+ end
56
+
57
+ for e = 1, n do
58
+ local id = ARGV[idx]; idx = idx + 1
59
+ local num_pairs = tonumber(ARGV[idx]); idx = idx + 1
60
+
61
+ local a = {}
62
+ local a_len = 0
63
+
64
+ for i = 1, co_len do a_len = a_len + 1; a[a_len] = common_opts[i] end
65
+
66
+ a_len = a_len + 1; a[a_len] = id
67
+
68
+ for j = 1, (num_pairs * 2) do
69
+ a_len = a_len + 1; a[a_len] = ARGV[idx]; idx = idx + 1
70
+ end
71
+
72
+ local addedId = redis.call('XADD', stream, UNPACK(a))
73
+ out[#out+1] = addedId or ''
74
+ end
75
+
76
+ return out
77
+ `;
78
+ var Approve = `
79
+ local stream = KEYS[1]
80
+ local group = ARGV[1]
81
+ local delFlag = tonumber(ARGV[2]) == 1
82
+
83
+ local acked = 0
84
+ local nids = #ARGV - 2
85
+ if nids > 0 then
86
+ acked = tonumber(redis.call('XACK', stream, group, unpack(ARGV, 3))) or 0
87
+ if delFlag and nids > 0 then
88
+ local ok, deln = pcall(redis.call, 'XDEL', stream, unpack(ARGV, 3))
89
+ if not ok then
90
+ deln = 0
91
+ for i = 3, #ARGV do
92
+ deln = deln + (tonumber(redis.call('XDEL', stream, ARGV[i])) or 0)
64
93
  end
65
94
  end
66
- return moved
67
- `;
68
- }
69
- getReserveScriptRPOPLPUSH() {
70
- return `
71
- local source = KEYS[1]
72
- local processing = KEYS[2]
73
- local vtkey = KEYS[3]
74
- local limit = tonumber(ARGV[1])
75
- local deadline = tonumber(ARGV[2])
76
- local moved = {}
77
- for i = 1, limit do
78
- local v = redis.call('RPOPLPUSH', source, processing)
79
- if not v then break end
80
- table.insert(moved, v)
81
- end
82
- if #moved > 0 then
83
- for i = 1, #moved do
84
- redis.call('ZADD', vtkey, deadline, moved[i])
85
- end
95
+ end
96
+ end
97
+ return acked
98
+ `;
99
+ var IdempotencyAllow = `
100
+ local doneKey = KEYS[1]
101
+ local lockKey = KEYS[2]
102
+ local startKey = KEYS[3]
103
+
104
+ if redis.call('EXISTS', doneKey) == 1 then
105
+ return 1
106
+ end
107
+
108
+ local ttl = tonumber(ARGV[1]) or 0
109
+ if ttl <= 0 then return 0 end
110
+
111
+ local ok = redis.call('SET', lockKey, ARGV[2], 'NX', 'PX', ttl)
112
+ if ok then
113
+ if startKey and startKey ~= '' then
114
+ redis.call('SET', startKey, 1, 'PX', ttl)
115
+ end
116
+ return 2
117
+ else
118
+ return 0
119
+ end
120
+ `;
121
+ var IdempotencyStart = `
122
+ local lockKey = KEYS[1]
123
+ local startKey = KEYS[2]
124
+ if redis.call('GET', lockKey) == ARGV[1] then
125
+ local ttl = tonumber(ARGV[2]) or 0
126
+ if ttl > 0 then
127
+ redis.call('SET', startKey, 1, 'PX', ttl)
128
+ redis.call('PEXPIRE', lockKey, ttl)
129
+ else
130
+ redis.call('SET', startKey, 1)
131
+ end
132
+ return 1
133
+ end
134
+ return 0
135
+ `;
136
+ var IdempotencyDone = `
137
+ local doneKey = KEYS[1]
138
+ local lockKey = KEYS[2]
139
+ local startKey = KEYS[3]
140
+ redis.call('SET', doneKey, 1)
141
+ local ttlSec = tonumber(ARGV[1]) or 0
142
+ if ttlSec > 0 then redis.call('EXPIRE', doneKey, ttlSec) end
143
+ if redis.call('GET', lockKey) == ARGV[2] then
144
+ redis.call('DEL', lockKey)
145
+ if startKey then redis.call('DEL', startKey) end
146
+ end
147
+ return 1
148
+ `;
149
+ var IdempotencyFree = `
150
+ local lockKey = KEYS[1]
151
+ local startKey = KEYS[2]
152
+ if redis.call('GET', lockKey) == ARGV[1] then
153
+ redis.call('DEL', lockKey)
154
+ if startKey then redis.call('DEL', startKey) end
155
+ return 1
156
+ end
157
+ return 0
158
+ `;
159
+ var SelectStuck = `
160
+ local stream = KEYS[1]
161
+ local group = ARGV[1]
162
+ local consumer = ARGV[2]
163
+ local pendingIdleMs = tonumber(ARGV[3])
164
+ local count = tonumber(ARGV[4]) or 0
165
+ if count < 1 then count = 1 end
166
+
167
+ local timeBudgetMs = tonumber(ARGV[5]) or 15
168
+ local t0 = redis.call('TIME')
169
+ local start_ms = (tonumber(t0[1]) * 1000) + math.floor(tonumber(t0[2]) / 1000)
170
+
171
+ local results = {}
172
+ local collected = 0
173
+ local start_id = '0-0'
174
+ local iters = 0
175
+ local max_iters = math.max(16, math.ceil(count / 100))
176
+
177
+ local function time_exceeded()
178
+ local t1 = redis.call('TIME')
179
+ local now_ms = (tonumber(t1[1]) * 1000) + math.floor(tonumber(t1[2]) / 1000)
180
+ return (now_ms - start_ms) >= timeBudgetMs
181
+ end
182
+
183
+ while (collected < count) and (iters < max_iters) do
184
+ local to_claim = count - collected
185
+ if to_claim < 1 then break end
186
+
187
+ local claim = redis.call('XAUTOCLAIM', stream, group, consumer, pendingIdleMs, start_id, 'COUNT', to_claim)
188
+ iters = iters + 1
189
+
190
+ local bucket = nil
191
+ if claim then
192
+ bucket = claim[2]
193
+ end
194
+ if bucket and #bucket > 0 then
195
+ for i = 1, #bucket do
196
+ results[#results+1] = bucket[i]
86
197
  end
87
- return moved
88
- `;
89
- }
90
- getRequeueScript() {
91
- return `
92
- -- KEYS: 1=processing, 2=processingVt, 3=ready
93
- -- ARGV: 1=now, 2=limit
94
- local processing = KEYS[1]
95
- local vt = KEYS[2]
96
- local ready = KEYS[3]
97
- local now = tonumber(ARGV[1])
98
- local limit = tonumber(ARGV[2])
198
+ collected = #results
199
+ end
99
200
 
100
- local members = redis.call('ZRANGEBYSCORE', vt, 0, now, 'LIMIT', 0, limit)
101
- for i=1,#members do
102
- local m = members[i]
103
- redis.call('ZREM', vt, m)
104
- redis.call('LREM', processing, 1, m)
105
- redis.call('RPUSH', ready, m)
201
+ local next_id = claim and claim[1] or start_id
202
+ if next_id == start_id then
203
+ local s, seq = string.match(start_id, '^(%d+)%-(%d+)$')
204
+ if s and seq then
205
+ start_id = s .. '-' .. tostring(tonumber(seq) + 1)
206
+ else
207
+ start_id = '0-1'
106
208
  end
107
- return #members
108
- `;
109
- }
110
- getPromoteScript() {
111
- return `
112
- -- KEYS: 1=delayed, 2=ready
113
- -- ARGV: 1=now, 2=limit
114
- local delayed = KEYS[1]
115
- local ready = KEYS[2]
116
- local now = tonumber(ARGV[1])
117
- local limit = tonumber(ARGV[2])
209
+ else
210
+ start_id = next_id
211
+ end
118
212
 
119
- local due = redis.call('ZRANGEBYSCORE', delayed, 0, now, 'LIMIT', 0, limit)
120
- for i=1,#due do
121
- local m = due[i]
122
- redis.call('ZREM', delayed, m)
123
- redis.call('RPUSH', ready, m)
213
+ if time_exceeded() then
214
+ break
215
+ end
216
+ end
217
+
218
+ local left = count - collected
219
+ if left > 0 then
220
+ local xr = redis.call('XREADGROUP', 'GROUP', group, consumer, 'COUNT', left, 'STREAMS', stream, '>')
221
+ if xr and xr[1] and xr[1][2] then
222
+ local entries = xr[1][2]
223
+ for i = 1, #entries do
224
+ results[#results+1] = entries[i]
124
225
  end
125
- return #due
126
- `;
226
+ end
227
+ end
228
+
229
+ return results
230
+ `;
231
+
232
+ // src/PowerQueues.ts
233
+ var PowerQueues = class extends PowerRedis {
234
+ constructor() {
235
+ super(...arguments);
236
+ this.abort = new AbortController();
237
+ this.strictCheckingConnection = ["true", "on", "yes", "y", "1"].includes(String(process.env.REDIS_STRICT_CHECK_CONNECTION ?? "").trim().toLowerCase());
238
+ this.scripts = {};
239
+ this.addingBatchTasksCount = 800;
240
+ this.addingBatchKeysLimit = 1e4;
241
+ this.idemOn = true;
242
+ this.idemKey = "";
243
+ this.workerExecuteLockTimeoutMs = 18e4;
244
+ this.workerCacheTaskTimeoutMs = 60;
245
+ this.approveBatchTasksCount = 2e3;
246
+ this.removeOnExecuted = false;
247
+ this.executeBatchAtOnce = false;
248
+ this.executeJobStatus = false;
249
+ this.executeJobStatusTtlSec = 300;
250
+ this.consumerHost = "host";
251
+ this.stream = "stream";
252
+ this.group = "group";
253
+ this.workerBatchTasksCount = 200;
254
+ this.recoveryStuckTasksTimeoutMs = 6e4;
255
+ this.workerLoopIntervalMs = 5e3;
256
+ this.workerSelectionTimeoutMs = 80;
257
+ }
258
+ async onSelected(data) {
259
+ return data;
127
260
  }
128
- async ensureReserveScript(force = false) {
129
- if (!force && (this.reserveSha || this.reserveShaRpoplpush || !isFunc(this.redis?.script))) {
130
- return;
131
- }
132
- this.reserveSha = void 0;
133
- this.reserveShaRpoplpush = void 0;
134
- try {
135
- this.reserveSha = await this.redis?.script("LOAD", this.getReserveScriptLMOVE());
136
- } catch {
137
- this.reserveShaRpoplpush = await this.redis?.script("LOAD", this.getReserveScriptRPOPLPUSH());
138
- }
261
+ async onExecute(id, payload, createdAt, job, key) {
139
262
  }
140
- async ensureRequeueScript(force = false) {
141
- if (!force && this.requeueSha) {
142
- return;
143
- }
144
- const scriptFn = this.redis?.script;
145
- if (!scriptFn) {
146
- return;
147
- }
148
- this.requeueSha = await scriptFn("LOAD", this.getRequeueScript());
263
+ async onExecuted(data) {
149
264
  }
150
- async ensurePromoteScript(force = false) {
151
- if (!force && this.promoteSha) {
152
- return;
153
- }
154
- const scriptFn = this.redis?.script;
155
- if (!scriptFn) {
156
- return;
157
- }
158
- this.promoteSha = await scriptFn("LOAD", this.getPromoteScript());
265
+ async onSuccess(id, payload, createdAt, job, key) {
159
266
  }
160
- async moveOneToProcessing(source, processing) {
161
- const cli = this.redis;
162
- try {
163
- if (isFunc(cli.lmove)) {
164
- const v = await cli.lmove(source, processing, "LEFT", "RIGHT");
165
- return isStr(v) ? v : null;
166
- }
167
- } catch {
168
- }
169
- try {
170
- if (isFunc(cli.rpoplpush)) {
171
- const v = await cli.rpoplpush(source, processing);
172
- return isStr(v) ? v : null;
267
+ async runQueue() {
268
+ await this.createGroup("0-0");
269
+ await this.consumerLoop();
270
+ }
271
+ async consumerLoop() {
272
+ const signal = this.signal();
273
+ while (!signal?.aborted) {
274
+ try {
275
+ const tasks = await this.select();
276
+ if (!Array.isArray(tasks) || !(tasks.length > 0)) {
277
+ await wait(600);
278
+ continue;
279
+ }
280
+ const tasksP = await this.onSelected(tasks);
281
+ const ids = await this.execute(Array.isArray(tasksP) && tasksP.length > 0 ? tasksP : tasks);
282
+ if (Array.isArray(ids) && ids.length > 0) {
283
+ await this.approve(ids);
284
+ }
285
+ } catch (err) {
286
+ await wait(600);
173
287
  }
174
- } catch {
175
288
  }
176
- return null;
177
- }
178
- async evalshaWithReload(shaGetter, ensure, numKeys, keysAndArgs) {
179
- await ensure(false);
180
- const sha = shaGetter();
181
- const evalshaFn = this.redis?.evalsha;
182
- if (!sha || !evalshaFn) {
183
- throw new Error("EVALSHA not available or SHA missing");
289
+ }
290
+ async addTasks(queueName, data, opts = {}) {
291
+ if (!Array.isArray(data) || !(data.length > 0)) {
292
+ throw new Error("Tasks is not filled.");
293
+ }
294
+ if (typeof queueName !== "string" || !(queueName.length > 0)) {
295
+ throw new Error("Queue name is required.");
296
+ }
297
+ const batches = this.buildBatches(data);
298
+ const result = new Array(data.length);
299
+ const promises = [];
300
+ let cursor = 0;
301
+ for (const batch of batches) {
302
+ const start = cursor;
303
+ const end = start + batch.length;
304
+ cursor = end;
305
+ promises.push(async () => {
306
+ const partIds = await this.xaddBatch(queueName, ...this.payloadBatch(batch, opts));
307
+ for (let k = 0; k < partIds.length; k++) {
308
+ result[start + k] = partIds[k];
309
+ }
310
+ });
184
311
  }
185
- try {
186
- return await evalshaFn(sha, numKeys, ...keysAndArgs.map(String));
187
- } catch (err) {
188
- const msg = err?.message;
189
- if (typeof msg === "string" && msg.includes("NOSCRIPT")) {
190
- await ensure(true);
191
- const sha2 = shaGetter();
192
- if (!sha2) {
193
- throw new Error("EVALSHA NOSCRIPT and reload failed (no SHA)");
312
+ const runners = Array.from({ length: promises.length }, async () => {
313
+ while (promises.length) {
314
+ const promise = promises.shift();
315
+ if (promise) {
316
+ await promise();
194
317
  }
195
- return await evalshaFn(sha2, numKeys, ...keysAndArgs.map(String));
196
318
  }
197
- throw err;
198
- }
319
+ });
320
+ await Promise.all(runners);
321
+ return result;
199
322
  }
200
- async zaddCompatXXCH(key, score, member) {
201
- const zadd = this.redis?.zadd;
202
- try {
203
- if (zadd) {
204
- await zadd.call(this.redis, key, "XX", "CH", score, member);
205
- return;
206
- }
207
- } catch {
208
- }
209
- try {
210
- if (zadd) {
211
- await zadd.call(this.redis, key, "CH", "XX", score, member);
212
- return;
323
+ async loadScripts(full = false) {
324
+ const scripts = full ? [
325
+ ["XAddBulk", XAddBulk],
326
+ ["Approve", Approve],
327
+ ["IdempotencyAllow", IdempotencyAllow],
328
+ ["IdempotencyStart", IdempotencyStart],
329
+ ["IdempotencyDone", IdempotencyDone],
330
+ ["IdempotencyFree", IdempotencyFree],
331
+ ["SelectStuck", SelectStuck]
332
+ ] : [
333
+ ["XAddBulk", XAddBulk]
334
+ ];
335
+ for (const [name, code] of scripts) {
336
+ await this.loadScript(this.saveScript(name, code));
337
+ }
338
+ }
339
+ async loadScript(code) {
340
+ for (let i = 0; i < 3; i++) {
341
+ try {
342
+ return await this.redis.script("LOAD", code);
343
+ } catch (e) {
344
+ if (i === 2) {
345
+ throw e;
346
+ }
347
+ await new Promise((r) => setTimeout(r, 10 + Math.floor(Math.random() * 40)));
213
348
  }
214
- } catch {
215
- }
216
- try {
217
- await this.redis.zadd(key, score, member);
218
- } catch {
219
349
  }
350
+ throw new Error("Load lua script failed.");
220
351
  }
221
- startHeartbeat(task) {
222
- const raw = this.processingRaw.get(task.id);
223
- if (!raw) {
224
- return;
225
- }
226
- const vtKey = this.processingVtKey(task.queueName);
227
- const periodMs = Math.max(1e3, Math.floor(this.visibilityTimeoutSec * 1e3 * 0.4));
228
- const t = setInterval(() => {
229
- this.extendVisibility(vtKey, raw, this.visibilityTimeoutSec).catch(() => {
230
- });
231
- }, periodMs);
232
- t.unref?.();
233
- this.heartbeatTimers.set(task.id, t);
234
- }
235
- stopHeartbeat(task) {
236
- const t = this.heartbeatTimers.get(task.id);
237
- if (t) {
238
- clearInterval(t);
352
+ saveScript(name, codeBody) {
353
+ if (typeof codeBody !== "string" || !(codeBody.length > 0)) {
354
+ throw new Error("Script body is empty.");
239
355
  }
240
- this.heartbeatTimers.delete(task.id);
356
+ this.scripts[name] = { codeBody };
357
+ return codeBody;
241
358
  }
242
- async reserveMany(source, processing, processingVt, limit = 100, visibilitySec = 60) {
243
- if (!this.checkConnection()) {
244
- throw new Error("Redis connection error.");
359
+ async runScript(name, keys, args, defaultCode) {
360
+ if (!this.scripts[name]) {
361
+ if (typeof defaultCode !== "string" || !(defaultCode.length > 0)) {
362
+ throw new Error(`Undefined script "${name}". Save it before executing.`);
363
+ }
364
+ this.saveScript(name, defaultCode);
245
365
  }
246
- if (!isStrFilled(source) || !isStrFilled(processing) || !isStrFilled(processingVt)) {
247
- throw new Error("Key format error.");
366
+ if (!this.scripts[name].codeReady) {
367
+ this.scripts[name].codeReady = await this.loadScript(this.scripts[name].codeBody);
248
368
  }
249
- if (!isNumP(limit) || !isNumP(visibilitySec)) {
250
- throw new Error("Limit/visibility format error.");
369
+ try {
370
+ return await this.redis.evalsha(this.scripts[name].codeReady, keys.length, ...keys, ...args);
371
+ } catch (err) {
372
+ if (String(err?.message || "").includes("NOSCRIPT")) {
373
+ this.scripts[name].codeReady = await this.loadScript(this.scripts[name].codeBody);
374
+ return await this.redis.evalsha(this.scripts[name].codeReady, keys.length, ...keys, ...args);
375
+ }
376
+ throw err;
251
377
  }
252
- await this.ensureReserveScript();
253
- const deadline = this.nowSec() + visibilitySec;
254
- const tryEval = async () => {
255
- if (isFunc(this.redis?.evalsha)) {
256
- if (this.reserveSha) {
257
- return await this.redis?.evalsha(this.reserveSha, 3, source, processing, processingVt, String(limit), String(deadline));
378
+ }
379
+ async xaddBatch(queueName, ...batches) {
380
+ return await this.runScript("XAddBulk", [queueName], batches, XAddBulk);
381
+ }
382
+ payloadBatch(data, opts) {
383
+ const maxlen = Math.max(0, Math.floor(opts?.maxlen ?? 0));
384
+ const approx = opts?.exact ? 0 : opts?.approx !== false ? 1 : 0;
385
+ const exact = opts?.exact ? 1 : 0;
386
+ const nomkstream = opts?.nomkstream ? 1 : 0;
387
+ const trimLimit = Math.max(0, Math.floor(opts?.trimLimit ?? 0));
388
+ const minidWindowMs = Math.max(0, Math.floor(opts?.minidWindowMs ?? 0));
389
+ const minidExact = opts?.minidExact ? 1 : 0;
390
+ const argv = [
391
+ String(maxlen),
392
+ String(approx),
393
+ String(data.length),
394
+ String(exact),
395
+ String(nomkstream),
396
+ String(trimLimit),
397
+ String(minidWindowMs),
398
+ String(minidExact)
399
+ ];
400
+ for (const item of data) {
401
+ const entry = item;
402
+ const id = entry.id ?? "*";
403
+ let flat;
404
+ if ("flat" in entry && Array.isArray(entry.flat) && entry.flat.length > 0) {
405
+ flat = entry.flat;
406
+ if (flat.length % 2 !== 0) {
407
+ throw new Error('Property "flat" must contain an even number of realKeysLength (field/value pairs).');
258
408
  }
259
- if (this.reserveShaRpoplpush) {
260
- return await this.redis?.evalsha(this.reserveShaRpoplpush, 3, source, processing, processingVt, String(limit), String(deadline));
409
+ } else if ("payload" in entry && typeof entry.payload === "object" && Object.keys(entry.payload || {}).length > 0) {
410
+ flat = [];
411
+ for (const [k, v] of Object.entries(entry.payload)) {
412
+ flat.push(k, v);
261
413
  }
414
+ } else {
415
+ throw new Error('Task must have "payload" or "flat".');
262
416
  }
263
- return null;
264
- };
265
- try {
266
- const res = await tryEval();
267
- if (isArr(res)) {
268
- return Array.from(res).map(String);
417
+ const pairs = flat.length / 2;
418
+ if (pairs <= 0) {
419
+ throw new Error('Task must have "payload" or "flat".');
269
420
  }
270
- } catch (err) {
271
- if (isStr(err?.message) && String(err?.message ?? "").includes("NOSCRIPT")) {
272
- await this.ensureReserveScript(true);
273
- try {
274
- const res2 = await tryEval();
275
- if (isArr(res2)) {
276
- return Array.from(res2).map(String);
421
+ argv.push(String(id));
422
+ argv.push(String(pairs));
423
+ for (const token of flat) {
424
+ argv.push(!token ? "" : typeof token === "string" && token.length > 0 ? token : String(token));
425
+ }
426
+ }
427
+ return argv;
428
+ }
429
+ buildBatches(tasks) {
430
+ const job = uuid();
431
+ const batches = [];
432
+ let batch = [], realKeysLength = 0;
433
+ for (let task of tasks) {
434
+ let entry = task;
435
+ if (this.idemOn) {
436
+ const createdAt = entry?.createdAt || Date.now();
437
+ let idemKey = entry?.idemKey || uuid();
438
+ if (typeof entry.payload === "object") {
439
+ if (this.idemKey && typeof entry.payload[this.idemKey] === "string" && entry.payload[this.idemKey].length > 0) {
440
+ idemKey = entry.payload[this.idemKey];
277
441
  }
278
- } catch {
442
+ entry = {
443
+ ...entry,
444
+ payload: {
445
+ payload: JSON.stringify(entry.payload),
446
+ createdAt,
447
+ job,
448
+ idemKey
449
+ }
450
+ };
451
+ } else if (Array.isArray(entry.flat)) {
452
+ entry.flat.push("createdAt");
453
+ entry.flat.push(String(createdAt));
454
+ entry.flat.push("job");
455
+ entry.flat.push(job);
456
+ entry.flat.push("idemKey");
457
+ entry.flat.push(idemKey);
279
458
  }
280
459
  }
281
- }
282
- const moved = [];
283
- for (let i = 0; i < limit; i++) {
284
- const v = await this.moveOneToProcessing(source, processing);
285
- if (!v) {
286
- break;
460
+ const reqKeysLength = this.keysLength(entry);
461
+ if (batch.length && (batch.length >= this.addingBatchTasksCount || realKeysLength + reqKeysLength > this.addingBatchKeysLimit)) {
462
+ batches.push(batch);
463
+ batch = [];
464
+ realKeysLength = 0;
287
465
  }
288
- moved.push(v);
289
- }
290
- if (moved.length) {
291
- const tx = this.redis?.multi();
292
- for (const v of moved) {
293
- tx.zadd(processingVt, deadline, v);
466
+ batch.push(entry);
467
+ realKeysLength += reqKeysLength;
468
+ }
469
+ if (batch.length) {
470
+ batches.push(batch);
471
+ }
472
+ return batches;
473
+ }
474
+ keysLength(task) {
475
+ return 2 + ("flat" in task && Array.isArray(task.flat) && task.flat.length ? task.flat.length : Object.keys(task).length * 2);
476
+ }
477
+ async success(id, payload, createdAt, job, key) {
478
+ if (this.executeJobStatus) {
479
+ await this.status(id, payload, createdAt, job, key);
480
+ }
481
+ await this.onSuccess(id, payload, createdAt, job, key);
482
+ }
483
+ async status(id, payload, createdAt, job, key) {
484
+ const prefix = `s:${this.stream}:`;
485
+ const { ready = 0, ok = 0 } = await this.getMany(prefix);
486
+ await this.setMany([{ key: `${prefix}ready`, value: ready + 1 }, { key: `${prefix}ok`, value: ok + 1 }], this.executeJobStatusTtlSec);
487
+ }
488
+ async execute(tasks) {
489
+ const result = [];
490
+ let contended = 0, promises = [];
491
+ for (const [id, payload, createdAt, job, idemKey] of tasks) {
492
+ if (this.executeBatchAtOnce) {
493
+ promises.push((async () => {
494
+ const r = await this.executeProcess(id, payload, createdAt, job, idemKey);
495
+ if (r.id) {
496
+ result.push(id);
497
+ } else if (r.contended) {
498
+ contended++;
499
+ }
500
+ })());
501
+ } else {
502
+ const r = await this.executeProcess(id, payload, createdAt, job, idemKey);
503
+ if (r.id) {
504
+ result.push(id);
505
+ } else if (r.contended) {
506
+ contended++;
507
+ }
294
508
  }
295
- await tx.exec();
296
509
  }
297
- return moved;
298
- }
299
- async ackProcessing(processing, processingVt, raw) {
300
- if (!this.checkConnection()) {
301
- throw new Error("Redis connection error.");
302
- }
303
- const tx = this.redis?.multi();
304
- tx.lrem(processing, 1, raw);
305
- tx.zrem(processingVt, raw);
306
- await tx.exec();
307
- return;
308
- }
309
- async requeueExpired(processing, processingVt, ready, nowTs, chunk = 1e3) {
310
- if (!this.checkConnection()) {
311
- throw new Error("Redis connection error.");
312
- }
313
- const now = isNumP(nowTs) ? nowTs : this.nowSec();
314
510
  try {
315
- const moved = await this.evalshaWithReload(
316
- () => this.requeueSha,
317
- (force) => this.ensureRequeueScript(!!force),
318
- 3,
319
- [processing, processingVt, ready, String(now), String(chunk)]
320
- );
321
- return isNumP(moved) ? moved : 0;
322
- } catch {
323
- const expired = await this.redis?.zrangebyscore(processingVt, 0, now, "LIMIT", 0, chunk);
324
- if (!isArrFilled(expired)) {
325
- return 0;
511
+ if (this.executeBatchAtOnce && promises.length > 0) {
512
+ await Promise.all(promises);
326
513
  }
327
- const tx = this.redis?.multi();
328
- for (const raw of expired) {
329
- tx.lrem(processing, 1, raw);
330
- tx.zrem(processingVt, raw);
331
- tx.rpush(ready, raw);
514
+ await this.onExecuted(tasks);
515
+ if ((!Array.isArray(result) || !(result.length > 0)) && contended > tasks.length >> 1) {
516
+ await this.waitAbortable(15 + Math.floor(Math.random() * 35) + Math.min(250, 15 * contended + Math.floor(Math.random() * 40)));
332
517
  }
333
- await tx.exec();
334
- return expired.length;
518
+ } catch (err) {
335
519
  }
520
+ return result;
336
521
  }
337
- async promoteDelayed(delayed, ready, nowTs, chunk = 1e3) {
338
- if (!this.checkConnection()) {
339
- throw new Error("Redis connection error.");
340
- }
341
- const now = isNumP(nowTs) ? nowTs : this.nowSec();
342
- try {
343
- const promoted = await this.evalshaWithReload(
344
- () => this.promoteSha,
345
- (force) => this.ensurePromoteScript(!!force),
346
- 2,
347
- [delayed, ready, String(now), String(chunk)]
348
- );
349
- return isNumP(promoted) ? promoted : 0;
350
- } catch {
351
- const due = await this.redis?.zrangebyscore(delayed, 0, now, "LIMIT", 0, chunk);
352
- if (!isArrFilled(due)) {
353
- return 0;
354
- }
355
- const tx = this.redis?.multi();
356
- for (const raw of due) {
357
- tx.zrem(delayed, raw);
358
- tx.rpush(ready, raw);
522
+ async executeProcess(id, payload, createdAt, job, key) {
523
+ if (key) {
524
+ return await this.idempotency(id, payload, createdAt, job, key);
525
+ } else {
526
+ try {
527
+ await this.onExecute(id, payload, createdAt, job, key);
528
+ await this.success(id, payload, createdAt, job, key);
529
+ return { id };
530
+ } catch (err) {
359
531
  }
360
- await tx.exec();
361
- return due.length;
362
532
  }
533
+ return {};
363
534
  }
364
- async enqueue(ready, delayed, payload, delaySec) {
365
- if (!this.checkConnection()) {
366
- throw new Error("Redis connection error.");
535
+ async approve(ids) {
536
+ if (!Array.isArray(ids) || !(ids.length > 0)) {
537
+ return 0;
367
538
  }
368
- const raw = this.toPayload(payload);
369
- if (isNumP(delaySec) && delaySec > 0) {
370
- const score = this.nowSec() + delaySec;
371
- return await this.redis?.zadd(delayed, score, raw);
539
+ const approveBatchTasksCount = Math.max(500, Math.min(4e3, this.approveBatchTasksCount));
540
+ let total = 0, i = 0;
541
+ while (i < ids.length) {
542
+ const room = Math.min(approveBatchTasksCount, ids.length - i);
543
+ const part = ids.slice(i, i + room);
544
+ const approved = await this.runScript("Approve", [this.stream], [this.group, this.removeOnExecuted ? "1" : "0", ...part], Approve);
545
+ total += Number(approved || 0);
546
+ i += room;
372
547
  }
373
- return await this.redis?.rpush(ready, raw);
374
- }
375
- async extendVisibility(processingVt, raw, visibilitySec) {
376
- const deadline = this.nowSec() + Math.max(1, visibilitySec);
377
- await this.zaddCompatXXCH(processingVt, deadline, raw);
548
+ return total;
378
549
  }
379
- run(queueName) {
380
- if (!isStrFilled(queueName)) {
381
- throw new Error("Queue name is not valid.");
550
+ async idempotency(id, payload, createdAt, job, key) {
551
+ const keys = this.idempotencyKeys(key);
552
+ const allow = await this.idempotencyAllow(keys);
553
+ if (allow === 1) {
554
+ return { id };
555
+ } else if (allow === 0) {
556
+ let ttl = -2;
557
+ try {
558
+ ttl = await this.redis.pttl(keys.startKey);
559
+ } catch (err) {
560
+ }
561
+ await this.waitAbortable(ttl);
562
+ return { contended: true };
382
563
  }
383
- const r = this.runners.get(queueName) ?? { running: false };
384
- if (r.running) {
385
- throw new Error(`Queue "${queueName}" already started.`);
564
+ if (!await this.idempotencyStart(keys)) {
565
+ return { contended: true };
386
566
  }
387
- r.running = true;
388
- this.runners.set(queueName, r);
389
- this.loop(queueName, r).catch(() => {
390
- r.running = false;
567
+ const heartbeat = this.heartbeat(keys) || (() => {
391
568
  });
392
- }
393
- stop(queueName) {
394
- const r = this.runners.get(queueName);
395
- if (r) {
396
- r.running = false;
397
- this.runners.delete(queueName);
569
+ try {
570
+ await this.onExecute(id, payload, createdAt, job, key);
571
+ await this.idempotencyDone(keys);
572
+ await this.success(id, payload, createdAt, job, key);
573
+ return { id };
574
+ } catch (err) {
575
+ try {
576
+ await this.idempotencyFree(keys);
577
+ } catch (err2) {
578
+ }
579
+ } finally {
580
+ heartbeat();
398
581
  }
399
582
  }
400
- buildTask(data) {
401
- if (!isObjFilled(data)) {
402
- throw new Error("Data property is not valid.");
403
- }
404
- if (!isStrFilled(data.queueName)) {
405
- throw new Error("Queue name is not valid.");
406
- }
583
+ idempotencyKeys(key) {
584
+ const prefix = `q:${this.stream.replace(/[^\w:\-]/g, "_")}:`;
585
+ const keyP = key.replace(/[^\w:\-]/g, "_");
586
+ const doneKey = `${prefix}done:${keyP}`;
587
+ const lockKey = `${prefix}lock:${keyP}`;
588
+ const startKey = `${prefix}start:${keyP}`;
589
+ const token = `${this.consumer()}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`;
407
590
  return {
408
- queueName: data.queueName,
409
- iterationId: isStrFilled(data.iterationId) ? data.iterationId : uuid(),
410
- iterationLength: Number(data.iterationLength || 0),
411
- id: isStrFilled(data.id) ? data.id : uuid(),
412
- maxAttempts: isNumPZ(data.maxAttempts) ? data.maxAttempts : this.maxAttempts,
413
- currentAttempt: isNumPZ(data.currentAttempt) ? data.currentAttempt : 0,
414
- chain: isObjFilled(data.chain) && isArrFilled(data.chain.queues) && isNumPZ(data.chain.index) ? data.chain : {
415
- queues: [],
416
- index: 0
417
- },
418
- payload: isObjFilled(data.payload) ? data.payload : {},
419
- progress: {
420
- createdAt: Date.now(),
421
- successAt: 0,
422
- errorAt: 0,
423
- failAt: 0,
424
- fatalAt: 0,
425
- retries: [],
426
- chain: [],
427
- ...isObjFilled(data.progress) ? data.progress : {}
428
- },
429
- result: isObjFilled(data.result) ? data.result : {}
591
+ prefix,
592
+ doneKey,
593
+ lockKey,
594
+ startKey,
595
+ token
430
596
  };
431
597
  }
432
- async addTask(data, delaySec) {
433
- const ready = this.readyKey(String(data.queueName));
434
- const delayed = this.delayedKey(String(data.queueName));
435
- return await this.enqueue(ready, delayed, this.buildTask(data), isNumP(delaySec) ? delaySec : 0);
436
- }
437
- async addTasks(data) {
438
- if (!this.checkConnection()) {
439
- throw new Error("Redis connection error.");
440
- }
441
- if (!isObjFilled(data) || !isStrFilled(data.queueName)) {
442
- throw new Error("Queue name is not valid.");
443
- }
444
- if (!isArrFilled(data.payloads)) {
445
- return 0;
446
- }
447
- const queueName = String(data.queueName);
448
- const ready = this.readyKey(queueName);
449
- const delayed = this.delayedKey(queueName);
450
- const now = this.nowSec();
451
- const uniformDelay = isNumP(data.delaySec) ? Math.max(0, Number(data.delaySec)) : void 0;
452
- const perItemDelays = isArr(data.delaySec) ? data.delaySec.map((v) => Math.max(0, Number(v || 0))) : void 0;
453
- const batchSize = Math.max(1, Math.min(this.portionLength, 1e3));
454
- let idx = 0, total = 0;
455
- while (idx < data.payloads.length) {
456
- const end = Math.min(idx + batchSize, data.payloads.length);
457
- const tx = this.redis?.multi();
458
- for (let i = idx; i < end; i++) {
459
- const item = data.payloads[i];
460
- let partial;
461
- if (isObjFilled(item) && Object.prototype.hasOwnProperty.call(item, "payload")) {
462
- partial = { ...item, queueName };
463
- } else {
464
- partial = { queueName, payload: item };
465
- }
466
- const task = this.buildTask(partial);
467
- const raw = this.toPayload(task);
468
- let d = 0;
469
- if (isNumP(uniformDelay)) {
470
- d = uniformDelay;
471
- } else if (isArr(perItemDelays)) {
472
- d = Number(perItemDelays[i] || 0);
473
- }
474
- if (d > 0) {
475
- tx.zadd(delayed, now + d, raw);
476
- } else {
477
- tx.rpush(ready, raw);
478
- }
479
- total++;
480
- }
481
- await tx.exec();
482
- idx = end;
483
- }
484
- return total;
485
- }
486
- async iteration(tasks) {
487
- const tasksProcessed = await this.beforeIterationExecution(tasks);
488
- const limit = Math.max(1, Number(this.concurrency) || 1);
489
- let i = 0;
490
- while (i < tasksProcessed.length) {
491
- const slice = tasksProcessed.slice(i, i + limit);
492
- await Promise.all(slice.map((task) => this.logic(task)));
493
- i += limit;
494
- }
495
- await this.afterIterationExecution(tasksProcessed, tasksProcessed.map((t) => t.result ?? {}));
496
- }
497
- async beforeIterationExecution(data) {
498
- return data;
499
- }
500
- async afterIterationExecution(data, results) {
501
- }
502
- async beforeExecution(task) {
503
- return task;
504
- }
505
- async afterExecution(task, result) {
506
- return result;
507
- }
508
- async execute(task) {
509
- return {};
598
+ async idempotencyAllow(keys) {
599
+ const res = await this.runScript("IdempotencyAllow", [keys.doneKey, keys.lockKey, keys.startKey], [String(this.workerExecuteLockTimeoutMs), keys.token], IdempotencyAllow);
600
+ return Number(res || 0);
510
601
  }
511
- async onRetry(task) {
602
+ async idempotencyStart(keys) {
603
+ const res = await this.runScript("IdempotencyStart", [keys.lockKey, keys.startKey], [keys.token, String(this.workerExecuteLockTimeoutMs)], IdempotencyStart);
604
+ return Number(res || 0) === 1;
512
605
  }
513
- async onError(err, task) {
606
+ async idempotencyDone(keys) {
607
+ await this.runScript("IdempotencyDone", [keys.doneKey, keys.lockKey, keys.startKey], [String(this.workerCacheTaskTimeoutMs), keys.token], IdempotencyDone);
514
608
  }
515
- async onFail(err, task) {
609
+ async idempotencyFree(keys) {
610
+ await this.runScript("IdempotencyFree", [keys.lockKey, keys.startKey], [keys.token], IdempotencyFree);
516
611
  }
517
- async onFatal(err, task) {
518
- }
519
- async onSuccess(task, result) {
520
- }
521
- async onChainSuccess(task, result) {
522
- }
523
- async onIterationError(err, queueName) {
524
- }
525
- async logic(task) {
526
- let data = task;
612
+ async createGroup(from = "$") {
527
613
  try {
528
- data = await this.beforeExecution(task);
529
- const before = data?.result ?? {};
530
- const after = await this.execute(data);
531
- data.result = {
532
- ...isObjFilled(before) ? before : {},
533
- ...isObjFilled(after) ? after : {}
534
- };
535
- await this.success(data, data.result);
536
- return await this.afterExecution(data, data.result);
614
+ await this.redis.xgroup("CREATE", this.stream, this.group, from, "MKSTREAM");
537
615
  } catch (err) {
538
- try {
539
- await this.retry(data);
540
- } catch (err2) {
541
- await this.error(err2, data);
542
- }
543
- } finally {
544
- try {
545
- this.stopHeartbeat(data);
546
- await this.ack(data).catch(() => {
547
- });
548
- } catch {
616
+ const msg = String(err?.message || "");
617
+ if (!msg.includes("BUSYGROUP")) {
618
+ throw err;
549
619
  }
550
620
  }
551
- return {};
552
621
  }
553
- jitteredBackoffSec(attempt) {
554
- const base = Math.max(1, Number(this.retryBaseSec) || 1);
555
- const maxD = Math.max(base, Number(this.retryMaxSec) || 3600);
556
- const pow = Math.min(maxD, base * Math.pow(2, Math.max(0, attempt - 1)));
557
- const jitter = Math.floor(Math.random() * base);
558
- return Math.min(maxD, pow + jitter);
559
- }
560
- async retry(task) {
561
- if (!isObjFilled(task) || !isStrFilled(task.iterationId) || !isStrFilled(task.id) || !isStrFilled(task.queueName) || !isNumPZ(task.currentAttempt) || !isNumPZ(task.maxAttempts)) {
562
- await this.error(new Error("Task format error."), task);
563
- return;
564
- }
565
- const maxAttempts = task.maxAttempts ?? this.maxAttempts;
566
- try {
567
- if (task.currentAttempt < maxAttempts - 1) {
568
- const taskProcessed = { ...task, currentAttempt: task.currentAttempt + 1 };
569
- const delaySec = this.jitteredBackoffSec(taskProcessed.currentAttempt);
570
- await this.addTask(taskProcessed, delaySec);
571
- await this.onRetry(taskProcessed);
572
- return;
573
- }
574
- } catch (err) {
575
- await this.fail(err, task);
576
- return;
622
+ async select() {
623
+ let entries = await this.selectStuck();
624
+ if (!entries?.length) {
625
+ entries = await this.selectFresh();
577
626
  }
578
- await this.fail(new Error("The attempt limit has been reached."), task);
627
+ return this.normalizeEntries(entries);
579
628
  }
580
- async iterationError(err, queueName, data) {
629
+ async selectStuck() {
581
630
  try {
582
- await this.onIterationError(err, queueName);
583
- } catch (err2) {
584
- }
585
- for (const t of data || []) {
586
- if (isStrFilled(t.id)) {
587
- this.processingRaw.delete(t.id);
631
+ const res = await this.runScript("SelectStuck", [this.stream], [this.group, this.consumer(), String(this.recoveryStuckTasksTimeoutMs), String(this.workerBatchTasksCount), String(this.workerSelectionTimeoutMs)], SelectStuck);
632
+ return Array.isArray(res) ? res : [];
633
+ } catch (err) {
634
+ if (String(err?.message || "").includes("NOGROUP")) {
635
+ await this.createGroup();
588
636
  }
589
637
  }
638
+ return [];
590
639
  }
591
- async error(err, task) {
640
+ async selectFresh() {
641
+ let entries = [];
592
642
  try {
593
- await this.addTask({
594
- ...task,
595
- queueName: [task.queueName, task.iterationId, "error", "list"].join(":"),
596
- currentAttempt: 0,
597
- payload: {
598
- ...task.payload,
599
- errorMessage: String(err?.message ?? "")
600
- }
601
- });
602
- await this.onError(err, task);
603
- } catch (err2) {
604
- try {
605
- await this.onFatal(err2, task);
606
- } catch {
643
+ const res = await this.redis.xreadgroup(
644
+ "GROUP",
645
+ this.group,
646
+ this.consumer(),
647
+ "BLOCK",
648
+ Math.max(2, this.workerLoopIntervalMs | 0),
649
+ "COUNT",
650
+ this.workerBatchTasksCount,
651
+ "STREAMS",
652
+ this.stream,
653
+ ">"
654
+ );
655
+ if (!res?.[0]?.[1]?.length) {
656
+ return [];
607
657
  }
608
- }
609
- try {
610
- await this.status(task, "error");
611
- } catch {
612
- }
613
- }
614
- async fail(err, task) {
615
- try {
616
- await this.addTask({
617
- ...task,
618
- queueName: [task.queueName, task.iterationId, "fail", "list"].join(":"),
619
- currentAttempt: 0,
620
- payload: {
621
- ...task.payload,
622
- errorMessage: String(err?.message ?? "")
623
- }
624
- });
625
- await this.onFail(err, task);
626
- } catch (err2) {
627
- try {
628
- await this.onFatal(err2, task);
629
- } catch {
658
+ entries = res?.[0]?.[1] ?? [];
659
+ if (!entries?.length) {
660
+ return [];
661
+ }
662
+ } catch (err) {
663
+ if (String(err?.message || "").includes("NOGROUP")) {
664
+ await this.createGroup();
630
665
  }
631
666
  }
632
- try {
633
- await this.status(task, "fail");
634
- } catch {
635
- }
667
+ return entries;
636
668
  }
637
- async success(task, result) {
638
- const taskProcessed = {
639
- ...task,
640
- progress: {
641
- ...task.progress,
642
- successAt: Date.now()
669
+ async waitAbortable(ttl) {
670
+ return new Promise((resolve) => {
671
+ const signal = this.signal();
672
+ if (signal?.aborted) {
673
+ return resolve();
643
674
  }
644
- };
645
- try {
646
- if (isObjFilled(taskProcessed.chain) && isArrFilled(taskProcessed.chain.queues) && isNumPZ(taskProcessed.chain.index)) {
647
- const currentIndex = taskProcessed.chain.index;
648
- const newIndex = currentIndex + 1;
649
- taskProcessed.progress.chain.push(Date.now());
650
- if (currentIndex === taskProcessed.chain.queues.length - 1) {
651
- await this.status(taskProcessed, "success");
652
- await this.onChainSuccess(taskProcessed, result);
653
- } else if (newIndex <= taskProcessed.chain.queues.length - 1) {
654
- const newQueueName = taskProcessed.chain.queues[newIndex];
655
- if (isStrFilled(newQueueName)) {
656
- await this.addTask({
657
- ...taskProcessed,
658
- queueName: newQueueName,
659
- currentAttempt: 0,
660
- chain: {
661
- ...taskProcessed.chain,
662
- index: newIndex
663
- },
664
- result
665
- });
666
- } else {
667
- await this.fail(new Error("Next queue format error."), taskProcessed);
668
- }
675
+ const t = setTimeout(() => {
676
+ if (signal) {
677
+ signal.removeEventListener("abort", onAbort);
669
678
  }
670
- } else {
671
- await this.status(taskProcessed, "success");
679
+ resolve();
680
+ }, ttl > 0 ? 25 + Math.floor(Math.random() * 50) : 5 + Math.floor(Math.random() * 15));
681
+ t.unref?.();
682
+ function onAbort() {
683
+ clearTimeout(t);
684
+ resolve();
672
685
  }
673
- await this.onSuccess(taskProcessed, result);
674
- } catch (err) {
675
- try {
676
- await this.status(taskProcessed, "fatal");
677
- } catch {
678
- }
679
- try {
680
- await this.onFatal(err, taskProcessed);
681
- } catch {
682
- }
683
- }
686
+ signal?.addEventListener("abort", onAbort, { once: true });
687
+ });
684
688
  }
685
- async status(task, category = "success") {
686
- if (!this.checkConnection()) {
687
- throw new Error("Redis connection error.");
688
- }
689
- const processedKey = this.toKeyString(task.queueName, task.iterationId, "processed");
690
- const categoryKey = this.toKeyString(task.queueName, task.iterationId, category);
691
- await this.redis?.incr(processedKey);
692
- await this.redis?.incr(categoryKey);
693
- await this.redis?.expire(processedKey, this.expireStatusSec);
694
- await this.redis?.expire(categoryKey, this.expireStatusSec);
695
- }
696
- async loop(queueName, runner) {
697
- if (!isStrFilled(queueName)) {
698
- throw new Error(`Queue name is not valid: "${queueName}"; Type: "${typeof queueName}".`);
689
+ heartbeat(keys) {
690
+ if (this.workerExecuteLockTimeoutMs <= 0) {
691
+ return;
699
692
  }
700
- const ready = this.readyKey(queueName);
701
- const processing = this.processingKey(queueName);
702
- const processingVt = this.processingVtKey(queueName);
703
- const delayed = this.delayedKey(queueName);
704
- while (runner.running) {
705
- if (!this.checkConnection()) {
706
- await wait(this.iterationTimeout);
707
- continue;
693
+ let timer, alive = true, hbFails = 0;
694
+ const workerHeartbeatTimeoutMs = Math.max(1e3, Math.floor(Math.max(5e3, this.workerExecuteLockTimeoutMs | 0) / 4));
695
+ const stop = () => {
696
+ alive = false;
697
+ if (timer) {
698
+ clearTimeout(timer);
708
699
  }
709
- try {
710
- await this.promoteDelayed(delayed, ready);
711
- } catch {
700
+ };
701
+ const onAbort = () => stop();
702
+ const signal = this.signal();
703
+ signal?.addEventListener?.("abort", onAbort, { once: true });
704
+ const tick = async () => {
705
+ if (!alive) {
706
+ return;
712
707
  }
713
708
  try {
714
- await this.requeueExpired(processing, processingVt, ready);
709
+ const r = await this.heartbeat(keys);
710
+ hbFails = r ? 0 : hbFails + 1;
711
+ if (hbFails >= 3) {
712
+ throw new Error("Heartbeat lost.");
713
+ }
715
714
  } catch {
716
- }
717
- let data = [];
718
- try {
719
- data = await this.data(queueName);
720
- if (!isArrFilled(data)) {
721
- await wait(this.iterationTimeout);
722
- continue;
715
+ hbFails++;
716
+ if (hbFails >= 6) {
717
+ stop();
718
+ return;
723
719
  }
724
- await this.iteration(data);
725
- } catch (err) {
726
- await this.iterationError(err, queueName, data);
727
- await wait(this.iterationTimeout);
728
720
  }
729
- }
721
+ timer = setTimeout(tick, workerHeartbeatTimeoutMs).unref?.();
722
+ };
723
+ timer = setTimeout(tick, workerHeartbeatTimeoutMs).unref?.();
724
+ return () => {
725
+ signal?.removeEventListener?.("abort", onAbort);
726
+ stop();
727
+ };
730
728
  }
731
- async data(queueName) {
732
- const ready = this.readyKey(queueName);
733
- const processing = this.processingKey(queueName);
734
- const processingVt = this.processingVtKey(queueName);
735
- const raws = await this.reserveMany(ready, processing, processingVt, this.portionLength, this.visibilityTimeoutSec);
736
- if (!isArrFilled(raws)) {
729
+ normalizeEntries(raw) {
730
+ if (!Array.isArray(raw)) {
737
731
  return [];
738
732
  }
739
- const tasks = [];
740
- for (const raw of raws) {
741
- const obj = this.fromPayload(raw);
742
- if (isObjFilled(obj) && isStrFilled(obj.iterationId) && isStrFilled(obj.id) && isStrFilled(obj.queueName) && isNumPZ(obj.maxAttempts) && isNumPZ(obj.currentAttempt)) {
743
- const t = obj;
744
- this.processingRaw.set(t.id, raw);
745
- this.startHeartbeat(t);
746
- tasks.push(t);
747
- }
733
+ return Array.from(raw || []).map((e) => {
734
+ const id = Buffer.isBuffer(e?.[0]) ? e[0].toString() : e?.[0];
735
+ const kvRaw = e?.[1] ?? [];
736
+ const kv = Array.isArray(kvRaw) ? kvRaw.map((x) => Buffer.isBuffer(x) ? x.toString() : x) : [];
737
+ return [id, kv, 0, "", ""];
738
+ }).filter(([id, kv]) => typeof id === "string" && id.length > 0 && Array.isArray(kv) && (kv.length & 1) === 0).map(([id, kv]) => {
739
+ const values = this.values(kv);
740
+ const { idemKey = "", createdAt, job, ...data } = this.payload(values);
741
+ return [id, data, createdAt, job, idemKey];
742
+ });
743
+ }
744
+ values(value) {
745
+ const result = {};
746
+ for (let i = 0; i < value.length; i += 2) {
747
+ result[value[i]] = value[i + 1];
748
748
  }
749
- return tasks;
749
+ return result;
750
750
  }
751
- async ack(task) {
751
+ payload(data) {
752
752
  try {
753
- const raw = this.processingRaw.get(task.id);
754
- if (!isStrFilled(raw)) {
755
- return;
756
- }
757
- this.processingRaw.delete(task.id);
758
- await this.ackProcessing(this.processingKey(task.queueName), this.processingVtKey(task.queueName), raw);
759
- } catch {
753
+ return JSON.parse(data?.payload);
754
+ } catch (err) {
760
755
  }
756
+ return data;
757
+ }
758
+ signal() {
759
+ return this.abort.signal;
760
+ }
761
+ consumer() {
762
+ return `${String(this.consumerHost || "host")}:${process.pid}`;
761
763
  }
762
764
  };
763
765
  export {
764
- PowerQueue
766
+ PowerQueues
765
767
  };