@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/dist/index.cjs CHANGED
@@ -922,7 +922,109 @@ Recommended: Remove sslmode from the connection string when using a custom CA.
922
922
  }
923
923
  return pool;
924
924
  };
925
+
926
+ // src/job-dependencies.ts
927
+ function batchDepRef(batchIndex) {
928
+ if (!Number.isInteger(batchIndex) || batchIndex < 0) {
929
+ throw new Error(
930
+ `batchDepRef: expected non-negative integer index, got ${batchIndex}`
931
+ );
932
+ }
933
+ return -(batchIndex + 1);
934
+ }
935
+ function normalizeDependsOn(dep) {
936
+ if (!dep) return { jobIds: void 0, tags: void 0 };
937
+ const jobIds = dep.jobIds && dep.jobIds.length > 0 ? [...new Set(dep.jobIds)] : void 0;
938
+ const tags = dep.tags && dep.tags.length > 0 ? [...new Set(dep.tags)] : void 0;
939
+ return { jobIds, tags };
940
+ }
941
+ function resolveDependsOnJobIdsForBatch(jobIds, insertedIds) {
942
+ return jobIds.map((id) => {
943
+ if (id >= 0) return id;
944
+ const idx = -id - 1;
945
+ if (idx < 0 || idx >= insertedIds.length) {
946
+ throw new Error(
947
+ `Invalid batch-relative job id ${id}: index ${idx} out of range for ${insertedIds.length} inserted job(s)`
948
+ );
949
+ }
950
+ return insertedIds[idx];
951
+ });
952
+ }
953
+ function tagsAreSuperset(holderTags, requiredTags) {
954
+ if (!requiredTags || requiredTags.length === 0) return false;
955
+ if (!holderTags || holderTags.length === 0) return false;
956
+ const set = new Set(holderTags);
957
+ for (const t of requiredTags) {
958
+ if (!set.has(t)) return false;
959
+ }
960
+ return true;
961
+ }
962
+ async function validatePrerequisiteJobIdsExist(client, jobIds) {
963
+ if (jobIds.length === 0) return;
964
+ const r = await client.query(
965
+ `SELECT COUNT(*)::int AS c FROM job_queue WHERE id = ANY($1::int[])`,
966
+ [jobIds]
967
+ );
968
+ const c = r.rows[0]?.c ?? 0;
969
+ if (c !== jobIds.length) {
970
+ throw new Error(
971
+ `dependsOn.jobIds: one or more job ids do not exist (${jobIds.join(", ")})`
972
+ );
973
+ }
974
+ }
975
+ async function assertNoDependencyCycle(client, newJobId, dependsOnJobIds) {
976
+ if (dependsOnJobIds.length === 0) return;
977
+ if (dependsOnJobIds.includes(newJobId)) {
978
+ throw new Error(
979
+ `Job ${newJobId} cannot depend on itself (dependsOn.jobIds)`
980
+ );
981
+ }
982
+ const result = await client.query(
983
+ `
984
+ WITH RECURSIVE downstream AS (
985
+ SELECT j.id
986
+ FROM job_queue j
987
+ WHERE j.depends_on_job_ids @> ARRAY[$1::integer]::integer[]
988
+ UNION
989
+ SELECT j.id
990
+ FROM job_queue j
991
+ INNER JOIN downstream d ON j.depends_on_job_ids @> ARRAY[d.id]::integer[]
992
+ )
993
+ SELECT 1 FROM downstream WHERE id = ANY($2::integer[]) LIMIT 1
994
+ `,
995
+ [newJobId, dependsOnJobIds]
996
+ );
997
+ if (result.rows.length > 0) {
998
+ throw new Error(
999
+ `Adding job ${newJobId} would create a dependency cycle (dependsOn.jobIds)`
1000
+ );
1001
+ }
1002
+ }
1003
+
1004
+ // src/backends/postgres.ts
925
1005
  var MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1e3;
