bunqueue 2.8.52 → 2.8.54

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.
Files changed (80) hide show
  1. package/dist/application/backgroundTasks.js +5 -1
  2. package/dist/application/cleanupTasks.js +1 -1
  3. package/dist/application/clientTracking.js +1 -1
  4. package/dist/application/contextFactory.js +1 -0
  5. package/dist/application/dlqManager.d.ts +7 -1
  6. package/dist/application/dlqManager.js +16 -5
  7. package/dist/application/lockManager.js +3 -3
  8. package/dist/application/operations/ack.js +2 -2
  9. package/dist/application/operations/ackHelpers.js +1 -1
  10. package/dist/application/operations/jobManagement.js +6 -4
  11. package/dist/application/operations/jobMoveOperations.js +3 -3
  12. package/dist/application/operations/jobStateTransitions.d.ts +1 -1
  13. package/dist/application/operations/jobStateTransitions.js +16 -2
  14. package/dist/application/queueManager.d.ts +22 -2
  15. package/dist/application/queueManager.js +48 -6
  16. package/dist/application/stallDetection.js +3 -3
  17. package/dist/client/flowJobDependencies.js +3 -1
  18. package/dist/client/flowJobMoveMethods.js +7 -2
  19. package/dist/client/jobConversion.d.ts +3 -3
  20. package/dist/client/jobConversion.js +10 -6
  21. package/dist/client/jobConversionHelpers.d.ts +1 -1
  22. package/dist/client/jobConversionHelpers.js +3 -6
  23. package/dist/client/jobConversionTypes.d.ts +4 -0
  24. package/dist/client/jobDeduplication.d.ts +6 -0
  25. package/dist/client/jobDeduplication.js +14 -0
  26. package/dist/client/queue/bullmqCompat.d.ts +18 -8
  27. package/dist/client/queue/bullmqCompat.js +39 -71
  28. package/dist/client/queue/deduplication.d.ts +2 -0
  29. package/dist/client/queue/deduplication.js +20 -11
  30. package/dist/client/queue/dlq.d.ts +11 -6
  31. package/dist/client/queue/dlq.js +47 -4
  32. package/dist/client/queue/dlqJobMethods.d.ts +9 -0
  33. package/dist/client/queue/dlqJobMethods.js +176 -0
  34. package/dist/client/queue/dlqOps.d.ts +2 -1
  35. package/dist/client/queue/dlqOps.js +2 -2
  36. package/dist/client/queue/jobMove.js +6 -1
  37. package/dist/client/queue/jobProxy.js +10 -5
  38. package/dist/client/queue/operations/add.js +17 -0
  39. package/dist/client/queue/operations/index.d.ts +1 -0
  40. package/dist/client/queue/operations/index.js +1 -0
  41. package/dist/client/queue/operations/management.d.ts +0 -4
  42. package/dist/client/queue/operations/management.js +33 -5
  43. package/dist/client/queue/operations/query.d.ts +1 -20
  44. package/dist/client/queue/operations/query.js +18 -53
  45. package/dist/client/queue/operations/queryStates.d.ts +13 -0
  46. package/dist/client/queue/operations/queryStates.js +32 -0
  47. package/dist/client/queue/operations/queryTcpPages.d.ts +14 -0
  48. package/dist/client/queue/operations/queryTcpPages.js +40 -0
  49. package/dist/client/queue/queue.d.ts +3 -0
  50. package/dist/client/queue/queue.js +23 -11
  51. package/dist/client/queue/rateLimit.d.ts +4 -4
  52. package/dist/client/queue/rateLimit.js +17 -8
  53. package/dist/client/queue/workers.d.ts +12 -0
  54. package/dist/client/queue/workers.js +20 -2
  55. package/dist/client/queueGroup.d.ts +11 -0
  56. package/dist/client/queueGroup.js +28 -1
  57. package/dist/client/sandboxed/worker.js +1 -1
  58. package/dist/client/worker/processor.js +1 -1
  59. package/dist/client/worker/processorHandlers.d.ts +1 -5
  60. package/dist/client/worker/processorHandlers.js +5 -7
  61. package/dist/client/worker/worker.d.ts +1 -1
  62. package/dist/client/worker/worker.js +37 -13
  63. package/dist/domain/queue/limiterManager.d.ts +11 -0
  64. package/dist/domain/queue/limiterManager.js +28 -0
  65. package/dist/domain/queue/shard.d.ts +10 -2
  66. package/dist/domain/queue/shard.js +23 -4
  67. package/dist/domain/queue/uniqueKeyManager.d.ts +3 -1
  68. package/dist/domain/queue/uniqueKeyManager.js +9 -1
  69. package/dist/domain/types/command.d.ts +34 -1
  70. package/dist/domain/types/queue.d.ts +5 -0
  71. package/dist/domain/types/queue.js +13 -0
  72. package/dist/infrastructure/persistence/sqlite.d.ts +4 -0
  73. package/dist/infrastructure/persistence/sqlite.js +16 -0
  74. package/dist/infrastructure/server/handlerRoutes.js +14 -1
  75. package/dist/infrastructure/server/handlers/dlq.d.ts +3 -0
  76. package/dist/infrastructure/server/handlers/dlq.js +18 -4
  77. package/dist/infrastructure/server/handlers/introspection.d.ts +19 -0
  78. package/dist/infrastructure/server/handlers/introspection.js +21 -0
  79. package/dist/infrastructure/server/handlers/query.js +1 -1
  80. package/package.json +1 -1
