@boringnode/queue 0.2.0 → 0.3.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,6 +1,7 @@
1
1
  import {
2
- calculateScore
3
- } from "../../chunk-NPQKBCCY.js";
2
+ calculateScore,
3
+ resolveRetention
4
+ } from "../../chunk-PBGPIFI5.js";
4
5
  import {
5
6
  DEFAULT_PRIORITY
6
7
  } from "../../chunk-SMOKFZ46.js";
@@ -11,26 +12,51 @@ import { Redis } from "ioredis";
11
12
  var redisKey = "jobs";
12
13
  var schedulesKey = "schedules";
13
14
  var schedulesIndexKey = "schedules::index";
15
+ var PUSH_JOB_SCRIPT = `
16
+ local data_key = KEYS[1]
17
+ local pending_key = KEYS[2]
18
+ local job_id = ARGV[1]
19
+ local job_data = ARGV[2]
20
+ local score = tonumber(ARGV[3])
21
+
22
+ redis.call('HSET', data_key, job_id, job_data)
23
+ redis.call('ZADD', pending_key, score, job_id)
24
+
25
+ return 1
26
+ `;
27
+ var PUSH_DELAYED_JOB_SCRIPT = `
28
+ local data_key = KEYS[1]
29
+ local delayed_key = KEYS[2]
30
+ local job_id = ARGV[1]
31
+ local job_data = ARGV[2]
32
+ local execute_at = tonumber(ARGV[3])
33
+
34
+ redis.call('HSET', data_key, job_id, job_data)
35
+ redis.call('ZADD', delayed_key, execute_at, job_id)
36
+
37
+ return 1
38
+ `;
14
39
  var ACQUIRE_JOB_SCRIPT = `
15
- local pending_key = KEYS[1]
16
- local active_key = KEYS[2]
17
- local delayed_key = KEYS[3]
40
+ local data_key = KEYS[1]
41
+ local pending_key = KEYS[2]
42
+ local active_key = KEYS[3]
43
+ local delayed_key = KEYS[4]
18
44
  local worker_id = ARGV[1]
19
- local now = ARGV[2]
20
-
21
- -- First, process delayed jobs
22
- local ready_jobs = redis.call('ZRANGEBYSCORE', delayed_key, 0, now)
23
- if #ready_jobs > 0 then
24
- for i = 1, #ready_jobs do
25
- local job_data = ready_jobs[i]
26
- local job = cjson.decode(job_data)
27
- -- Score = priority * 1e13 + timestamp
28
- -- Lower score = higher priority, FIFO within same priority
29
- local priority = job.priority or 5
30
- local timestamp = tonumber(now)
31
- local score = priority * 10000000000000 + timestamp
32
- redis.call('ZADD', pending_key, score, job_data)
33
- redis.call('ZREM', delayed_key, job_data)
45
+ local now = tonumber(ARGV[2])
46
+
47
+ -- Process delayed jobs: move ready jobs to pending
48
+ local ready_job_ids = redis.call('ZRANGEBYSCORE', delayed_key, 0, now)
49
+ if #ready_job_ids > 0 then
50
+ for i = 1, #ready_job_ids do
51
+ local job_id = ready_job_ids[i]
52
+ local job_data = redis.call('HGET', data_key, job_id)
53
+ if job_data then
54
+ local job = cjson.decode(job_data)
55
+ local priority = job.priority or 5
56
+ local score = priority * 10000000000000 + now
57
+ redis.call('ZADD', pending_key, score, job_id)
58
+ redis.call('ZREM', delayed_key, job_id)
59
+ end
34
60
  end
35
61
  end
36
62
 
@@ -40,76 +66,139 @@ var ACQUIRE_JOB_SCRIPT = `
40
66
  return nil
41
67
  end
42
68
 
43
- local job_data = result[1]
44
- local job = cjson.decode(job_data)
69
+ local job_id = result[1]
70
+ local job_data = redis.call('HGET', data_key, job_id)
71
+ if not job_data then
72
+ return nil
73
+ end
45
74
 