1006
+ var JOB_DEPENDS_ON_PREDICATE = `
1007
+ AND (
1008
+ candidate.depends_on_job_ids IS NULL
1009
+ OR cardinality(candidate.depends_on_job_ids) = 0
1010
+ OR NOT EXISTS (
1011
+ SELECT 1
1012
+ FROM unnest(candidate.depends_on_job_ids) AS dep(id)
1013
+ LEFT JOIN job_queue prereq ON prereq.id = dep.id
1014
+ WHERE prereq.id IS NULL OR prereq.status <> 'completed'
1015
+ )
1016
+ )
1017
+ AND (
1018
+ candidate.depends_on_tags IS NULL
1019
+ OR cardinality(candidate.depends_on_tags) = 0
1020
+ OR NOT EXISTS (
1021
+ SELECT 1 FROM job_queue blocker
1022
+ WHERE blocker.id <> candidate.id
1023
+ AND blocker.status IN ('pending', 'processing', 'waiting')
1024
+ AND blocker.tags IS NOT NULL
1025
+ AND blocker.tags @> candidate.depends_on_tags
1026
+ )
1027
+ )`;
926
1028
  function parseTimeoutString(timeout) {
927
1029
  const match = timeout.match(/^(\d+)(s|m|h|d)$/);
928
1030
  if (!match) {
@@ -1029,18 +1131,35 @@ var PostgresBackend = class {
1029
1131
  retryBackoff = void 0,
1030
1132
  retryDelayMax = void 0,
1031
1133
  deadLetterJobType = void 0,
1032
- group = void 0
1134
+ group = void 0,
1135
+ dependsOn
1033
1136
  }, options) {
1034
1137
  const externalClient = options?.db;
1035
1138
  const client = externalClient ?? await this.pool.connect();
1139
+ let manageTx = false;
1036
1140
  try {
1141
+ const { jobIds: depJobIdsRaw, tags: depTags } = normalizeDependsOn(dependsOn);
1142
+ let resolvedDepJobIds = [];
1143
+ if (depJobIdsRaw?.length) {
1144
+ if (depJobIdsRaw.some((id) => id < 0)) {
1145
+ throw new Error(
1146
+ "dependsOn.jobIds: batch-relative (negative) ids are only supported in addJobs()"
1147
+ );
1148
+ }
1149
+ resolvedDepJobIds = depJobIdsRaw;
1150
+ await validatePrerequisiteJobIdsExist(client, resolvedDepJobIds);
1151
+ }
1152
+ const dependsOnJobIdsParam = resolvedDepJobIds.length > 0 ? resolvedDepJobIds : null;
1153
+ const dependsOnTagsParam = depTags?.length ? depTags : null;
1154
+ manageTx = resolvedDepJobIds.length > 0 && !externalClient;
1155
+ if (manageTx) await client.query("BEGIN");
1037
1156
  let result;
1038
1157
  const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
1039
1158
  if (runAt) {
1040
1159
  result = await client.query(
1041
1160
  `INSERT INTO job_queue
1042
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier)
1043
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1161
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier, depends_on_job_ids, depends_on_tags)
1162
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
1044
1163
  ${onConflict}
1045
1164
  RETURNING id`,
1046
1165
  [
@@ -1058,14 +1177,16 @@ var PostgresBackend = class {
1058
1177
  retryDelayMax ?? null,
1059
1178
  deadLetterJobType ?? null,
1060
1179
  group?.id ?? null,
1061
- group?.tier ?? null
1180
+ group?.tier ?? null,
1181
+ dependsOnJobIdsParam,
1182
+ dependsOnTagsParam
1062
1183
  ]
1063
1184
  );
1064
1185
  } else {
1065
1186
  result = await client.query(
1066
1187
  `INSERT INTO job_queue
1067
- (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier)
1068
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
1188
+ (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier, depends_on_job_ids, depends_on_tags)
1189
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
1069
1190
  ${onConflict}
1070
1191
  RETURNING id`,
1071
1192
  [
@@ -1082,11 +1203,14 @@ var PostgresBackend = class {
1082
1203
  retryDelayMax ?? null,
1083
1204
  deadLetterJobType ?? null,
1084
1205
  group?.id ?? null,
1085
- group?.tier ?? null
1206
+ group?.tier ?? null,
1207
+ dependsOnJobIdsParam,
1208
+ dependsOnTagsParam
1086
1209
  ]
1087
1210
  );
1088
1211
  }
1089
1212
  if (result.rows.length === 0 && idempotencyKey) {
1213
+ if (manageTx) await client.query("ROLLBACK");
1090
1214
  const existing = await client.query(
1091
1215
  `SELECT id FROM job_queue WHERE idempotency_key = $1`,
1092
1216
  [idempotencyKey]
@@ -1097,37 +1221,46 @@ var PostgresBackend = class {
1097
1221
  );
1098
1222
  return existing.rows[0].id;
1099
1223
  }
1224
+ if (manageTx) await client.query("ROLLBACK");
1100
1225
  throw new Error(
1101
1226
  `Failed to insert job and could not find existing job with idempotency key "${idempotencyKey}"`
1102
1227
  );
1103
1228
  }
1104
1229
  const jobId = result.rows[0].id;
1230
+ if (resolvedDepJobIds.length > 0) {
1231
+ await assertNoDependencyCycle(client, jobId, resolvedDepJobIds);
1232
+ }
1105
1233
  log(
1106
1234
  `Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ""}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ""}`
1107
1235
  );
1236
+ const addedMeta = {
1237
+ jobType,
1238
+ payload,
1239
+ tags,
1240
+ idempotencyKey,
1241
+ dependsOn: dependsOnJobIdsParam || dependsOnTagsParam ? dependsOn : void 0
1242
+ };
1108
1243
  if (externalClient) {
1109
1244
  try {
1110
1245
  await client.query(
1111
1246
  `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
1112
- [
1113
- jobId,
1114
- "added" /* Added */,
1115
- JSON.stringify({ jobType, payload, tags, idempotencyKey })
1116
- ]
1247
+ [jobId, "added" /* Added */, JSON.stringify(addedMeta)]
1117
1248
  );
1118
1249
  } catch (error) {
1119
1250
  log(`Error recording job event for job ${jobId}: ${error}`);
1120
1251
  }
1121
1252
  } else {
1122
- await this.recordJobEvent(jobId, "added" /* Added */, {
1123
- jobType,
1124
- payload,
1125
- tags,
1126
- idempotencyKey
1127
- });
1253
+ await this.recordJobEvent(jobId, "added" /* Added */, addedMeta);
1128
1254
  }
1255
+ if (manageTx) await client.query("COMMIT");
1129
1256
  return jobId;
1130
1257
  } catch (error) {
1258
+ if (manageTx) {
1259
+ try {
1260
+ await client.query("ROLLBACK");
1261
+ } catch {
1262
+ }
1263
+ }
1131
1264
  log(`Error adding job: ${error}`);
1132
1265
  throw error;
1133
1266
  } finally {
@@ -1145,7 +1278,50 @@ var PostgresBackend = class {
1145
1278
  const externalClient = options?.db;
1146
1279
  const client = externalClient ?? await this.pool.connect();
1147
1280
  try {
1148
- const COLS_PER_JOB = 15;
1281
+ const needsSequential = jobs.some((j) => {
1282
+ const n = normalizeDependsOn(j.dependsOn);
1283
+ return Boolean(n.jobIds?.length || n.tags?.length);
1284
+ });
1285
+ if (needsSequential) {
1286
+ const useOuterTx = !externalClient;
1287
+ if (useOuterTx) await client.query("BEGIN");
1288
+ try {
1289
+ const ids2 = [];
1290
+ for (let i = 0; i < jobs.length; i++) {
1291
+ let job = jobs[i];
1292
+ const nd = normalizeDependsOn(job.dependsOn);
1293
+ if (nd.jobIds?.some((id2) => id2 < 0)) {
1294
+ const resolvedJobIds = resolveDependsOnJobIdsForBatch(
1295
+ nd.jobIds,
1296
+ ids2
1297
+ );
1298
+ job = {
1299
+ ...job,
1300
+ dependsOn: {
1301
+ jobIds: resolvedJobIds,
1302
+ tags: job.dependsOn?.tags
1303
+ }
1304
+ };
1305
+ }
1306
+ const id = await this.addJob(job, { db: client });
1307
+ ids2.push(id);
1308
+ }
1309
+ if (useOuterTx) await client.query("COMMIT");
1310
+ log(
1311
+ `Batch-inserted ${jobs.length} jobs (sequential), IDs: [${ids2.join(", ")}]`
1312
+ );
1313
+ return ids2;
1314
+ } catch (e) {
1315
+ if (!externalClient) {
1316
+ try {
1317
+ await client.query("ROLLBACK");
1318
+ } catch {
1319
+ }
1320
+ }
1321
+ throw e;
1322
+ }
1323
+ }
1324
+ const COLS_PER_JOB = 17;
1149
1325
  const valueClauses = [];
1150
1326
  const params = [];
1151
1327
  const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
@@ -1168,7 +1344,7 @@ var PostgresBackend = class {
1168
1344
  } = jobs[i];
1169
1345
  const base = i * COLS_PER_JOB;
1170
1346
  valueClauses.push(
1171
- `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`
1347
+ `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15}, $${base + 16}, $${base + 17})`
1172
1348
  );
1173
1349
  params.push(
1174
1350
  jobType,
@@ -1185,13 +1361,15 @@ var PostgresBackend = class {
1185
1361
  retryDelayMax ?? null,
1186
1362
  deadLetterJobType ?? null,
1187
1363
  group?.id ?? null,
1188
- group?.tier ?? null
1364
+ group?.tier ?? null,
1365
+ null,
1366
+ null
1189
1367
  );
1190
1368
  }
1191
1369
  const onConflict = hasAnyIdempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
1192
1370
  const result = await client.query(
1193
1371
  `INSERT INTO job_queue
1194
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier)
1372
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, dead_letter_job_type, group_id, group_tier, depends_on_job_ids, depends_on_tags)
1195
1373
  VALUES ${valueClauses.join(", ")}
1196
1374
  ${onConflict}
1197
1375
  RETURNING id, idempotency_key`,
@@ -1242,6 +1420,7 @@ var PostgresBackend = class {
1242
1420
  const job = jobs[i];
1243
1421
  const wasInserted = !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
1244
1422
  if (wasInserted) {
1423
+ const nd = normalizeDependsOn(job.dependsOn);
1245
1424
  newJobEvents.push({
1246
1425
  jobId: ids[i],
1247
1426
  eventType: "added" /* Added */,
@@ -1249,7 +1428,8 @@ var PostgresBackend = class {
1249
1428
  jobType: job.jobType,
1250
1429
  payload: job.payload,
1251
1430
  tags: job.tags,
1252
- idempotencyKey: job.idempotencyKey
1431
+ idempotencyKey: job.idempotencyKey,
1432
+ ...nd.jobIds?.length || nd.tags?.length ? { dependsOn: job.dependsOn } : {}
1253
1433
  }
1254
1434
  });
1255
1435
  }
@@ -1291,7 +1471,7 @@ var PostgresBackend = class {
1291
1471
  const client = await this.pool.connect();
1292
1472
  try {
1293
1473
  const result = await client.query(
1294
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue WHERE id = $1`,
1474
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output FROM job_queue WHERE id = $1`,
1295
1475
  [id]
1296
1476
  );
1297
1477
  if (result.rows.length === 0) {
@@ -1318,7 +1498,7 @@ var PostgresBackend = class {
1318
1498
  const client = await this.pool.connect();
1319
1499
  try {
1320
1500
  const result = await client.query(
1321
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
1501
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
1322
1502
  [status, limit, offset]
1323
1503
  );
1324
1504
  log(`Found ${result.rows.length} jobs by status ${status}`);
@@ -1340,7 +1520,7 @@ var PostgresBackend = class {
1340
1520
  const client = await this.pool.connect();
1341
1521
  try {
1342
1522
  const result = await client.query(
1343
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
1523
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
1344
1524
  [limit, offset]
1345
1525
  );
1346
1526
  log(`Found ${result.rows.length} jobs (all)`);
@@ -1360,7 +1540,7 @@ var PostgresBackend = class {
1360
1540
  async getJobs(filters, limit = 100, offset = 0) {
1361
1541
  const client = await this.pool.connect();
1362
1542
  try {
1363
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue`;
1543
+ let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output FROM job_queue`;
1364
1544
  const params = [];
1365
1545
  const where = [];
1366
1546
  let paramIdx = 1;
@@ -1461,7 +1641,7 @@ var PostgresBackend = class {
1461
1641
  async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
1462
1642
  const client = await this.pool.connect();
1463
1643
  try {
1464
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output
1644
+ let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output
1465
1645
  FROM job_queue`;
1466
1646
  let params = [];
1467
1647
  switch (mode) {
@@ -1553,11 +1733,12 @@ var PostgresBackend = class {
1553
1733
  )
1554
1734
  )
1555
1735
  ${jobTypeFilter}
1736
+ ${JOB_DEPENDS_ON_PREDICATE}
1556
1737
  ORDER BY candidate.priority DESC, candidate.created_at ASC
1557
1738
  LIMIT $2
1558
1739
  FOR UPDATE SKIP LOCKED
1559
1740
  )
1560
- RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output
1741
+ RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output
1561
1742
  `,
1562
1743
  params
1563
1744
  );
@@ -1583,6 +1764,7 @@ var PostgresBackend = class {
1583
1764
  )
1584
1765
  )
1585
1766
  ${jobTypeFilter}
1767
+ ${JOB_DEPENDS_ON_PREDICATE}
1586
1768
  FOR UPDATE SKIP LOCKED
1587
1769
  ),
1588
1770
  ranked AS (
@@ -1625,7 +1807,7 @@ var PostgresBackend = class {
1625
1807
  last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
1626
1808
  wait_until = NULL
1627
1809
  WHERE id IN (SELECT id FROM selected)
1628
- RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", output
1810
+ RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", dead_letter_job_type AS "deadLetterJobType", dead_lettered_at AS "deadLetteredAt", dead_letter_job_id AS "deadLetterJobId", group_id AS "groupId", group_tier AS "groupTier", depends_on_job_ids AS "dependsOnJobIds", depends_on_tags AS "dependsOnTags", output
1629
1811
  `,
1630
1812
  constrainedParams
1631
1813
  );
@@ -1682,6 +1864,75 @@ var PostgresBackend = class {
1682
1864
  client.release();
1683
1865
  }
1684
1866
  }
1867
+ /**
1868
+ * Cancel pending/waiting jobs that depend on any seed job (by job id or tag superset), transitively.
1869
+ *
1870
+ * @param client - Database client (must be inside an open transaction when used from fail/cancel).
1871
+ * @param initialSeeds - Job ids that just failed or were cancelled.
1872
+ * @param rootJobId - Original job id for event metadata.
1873
+ */
1874
+ async propagateDependencyCancellations(client, initialSeeds, rootJobId) {
1875
+ const seeds = [...new Set(initialSeeds.filter((id) => id > 0))];
1876
+ if (seeds.length === 0) return;
1877
+ const cancelled = /* @__PURE__ */ new Set();
1878
+ const reasonJson = JSON.stringify({
1879
+ rootJobId,
1880
+ dependencyCascade: true
1881
+ });
1882
+ let frontier = seeds;
1883
+ while (frontier.length > 0) {
1884
+ const res = await client.query(
1885
+ `
1886
+ SELECT DISTINCT j.id
1887
+ FROM job_queue j
1888
+ CROSS JOIN unnest($1::int[]) AS s(id)
1889
+ INNER JOIN job_queue sx ON sx.id = s.id
1890
+ WHERE j.status IN ('pending', 'waiting')
1891
+ AND j.id <> sx.id
1892
+ AND (
1893
+ j.depends_on_job_ids @> ARRAY[s.id]::integer[]
1894
+ OR (
1895
+ j.depends_on_tags IS NOT NULL
1896
+ AND cardinality(j.depends_on_tags) > 0
1897
+ AND sx.tags IS NOT NULL
1898
+ AND sx.tags @> j.depends_on_tags
1899
+ )
1900
+ )
1901
+ `,
1902
+ [frontier]
1903
+ );
1904
+ const toCancel = [];
1905
+ for (const row of res.rows) {
1906
+ const pid = row.id;
1907
+ if (cancelled.has(pid)) continue;
1908
+ cancelled.add(pid);
1909
+ toCancel.push(pid);
1910
+ }
1911
+ if (toCancel.length === 0) break;
1912
+ await client.query(
1913
+ `
1914
+ UPDATE job_queue
1915
+ SET status = 'cancelled',
1916
+ updated_at = NOW(),
1917
+ last_cancelled_at = NOW(),
1918
+ wait_until = NULL,
1919
+ wait_token_id = NULL,
1920
+ pending_reason = $2
1921
+ WHERE id = ANY($1::int[])
1922
+ AND status IN ('pending', 'waiting')
1923
+ `,
1924
+ [toCancel, reasonJson]
1925
+ );
1926
+ const meta = JSON.stringify({ rootJobId, dependencyCascade: true });
1927
+ for (const jid of toCancel) {
1928
+ await client.query(
1929
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
1930
+ [jid, "cancelled" /* Cancelled */, meta]
1931
+ );
1932
+ }
1933
+ frontier = toCancel;
1934
+ }
1935
+ }
1685
1936
  async failJob(jobId, error, failureReason) {
1686
1937
  const client = await this.pool.connect();
1687
1938
  try {
@@ -1751,8 +2002,8 @@ var PostgresBackend = class {
1751
2002
  });
1752
2003
  const deadLetterInsert = await client.query(
1753
2004
  `INSERT INTO job_queue
1754
- (job_type, payload, max_attempts, priority, run_at)
1755
- VALUES ($1, $2, $3, $4, NOW())
2005
+ (job_type, payload, max_attempts, priority, run_at, depends_on_job_ids, depends_on_tags)
2006
+ VALUES ($1, $2, $3, $4, NOW(), NULL, NULL)
1756
2007
  RETURNING id`,
1757
2008
  [failedJob.deadLetterJobType, deadLetterPayload, 1, 0]
1758
2009
  );
@@ -1789,6 +2040,7 @@ var PostgresBackend = class {
1789
2040
  })
1790
2041
  ]
