@nicnocquee/dataqueue 1.38.0 → 1.39.0-beta.20260322125514
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/ai/docs-content.json +11 -5
- package/ai/rules/advanced.md +27 -0
- package/ai/rules/basic.md +1 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +40 -1
- package/ai/skills/dataqueue-core/SKILL.md +9 -0
- package/dist/index.cjs +563 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -2
- package/dist/index.d.ts +93 -2
- package/dist/index.js +558 -62
- package/dist/index.js.map +1 -1
- package/migrations/1781200000009_add_depends_on_to_job_queue.sql +10 -0
- package/package.json +1 -1
- package/src/backends/postgres.ts +254 -29
- package/src/backends/redis-scripts.ts +100 -4
- package/src/backends/redis.ts +194 -24
- package/src/index.ts +8 -0
- package/src/job-dependencies.test.ts +129 -0
- package/src/job-dependencies.ts +140 -0
- package/src/types.ts +36 -0
|
@@ -32,7 +32,8 @@ const SCORE_RANGE = '1000000000000000'; // 1e15
|
|
|
32
32
|
* KEYS: [prefix]
|
|
33
33
|
* ARGV: [jobType, payloadJson, maxAttempts, priority, runAtMs, timeoutMs,
|
|
34
34
|
* forceKillOnTimeout, tagsJson, idempotencyKey, nowMs,
|
|
35
|
-
* retryDelay, retryBackoff, retryDelayMax, deadLetterJobType, groupId, groupTier
|
|
35
|
+
* retryDelay, retryBackoff, retryDelayMax, deadLetterJobType, groupId, groupTier,
|
|
36
|
+
* dependsOnJobIdsJson, dependsOnTagsJson]
|
|
36
37
|
* Returns: job ID (number)
|
|
37
38
|
*/
|
|
38
39
|
export const ADD_JOB_SCRIPT = `
|
|
@@ -53,6 +54,8 @@ local retryDelayMax = ARGV[13] -- "null" or seconds string
|
|
|
53
54
|
local deadLetterJobType = ARGV[14] -- "null" or jobType string
|
|
54
55
|
local groupId = ARGV[15] -- "null" or group ID
|
|
55
56
|
local groupTier = ARGV[16] -- "null" or group tier
|
|
57
|
+
local dependsOnJobIdsJson = ARGV[17] -- "null" or JSON array of job ids
|
|
58
|
+
local dependsOnTagsJson = ARGV[18] -- "null" or JSON array of tags
|
|
56
59
|
|
|
57
60
|
-- Idempotency check
|
|
58
61
|
if idempotencyKey ~= "null" then
|
|
@@ -104,9 +107,18 @@ redis.call('HMSET', jobKey,
|
|
|
104
107
|
'deadLetteredAt', 'null',
|
|
105
108
|
'deadLetterJobId', 'null',
|
|
106
109
|
'groupId', groupId,
|
|
107
|
-
'groupTier', groupTier
|
|
110
|
+
'groupTier', groupTier,
|
|
111
|
+
'dependsOnJobIds', dependsOnJobIdsJson,
|
|
112
|
+
'dependsOnTags', dependsOnTagsJson
|
|
108
113
|
)
|
|
109
114
|
|
|
115
|
+
if dependsOnJobIdsJson ~= "null" then
|
|
116
|
+
local depIds = cjson.decode(dependsOnJobIdsJson)
|
|
117
|
+
for _, parentId in ipairs(depIds) do
|
|
118
|
+
redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
110
122
|
-- Status index
|
|
111
123
|
redis.call('SADD', prefix .. 'status:pending', id)
|
|
112
124
|
|
|
@@ -155,6 +167,9 @@ return id
|
|
|
155
167
|
* runAtMs, timeoutMs, forceKillOnTimeout, tags (JSON or "null"),
|
|
156
168
|
* idempotencyKey, retryDelay, retryBackoff, retryDelayMax, deadLetterJobType
|
|
157
169
|
* Returns: array of job IDs (one per input job, in order)
|
|
170
|
+
*
|
|
171
|
+
* Note: JSON null decodes to cjson.null, which is truthy in Lua; optional fields must
|
|
172
|
+
* be normalized with explicit nil/cjson.null checks before tostring/cjson.decode.
|
|
158
173
|
*/
|
|
159
174
|
export const ADD_JOBS_SCRIPT = `
|
|
160
175
|
local prefix = KEYS[1]
|
|
@@ -180,6 +195,8 @@ for i, job in ipairs(jobs) do
|
|
|
180
195
|
local deadLetterJobType = tostring(job.deadLetterJobType)
|
|
181
196
|
local groupId = tostring(job.groupId)
|
|
182
197
|
local groupTier = tostring(job.groupTier)
|
|
198
|
+
local dependsOnJobIdsJson = (job.dependsOnJobIds ~= nil and job.dependsOnJobIds ~= cjson.null) and tostring(job.dependsOnJobIds) or "null"
|
|
199
|
+
local dependsOnTagsJson = (job.dependsOnTags ~= nil and job.dependsOnTags ~= cjson.null) and tostring(job.dependsOnTags) or "null"
|
|
183
200
|
|
|
184
201
|
-- Idempotency check
|
|
185
202
|
local skip = false
|
|
@@ -234,9 +251,18 @@ for i, job in ipairs(jobs) do
|
|
|
234
251
|
'deadLetteredAt', 'null',
|
|
235
252
|
'deadLetterJobId', 'null',
|
|
236
253
|
'groupId', groupId,
|
|
237
|
-
'groupTier', groupTier
|
|
254
|
+
'groupTier', groupTier,
|
|
255
|
+
'dependsOnJobIds', dependsOnJobIdsJson,
|
|
256
|
+
'dependsOnTags', dependsOnTagsJson
|
|
238
257
|
)
|
|
239
258
|
|
|
259
|
+
if dependsOnJobIdsJson ~= "null" then
|
|
260
|
+
local depIds = cjson.decode(dependsOnJobIdsJson)
|
|
261
|
+
for _, parentId in ipairs(depIds) do
|
|
262
|
+
redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
240
266
|
-- Status index
|
|
241
267
|
redis.call('SADD', prefix .. 'status:pending', id)
|
|
242
268
|
|
|
@@ -409,6 +435,48 @@ for i = 1, #candidates, 2 do
|
|
|
409
435
|
end
|
|
410
436
|
end
|
|
411
437
|
|
|
438
|
+
if canClaim then
|
|
439
|
+
local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
|
|
440
|
+
local depTagsJson = redis.call('HGET', jk, 'dependsOnTags')
|
|
441
|
+
local depsOk = true
|
|
442
|
+
if depIdsJson and depIdsJson ~= 'null' then
|
|
443
|
+
local dids = cjson.decode(depIdsJson)
|
|
444
|
+
for _, pid in ipairs(dids) do
|
|
445
|
+
local pst = redis.call('HGET', prefix .. 'job:' .. pid, 'status')
|
|
446
|
+
if pst ~= 'completed' then depsOk = false break end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
if depsOk and depTagsJson and depTagsJson ~= 'null' then
|
|
450
|
+
local req = cjson.decode(depTagsJson)
|
|
451
|
+
if #req > 0 then
|
|
452
|
+
for _, stname in ipairs({'pending','processing','waiting'}) do
|
|
453
|
+
local members = redis.call('SMEMBERS', prefix .. 'status:' .. stname)
|
|
454
|
+
for _, oid in ipairs(members) do
|
|
455
|
+
if oid ~= jobId then
|
|
456
|
+
local otags = redis.call('HGET', prefix .. 'job:' .. oid, 'tags')
|
|
457
|
+
if otags and otags ~= 'null' then
|
|
458
|
+
local oarr = cjson.decode(otags)
|
|
459
|
+
local tagset = {}
|
|
460
|
+
for _, t in ipairs(oarr) do tagset[t] = true end
|
|
461
|
+
local all = true
|
|
462
|
+
for _, rt in ipairs(req) do
|
|
463
|
+
if not tagset[rt] then all = false break end
|
|
464
|
+
end
|
|
465
|
+
if all then depsOk = false break end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
if not depsOk then break end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
if not depsOk then
|
|
474
|
+
table.insert(putBack, score)
|
|
475
|
+
table.insert(putBack, jobId)
|
|
476
|
+
canClaim = false
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
412
480
|
if canClaim then
|
|
413
481
|
-- Claim this job
|
|
414
482
|
local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
|
|
@@ -495,6 +563,14 @@ if groupId and groupId ~= 'null' then
|
|
|
495
563
|
end
|
|
496
564
|
end
|
|
497
565
|
|
|
566
|
+
local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
|
|
567
|
+
if depIdsJson and depIdsJson ~= 'null' then
|
|
568
|
+
local dids = cjson.decode(depIdsJson)
|
|
569
|
+
for _, pid in ipairs(dids) do
|
|
570
|
+
redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
498
574
|
return 1
|
|
499
575
|
`;
|
|
500
576
|
|
|
@@ -650,7 +726,11 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
|
|
|
650
726
|
'retryDelayMax', 'null',
|
|
651
727
|
'deadLetterJobType', 'null',
|
|
652
728
|
'deadLetteredAt', 'null',
|
|
653
|
-
'deadLetterJobId', 'null'
|
|
729
|
+
'deadLetterJobId', 'null',
|
|
730
|
+
'dependsOnJobIds', 'null',
|
|
731
|
+
'dependsOnTags', 'null',
|
|
732
|
+
'groupId', 'null',
|
|
733
|
+
'groupTier', 'null'
|
|
654
734
|
)
|
|
655
735
|
|
|
656
736
|
redis.call('SADD', prefix .. 'status:pending', deadLetterJobId)
|
|
@@ -665,6 +745,14 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
|
|
|
665
745
|
)
|
|
666
746
|
end
|
|
667
747
|
|
|
748
|
+
local depIdsJsonFail = redis.call('HGET', jk, 'dependsOnJobIds')
|
|
749
|
+
if depIdsJsonFail and depIdsJsonFail ~= 'null' then
|
|
750
|
+
local dids = cjson.decode(depIdsJsonFail)
|
|
751
|
+
for _, pid in ipairs(dids) do
|
|
752
|
+
redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
668
756
|
return deadLetterJobId
|
|
669
757
|
`;
|
|
670
758
|
|
|
@@ -743,6 +831,14 @@ redis.call('ZREM', prefix .. 'queue', jobId)
|
|
|
743
831
|
redis.call('ZREM', prefix .. 'delayed', jobId)
|
|
744
832
|
redis.call('ZREM', prefix .. 'waiting', jobId)
|
|
745
833
|
|
|
834
|
+
local depIdsJsonCan = redis.call('HGET', jk, 'dependsOnJobIds')
|
|
835
|
+
if depIdsJsonCan and depIdsJsonCan ~= 'null' then
|
|
836
|
+
local dids = cjson.decode(depIdsJsonCan)
|
|
837
|
+
for _, pid in ipairs(dids) do
|
|
838
|
+
redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
|
|
746
842
|
return 1
|
|
747
843
|
`;
|
|
748
844
|
|
package/src/backends/redis.ts
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
CronScheduleInput,
|
|
24
24
|
} from '../backend.js';
|
|
25
25
|
import { log } from '../log-context.js';
|
|
26
|
+
import {
|
|
27
|
+
normalizeDependsOn,
|
|
28
|
+
resolveDependsOnJobIdsForBatch,
|
|
29
|
+
tagsAreSuperset,
|
|
30
|
+
} from '../job-dependencies.js';
|
|
26
31
|
|
|
27
32
|
const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
|
|
28
33
|
|
|
@@ -181,9 +186,31 @@ function deserializeJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
|
181
186
|
groupId: nullish(h.groupId) as string | null | undefined,
|
|
182
187
|
groupTier: nullish(h.groupTier) as string | null | undefined,
|
|
183
188
|
output: parseJsonField(h.output),
|
|
189
|
+
dependsOnJobIds: parseOptionalIntArray(h.dependsOnJobIds),
|
|
190
|
+
dependsOnTags: parseOptionalStringArray(h.dependsOnTags),
|
|
184
191
|
};
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
function parseOptionalIntArray(raw: string | undefined): number[] | null {
|
|
195
|
+
if (!raw || raw === 'null') return null;
|
|
196
|
+
try {
|
|
197
|
+
const arr = JSON.parse(raw) as number[];
|
|
198
|
+
return Array.isArray(arr) && arr.length > 0 ? arr : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseOptionalStringArray(raw: string | undefined): string[] | null {
|
|
205
|
+
if (!raw || raw === 'null') return null;
|
|
206
|
+
try {
|
|
207
|
+
const arr = JSON.parse(raw) as string[];
|
|
208
|
+
return Array.isArray(arr) && arr.length > 0 ? arr : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
187
214
|
/** Parse a JSON field from a Redis hash, returning null for missing/null values. */
|
|
188
215
|
function parseJsonField(raw: string | undefined): unknown {
|
|
189
216
|
if (!raw || raw === 'null') return null;
|
|
@@ -271,6 +298,76 @@ export class RedisBackend implements QueueBackend {
|
|
|
271
298
|
return Date.now();
|
|
272
299
|
}
|
|
273
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Cancel pending/waiting jobs that depend on seed jobs (job id or tag), transitively.
|
|
303
|
+
*
|
|
304
|
+
* @param initialSeeds - Job ids that failed or were cancelled.
|
|
305
|
+
* @param rootJobId - Root id for event metadata.
|
|
306
|
+
*/
|
|
307
|
+
private async propagateDependencyCancellationsRedis(
|
|
308
|
+
initialSeeds: number[],
|
|
309
|
+
rootJobId: number,
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
const cancelled = new Set<number>();
|
|
312
|
+
let frontier = [...new Set(initialSeeds.filter((id) => id > 0))];
|
|
313
|
+
|
|
314
|
+
while (frontier.length > 0) {
|
|
315
|
+
const pendingRaw = await this.client.sunion(
|
|
316
|
+
`${this.prefix}status:pending`,
|
|
317
|
+
`${this.prefix}status:waiting`,
|
|
318
|
+
);
|
|
319
|
+
const toCancel: number[] = [];
|
|
320
|
+
|
|
321
|
+
for (const pidStr of pendingRaw) {
|
|
322
|
+
const pid = Number(pidStr);
|
|
323
|
+
if (cancelled.has(pid)) continue;
|
|
324
|
+
const job = await this.getJob<any, any>(pid);
|
|
325
|
+
if (!job || (job.status !== 'pending' && job.status !== 'waiting')) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const seedId of frontier) {
|
|
330
|
+
if (pid === seedId) continue;
|
|
331
|
+
const seedJob = await this.getJob<any, any>(seedId);
|
|
332
|
+
if (!seedJob) continue;
|
|
333
|
+
|
|
334
|
+
const byJobId = job.dependsOnJobIds?.includes(seedId) ?? false;
|
|
335
|
+
const byTag =
|
|
336
|
+
job.dependsOnTags &&
|
|
337
|
+
job.dependsOnTags.length > 0 &&
|
|
338
|
+
tagsAreSuperset(seedJob.tags, job.dependsOnTags);
|
|
339
|
+
|
|
340
|
+
if (byJobId || byTag) {
|
|
341
|
+
toCancel.push(pid);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (toCancel.length === 0) break;
|
|
348
|
+
|
|
349
|
+
const now = this.nowMs();
|
|
350
|
+
for (const jid of toCancel) {
|
|
351
|
+
const ok = await this.client.eval(
|
|
352
|
+
CANCEL_JOB_SCRIPT,
|
|
353
|
+
1,
|
|
354
|
+
this.prefix,
|
|
355
|
+
jid,
|
|
356
|
+
now,
|
|
357
|
+
);
|
|
358
|
+
if (Number(ok) === 1) {
|
|
359
|
+
cancelled.add(jid);
|
|
360
|
+
await this.recordJobEvent(jid, JobEventType.Cancelled, {
|
|
361
|
+
rootJobId,
|
|
362
|
+
dependencyCascade: true,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
frontier = toCancel;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
274
371
|
// ── Events ──────────────────────────────────────────────────────────
|
|
275
372
|
|
|
276
373
|
async recordJobEvent(
|
|
@@ -327,6 +424,7 @@ export class RedisBackend implements QueueBackend {
|
|
|
327
424
|
retryDelayMax = undefined,
|
|
328
425
|
deadLetterJobType = undefined,
|
|
329
426
|
group = undefined,
|
|
427
|
+
dependsOn,
|
|
330
428
|
}: JobOptions<PayloadMap, T>,
|
|
331
429
|
options?: AddJobOptions,
|
|
332
430
|
): Promise<number> {
|
|
@@ -336,6 +434,20 @@ export class RedisBackend implements QueueBackend {
|
|
|
336
434
|
'Transactional job creation is only available with PostgreSQL.',
|
|
337
435
|
);
|
|
338
436
|
}
|
|
437
|
+
const { jobIds: depJobIdsRaw, tags: depTags } =
|
|
438
|
+
normalizeDependsOn(dependsOn);
|
|
439
|
+
if (depJobIdsRaw?.some((id) => id < 0)) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
'dependsOn.jobIds: batch-relative (negative) ids are only supported in addJobs()',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const dependsOnJobIdsJson =
|
|
445
|
+
depJobIdsRaw && depJobIdsRaw.length > 0
|
|
446
|
+
? JSON.stringify(depJobIdsRaw)
|
|
447
|
+
: 'null';
|
|
448
|
+
const dependsOnTagsJson =
|
|
449
|
+
depTags && depTags.length > 0 ? JSON.stringify(depTags) : 'null';
|
|
450
|
+
|
|
339
451
|
const now = this.nowMs();
|
|
340
452
|
const runAtMs = runAt ? runAt.getTime() : 0;
|
|
341
453
|
|
|
@@ -359,6 +471,8 @@ export class RedisBackend implements QueueBackend {
|
|
|
359
471
|
deadLetterJobType ?? 'null',
|
|
360
472
|
group?.id ?? 'null',
|
|
361
473
|
group?.tier ?? 'null',
|
|
474
|
+
dependsOnJobIdsJson,
|
|
475
|
+
dependsOnTagsJson,
|
|
362
476
|
)) as number;
|
|
363
477
|
|
|
364
478
|
const jobId = Number(result);
|
|
@@ -370,6 +484,10 @@ export class RedisBackend implements QueueBackend {
|
|
|
370
484
|
payload,
|
|
371
485
|
tags,
|
|
372
486
|
idempotencyKey,
|
|
487
|
+
dependsOn:
|
|
488
|
+
dependsOnJobIdsJson !== 'null' || dependsOnTagsJson !== 'null'
|
|
489
|
+
? dependsOn
|
|
490
|
+
: undefined,
|
|
373
491
|
});
|
|
374
492
|
return jobId;
|
|
375
493
|
}
|
|
@@ -391,29 +509,67 @@ export class RedisBackend implements QueueBackend {
|
|
|
391
509
|
);
|
|
392
510
|
}
|
|
393
511
|
|
|
512
|
+
const needsSequential = jobs.some((j) => {
|
|
513
|
+
const n = normalizeDependsOn(j.dependsOn);
|
|
514
|
+
return Boolean(n.jobIds?.length || n.tags?.length);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (needsSequential) {
|
|
518
|
+
const ids: number[] = [];
|
|
519
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
520
|
+
let job = jobs[i]!;
|
|
521
|
+
const nd = normalizeDependsOn(job.dependsOn);
|
|
522
|
+
if (nd.jobIds?.some((id) => id < 0)) {
|
|
523
|
+
const resolvedJobIds = resolveDependsOnJobIdsForBatch(
|
|
524
|
+
nd.jobIds!,
|
|
525
|
+
ids,
|
|
526
|
+
);
|
|
527
|
+
job = {
|
|
528
|
+
...job,
|
|
529
|
+
dependsOn: {
|
|
530
|
+
jobIds: resolvedJobIds,
|
|
531
|
+
tags: job.dependsOn?.tags,
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
ids.push(await this.addJob(job));
|
|
536
|
+
}
|
|
537
|
+
log(
|
|
538
|
+
`Batch-inserted ${jobs.length} jobs (sequential), IDs: [${ids.join(', ')}]`,
|
|
539
|
+
);
|
|
540
|
+
return ids;
|
|
541
|
+
}
|
|
542
|
+
|
|
394
543
|
const now = this.nowMs();
|
|
395
544
|
|
|
396
|
-
const jobsPayload = jobs.map((job) =>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
job.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
545
|
+
const jobsPayload = jobs.map((job) => {
|
|
546
|
+
const nd = normalizeDependsOn(job.dependsOn);
|
|
547
|
+
return {
|
|
548
|
+
jobType: job.jobType,
|
|
549
|
+
payload: JSON.stringify(job.payload),
|
|
550
|
+
maxAttempts: job.maxAttempts ?? 3,
|
|
551
|
+
priority: job.priority ?? 0,
|
|
552
|
+
runAtMs: job.runAt ? job.runAt.getTime() : 0,
|
|
553
|
+
timeoutMs:
|
|
554
|
+
job.timeoutMs !== undefined ? job.timeoutMs.toString() : 'null',
|
|
555
|
+
forceKillOnTimeout: job.forceKillOnTimeout ? 'true' : 'false',
|
|
556
|
+
tags: job.tags ? JSON.stringify(job.tags) : 'null',
|
|
557
|
+
idempotencyKey: job.idempotencyKey ?? 'null',
|
|
558
|
+
retryDelay:
|
|
559
|
+
job.retryDelay !== undefined ? job.retryDelay.toString() : 'null',
|
|
560
|
+
retryBackoff:
|
|
561
|
+
job.retryBackoff !== undefined ? job.retryBackoff.toString() : 'null',
|
|
562
|
+
retryDelayMax:
|
|
563
|
+
job.retryDelayMax !== undefined
|
|
564
|
+
? job.retryDelayMax.toString()
|
|
565
|
+
: 'null',
|
|
566
|
+
deadLetterJobType: job.deadLetterJobType ?? 'null',
|
|
567
|
+
groupId: job.group?.id ?? 'null',
|
|
568
|
+
groupTier: job.group?.tier ?? 'null',
|
|
569
|
+
dependsOnJobIds: nd.jobIds?.length ? JSON.stringify(nd.jobIds) : null,
|
|
570
|
+
dependsOnTags: nd.tags?.length ? JSON.stringify(nd.tags) : null,
|
|
571
|
+
};
|
|
572
|
+
});
|
|
417
573
|
|
|
418
574
|
const result = (await this.client.eval(
|
|
419
575
|
ADD_JOBS_SCRIPT,
|
|
@@ -665,6 +821,7 @@ export class RedisBackend implements QueueBackend {
|
|
|
665
821
|
failureReason,
|
|
666
822
|
deadLetterJobId,
|
|
667
823
|
});
|
|
824
|
+
await this.propagateDependencyCancellationsRedis([jobId], jobId);
|
|
668
825
|
if (deadLetterJobId) {
|
|
669
826
|
const sourceJob = await this.client.hget(
|
|
670
827
|
`${this.prefix}job:${jobId}`,
|
|
@@ -743,9 +900,22 @@ export class RedisBackend implements QueueBackend {
|
|
|
743
900
|
|
|
744
901
|
async cancelJob(jobId: number): Promise<void> {
|
|
745
902
|
const now = this.nowMs();
|
|
746
|
-
await this.client.eval(
|
|
747
|
-
|
|
748
|
-
|
|
903
|
+
const ok = await this.client.eval(
|
|
904
|
+
CANCEL_JOB_SCRIPT,
|
|
905
|
+
1,
|
|
906
|
+
this.prefix,
|
|
907
|
+
jobId,
|
|
908
|
+
now,
|
|
909
|
+
);
|
|
910
|
+
if (Number(ok) === 1) {
|
|
911
|
+
await this.recordJobEvent(jobId, JobEventType.Cancelled);
|
|
912
|
+
await this.propagateDependencyCancellationsRedis([jobId], jobId);
|
|
913
|
+
log(`Cancelled job ${jobId}`);
|
|
914
|
+
} else {
|
|
915
|
+
log(
|
|
916
|
+
`Job ${jobId} could not be cancelled (not in pending/waiting state or does not exist)`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
749
919
|
}
|
|
750
920
|
|
|
751
921
|
async cancelAllUpcomingJobs(filters?: JobFilters): Promise<number> {
|
package/src/index.ts
CHANGED
|
@@ -442,6 +442,14 @@ const withLogContext =
|
|
|
442
442
|
};
|
|
443
443
|
|
|
444
444
|
export * from './types.js';
|
|
445
|
+
export {
|
|
446
|
+
batchDepRef,
|
|
447
|
+
normalizeDependsOn,
|
|
448
|
+
resolveDependsOnJobIdsForBatch,
|
|
449
|
+
tagsAreSuperset,
|
|
450
|
+
validatePrerequisiteJobIdsExist,
|
|
451
|
+
assertNoDependencyCycle,
|
|
452
|
+
} from './job-dependencies.js';
|
|
445
453
|
export { QueueBackend, CronScheduleInput } from './backend.js';
|
|
446
454
|
export { PostgresBackend } from './backends/postgres.js';
|
|
447
455
|
export {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertNoDependencyCycle,
|
|
4
|
+
batchDepRef,
|
|
5
|
+
normalizeDependsOn,
|
|
6
|
+
resolveDependsOnJobIdsForBatch,
|
|
7
|
+
tagsAreSuperset,
|
|
8
|
+
validatePrerequisiteJobIdsExist,
|
|
9
|
+
} from './job-dependencies.js';
|
|
10
|
+
import type { DatabaseClient } from './types.js';
|
|
11
|
+
|
|
12
|
+
describe('batchDepRef', () => {
|
|
13
|
+
it('returns negative index encoding', () => {
|
|
14
|
+
expect(batchDepRef(0)).toBe(-1);
|
|
15
|
+
expect(batchDepRef(2)).toBe(-3);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('throws on invalid index', () => {
|
|
19
|
+
expect(() => batchDepRef(-1)).toThrow();
|
|
20
|
+
expect(() => batchDepRef(1.5)).toThrow();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('normalizeDependsOn', () => {
|
|
25
|
+
it('returns undefined for empty input', () => {
|
|
26
|
+
expect(normalizeDependsOn(undefined)).toEqual({
|
|
27
|
+
jobIds: undefined,
|
|
28
|
+
tags: undefined,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('deduplicates and drops empty', () => {
|
|
33
|
+
expect(
|
|
34
|
+
normalizeDependsOn({ jobIds: [1, 1, 2], tags: ['a', 'a', 'b'] }),
|
|
35
|
+
).toEqual({
|
|
36
|
+
jobIds: [1, 2],
|
|
37
|
+
tags: ['a', 'b'],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('resolveDependsOnJobIdsForBatch', () => {
|
|
43
|
+
it('resolves negative placeholders', () => {
|
|
44
|
+
expect(resolveDependsOnJobIdsForBatch([-1, -2], [10, 20])).toEqual([
|
|
45
|
+
10, 20,
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('passes through positive ids', () => {
|
|
50
|
+
expect(resolveDependsOnJobIdsForBatch([5, -1], [99])).toEqual([5, 99]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws when index out of range', () => {
|
|
54
|
+
expect(() => resolveDependsOnJobIdsForBatch([-3], [1, 2])).toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('tagsAreSuperset', () => {
|
|
59
|
+
it('returns false for empty required', () => {
|
|
60
|
+
expect(tagsAreSuperset(['a'], [])).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('checks inclusion', () => {
|
|
64
|
+
expect(tagsAreSuperset(['a', 'b'], ['a'])).toBe(true);
|
|
65
|
+
expect(tagsAreSuperset(['a'], ['a', 'b'])).toBe(false);
|
|
66
|
+
expect(tagsAreSuperset(null, ['a'])).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('validatePrerequisiteJobIdsExist', () => {
|
|
71
|
+
it('no-ops for empty', async () => {
|
|
72
|
+
const client: DatabaseClient = {
|
|
73
|
+
query: vi.fn(),
|
|
74
|
+
};
|
|
75
|
+
await validatePrerequisiteJobIdsExist(client, []);
|
|
76
|
+
expect(client.query).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws when count mismatches', async () => {
|
|
80
|
+
const client: DatabaseClient = {
|
|
81
|
+
query: vi.fn().mockResolvedValue({ rows: [{ c: 1 }], rowCount: 1 }),
|
|
82
|
+
};
|
|
83
|
+
await expect(
|
|
84
|
+
validatePrerequisiteJobIdsExist(client, [1, 2]),
|
|
85
|
+
).rejects.toThrow(/do not exist/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('resolves when all exist', async () => {
|
|
89
|
+
const client: DatabaseClient = {
|
|
90
|
+
query: vi.fn().mockResolvedValue({ rows: [{ c: 2 }], rowCount: 1 }),
|
|
91
|
+
};
|
|
92
|
+
await validatePrerequisiteJobIdsExist(client, [1, 2]);
|
|
93
|
+
expect(client.query).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('assertNoDependencyCycle', () => {
|
|
98
|
+
it('throws on self-dependency', async () => {
|
|
99
|
+
const client: DatabaseClient = { query: vi.fn() };
|
|
100
|
+
await expect(assertNoDependencyCycle(client, 1, [1])).rejects.toThrow(
|
|
101
|
+
/cannot depend on itself/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('throws when cycle detected', async () => {
|
|
106
|
+
const client: DatabaseClient = {
|
|
107
|
+
query: vi
|
|
108
|
+
.fn()
|
|
109
|
+
.mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }),
|
|
110
|
+
};
|
|
111
|
+
await expect(assertNoDependencyCycle(client, 5, [1, 2])).rejects.toThrow(
|
|
112
|
+
/cycle/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('no-ops when no deps', async () => {
|
|
117
|
+
const client: DatabaseClient = { query: vi.fn() };
|
|
118
|
+
await assertNoDependencyCycle(client, 1, []);
|
|
119
|
+
expect(client.query).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('no-ops when query returns no cycle', async () => {
|
|
123
|
+
const client: DatabaseClient = {
|
|
124
|
+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
|
|
125
|
+
};
|
|
126
|
+
await assertNoDependencyCycle(client, 5, [1]);
|
|
127
|
+
expect(client.query).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
});
|