46
- -- Store in active hash: jobId -> {workerId, acquiredAt, data}
75
+ -- Store in active hash (without data, it's in data_key)
47
76
  local active_data = cjson.encode({
48
77
  workerId = worker_id,
49
- acquiredAt = tonumber(now),
50
- data = job
78
+ acquiredAt = now
51
79
  })
52
- redis.call('HSET', active_key, job.id, active_data)
80
+ redis.call('HSET', active_key, job_id, active_data)
53
81
 
54
82
  -- Return job with acquiredAt
55
- job.acquiredAt = tonumber(now)
83
+ local job = cjson.decode(job_data)
84
+ job.acquiredAt = now
56
85
  return cjson.encode(job)
57
86
  `;
58
- var COMPLETE_JOB_SCRIPT = `
59
- local active_key = KEYS[1]
87
+ var REMOVE_JOB_SCRIPT = `
88
+ local data_key = KEYS[1]
89
+ local active_key = KEYS[2]
60
90
  local job_id = ARGV[1]
61
91
 
92
+ if redis.call('HEXISTS', active_key, job_id) == 0 then
93
+ return 0
94
+ end
95
+
62
96
  redis.call('HDEL', active_key, job_id)
97
+ redis.call('HDEL', data_key, job_id)
98
+
63
99
  return 1
64
100
  `;
65
- var FAIL_JOB_SCRIPT = `
66
- local active_key = KEYS[1]
101
+ var FINALIZE_JOB_SCRIPT = `
102
+ local data_key = KEYS[1]
103
+ local active_key = KEYS[2]
104
+ local history_key = KEYS[3]
105
+ local index_key = KEYS[4]
67
106
  local job_id = ARGV[1]
107
+ local now = tonumber(ARGV[2])
108
+ local max_age = tonumber(ARGV[3])
109
+ local max_count = tonumber(ARGV[4])
110
+ local error_message = ARGV[5]
111
+
112
+ -- Verify job is active
113
+ if redis.call('HEXISTS', active_key, job_id) == 0 then
114
+ return 0
115
+ end
68
116
 
117
+ -- Remove from active
69
118
  redis.call('HDEL', active_key, job_id)
119
+
120
+ -- Store finalization info (data stays in data_key)
121
+ local record = {
122
+ finishedAt = now
123
+ }
124
+ if error_message and error_message ~= '' then
125
+ record.error = error_message
126
+ end
127
+ redis.call('HSET', history_key, job_id, cjson.encode(record))
128
+ redis.call('ZADD', index_key, now, job_id)
129
+
130
+ -- Prune by age
131
+ if max_age and max_age > 0 then
132
+ local cutoff = now - max_age
133
+ local expired = redis.call('ZRANGEBYSCORE', index_key, 0, cutoff)
134
+ if #expired > 0 then
135
+ redis.call('ZREM', index_key, unpack(expired))
136
+ redis.call('HDEL', history_key, unpack(expired))
137
+ redis.call('HDEL', data_key, unpack(expired))
138
+ end
139
+ end
140
+
141
+ -- Prune by count
142
+ if max_count and max_count > 0 then
143
+ local size = tonumber(redis.call('ZCARD', index_key))
144
+ if size > max_count then
145
+ local excess = size - max_count
146
+ local stale = redis.call('ZRANGE', index_key, 0, excess - 1)
147
+ if #stale > 0 then
148
+ redis.call('ZREM', index_key, unpack(stale))
149
+ redis.call('HDEL', history_key, unpack(stale))
150
+ redis.call('HDEL', data_key, unpack(stale))
151
+ end
152
+ end
153
+ end
154
+
70
155
  return 1
71
156
  `;
72
157
  var RETRY_JOB_SCRIPT = `
73
- local active_key = KEYS[1]
74
- local pending_key = KEYS[2]
75
- local delayed_key = KEYS[3]
158
+ local data_key = KEYS[1]
159
+ local active_key = KEYS[2]
160
+ local pending_key = KEYS[3]
161
+ local delayed_key = KEYS[4]
76
162
  local job_id = ARGV[1]
77
163
  local retry_at = tonumber(ARGV[2])
78
164
  local now = tonumber(ARGV[3])
79
165
 
80
- -- Get job from active hash
81
- local active_data = redis.call('HGET', active_key, job_id)
82
- if not active_data then
166
+ -- Verify job is active
167
+ if redis.call('HEXISTS', active_key, job_id) == 0 then
83
168
  return 0
84
169
  end
85
170
 
86
- local active = cjson.decode(active_data)
87
- local job = active.data
171
+ -- Get job data
172
+ local job_data = redis.call('HGET', data_key, job_id)
173
+ if not job_data then
174
+ return 0
175
+ end
88
176
 
89
177
  -- Remove from active
90
178
  redis.call('HDEL', active_key, job_id)
91
179
 
92
- -- Increment attempts
180
+ -- Increment attempts and update data
181
+ local job = cjson.decode(job_data)
93
182
  job.attempts = (job.attempts or 0) + 1
94
-
95
- local job_data = cjson.encode(job)
183
+ redis.call('HSET', data_key, job_id, cjson.encode(job))
96
184
 
97
185
  -- Add back to pending or delayed
98
186
  if retry_at and retry_at > now then
99
- redis.call('ZADD', delayed_key, retry_at, job_data)
187
+ redis.call('ZADD', delayed_key, retry_at, job_id)
100
188
  else
101
189
  -- Score = priority * 1e13 + timestamp
102
190
  -- Lower score = higher priority, FIFO within same priority
103
191
  local priority = job.priority or 5
104
192
  local score = priority * 10000000000000 + now
105
- redis.call('ZADD', pending_key, score, job_data)
193
+ redis.call('ZADD', pending_key, score, job_id)
106
194
  end
107
195
 
108
196
  return 1
109
197
  `;
110
198
  var RECOVER_STALLED_JOBS_SCRIPT = `
111
- local active_key = KEYS[1]
112
- local pending_key = KEYS[2]
199
+ local data_key = KEYS[1]
200
+ local active_key = KEYS[2]
201
+ local pending_key = KEYS[3]
113
202
  local now = tonumber(ARGV[1])
114
203
  local stalled_threshold = tonumber(ARGV[2])
115
204
  local max_stalled_count = tonumber(ARGV[3])
@@ -128,31 +217,87 @@ var RECOVER_STALLED_JOBS_SCRIPT = `
128
217
 
129
218
  -- Check if job is stalled
130
219
  if active.acquiredAt < stalled_cutoff then
131
- local job = active.data
132
- local current_stalled_count = job.stalledCount or 0
133
-
134
- -- Remove from active hash
135
- redis.call('HDEL', active_key, job_id)
136
-
137
- -- Check if job has exceeded max stalled count
138
- if current_stalled_count >= max_stalled_count then
139
- -- Job failed permanently, just remove (already done above)
140
- else
141
- -- Recover: increment stalledCount and put back in pending
142
- job.stalledCount = current_stalled_count + 1
143
- local job_data = cjson.encode(job)
144
- -- Score = priority * 1e13 + timestamp
145
- -- Lower score = higher priority, FIFO within same priority
146
- local priority = job.priority or 5
147
- local score = priority * 10000000000000 + now
148
- redis.call('ZADD', pending_key, score, job_data)
149
- recovered = recovered + 1
220
+ local job_data = redis.call('HGET', data_key, job_id)
221
+ if job_data then
222
+ local job = cjson.decode(job_data)
223
+ local current_stalled_count = job.stalledCount or 0
224
+
225
+ -- Remove from active hash
226
+ redis.call('HDEL', active_key, job_id)
227
+
228
+ -- Check if job has exceeded max stalled count
229
+ if current_stalled_count >= max_stalled_count then
230
+ -- Job failed permanently, remove data too
231
+ redis.call('HDEL', data_key, job_id)
232
+ else
233
+ -- Recover: increment stalledCount and put back in pending
234
+ job.stalledCount = current_stalled_count + 1
235
+ redis.call('HSET', data_key, job_id, cjson.encode(job))
236
+ -- Score = priority * 1e13 + timestamp
237
+ local priority = job.priority or 5
238
+ local score = priority * 10000000000000 + now
239
+ redis.call('ZADD', pending_key, score, job_id)
240
+ recovered = recovered + 1
241
+ end
150
242
  end
151
243
  end
152
244
  end
153
245
 
154
246
  return recovered
155
247
  `;
248
+ var GET_JOB_SCRIPT = `
249
+ local data_key = KEYS[1]
250
+ local pending_key = KEYS[2]
251
+ local delayed_key = KEYS[3]
252
+ local active_key = KEYS[4]
253
+ local completed_key = KEYS[5]
254
+ local failed_key = KEYS[6]
255
+ local job_id = ARGV[1]
256
+
257
+ local job_data = redis.call('HGET', data_key, job_id)
258
+ if not job_data then
259
+ return nil
260
+ end
261
+
262
+ local status = nil
263
+ local finished_at = nil
264
+ local error_msg = nil
265
+
266
+ -- Check status in order
267
+ if redis.call('HEXISTS', active_key, job_id) == 1 then
268
+ status = 'active'
269
+ elseif redis.call('ZSCORE', pending_key, job_id) then
270
+ status = 'pending'
271
+ elseif redis.call('ZSCORE', delayed_key, job_id) then
272
+ status = 'delayed'
273
+ else
274
+ local completed_data = redis.call('HGET', completed_key, job_id)
275
+ if completed_data then
276
+ status = 'completed'
277
+ local record = cjson.decode(completed_data)
278
+ finished_at = record.finishedAt
279
+ else
280
+ local failed_data = redis.call('HGET', failed_key, job_id)
281
+ if failed_data then
282
+ status = 'failed'
283
+ local record = cjson.decode(failed_data)
284
+ finished_at = record.finishedAt
285
+ error_msg = record.error
286
+ end
287
+ end
288
+ end
289
+
290
+ if not status then
291
+ return nil
292
+ end
293
+
294
+ return cjson.encode({
295
+ status = status,
296
+ data = cjson.decode(job_data),
297
+ finishedAt = finished_at,
298
+ error = error_msg
299
+ })
300
+ `;
156
301
  var CLAIM_SCHEDULE_SCRIPT = `
157
302
  local schedule_key = KEYS[1]
158
303
  local now = tonumber(ARGV[1])
@@ -246,6 +391,18 @@ var RedisAdapter = class {
246
391
  this.#connection = connection;
247
392
  this.#ownsConnection = ownsConnection;
248
393
  }
394
+ #getKeys(queue) {
395
+ return {
396
+ data: `${redisKey}::${queue}::data`,
397
+ pending: `${redisKey}::${queue}::pending`,
398
+ delayed: `${redisKey}::${queue}::delayed`,
399
+ active: `${redisKey}::${queue}::active`,
400
+ completed: `${redisKey}::${queue}::completed`,
401
+ completedIndex: `${redisKey}::${queue}::completed::index`,
402
+ failed: `${redisKey}::${queue}::failed`,
403
+ failedIndex: `${redisKey}::${queue}::failed::index`
404
+ };
405
+ }
249
406
  setWorkerId(workerId) {
250
407
  this.#workerId = workerId;
251
408
  }
@@ -258,16 +415,15 @@ var RedisAdapter = class {
258
415
  return this.popFrom("default");
259
416
  }
260
417
  async popFrom(queue) {
418
+ const keys = this.#getKeys(queue);
261
419
  const now = Date.now();
262
- const pendingKey = `${redisKey}::${queue}`;
263
- const activeKey = `${redisKey}::${queue}::active`;
264
- const delayedKey = `${redisKey}::delayed::${queue}`;
265
420
  const result = await this.#connection.eval(
266
421
  ACQUIRE_JOB_SCRIPT,
267
- 3,
268
- pendingKey,
269
- activeKey,
270
- delayedKey,
422
+ 4,
423
+ keys.data,
424
+ keys.pending,
425
+ keys.active,
426
+ keys.delayed,
271
427
  this.#workerId,
272
428
  now.toString()
273
429
  );
@@ -276,30 +432,81 @@ var RedisAdapter = class {
276
432
  }
277
433
  return JSON.parse(result);
278
434
  }
279
- async completeJob(jobId, queue) {
280
- const activeKey = `${redisKey}::${queue}::active`;
281
- await this.#connection.eval(COMPLETE_JOB_SCRIPT, 1, activeKey, jobId);
435
+ async completeJob(jobId, queue, removeOnComplete) {
436
+ const keys = this.#getKeys(queue);
437
+ const { keep, maxAge, maxCount } = resolveRetention(removeOnComplete);
438
+ if (!keep) {
439
+ await this.#connection.eval(REMOVE_JOB_SCRIPT, 2, keys.data, keys.active, jobId);
440
+ return;
441
+ }
442
+ await this.#connection.eval(
443
+ FINALIZE_JOB_SCRIPT,
444
+ 4,
445
+ keys.data,
446
+ keys.active,
447
+ keys.completed,
448
+ keys.completedIndex,
449
+ jobId,
450
+ Date.now().toString(),
451
+ maxAge.toString(),
452
+ maxCount.toString(),
453
+ ""
454
+ );
282
455
  }
283
- async failJob(jobId, queue, _error) {
284
- const activeKey = `${redisKey}::${queue}::active`;
285
- await this.#connection.eval(FAIL_JOB_SCRIPT, 1, activeKey, jobId);
456
+ async failJob(jobId, queue, error, removeOnFail) {
457
+ const keys = this.#getKeys(queue);
458
+ const { keep, maxAge, maxCount } = resolveRetention(removeOnFail);
459
+ if (!keep) {
460
+ await this.#connection.eval(REMOVE_JOB_SCRIPT, 2, keys.data, keys.active, jobId);
461
+ return;
462
+ }
463
+ await this.#connection.eval(
464
+ FINALIZE_JOB_SCRIPT,
465
+ 4,
466
+ keys.data,
467
+ keys.active,
468
+ keys.failed,
469
+ keys.failedIndex,
470
+ jobId,
471
+ Date.now().toString(),
472
+ maxAge.toString(),
473
+ maxCount.toString(),
474
+ error?.message || ""
475
+ );
286
476
  }
287
477
  async retryJob(jobId, queue, retryAt) {
478
+ const keys = this.#getKeys(queue);
288
479
  const now = Date.now();
289
- const activeKey = `${redisKey}::${queue}::active`;
290
- const pendingKey = `${redisKey}::${queue}`;
291
- const delayedKey = `${redisKey}::delayed::${queue}`;
292
480
  await this.#connection.eval(
293
481
  RETRY_JOB_SCRIPT,
294
- 3,
295
- activeKey,
296
- pendingKey,
297
- delayedKey,
482
+ 4,
483
+ keys.data,
484
+ keys.active,
485
+ keys.pending,
486
+ keys.delayed,
298
487
  jobId,
299
488
  retryAt ? retryAt.getTime().toString() : "0",
300
489
  now.toString()
301
490
  );
302
491
  }
492
+ async getJob(jobId, queue) {
493
+ const keys = this.#getKeys(queue);
494
+ const result = await this.#connection.eval(
495
+ GET_JOB_SCRIPT,
496
+ 6,
497
+ keys.data,
498
+ keys.pending,
499
+ keys.delayed,
500
+ keys.active,
501
+ keys.completed,
502
+ keys.failed,
503
+ jobId
504
+ );
505
+ if (!result) {
506
+ return null;
507
+ }
508
+ return JSON.parse(result);
509
+ }
303
510
  push(jobData) {
304
511
  return this.pushOn("default", jobData);
305
512
  }
@@ -307,31 +514,65 @@ var RedisAdapter = class {
307
514
  return this.pushLaterOn("default", jobData, delay);
308
515
  }
309
516
  async pushLaterOn(queue, jobData, delay) {
517
+ const keys = this.#getKeys(queue);
310
518
  const executeAt = Date.now() + delay;
311
- const delayedKey = `${redisKey}::delayed::${queue}`;
312
- await this.#connection.zadd(delayedKey, executeAt, JSON.stringify(jobData));
519
+ await this.#connection.eval(
520
+ PUSH_DELAYED_JOB_SCRIPT,
521
+ 2,
522
+ keys.data,
523
+ keys.delayed,
524
+ jobData.id,
525
+ JSON.stringify(jobData),
526
+ executeAt.toString()
527
+ );
313
528
  }
314
529
  async pushOn(queue, jobData) {
530
+ const keys = this.#getKeys(queue);
315
531
  const priority = jobData.priority ?? DEFAULT_PRIORITY;
316
532
  const timestamp = Date.now();
317
533
  const score = calculateScore(priority, timestamp);
318
- await this.#connection.zadd(`${redisKey}::${queue}`, score, JSON.stringify(jobData));
534
+ await this.#connection.eval(
535
+ PUSH_JOB_SCRIPT,
536
+ 2,
537
+ keys.data,
538
+ keys.pending,
539
+ jobData.id,
540
+ JSON.stringify(jobData),
541
+ score.toString()
542
+ );
543
+ }
544
+ pushMany(jobs) {
545
+ return this.pushManyOn("default", jobs);
546
+ }
547
+ async pushManyOn(queue, jobs) {
548
+ if (jobs.length === 0) return;
549
+ const keys = this.#getKeys(queue);
550
+ const now = Date.now();
551
+ const multi = this.#connection.multi();
552
+ for (const job of jobs) {
553
+ const priority = job.priority ?? DEFAULT_PRIORITY;
554
+ const score = calculateScore(priority, now);
555
+ multi.hset(keys.data, job.id, JSON.stringify(job));
556
+ multi.zadd(keys.pending, score, job.id);
557
+ }
558
+ await multi.exec();
319
559
  }
320
560
  size() {
321
561
  return this.sizeOf("default");
322
562
  }
323
563
  sizeOf(queue) {
324
- return this.#connection.zcard(`${redisKey}::${queue}`);
564
+ const keys = this.#getKeys(queue);
565
+ return this.#connection.zcard(keys.pending);
325
566
  }
326
567
  async recoverStalledJobs(queue, stalledThreshold, maxStalledCount) {
568
+ const keys = this.#getKeys(queue);
327
569
  const now = Date.now();
328
- const activeKey = `${redisKey}::${queue}::active`;
329
- const pendingKey = `${redisKey}::${queue}`;
330
570
  const recovered = await this.#connection.eval(
331
571
  RECOVER_STALLED_JOBS_SCRIPT,
332
- 2,
333
- activeKey,
334
- pendingKey,
572
+ 3,
573
+ keys.data,
574
+ keys.active,
575
+ keys.pending,
335
576
  now.toString(),
336
577
  stalledThreshold.toString(),
337
578
  maxStalledCount.toString()