1791
2042
  );
2043
+ await this.propagateDependencyCancellations(client, [jobId], jobId);
1792
2044
  await client.query("COMMIT");
1793
2045
  log(
1794
2046
  `Failed job ${jobId}${deadLetterJobId ? ` and routed to dead-letter job ${deadLetterJobId}` : ""}`
@@ -1884,7 +2136,8 @@ var PostgresBackend = class {
1884
2136
  async cancelJob(jobId) {
1885
2137
  const client = await this.pool.connect();
1886
2138
  try {
1887
- await client.query(
2139
+ await client.query("BEGIN");
2140
+ const upd = await client.query(
1888
2141
  `
1889
2142
  UPDATE job_queue
1890
2143
  SET status = 'cancelled', updated_at = NOW(), last_cancelled_at = NOW(),
@@ -1893,9 +2146,25 @@ var PostgresBackend = class {
1893
2146
  `,
1894
2147
  [jobId]
1895
2148
  );
1896
- await this.recordJobEvent(jobId, "cancelled" /* Cancelled */);
2149
+ if (upd.rowCount === 0) {
2150
+ await client.query("ROLLBACK");
2151
+ log(
2152
+ `Job ${jobId} could not be cancelled (not in pending/waiting state or does not exist)`
2153
+ );
2154
+ return;
2155
+ }
2156
+ await client.query(
2157
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
2158
+ [jobId, "cancelled" /* Cancelled */, null]
2159
+ );
2160
+ await this.propagateDependencyCancellations(client, [jobId], jobId);
2161
+ await client.query("COMMIT");
1897
2162
  log(`Cancelled job ${jobId}`);
1898
2163
  } catch (error) {
2164
+ try {
2165
+ await client.query("ROLLBACK");
2166
+ } catch {
2167
+ }
1899
2168
  log(`Error cancelling job ${jobId}: ${error}`);
1900
2169
  throw error;
1901
2170
  } finally {
@@ -2928,6 +3197,8 @@ local retryDelayMax = ARGV[13] -- "null" or seconds string
2928
3197
  local deadLetterJobType = ARGV[14] -- "null" or jobType string
2929
3198
  local groupId = ARGV[15] -- "null" or group ID
2930
3199
  local groupTier = ARGV[16] -- "null" or group tier
3200
+ local dependsOnJobIdsJson = ARGV[17] -- "null" or JSON array of job ids
3201
+ local dependsOnTagsJson = ARGV[18] -- "null" or JSON array of tags
2931
3202
 
2932
3203
  -- Idempotency check
2933
3204
  if idempotencyKey ~= "null" then
@@ -2979,9 +3250,18 @@ redis.call('HMSET', jobKey,
2979
3250
  'deadLetteredAt', 'null',
2980
3251
  'deadLetterJobId', 'null',
2981
3252
  'groupId', groupId,
2982
- 'groupTier', groupTier
3253
+ 'groupTier', groupTier,
3254
+ 'dependsOnJobIds', dependsOnJobIdsJson,
3255
+ 'dependsOnTags', dependsOnTagsJson
2983
3256
  )
2984
3257
 
3258
+ if dependsOnJobIdsJson ~= "null" then
3259
+ local depIds = cjson.decode(dependsOnJobIdsJson)
3260
+ for _, parentId in ipairs(depIds) do
3261
+ redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
3262
+ end
3263
+ end
3264
+
2985
3265
  -- Status index
2986
3266
  redis.call('SADD', prefix .. 'status:pending', id)
2987
3267
 
@@ -3044,6 +3324,8 @@ for i, job in ipairs(jobs) do
3044
3324
  local deadLetterJobType = tostring(job.deadLetterJobType)
3045
3325
  local groupId = tostring(job.groupId)
3046
3326
  local groupTier = tostring(job.groupTier)
3327
+ local dependsOnJobIdsJson = (job.dependsOnJobIds ~= nil and job.dependsOnJobIds ~= cjson.null) and tostring(job.dependsOnJobIds) or "null"
3328
+ local dependsOnTagsJson = (job.dependsOnTags ~= nil and job.dependsOnTags ~= cjson.null) and tostring(job.dependsOnTags) or "null"
3047
3329
 
3048
3330
  -- Idempotency check
3049
3331
  local skip = false
@@ -3098,9 +3380,18 @@ for i, job in ipairs(jobs) do
3098
3380
  'deadLetteredAt', 'null',
3099
3381
  'deadLetterJobId', 'null',
3100
3382
  'groupId', groupId,
3101
- 'groupTier', groupTier
3383
+ 'groupTier', groupTier,
3384
+ 'dependsOnJobIds', dependsOnJobIdsJson,
3385
+ 'dependsOnTags', dependsOnTagsJson
3102
3386
  )
3103
3387
 
3388
+ if dependsOnJobIdsJson ~= "null" then
3389
+ local depIds = cjson.decode(dependsOnJobIdsJson)
3390
+ for _, parentId in ipairs(depIds) do
3391
+ redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
3392
+ end
3393
+ end
3394
+
3104
3395
  -- Status index
3105
3396
  redis.call('SADD', prefix .. 'status:pending', id)
3106
3397
 
@@ -3264,6 +3555,48 @@ for i = 1, #candidates, 2 do
3264
3555
  end
3265
3556
  end
3266
3557
 
3558
+ if canClaim then
3559
+ local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
3560
+ local depTagsJson = redis.call('HGET', jk, 'dependsOnTags')
3561
+ local depsOk = true
3562
+ if depIdsJson and depIdsJson ~= 'null' then
3563
+ local dids = cjson.decode(depIdsJson)
3564
+ for _, pid in ipairs(dids) do
3565
+ local pst = redis.call('HGET', prefix .. 'job:' .. pid, 'status')
3566
+ if pst ~= 'completed' then depsOk = false break end
3567
+ end
3568
+ end
3569
+ if depsOk and depTagsJson and depTagsJson ~= 'null' then
3570
+ local req = cjson.decode(depTagsJson)
3571
+ if #req > 0 then
3572
+ for _, stname in ipairs({'pending','processing','waiting'}) do
3573
+ local members = redis.call('SMEMBERS', prefix .. 'status:' .. stname)
3574
+ for _, oid in ipairs(members) do
3575
+ if oid ~= jobId then
3576
+ local otags = redis.call('HGET', prefix .. 'job:' .. oid, 'tags')
3577
+ if otags and otags ~= 'null' then
3578
+ local oarr = cjson.decode(otags)
3579
+ local tagset = {}
3580
+ for _, t in ipairs(oarr) do tagset[t] = true end
3581
+ local all = true
3582
+ for _, rt in ipairs(req) do
3583
+ if not tagset[rt] then all = false break end
3584
+ end
3585
+ if all then depsOk = false break end
3586
+ end
3587
+ end
3588
+ end
3589
+ if not depsOk then break end
3590
+ end
3591
+ end
3592
+ end
3593
+ if not depsOk then
3594
+ table.insert(putBack, score)
3595
+ table.insert(putBack, jobId)
3596
+ canClaim = false
3597
+ end
3598
+ end
3599
+
3267
3600
  if canClaim then
3268
3601
  -- Claim this job
3269
3602
  local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
@@ -3344,6 +3677,14 @@ if groupId and groupId ~= 'null' then
3344
3677
  end
3345
3678
  end
3346
3679
 
3680
+ local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
3681
+ if depIdsJson and depIdsJson ~= 'null' then
3682
+ local dids = cjson.decode(depIdsJson)
3683
+ for _, pid in ipairs(dids) do
3684
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
3685
+ end
3686
+ end
3687
+
3347
3688
  return 1
3348
3689
  `;
3349
3690
  var FAIL_JOB_SCRIPT = `
@@ -3491,7 +3832,11 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
3491
3832
  'retryDelayMax', 'null',
3492
3833
  'deadLetterJobType', 'null',
3493
3834
  'deadLetteredAt', 'null',
3494
- 'deadLetterJobId', 'null'
3835
+ 'deadLetterJobId', 'null',
3836
+ 'dependsOnJobIds', 'null',
3837
+ 'dependsOnTags', 'null',
3838
+ 'groupId', 'null',
3839
+ 'groupTier', 'null'
3495
3840
  )
3496
3841
 
3497
3842
  redis.call('SADD', prefix .. 'status:pending', deadLetterJobId)
@@ -3506,6 +3851,14 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
3506
3851
  )
3507
3852
  end
3508
3853
 
3854
+ local depIdsJsonFail = redis.call('HGET', jk, 'dependsOnJobIds')
3855
+ if depIdsJsonFail and depIdsJsonFail ~= 'null' then
3856
+ local dids = cjson.decode(depIdsJsonFail)
3857
+ for _, pid in ipairs(dids) do
3858
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
3859
+ end
3860
+ end
3861
+
3509
3862
  return deadLetterJobId
3510
3863
  `;
3511
3864
  var RETRY_JOB_SCRIPT = `
@@ -3572,6 +3925,14 @@ redis.call('ZREM', prefix .. 'queue', jobId)
3572
3925
  redis.call('ZREM', prefix .. 'delayed', jobId)
3573
3926
  redis.call('ZREM', prefix .. 'waiting', jobId)
3574
3927
 
3928
+ local depIdsJsonCan = redis.call('HGET', jk, 'dependsOnJobIds')
3929
+ if depIdsJsonCan and depIdsJsonCan ~= 'null' then
3930
+ local dids = cjson.decode(depIdsJsonCan)
3931
+ for _, pid in ipairs(dids) do
3932
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
3933
+ end
3934
+ end
3935
+
3575
3936
  return 1
3576
3937
  `;
3577
3938
  var PROLONG_JOB_SCRIPT = `
@@ -3923,9 +4284,29 @@ function deserializeJob(h) {
3923
4284
  deadLetterJobId: numOrNull(h.deadLetterJobId),
3924
4285
  groupId: nullish(h.groupId),
3925
4286
  groupTier: nullish(h.groupTier),
3926
- output: parseJsonField(h.output)
4287
+ output: parseJsonField(h.output),
4288
+ dependsOnJobIds: parseOptionalIntArray(h.dependsOnJobIds),
4289
+ dependsOnTags: parseOptionalStringArray(h.dependsOnTags)
3927
4290
  };
3928
4291
  }
4292
+ function parseOptionalIntArray(raw) {
4293
+ if (!raw || raw === "null") return null;
4294
+ try {
4295
+ const arr = JSON.parse(raw);
4296
+ return Array.isArray(arr) && arr.length > 0 ? arr : null;
4297
+ } catch {
4298
+ return null;
4299
+ }
4300
+ }
4301
+ function parseOptionalStringArray(raw) {
4302
+ if (!raw || raw === "null") return null;
4303
+ try {
4304
+ const arr = JSON.parse(raw);
4305
+ return Array.isArray(arr) && arr.length > 0 ? arr : null;
4306
+ } catch {
4307
+ return null;
4308
+ }
4309
+ }
3929
4310
  function parseJsonField(raw) {
3930
4311
  if (!raw || raw === "null") return null;
3931
4312
  try {
@@ -3992,6 +4373,61 @@ var RedisBackend = class {
3992
4373
  nowMs() {
3993
4374
  return Date.now();
3994
4375
  }
4376
+ /**
4377
+ * Cancel pending/waiting jobs that depend on seed jobs (job id or tag), transitively.
4378
+ *
4379
+ * @param initialSeeds - Job ids that failed or were cancelled.
4380
+ * @param rootJobId - Root id for event metadata.
4381
+ */
4382
+ async propagateDependencyCancellationsRedis(initialSeeds, rootJobId) {
4383
+ const cancelled = /* @__PURE__ */ new Set();
4384
+ let frontier = [...new Set(initialSeeds.filter((id) => id > 0))];
4385
+ while (frontier.length > 0) {
4386
+ const pendingRaw = await this.client.sunion(
4387
+ `${this.prefix}status:pending`,
4388
+ `${this.prefix}status:waiting`
4389
+ );
4390
+ const toCancel = [];
4391
+ for (const pidStr of pendingRaw) {
4392
+ const pid = Number(pidStr);
4393
+ if (cancelled.has(pid)) continue;
4394
+ const job = await this.getJob(pid);
4395
+ if (!job || job.status !== "pending" && job.status !== "waiting") {
4396
+ continue;
4397
+ }
4398
+ for (const seedId of frontier) {
4399
+ if (pid === seedId) continue;
4400
+ const seedJob = await this.getJob(seedId);
4401
+ if (!seedJob) continue;
4402
+ const byJobId = job.dependsOnJobIds?.includes(seedId) ?? false;
4403
+ const byTag = job.dependsOnTags && job.dependsOnTags.length > 0 && tagsAreSuperset(seedJob.tags, job.dependsOnTags);
4404
+ if (byJobId || byTag) {
4405
+ toCancel.push(pid);
4406
+ break;
4407
+ }
4408
+ }
4409
+ }
4410
+ if (toCancel.length === 0) break;
4411
+ const now = this.nowMs();
4412
+ for (const jid of toCancel) {
4413
+ const ok = await this.client.eval(
4414
+ CANCEL_JOB_SCRIPT,
4415
+ 1,
4416
+ this.prefix,
4417
+ jid,
4418
+ now
4419
+ );
4420
+ if (Number(ok) === 1) {
4421
+ cancelled.add(jid);
4422
+ await this.recordJobEvent(jid, "cancelled" /* Cancelled */, {
4423
+ rootJobId,
4424
+ dependencyCascade: true
4425
+ });
4426
+ }
4427
+ }
4428
+ frontier = toCancel;
4429
+ }
4430
+ }
3995
4431
  // ── Events ──────────────────────────────────────────────────────────
3996
4432
  async recordJobEvent(jobId, eventType, metadata) {
3997
4433
  try {
@@ -4037,13 +4473,22 @@ var RedisBackend = class {
4037
4473
  retryBackoff = void 0,
4038
4474
  retryDelayMax = void 0,
4039
4475
  deadLetterJobType = void 0,
4040
- group = void 0
4476
+ group = void 0,
4477
+ dependsOn
4041
4478
  }, options) {
4042
4479
  if (options?.db) {
4043
4480
  throw new Error(
4044
4481
  "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
4045
4482
  );
4046
4483
  }
4484
+ const { jobIds: depJobIdsRaw, tags: depTags } = normalizeDependsOn(dependsOn);
4485
+ if (depJobIdsRaw?.some((id) => id < 0)) {
4486
+ throw new Error(
4487
+ "dependsOn.jobIds: batch-relative (negative) ids are only supported in addJobs()"
4488
+ );
4489
+ }
4490
+ const dependsOnJobIdsJson = depJobIdsRaw && depJobIdsRaw.length > 0 ? JSON.stringify(depJobIdsRaw) : "null";
4491
+ const dependsOnTagsJson = depTags && depTags.length > 0 ? JSON.stringify(depTags) : "null";
4047
4492
  const now = this.nowMs();
4048
4493
  const runAtMs = runAt ? runAt.getTime() : 0;
4049
4494
  const result = await this.client.eval(
@@ -4065,7 +4510,9 @@ var RedisBackend = class {
4065
4510
  retryDelayMax !== void 0 ? retryDelayMax.toString() : "null",
4066
4511
  deadLetterJobType ?? "null",
4067
4512
  group?.id ?? "null",
4068
- group?.tier ?? "null"
4513
+ group?.tier ?? "null",
4514
+ dependsOnJobIdsJson,
4515
+ dependsOnTagsJson
4069
4516
  );
4070
4517
  const jobId = Number(result);
4071
4518
  log(
@@ -4075,7 +4522,8 @@ var RedisBackend = class {
4075
4522
  jobType,
4076
4523
  payload,
4077
4524
  tags,
4078
- idempotencyKey
4525
+ idempotencyKey,
4526
+ dependsOn: dependsOnJobIdsJson !== "null" || dependsOnTagsJson !== "null" ? dependsOn : void 0
4079
4527
  });
4080
4528
  return jobId;
4081
4529
  }
@@ -4090,24 +4538,58 @@ var RedisBackend = class {
4090
4538
  "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
4091
4539
  );
4092
4540
  }
4541
+ const needsSequential = jobs.some((j) => {
4542
+ const n = normalizeDependsOn(j.dependsOn);
4543
+ return Boolean(n.jobIds?.length || n.tags?.length);
4544
+ });
4545
+ if (needsSequential) {
4546
+ const ids2 = [];
4547
+ for (let i = 0; i < jobs.length; i++) {
4548
+ let job = jobs[i];
4549
+ const nd = normalizeDependsOn(job.dependsOn);
4550
+ if (nd.jobIds?.some((id) => id < 0)) {
4551
+ const resolvedJobIds = resolveDependsOnJobIdsForBatch(
4552
+ nd.jobIds,
4553
+ ids2
4554
+ );
4555
+ job = {
4556
+ ...job,
4557
+ dependsOn: {
4558
+ jobIds: resolvedJobIds,
4559
+ tags: job.dependsOn?.tags
4560
+ }
4561
+ };
4562
+ }
4563
+ ids2.push(await this.addJob(job));
4564
+ }
4565
+ log(
4566
+ `Batch-inserted ${jobs.length} jobs (sequential), IDs: [${ids2.join(", ")}]`
4567
+ );
4568
+ return ids2;
4569
+ }
4093
4570
  const now = this.nowMs();
4094
- const jobsPayload = jobs.map((job) => ({
4095
- jobType: job.jobType,
4096
- payload: JSON.stringify(job.payload),
4097
- maxAttempts: job.maxAttempts ?? 3,
4098
- priority: job.priority ?? 0,
4099
- runAtMs: job.runAt ? job.runAt.getTime() : 0,
4100
- timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
4101
- forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
4102
- tags: job.tags ? JSON.stringify(job.tags) : "null",
4103
- idempotencyKey: job.idempotencyKey ?? "null",
4104
- retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
4105
- retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
4106
- retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null",
4107
- deadLetterJobType: job.deadLetterJobType ?? "null",
4108
- groupId: job.group?.id ?? "null",
4109
- groupTier: job.group?.tier ?? "null"
4110
- }));
4571
+ const jobsPayload = jobs.map((job) => {
4572
+ const nd = normalizeDependsOn(job.dependsOn);
4573
+ return {
4574
+ jobType: job.jobType,
4575
+ payload: JSON.stringify(job.payload),
4576
+ maxAttempts: job.maxAttempts ?? 3,
4577
+ priority: job.priority ?? 0,
4578
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
4579
+ timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
4580
+ forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
4581
+ tags: job.tags ? JSON.stringify(job.tags) : "null",
4582
+ idempotencyKey: job.idempotencyKey ?? "null",
4583
+ retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
4584
+ retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
4585
+ retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null",
4586
+ deadLetterJobType: job.deadLetterJobType ?? "null",
4587
+ groupId: job.group?.id ?? "null",
4588
+ groupTier: job.group?.tier ?? "null",
4589
+ dependsOnJobIds: nd.jobIds?.length ? JSON.stringify(nd.jobIds) : null,
4590
+ dependsOnTags: nd.tags?.length ? JSON.stringify(nd.tags) : null
4591
+ };
4592
+ });
4111
4593
  const result = await this.client.eval(
4112
4594
  ADD_JOBS_SCRIPT,
4113
4595
  1,
@@ -4277,6 +4759,7 @@ var RedisBackend = class {
4277
4759
  failureReason,
4278
4760
  deadLetterJobId
4279
4761
  });
4762
+ await this.propagateDependencyCancellationsRedis([jobId], jobId);
4280
4763
  if (deadLetterJobId) {
4281
4764
  const sourceJob = await this.client.hget(
4282
4765
  `${this.prefix}job:${jobId}`,
@@ -4345,9 +4828,22 @@ var RedisBackend = class {
4345
4828
  }
4346
4829
  async cancelJob(jobId) {
4347
4830
  const now = this.nowMs();
4348
- await this.client.eval(CANCEL_JOB_SCRIPT, 1, this.prefix, jobId, now);
4349
- await this.recordJobEvent(jobId, "cancelled" /* Cancelled */);
4350
- log(`Cancelled job ${jobId}`);
4831
+ const ok = await this.client.eval(
4832
+ CANCEL_JOB_SCRIPT,
4833
+ 1,
4834
+ this.prefix,
4835
+ jobId,
4836
+ now
4837
+ );
4838
+ if (Number(ok) === 1) {
4839
+ await this.recordJobEvent(jobId, "cancelled" /* Cancelled */);
4840
+ await this.propagateDependencyCancellationsRedis([jobId], jobId);
4841
+ log(`Cancelled job ${jobId}`);
4842
+ } else {
4843
+ log(
4844
+ `Job ${jobId} could not be cancelled (not in pending/waiting state or does not exist)`
4845
+ );
4846
+ }
4351
4847
  }
4352
4848
  async cancelAllUpcomingJobs(filters) {
4353
4849
  let ids = await this.client.smembers(`${this.prefix}status:pending`);
@@ -5678,10 +6174,16 @@ exports.FailureReason = FailureReason;
5678
6174
  exports.JobEventType = JobEventType;
5679
6175
  exports.PostgresBackend = PostgresBackend;
5680
6176
  exports.WaitSignal = WaitSignal;
6177
+ exports.assertNoDependencyCycle = assertNoDependencyCycle;
6178
+ exports.batchDepRef = batchDepRef;
5681
6179
  exports.getNextCronOccurrence = getNextCronOccurrence;
5682
6180
  exports.initJobQueue = initJobQueue;
6181
+ exports.normalizeDependsOn = normalizeDependsOn;
6182
+ exports.resolveDependsOnJobIdsForBatch = resolveDependsOnJobIdsForBatch;
6183
+ exports.tagsAreSuperset = tagsAreSuperset;
5683
6184
  exports.testHandlerSerialization = testHandlerSerialization;
5684
6185
  exports.validateCronExpression = validateCronExpression;
5685
6186
  exports.validateHandlerSerializable = validateHandlerSerializable2;
6187
+ exports.validatePrerequisiteJobIdsExist = validatePrerequisiteJobIdsExist;
5686
6188
  //# sourceMappingURL=index.cjs.map
5687
6189
  //# sourceMappingURL=index.cjs.map