@@ -326,7 +326,11 @@ export function recover(ctx) {
326
326
  const needsWaitingDeps = hasDependencies &&
327
327
  !wasAlreadyPromoted &&
328
328
  !job.dependsOn.every((depId) => ctx.completedJobs.has(depId) || completedInDb.has(depId));
329
- if (needsWaitingDeps) {
329
+ const manuallyWaitingChildren = recoveredState === 'waiting-children' && !hasDependencies;
330
+ if (manuallyWaitingChildren) {
331
+ shard.waitingChildren.set(job.id, job);
332
+ }
333
+ else if (needsWaitingDeps) {
330
334
  // Job is waiting for dependencies - don't add to main queue
331
335
  shard.waitingDeps.set(job.id, job);
332
336
  shard.registerDependencies(job.id, job.dependsOn);
@@ -85,7 +85,7 @@ async function cleanStaleWaitingDependencies(ctx, now) {
85
85
  shard.unregisterDependencies(job.id, job.dependsOn);
86
86
  released.push(...job.dependsOn);
87
87
  if (job.uniqueKey && shard.getUniqueKeyEntry(job.queue, job.uniqueKey)?.jobId === job.id) {
88
- shard.releaseUniqueKey(job.queue, job.uniqueKey);
88
+ shard.releaseUniqueKeyIfOwned(job.queue, job.uniqueKey, job.id);
89
89
  }
90
90
  if (job.customId && ctx.customIdMap.get(job.customId) === job.id) {
91
91
  ctx.customIdMap.delete(job.customId);
@@ -179,7 +179,7 @@ function releaseJobToQueue(opts) {
179
179
  // Release lock if exists
180
180
  ctx.jobLocks.delete(jobId);
181
181
  // Release all job resources (concurrency, uniqueKey, groupId)
182
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
182
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
183
183
  // Discard cron jobs with preventOverlap instead of re-queuing (#73).
184
184
  // The cron scheduler will re-create them at the next scheduled tick.
185
185
  // Re-queuing them causes the "starts right away on reconnect" bug.
@@ -191,6 +191,7 @@ export class ContextFactory {
191
191
  jobLogs: this.deps.jobLogs,
192
192
  storage: this.deps.storage,
193
193
  completedJobs: this.deps.completedJobs,
194
+ completedJobsData: this.deps.completedJobsData,
194
195
  jobResults: this.deps.jobResults,
195
196
  };
196
197
  }
@@ -47,9 +47,15 @@ export interface RetryCompletedContext extends DlqContext {
47
47
  has(id: JobId): boolean;
48
48
  delete(id: JobId): boolean;
49
49
  } & Iterable<JobId>;
50
+ completedJobsData: {
51
+ get(id: JobId): Job | undefined;
52
+ };
50
53
  jobResults: {
51
54
  delete(id: JobId): boolean;
52
55
  };
53
56
  }
54
57
  /** Retry completed jobs */
55
- export declare function retryCompletedJobs(queue: string, ctx: RetryCompletedContext, jobId?: JobId): number;
58
+ export declare function retryCompletedJobs(queue: string, ctx: RetryCompletedContext, jobId?: JobId, options?: {
59
+ limit?: number;
60
+ timestamp?: number;
61
+ }): number;
@@ -260,20 +260,31 @@ export function getDlqConfig(queue, ctx) {
260
260
  return ctx.shards[idx].getDlqConfig(queue);
261
261
  }
262
262
  /** Retry completed jobs */
263
- export function retryCompletedJobs(queue, ctx, jobId) {
263
+ export function retryCompletedJobs(queue, ctx, jobId, options = {}) {
264
264
  if (jobId) {
265
265
  if (!ctx.completedJobs.has(jobId))
266
266
  return 0;
267
- const job = ctx.storage?.getJob(jobId);
267
+ const job = ctx.completedJobsData.get(jobId) ?? ctx.storage?.getJob(jobId);
268
268
  if (job?.queue !== queue)
269
269
  return 0;
270
270
  return requeueCompletedJob(job, ctx);
271
271
  }
272
+ const limit = options.limit === undefined
273
+ ? Number.POSITIVE_INFINITY
274
+ : Number.isFinite(options.limit) && options.limit > 0
275
+ ? Math.floor(options.limit)
276
+ : 0;
277
+ if (limit === 0)
278
+ return 0;
279
+ const timestamp = options.timestamp ?? Number.POSITIVE_INFINITY;
272
280
  let count = 0;
273
- for (const id of ctx.completedJobs) {
274
- const job = ctx.storage?.getJob(id);
275
- if (job?.queue === queue)
281
+ for (const id of [...ctx.completedJobs]) {
282
+ if (count >= limit)
283
+ break;
284
+ const job = ctx.completedJobsData.get(id) ?? ctx.storage?.getJob(id);
285
+ if (job?.queue === queue && job.completedAt !== null && job.completedAt <= timestamp) {
276
286
  count += requeueCompletedJob(job, ctx);
287
+ }
277
288
  }
278
289
  return count;
279
290
  }
@@ -81,7 +81,7 @@ function processExpiredLockInner(jobId, lock, job, shardIdx, procIdx, ctx, now)
81
81
  // the job finishes. Re-queuing would cause "starts right away on reconnect".
82
82
  // The cron scheduler will re-create the job at the next scheduled tick.
83
83
  if (job.uniqueKey?.startsWith('cron:')) {
84
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
84
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
85
85
  ctx.jobIndex.delete(jobId);
86
86
  ctx.storage?.deleteJob(jobId);
87
87
  ctx.jobLocks.delete(jobId);
@@ -124,7 +124,7 @@ function handleRecoveryBoundExceeded(opts) {
124
124
  // Release the concurrency slot (+group+uniqueKey) acquired at pull before
125
125
  // moving to DLQ — otherwise the slot leaks (mirrors
126
126
  // stallDetection.moveStalliedJobToDlq).
127
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
127
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
128
128
  const entry = shard.addToDlq(job, attemptsExhausted ? "max_attempts_exceeded" /* FailureReason.MaxAttemptsExceeded */ : "stalled" /* FailureReason.Stalled */, attemptsExhausted
129
129
  ? `Lock expired at max attempts (${job.maxAttempts})`
130
130
  : `Lock expired after ${lock.renewalCount} renewals`);
@@ -153,7 +153,7 @@ function requeueExpiredJob(opts) {
153
153
  // Release the concurrency slot (+group+uniqueKey) acquired at pull before
154
154
  // re-pushing — otherwise the slot leaks and the queue wedges (mirrors
155
155
  // stallDetection.retryStalliedJob).
156
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
156
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
157
157
  queue.push(job);
158
158
  const isDelayed = job.runAt > now;
159
159
  shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
@@ -28,7 +28,7 @@ export async function ackJob(jobId, result, ctx) {
28
28
  const idx = shardIndex(job.queue);
29
29
  await withWriteLock(ctx.shardLocks[idx], () => {
30
30
  const shard = ctx.shards[idx];
31
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
31
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
32
32
  // Releasing either queue concurrency or an active FIFO group can make a
33
33
  // parked job eligible. Wake only waiters for this queue.
34
34
  shard.notify(job.queue);
@@ -176,7 +176,7 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
176
176
  const flowFailure = willRetry ? null : flowFailureRecord(job, error);
177
177
  await withWriteLock(ctx.shardLocks[idx], () => {
178
178
  const shard = ctx.shards[idx];
179
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
179
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
180
180
  if (willRetry) {
181
181
  const now = Date.now();
182
182
  job.runAt = now + calculateBackoff(job);
@@ -97,7 +97,7 @@ export async function releaseResources(byQueueShard, ctx) {
97
97
  await withWriteLock(ctx.shardLocks[idx], () => {
98
98
  const shard = ctx.shards[idx];
99
99
  for (const job of jobs) {
100
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
100
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
101
101
  }
102
102
  });
103
103
  }));
@@ -19,8 +19,9 @@ export async function cancelJob(jobId, ctx) {
19
19
  if (job) {
20
20
  // Update running counters for O(1) stats
21
21
  shard.decrementQueued(jobId);
22
- if (job.uniqueKey)
23
- shard.releaseUniqueKey(location.queueName, job.uniqueKey);
22
+ if (job.uniqueKey) {
23
+ shard.releaseUniqueKeyIfOwned(location.queueName, job.uniqueKey, job.id);
24
+ }
24
25
  ctx.jobIndex.delete(jobId);
25
26
  ctx.storage?.deleteJob(jobId);
26
27
  ctx.dependencyResults.releaseConsumer(jobId);
@@ -46,8 +47,9 @@ export async function cancelJob(jobId, ctx) {
46
47
  if (waiting) {
47
48
  shard.waitingDeps.delete(jobId);
48
49
  shard.unregisterDependencies(jobId, waiting.dependsOn);
49
- if (waiting.uniqueKey)
50
- shard.releaseUniqueKey(location.queueName, waiting.uniqueKey);
50
+ if (waiting.uniqueKey) {
51
+ shard.releaseUniqueKeyIfOwned(location.queueName, waiting.uniqueKey, waiting.id);
52
+ }
51
53
  ctx.jobIndex.delete(jobId);
52
54
  ctx.storage?.deleteJob(jobId);
53
55
  ctx.dependencyResults.releaseConsumer(jobId);
@@ -27,7 +27,7 @@ export async function moveJobToDelayed(jobId, delay, ctx) {
27
27
  const queueName = job.queue;
28
28
  await withWriteLock(ctx.shardLocks[idx], () => {
29
29
  const shard = ctx.shards[idx];
30
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
30
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
31
31
  shard.getQueue(job.queue).push(job);
32
32
  const isDelayed = job.runAt > now;
33
33
  shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
@@ -78,11 +78,11 @@ export async function discardJob(jobId, ctx) {
78
78
  const entry = await withWriteLock(ctx.shardLocks[idx], () => {
79
79
  const shard = ctx.shards[idx];
80
80
  if (fromProcessing) {
81
- shard.releaseJobResources(validJob.queue, validJob.uniqueKey, validJob.groupId);
81
+ shard.releaseJobResources(validJob.queue, validJob.uniqueKey, validJob.groupId, validJob.id);
82
82
  shard.notify(validJob.queue);
83
83
  }
84
84
  else if (validJob.uniqueKey) {
85
- shard.releaseUniqueKey(validJob.queue, validJob.uniqueKey);
85
+ shard.releaseUniqueKeyIfOwned(validJob.queue, validJob.uniqueKey, validJob.id);
86
86
  }
87
87
  const dlqEntry = shard.addToDlq(validJob);
88
88
  ctx.jobIndex.set(jobId, { type: 'dlq', queueName: validJob.queue });
@@ -5,7 +5,7 @@
5
5
  * - changeWaitingDelay: Waiting -> Delayed
6
6
  * - moveToWaitingChildren: Active -> WaitingChildren
7
7
  */
8
- import type { JobId } from '../../domain/types/job';
8
+ import { type JobId } from '../../domain/types/job';
9
9
  import type { JobManagementContext } from './jobManagement';
10
10
  /** Move active job back to waiting */
11
11
  export declare function moveActiveToWait(jobId: JobId, ctx: JobManagementContext): Promise<boolean>;
@@ -5,6 +5,7 @@
5
5
  * - changeWaitingDelay: Waiting -> Delayed
6
6
  * - moveToWaitingChildren: Active -> WaitingChildren
7
7
  */
8
+ import { MAX_TIMELINE_ENTRIES } from '../../domain/types/job';
8
9
  import { shardIndex, processingShardIndex } from '../../shared/hash';
9
10
  import { withWriteLock } from '../../shared/lock';
10
11
  import { releaseClaimedJobOwnership } from './jobClaim';
@@ -31,7 +32,7 @@ export async function moveActiveToWait(jobId, ctx) {
31
32
  await withWriteLock(ctx.shardLocks[idx], () => {
32
33
  const shard = ctx.shards[idx];
33
34
  // Release concurrency/uniqueKey/group slots before re-queueing
34
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
35
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
35
36
  shard.getQueue(job.queue).push(job);
36
37
  shard.incrementQueued(jobId, false, job.createdAt, job.queue, job.runAt);
37
38
  ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
@@ -82,13 +83,26 @@ export async function moveToWaitingChildren(jobId, ctx) {
82
83
  });
83
84
  if (!job)
84
85
  return false;
86
+ const now = Date.now();
87
+ job.startedAt = null;
88
+ if (job.timeline.length < MAX_TIMELINE_ENTRIES) {
89
+ job.timeline.push({ state: 'waiting-children', timestamp: now });
90
+ }
85
91
  const idx = shardIndex(job.queue);
86
92
  await withWriteLock(ctx.shardLocks[idx], () => {
87
93
  const shard = ctx.shards[idx];
88
94
  // Release concurrency/uniqueKey/group slots before moving to waitingChildren
89
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
95
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
90
96
  shard.waitingChildren.set(jobId, job);
91
97
  ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
92
98
  });
99
+ ctx.storage?.markWaitingChildren(jobId, job.timeline);
100
+ ctx.eventsManager.broadcast({
101
+ eventType: "waiting-children" /* EventType.WaitingChildren */,
102
+ jobId,
103
+ queue: job.queue,
104
+ timestamp: now,
105
+ prev: 'active',
106
+ });
93
107
  return true;
94
108
  }
@@ -202,8 +202,12 @@ export declare class QueueManager {
202
202
  getDlqCount(queue: string): number;
203
203
  getDlqStats(queue: string): DlqStats;
204
204
  retryDlq(queue: string, jobId?: JobId, limit?: number): number;
205
+ retryDlqByFilter(queue: string, filter: DlqFilter): number;
205
206
  purgeDlq(queue: string): number;
206
- retryCompleted(queue: string, jobId?: JobId): number;
207
+ retryCompleted(queue: string, jobId?: JobId, options?: {
208
+ limit?: number;
209
+ timestamp?: number;
210
+ }): number;
207
211
  setRateLimit(queue: string, limit: number, durationMs?: number, ttlMs?: number): void;
208
212
  clearRateLimit(queue: string): void;
209
213
  setConcurrency(queue: string, limit: number): void;
@@ -214,11 +218,27 @@ export declare class QueueManager {
214
218
  * Reads the post-mutation state from the owning shard and UPSERTs the row.
215
219
  */
216
220
  private persistQueueState;
217
- /** Get rate limit and concurrency limit for a queue */
221
+ /** Get configured scalar limits (retained for internal/cloud compatibility). */
218
222
  getQueueLimits(queue: string): {
219
223
  rateLimit: number | null;
220
224
  concurrencyLimit: number | null;
221
225
  };
226
+ /** Get the full live limit status exposed by public Queue getters. */
227
+ getQueueLimitStatus(queue: string, maxJobs?: number): {
228
+ rateLimit: {
229
+ max: number;
230
+ duration: number;
231
+ } | null;
232
+ rateLimitTtl: number;
233
+ concurrencyLimit: number | null;
234
+ maxed: boolean;
235
+ };
236
+ /** Resolve the current owner of a queue-scoped deduplication key. */
237
+ getDeduplicationJobId(queue: string, key: string): string | null;
238
+ /** Release the current owner of a queue-scoped deduplication key. */
239
+ removeDeduplicationKey(queue: string, key: string): Promise<number>;
240
+ /** Release only the deduplication key still owned by a specific job. */
241
+ removeJobDeduplicationKey(id: string): Promise<boolean>;
222
242
  /** Get all job results (for cloud telemetry) */
223
243
  getAllJobResults(): Map<JobId, unknown>;
224
244
  /** Get all job logs (for cloud telemetry) */
@@ -2,7 +2,7 @@
2
2
  * Queue Manager
3
3
  * Core orchestrator for all queue operations
4
4
  */
5
- import { DEFAULT_LOCK_TTL } from '../domain/types/job';
5
+ import { DEFAULT_LOCK_TTL, jobId } from '../domain/types/job';
6
6
  import { DEFAULT_STALL_CONFIG } from '../domain/types/stall';
7
7
  import { DEFAULT_DLQ_CONFIG } from '../domain/types/dlq';
8
8
  import { Shard } from '../domain/queue/shard';
@@ -550,7 +550,7 @@ export class QueueManager {
550
550
  job = pq.remove(jId);
551
551
  if (job) {
552
552
  shard.decrementQueued(jId);
553
- shard.releaseJobResources(queueName, job.uniqueKey, job.groupId);
553
+ shard.releaseJobResources(queueName, job.uniqueKey, job.groupId, job.id);
554
554
  }
555
555
  });
556
556
  if (!job) {
@@ -974,11 +974,14 @@ export class QueueManager {
974
974
  retryDlq(queue, jobId, limit) {
975
975
  return dlqOps.retryDlqJobs(queue, this.contextFactory.getDlqContext(), jobId, limit);
976
976
  }
977
+ retryDlqByFilter(queue, filter) {
978
+ return dlqOps.retryDlqByFilter(queue, this.contextFactory.getDlqContext(), filter);
979
+ }
977
980
  purgeDlq(queue) {
978
981
  return dlqOps.purgeDlqJobs(queue, this.contextFactory.getDlqContext());
979
982
  }
980
- retryCompleted(queue, jobId) {
981
- return dlqOps.retryCompletedJobs(queue, this.contextFactory.getRetryCompletedContext(), jobId);
983
+ retryCompleted(queue, jobId, options) {
984
+ return dlqOps.retryCompletedJobs(queue, this.contextFactory.getRetryCompletedContext(), jobId, options);
982
985
  }
983
986
  // ============ Rate Limiting ============
984
987
  setRateLimit(queue, limit, durationMs, ttlMs) {
@@ -1039,14 +1042,53 @@ export class QueueManager {
1039
1042
  dlqConfig,
1040
1043
  });
1041
1044
  }
1042
- /** Get rate limit and concurrency limit for a queue */
1045
+ /** Get configured scalar limits (retained for internal/cloud compatibility). */
1043
1046
  getQueueLimits(queue) {
1044
1047
  const shard = this.shards[shardIndex(queue)];
1045
- // Lazy TTL expiry so reads never report a limit that no longer throttles.
1046
1048
  shard.expireRateLimitIfNeeded(queue);
1047
1049
  const state = shard.getState(queue);
1048
1050
  return { rateLimit: state.rateLimit, concurrencyLimit: state.concurrencyLimit };
1049
1051
  }
1052
+ /** Get the full live limit status exposed by public Queue getters. */
1053
+ getQueueLimitStatus(queue, maxJobs) {
1054
+ const shard = this.shards[shardIndex(queue)];
1055
+ return {
1056
+ rateLimit: shard.getRateLimit(queue),
1057
+ rateLimitTtl: shard.getRateLimitTtl(queue, maxJobs),
1058
+ concurrencyLimit: shard.getConcurrency(queue),
1059
+ maxed: shard.isConcurrencyMaxed(queue),
1060
+ };
1061
+ }
1062
+ /** Resolve the current owner of a queue-scoped deduplication key. */
1063
+ getDeduplicationJobId(queue, key) {
1064
+ return this.shards[shardIndex(queue)].getUniqueKeyEntry(queue, key)?.jobId ?? null;
1065
+ }
1066
+ /** Release the current owner of a queue-scoped deduplication key. */
1067
+ async removeDeduplicationKey(queue, key) {
1068
+ const idx = shardIndex(queue);
1069
+ return withWriteLock(this.shardLocks[idx], () => {
1070
+ const shard = this.shards[idx];
1071
+ const owner = shard.getUniqueKeyEntry(queue, key)?.jobId;
1072
+ if (!owner || !shard.releaseUniqueKeyIfOwned(queue, key, owner))
1073
+ return 0;
1074
+ this.storage?.clearJobUniqueKey(owner);
1075
+ return 1;
1076
+ });
1077
+ }
1078
+ /** Release only the deduplication key still owned by a specific job. */
1079
+ async removeJobDeduplicationKey(id) {
1080
+ const jid = jobId(id);
1081
+ const job = await this.getJob(jid);
1082
+ if (!job?.uniqueKey)
1083
+ return false;
1084
+ const idx = shardIndex(job.queue);
1085
+ return withWriteLock(this.shardLocks[idx], () => {
1086
+ const removed = this.shards[idx].releaseUniqueKeyIfOwned(job.queue, job.uniqueKey, jid);
1087
+ if (removed)
1088
+ this.storage?.clearJobUniqueKey(jid);
1089
+ return removed;
1090
+ });
1091
+ }
1050
1092
  /** Get all job results (for cloud telemetry) */
1051
1093
  getAllJobResults() {
1052
1094
  const map = new Map();
@@ -108,7 +108,7 @@ async function handleStalledJob(job, action, ctx) {
108
108
  /** Move stalled job to DLQ */
109
109
  function moveStalliedJobToDlq(job, ctx, shard, procIdx, attemptsExhausted) {
110
110
  ctx.processingShards[procIdx].delete(job.id);
111
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
111
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
112
112
  // Discard cron jobs with preventOverlap instead of sending to DLQ (#73).
113
113
  if (job.uniqueKey?.startsWith('cron:')) {
114
114
  ctx.jobIndex.delete(job.id);
@@ -132,7 +132,7 @@ function retryStalliedJob(job, ctx, shard, procIdx, idx) {
132
132
  // The cron scheduler will re-create them at the next scheduled tick.
133
133
  if (job.uniqueKey?.startsWith('cron:')) {
134
134
  ctx.processingShards[procIdx].delete(job.id);
135
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
135
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
136
136
  ctx.jobIndex.delete(job.id);
137
137
  ctx.storage?.deleteJob(job.id);
138
138
  return;
@@ -153,7 +153,7 @@ function retryStalliedJob(job, ctx, shard, procIdx, idx) {
153
153
  job.timeline.push({ state: 'waiting', timestamp: stallNow, attempt: job.attempts + 1 });
154
154
  }
155
155
  ctx.processingShards[procIdx].delete(job.id);
156
- shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
156
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId, job.id);
157
157
  shard.getQueue(job.queue).push(job);
158
158
  const isDelayed = job.runAt > Date.now();
159
159
  shard.incrementQueued(job.id, isDelayed, job.createdAt, job.queue, job.runAt);
@@ -5,12 +5,14 @@ async function fetchChildIds(id, embedded, tcp) {
5
5
  if (embedded) {
6
6
  const job = await getSharedManager().getJob(jobId(id));
7
7
  if (!job)
8
- throw new Error(`Flow job ${id} not found`);
8
+ return [];
9
9
  return job.childrenIds.map(String);
10
10
  }
11
11
  if (!tcp)
12
12
  return [];
13
13
  const response = await tcp.send({ cmd: 'GetJob', id });
14
+ if (response.ok !== true && response.error === 'Job not found')
15
+ return [];
14
16
  assertFlowTcpOk(response, 'GetJob');
15
17
  const parent = response.job;
16
18
  return (parent?.childrenIds ?? []).map(String);
@@ -2,6 +2,7 @@ import { jobId } from '../domain/types/job';
2
2
  import { getSharedManager } from './manager';
3
3
  import { buildFailCommand, failEmbeddedArgs } from './queue/failWire';
4
4
  import { assertFlowTcpOk } from './flowJobTypes';
5
+ import { removeJobDeduplicationKey } from './jobDeduplication';
5
6
  /** Lifecycle and failure-inspection methods exposed by a FlowProducer Job. */
6
7
  export function buildFlowJobMoveMethods(runtime) {
7
8
  const { id, embedded, tcp } = runtime;
@@ -42,7 +43,11 @@ export function buildFlowJobMoveMethods(runtime) {
42
43
  moveToWaitingChildren: async () => {
43
44
  if (embedded)
44
45
  return getSharedManager().moveToWaitingChildren(jobId(id));
45
- throw new Error('moveToWaitingChildren is not supported in TCP mode — no server command available');
46
+ if (!tcp)
47
+ return false;
48
+ const response = await tcp.send({ cmd: 'MoveToWaitingChildren', id });
49
+ assertFlowTcpOk(response, 'MoveToWaitingChildren');
50
+ return true;
46
51
  },
47
52
  waitUntilFinished: async (_queueEvents, ttl) => {
48
53
  const timeout = ttl ?? 30_000;
@@ -100,7 +105,7 @@ export function buildFlowJobMoveMethods(runtime) {
100
105
  assertFlowTcpOk(response, 'RemoveChildDependency');
101
106
  return response.removed ?? false;
102
107
  },
103
- removeDeduplicationKey: () => Promise.reject(new Error('removeDeduplicationKey is not implemented — no server primitive available')),
108
+ removeDeduplicationKey: () => removeJobDeduplicationKey(id, embedded, tcp),
104
109
  removeUnprocessedChildren: async () => {
105
110
  if (embedded)
106
111
  return void (await getSharedManager().removeUnprocessedChildren(jobId(id)));
@@ -4,11 +4,11 @@
4
4
  */
5
5
  import type { DlqEntry as InternalDlqEntry } from '../domain/types/dlq';
6
6
  import type { Job, DlqEntry } from './types';
7
- import type { CreatePublicJobOptions, ToPublicJobOptions } from './jobConversionTypes';
8
- export type { CreatePublicJobOptions, ToPublicJobOptions } from './jobConversionTypes';
7
+ import type { CreatePublicJobOptions, PublicJobMethodContext, ToPublicJobOptions } from './jobConversionTypes';
8
+ export type { CreatePublicJobOptions, PublicJobMethodContext, ToPublicJobOptions, } from './jobConversionTypes';
9
9
  /** Convert internal job to public job (with methods) */
10
10
  export declare function createPublicJob<T>(opts: CreatePublicJobOptions): Job<T>;
11
11
  /** Simple public job without methods (for Queue.getJob) */
12
12
  export declare function toPublicJob<T>(opts: ToPublicJobOptions): Job<T>;
13
13
  /** Convert internal DLQ entry to public DLQ entry */
14
- export declare function toDlqEntry<T>(entry: InternalDlqEntry): DlqEntry<T>;
14
+ export declare function toDlqEntry<T>(entry: InternalDlqEntry, methods: PublicJobMethodContext): DlqEntry<T>;
@@ -64,7 +64,7 @@ export function createPublicJob(opts) {
64
64
  }
65
65
  /** Simple public job without methods (for Queue.getJob) */
66
66
  export function toPublicJob(opts) {
67
- const { job, name, getState, remove, retry, getChildrenValues, updateData, promote, changeDelay, changePriority, extendLock, clearLogs, getDependencies, getDependenciesCount, moveToCompleted, moveToFailed, moveToWait, moveToDelayed, moveToWaitingChildren, waitUntilFinished, discard, getFailedChildrenValues, getIgnoredChildrenFailures, removeChildDependency, removeDeduplicationKey, removeUnprocessedChildren, stacktrace, } = opts;
67
+ const { job, name, updateProgress, log, getState, remove, retry, getChildrenValues, updateData, promote, changeDelay, changePriority, extendLock, clearLogs, getDependencies, getDependenciesCount, moveToCompleted, moveToFailed, moveToWait, moveToDelayed, moveToWaitingChildren, waitUntilFinished, discard, getFailedChildrenValues, getIgnoredChildrenFailures, removeChildDependency, removeDeduplicationKey, removeUnprocessedChildren, stacktrace, } = opts;
68
68
  const id = String(job.id);
69
69
  const jobOpts = buildJobOpts(job);
70
70
  const props = buildJobProperties(job, name, stacktrace, undefined, undefined);
@@ -74,9 +74,9 @@ export function toPublicJob(opts) {
74
74
  ...props,
75
75
  ...stateChecks,
76
76
  ...serialization,
77
- // Core methods (no-op for simple jobs)
78
- updateProgress: async () => { },
79
- log: async () => { },
77
+ // Core methods
78
+ updateProgress: (progress, message) => updateProgress ? updateProgress(id, progress, message) : Promise.resolve(),
79
+ log: (message) => (log ? log(id, message) : Promise.resolve()),
80
80
  getState: () => (getState ? getState(id) : Promise.resolve('unknown')),
81
81
  remove: () => (remove ? remove(id) : Promise.resolve()),
82
82
  retry: () => (retry ? retry(id) : Promise.resolve()),
@@ -121,10 +121,14 @@ export function toPublicJob(opts) {
121
121
  };
122
122
  }
123
123
  /** Convert internal DLQ entry to public DLQ entry */
124
- export function toDlqEntry(entry) {
124
+ export function toDlqEntry(entry, methods) {
125
125
  const jobData = entry.job.data;
126
126
  return {
127
- job: toPublicJob({ job: entry.job, name: jobData?.name ?? 'default' }),
127
+ job: createPublicJob({
128
+ job: entry.job,
129
+ name: jobData?.name ?? 'default',
130
+ ...methods,
131
+ }),
128
132
  enteredAt: entry.enteredAt,
129
133
  reason: entry.reason,
130
134
  error: entry.error,
@@ -7,6 +7,6 @@ import type { Job, JobStateType, JobOptions, GetDependenciesOpts, JobDependencie
7
7
  /** Build common job properties from internal job */
8
8
  export declare function buildJobProperties<T>(job: InternalJob, name: string, stacktrace?: string[] | null, token?: string, processedBy?: string): Pick<Job<T>, 'id' | 'name' | 'data' | 'queueName' | 'attemptsMade' | 'timestamp' | 'progress' | 'parent' | 'delay' | 'processedOn' | 'finishedOn' | 'stacktrace' | 'stalledCounter' | 'priority' | 'parentKey' | 'opts' | 'token' | 'processedBy' | 'deduplicationId' | 'repeatJobKey' | 'attemptsStarted'>;
9
9
  /** Build state check methods */
10
- export declare function buildStateCheckMethods(id: string, getState?: (id: string) => Promise<JobStateType>, getDependenciesCount?: (id: string, opts?: GetDependenciesOpts) => Promise<JobDependenciesCount>): Pick<Job, 'isWaiting' | 'isActive' | 'isDelayed' | 'isCompleted' | 'isFailed' | 'isWaitingChildren'>;
10
+ export declare function buildStateCheckMethods(id: string, getState?: (id: string) => Promise<JobStateType>, _getDependenciesCount?: (id: string, opts?: GetDependenciesOpts) => Promise<JobDependenciesCount>): Pick<Job, 'isWaiting' | 'isActive' | 'isDelayed' | 'isCompleted' | 'isFailed' | 'isWaitingChildren'>;
11
11
  /** Build serialization methods */
12
12
  export declare function buildSerializationMethods<T>(job: InternalJob, id: string, name: string, jobOpts: JobOptions, stacktrace?: string[] | null): Pick<Job<T>, 'toJSON' | 'asJSON'>;
@@ -38,7 +38,7 @@ export function buildJobProperties(job, name, stacktrace, token, processedBy) {
38
38
  };
39
39
  }
40
40
  /** Build state check methods */
41
- export function buildStateCheckMethods(id, getState, getDependenciesCount) {
41
+ export function buildStateCheckMethods(id, getState, _getDependenciesCount) {
42
42
  return {
43
43
  isWaiting: async () => {
44
44
  const state = await (getState ? getState(id) : Promise.resolve('unknown'));
@@ -61,11 +61,8 @@ export function buildStateCheckMethods(id, getState, getDependenciesCount) {
61
61
  return state === 'failed';
62
62
  },
63
63
  isWaitingChildren: async () => {
64
- if (getDependenciesCount) {
65
- const counts = await getDependenciesCount(id);
66
- return counts.unprocessed > 0;
67
- }
68
- return false;
64
+ const state = await (getState ? getState(id) : Promise.resolve('unknown'));
65
+ return state === 'waiting-children';
69
66
  },
70
67
  };
71
68
  }
@@ -43,10 +43,14 @@ export interface CreatePublicJobOptions {
43
43
  processedBy?: string;
44
44
  stacktrace?: string[] | null;
45
45
  }
46
+ /** Complete operation set required when exposing a live job from another view. */
47
+ export type PublicJobMethodContext = Required<Omit<CreatePublicJobOptions, 'job' | 'name' | 'token' | 'processedBy' | 'stacktrace'>>;
46
48
  /** Options for creating a simple public job */
47
49
  export interface ToPublicJobOptions {
48
50
  job: InternalJob;
49
51
  name: string;
52
+ updateProgress?: (id: string, progress: number, message?: string) => Promise<void>;
53
+ log?: (id: string, message: string) => Promise<void>;
50
54
  getState?: (id: string) => Promise<JobStateType>;
51
55
  remove?: (id: string) => Promise<void>;
52
56
  retry?: (id: string) => Promise<void>;
@@ -0,0 +1,6 @@
1
+ interface CommandTransport {
2
+ send(command: Record<string, unknown>): Promise<Record<string, unknown>>;
3
+ }
4
+ /** Remove only the deduplication key currently owned by the given job. */
5
+ export declare function removeJobDeduplicationKey(id: string, embedded: boolean, transport: CommandTransport | null | undefined): Promise<boolean>;
6
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jobId } from '../domain/types/job';
2
+ import { getSharedManager } from './manager';
3
+ /** Remove only the deduplication key currently owned by the given job. */
4
+ export async function removeJobDeduplicationKey(id, embedded, transport) {
5
+ if (embedded)
6
+ return getSharedManager().removeJobDeduplicationKey(jobId(id));
7
+ if (!transport)
8
+ return false;
9
+ const response = await transport.send({ cmd: 'RemoveJobDeduplicationKey', id });
10
+ if (!response.ok) {
11
+ throw new Error(response.error ?? 'Failed to remove deduplication key');
12
+ }
13
+ return response.data.removed;
14
+ }