bunqueue 2.9.0 → 2.9.2

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 (150) hide show
  1. package/README.md +7 -3
  2. package/dist/application/background/recovery/active.d.ts +1 -0
  3. package/dist/application/background/recovery/active.js +12 -0
  4. package/dist/application/background/recovery/index.js +3 -1
  5. package/dist/application/background/recovery/pending.js +1 -0
  6. package/dist/application/cleanupTasks.js +4 -16
  7. package/dist/application/contextFactory.js +3 -0
  8. package/dist/application/dlqManager.js +1 -0
  9. package/dist/application/dlqRetry.js +1 -0
  10. package/dist/application/eventsManager.d.ts +8 -1
  11. package/dist/application/eventsManager.js +76 -29
  12. package/dist/application/operations/ackHelpers.d.ts +1 -4
  13. package/dist/application/operations/ackHelpers.js +39 -14
  14. package/dist/application/operations/flowPush.js +1 -0
  15. package/dist/application/operations/flowValidation.js +4 -4
  16. package/dist/application/operations/pull.d.ts +7 -2
  17. package/dist/application/operations/pull.js +15 -57
  18. package/dist/application/operations/pullFinalization.d.ts +6 -0
  19. package/dist/application/operations/pullFinalization.js +69 -0
  20. package/dist/application/operations/pullStateTransition.d.ts +8 -7
  21. package/dist/application/operations/pullStateTransition.js +25 -18
  22. package/dist/application/operations/push.js +3 -0
  23. package/dist/application/operations/pushBatch.js +14 -7
  24. package/dist/application/operations/pushContext.d.ts +2 -1
  25. package/dist/application/postgres-queue-manager/control.d.ts +4 -0
  26. package/dist/application/postgres-queue-manager/control.js +24 -0
  27. package/dist/application/postgres-queue-manager/delivery.d.ts +5 -4
  28. package/dist/application/postgres-queue-manager/delivery.js +13 -10
  29. package/dist/application/postgres-queue-manager/groupQueries.d.ts +10 -0
  30. package/dist/application/postgres-queue-manager/groupQueries.js +40 -0
  31. package/dist/application/postgres-queue-manager/queries.d.ts +2 -2
  32. package/dist/application/postgres-queue-manager/queries.js +2 -2
  33. package/dist/application/queue-manager/control.js +1 -0
  34. package/dist/application/queue-manager/delivery.d.ts +5 -4
  35. package/dist/application/queue-manager/delivery.js +15 -8
  36. package/dist/application/queue-manager/limits.d.ts +11 -0
  37. package/dist/application/queue-manager/limits.js +61 -0
  38. package/dist/application/queue-manager/state.js +1 -1
  39. package/dist/application/queueTelemetryJournal.d.ts +2 -0
  40. package/dist/application/queueTelemetryJournal.js +23 -0
  41. package/dist/application/types/ack.d.ts +3 -1
  42. package/dist/cli/commandRouter.d.ts +1 -1
  43. package/dist/client/flowPush.js +6 -0
  44. package/dist/client/groupId.d.ts +1 -0
  45. package/dist/client/groupId.js +1 -0
  46. package/dist/client/index.d.ts +1 -1
  47. package/dist/client/jobHelpers.js +1 -0
  48. package/dist/client/queue/operations/add/bulk.js +4 -4
  49. package/dist/client/queue/operations/add/payload.d.ts +1 -0
  50. package/dist/client/queue/operations/add/payload.js +7 -1
  51. package/dist/client/queue/operations/add/single.js +3 -3
  52. package/dist/client/queue/operations/groups.d.ts +21 -0
  53. package/dist/client/queue/operations/groups.js +88 -0
  54. package/dist/client/queue/runtime/queries.d.ts +13 -0
  55. package/dist/client/queue/runtime/queries.js +31 -0
  56. package/dist/client/queue/runtime/state.d.ts +4 -0
  57. package/dist/client/queue/runtime/state.js +1 -0
  58. package/dist/client/types/index.d.ts +2 -2
  59. package/dist/client/types/options.d.ts +4 -0
  60. package/dist/client/types/worker.d.ts +8 -0
  61. package/dist/client/worker/runtime/execution.js +1 -0
  62. package/dist/client/worker/runtime/options.js +3 -0
  63. package/dist/client/worker/types/options.d.ts +2 -1
  64. package/dist/client/worker/workerPull.d.ts +2 -0
  65. package/dist/client/worker/workerPull.js +6 -4
  66. package/dist/domain/job/groupFifoOrder.d.ts +9 -0
  67. package/dist/domain/job/groupFifoOrder.js +26 -0
  68. package/dist/domain/queue/groupLimiterManager.d.ts +23 -0
  69. package/dist/domain/queue/groupLimiterManager.js +107 -0
  70. package/dist/domain/queue/groupScheduler.d.ts +31 -0
  71. package/dist/domain/queue/groupScheduler.js +256 -0
  72. package/dist/domain/queue/groupSchedulerOrder.d.ts +6 -0
  73. package/dist/domain/queue/groupSchedulerOrder.js +30 -0
  74. package/dist/domain/queue/priorityQueue.d.ts +13 -23
  75. package/dist/domain/queue/priorityQueue.js +53 -42
  76. package/dist/domain/queue/shard/keys.d.ts +1 -0
  77. package/dist/domain/queue/shard/keys.js +25 -2
  78. package/dist/domain/queue/shard/lifecycle.js +4 -0
  79. package/dist/domain/queue/shard/limits.d.ts +20 -1
  80. package/dist/domain/queue/shard/limits.js +56 -0
  81. package/dist/domain/queue/shard/state.d.ts +5 -0
  82. package/dist/domain/queue/shard/state.js +9 -1
  83. package/dist/domain/types/commands/core.d.ts +3 -0
  84. package/dist/domain/types/commands/limits.d.ts +41 -0
  85. package/dist/domain/types/commands/union.d.ts +1 -1
  86. package/dist/domain/types/group.d.ts +21 -0
  87. package/dist/domain/types/group.js +78 -0
  88. package/dist/domain/types/priorityQueue.d.ts +1 -0
  89. package/dist/infrastructure/persistence/jobOptionsBlob.d.ts +1 -0
  90. package/dist/infrastructure/persistence/jobOptionsBlob.js +10 -2
  91. package/dist/infrastructure/persistence/migrations.js +11 -0
  92. package/dist/infrastructure/persistence/postgres/admission.js +5 -2
  93. package/dist/infrastructure/persistence/postgres/batchAdmission.js +22 -4
  94. package/dist/infrastructure/persistence/postgres/claimBatch.d.ts +3 -0
  95. package/dist/infrastructure/persistence/postgres/claimBatch.js +16 -3
  96. package/dist/infrastructure/persistence/postgres/claimSelection.d.ts +8 -1
  97. package/dist/infrastructure/persistence/postgres/claimSelection.js +73 -131
  98. package/dist/infrastructure/persistence/postgres/claims.d.ts +8 -1
  99. package/dist/infrastructure/persistence/postgres/claims.js +19 -3
  100. package/dist/infrastructure/persistence/postgres/eventCatchupCursors.d.ts +32 -0
  101. package/dist/infrastructure/persistence/postgres/eventCatchupCursors.js +66 -0
  102. package/dist/infrastructure/persistence/postgres/eventJournalSchema.d.ts +2 -2
  103. package/dist/infrastructure/persistence/postgres/eventJournalSchema.js +16 -2
  104. package/dist/infrastructure/persistence/postgres/eventPruneWatermarks.d.ts +2 -0
  105. package/dist/infrastructure/persistence/postgres/eventPruneWatermarks.js +21 -3
  106. package/dist/infrastructure/persistence/postgres/events.d.ts +3 -3
  107. package/dist/infrastructure/persistence/postgres/events.js +44 -17
  108. package/dist/infrastructure/persistence/postgres/groupClaims.d.ts +15 -0
  109. package/dist/infrastructure/persistence/postgres/groupClaims.js +95 -0
  110. package/dist/infrastructure/persistence/postgres/groupSchema.d.ts +2 -0
  111. package/dist/infrastructure/persistence/postgres/groupSchema.js +155 -0
  112. package/dist/infrastructure/persistence/postgres/groupSchemaFingerprint.d.ts +3 -0
  113. package/dist/infrastructure/persistence/postgres/groupSchemaFingerprint.js +127 -0
  114. package/dist/infrastructure/persistence/postgres/groupStateRetention.d.ts +3 -0
  115. package/dist/infrastructure/persistence/postgres/groupStateRetention.js +93 -0
  116. package/dist/infrastructure/persistence/postgres/groups.d.ts +11 -0
  117. package/dist/infrastructure/persistence/postgres/groups.js +117 -0
  118. package/dist/infrastructure/persistence/postgres/queueDestruction.js +4 -0
  119. package/dist/infrastructure/persistence/postgres/runtime.d.ts +0 -1
  120. package/dist/infrastructure/persistence/postgres/runtime.js +3 -1
  121. package/dist/infrastructure/persistence/postgres/schema.d.ts +2 -2
  122. package/dist/infrastructure/persistence/postgres/schema.js +8 -4
  123. package/dist/infrastructure/persistence/postgres/schemaInitialization.js +6 -1
  124. package/dist/infrastructure/persistence/postgres/store.d.ts +11 -1
  125. package/dist/infrastructure/persistence/postgres/store.js +47 -3
  126. package/dist/infrastructure/persistence/postgres/telemetry.d.ts +2 -0
  127. package/dist/infrastructure/persistence/postgres/telemetry.js +33 -21
  128. package/dist/infrastructure/persistence/postgres/types.d.ts +1 -0
  129. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  130. package/dist/infrastructure/persistence/schema.js +12 -1
  131. package/dist/infrastructure/persistence/sqlite/control.d.ts +14 -0
  132. package/dist/infrastructure/persistence/sqlite/control.js +69 -0
  133. package/dist/infrastructure/persistence/sqlite/jobs.d.ts +12 -0
  134. package/dist/infrastructure/persistence/sqlite/jobs.js +36 -0
  135. package/dist/infrastructure/persistence/sqlite/telemetry.d.ts +2 -1
  136. package/dist/infrastructure/persistence/sqlite/telemetry.js +5 -64
  137. package/dist/infrastructure/persistence/sqlite/telemetryWrites.d.ts +4 -0
  138. package/dist/infrastructure/persistence/sqlite/telemetryWrites.js +123 -0
  139. package/dist/infrastructure/persistence/sqliteSerializer.js +2 -0
  140. package/dist/infrastructure/server/handler-routes/control.js +13 -0
  141. package/dist/infrastructure/server/handlers/core.d.ts +0 -5
  142. package/dist/infrastructure/server/handlers/core.js +15 -18
  143. package/dist/infrastructure/server/handlers/groups.d.ts +11 -0
  144. package/dist/infrastructure/server/handlers/groups.js +55 -0
  145. package/dist/infrastructure/server/handlers/pushBatchValidation.js +4 -1
  146. package/dist/infrastructure/server/protocol/validation.d.ts +1 -0
  147. package/dist/infrastructure/server/protocol/validation.js +1 -0
  148. package/dist/infrastructure/server/protocol.d.ts +1 -1
  149. package/dist/infrastructure/server/protocol.js +1 -1
  150. package/package.json +2 -1
package/README.md CHANGED
@@ -82,9 +82,12 @@ is not supported. CI validates PostgreSQL 15, 16, 17, and the pinned/recommended
82
82
  18.6 release. See the
83
83
  [storage guide](https://bunqueue.dev/guide/databases/).
84
84
 
85
- Release images also carry the exact package version. For reproducible
86
- deployments, pin `ghcr.io/egeominotti/bunqueue:2.9.0`; `latest` points to the
87
- same multi-arch image at release time.
85
+ Every completed release publishes `ghcr.io/egeominotti/bunqueue` with the exact
86
+ version tag alongside `latest`, the commit SHA and a build timestamp, so pin the
87
+ version tag for reproducible deployments. Confirm the tag exists before pinning
88
+ it, because a release whose pipeline does not complete pushes no image;
89
+ `docker buildx imagetools inspect ghcr.io/egeominotti/bunqueue:<tag>` resolves
90
+ the digest.
88
91
 
89
92
  Then produce and process from the language you already use:
90
93
 
@@ -123,6 +126,7 @@ Python, PHP, Go, Rust and Elixir clients speak the same protocol — see
123
126
  - **PostgreSQL 15–18 multi-broker mode** — PostgreSQL 18.6 is recommended;
124
127
  database-authoritative claims, fenced
125
128
  leases, shared limits, cron, workers, job-state/lifecycle metrics, and failover state
129
+ ([tested three-broker Docker example](https://bunqueue.dev/examples/postgres-multibroker/))
126
130
  - **BullMQ-compatible API** — same `Queue`, `Worker`, `QueueEvents`; [migrating takes minutes](https://bunqueue.dev/guide/migration/)
127
131
  - **MCP server included** — 73 tools; AI agents get full queue control out of the box
128
132
  - **Everything server-side** — retries with backoff, priorities, cron, rate limits, dead letter queue
@@ -1,4 +1,5 @@
1
1
  import { type JobId } from '../../../domain/types/job';
2
2
  import type { BackgroundContext } from '../../types';
3
+ export declare function restoreGroupPolicies(ctx: BackgroundContext, groupStates: ReturnType<NonNullable<BackgroundContext['storage']>['loadGroupState']>): void;
3
4
  export declare function restoreRecoveryPolicies(ctx: BackgroundContext, queueStates: ReturnType<NonNullable<BackgroundContext['storage']>['loadQueueState']>): void;
4
5
  export declare function recoverActiveJobs(ctx: BackgroundContext, dlqJobIds: Set<JobId>, now: number): void;
@@ -3,6 +3,18 @@ import { calculateBackoff } from '../../../domain/types/job';
3
3
  import { isCorruptDependsOn } from '../../../infrastructure/persistence/sqliteSerializer';
4
4
  import { shardIndex } from '../../../shared/hash';
5
5
  import { quarantineCorruptDependsOn, RECOVERY_BATCH_SIZE } from './shared';
6
+ export function restoreGroupPolicies(ctx, groupStates) {
7
+ for (const state of groupStates) {
8
+ const shard = ctx.shards[shardIndex(state.queue)];
9
+ if (state.rateLimit !== null && state.rateDuration !== null) {
10
+ shard.setGroupRateLimit(state.queue, state.groupId, state.rateLimit, state.rateDuration);
11
+ }
12
+ if (state.concurrencyLimit !== null) {
13
+ shard.setGroupConcurrency(state.queue, state.groupId, state.concurrencyLimit);
14
+ }
15
+ ctx.registerQueueName(state.queue);
16
+ }
17
+ }
6
18
  export function restoreRecoveryPolicies(ctx, queueStates) {
7
19
  for (const queueState of queueStates) {
8
20
  if (queueState.stallConfig) {
@@ -1,5 +1,5 @@
1
1
  import { reconcileDependencyCompletionPins } from '../../dependencyCompletions';
2
- import { recoverActiveJobs, restoreRecoveryPolicies } from './active';
2
+ import { recoverActiveJobs, restoreGroupPolicies, restoreRecoveryPolicies } from './active';
3
3
  import { recoverPendingJobs } from './pending';
4
4
  import { recoverCompletedJobs, restoreDlq, restoreQueueState } from './restore';
5
5
  export function recover(ctx) {
@@ -11,7 +11,9 @@ export function recover(ctx) {
11
11
  completedInDatabase.add(record.jobId);
12
12
  const dlqJobIds = ctx.storage.loadDlqJobIds();
13
13
  const queueStates = ctx.storage.loadQueueState();
14
+ const groupStates = ctx.storage.loadGroupState();
14
15
  restoreRecoveryPolicies(ctx, queueStates);
16
+ restoreGroupPolicies(ctx, groupStates);
15
17
  const now = Date.now();
16
18
  recoverActiveJobs(ctx, dlqJobIds, now);
17
19
  recoverPendingJobs(ctx, completedInDatabase, now);
@@ -18,6 +18,7 @@ export function recoverPendingJobs(ctx, completedInDatabase, now) {
18
18
  corruptPendingJobs.push(job);
19
19
  continue;
20
20
  }
21
+ shard.assignGroupFifoOrder(job);
21
22
  const hasDependencies = job.dependsOn && job.dependsOn.length > 0;
22
23
  const recoveredState = persistedJobState(job);
23
24
  const wasAlreadyPromoted = recoveredState === 'waiting' ||
@@ -26,7 +26,7 @@ export async function cleanup(ctx) {
26
26
  }
27
27
  await cleanOrphanedProcessingEntries(ctx, now, stallTimeout);
28
28
  await cleanStaleWaitingDependencies(ctx, now);
29
- cleanUniqueKeysAndGroups(ctx);
29
+ cleanUniqueKeysAndGroups(ctx, now);
30
30
  cleanStalledCandidates(ctx);
31
31
  await cleanOrphanedJobIndex(ctx);
32
32
  cleanOrphanedJobLocks(ctx);
@@ -103,9 +103,10 @@ async function cleanStaleWaitingDependencies(ctx, now) {
103
103
  }
104
104
  }
105
105
  }
106
- function cleanUniqueKeysAndGroups(ctx) {
106
+ function cleanUniqueKeysAndGroups(ctx, now) {
107
107
  for (let i = 0; i < SHARD_COUNT; i++) {
108
108
  const shard = ctx.shards[i];
109
+ shard.cleanExpiredGroupWindows(now);
109
110
  // Clean expired unique keys
110
111
  shard.cleanExpiredUniqueKeys();
111
112
  // Trim if too many keys remain
@@ -121,19 +122,6 @@ function cleanUniqueKeysAndGroups(ctx) {
121
122
  }
122
123
  }
123
124
  }
124
- // Clean orphaned active groups
125
- for (const [_queueName, groups] of shard.activeGroups) {
126
- if (groups.size > 1000) {
127
- const toRemove = Math.floor(groups.size / 2);
128
- const iter = groups.values();
129
- for (let j = 0; j < toRemove; j++) {
130
- const { value, done } = iter.next();
131
- if (done)
132
- break;
133
- groups.delete(value);
134
- }
135
- }
136
- }
137
125
  }
138
126
  }
139
127
  function cleanStalledCandidates(ctx) {
@@ -244,7 +232,7 @@ function cleanEmptyQueues(ctx) {
244
232
  shard.dlq.delete(queueName);
245
233
  shard.uniqueKeys.delete(queueName);
246
234
  shard.queueState.delete(queueName);
247
- shard.activeGroups.delete(queueName);
235
+ shard.clearEmptyGroupRuntime(queueName);
248
236
  shard.clearQueueLimiters(queueName);
249
237
  shard.stallConfig.delete(queueName);
250
238
  shard.dlqConfig.delete(queueName);
@@ -102,6 +102,7 @@ export class ContextFactory {
102
102
  jobIndex: this.deps.jobIndex,
103
103
  totalPushed: this.deps.metrics.totalPushed,
104
104
  broadcast: this.deps.eventsManager.broadcast.bind(this.deps.eventsManager),
105
+ broadcastBatch: this.deps.eventsManager.broadcastBatch.bind(this.deps.eventsManager),
105
106
  dashboardEmit: this.callbacks.emitDashboardEvent,
106
107
  registerQueueName: this.callbacks.registerQueueName,
107
108
  };
@@ -116,6 +117,7 @@ export class ContextFactory {
116
117
  jobIndex: this.deps.jobIndex,
117
118
  totalPulled: this.deps.metrics.totalPulled,
118
119
  broadcast: this.deps.eventsManager.broadcast.bind(this.deps.eventsManager),
120
+ broadcastBatch: this.deps.eventsManager.broadcastBatch.bind(this.deps.eventsManager),
119
121
  dashboardEmit: this.callbacks.emitDashboardEvent,
120
122
  };
121
123
  }
@@ -139,6 +141,7 @@ export class ContextFactory {
139
141
  totalFailed: this.deps.metrics.totalFailed,
140
142
  perQueueMetrics: this.deps.perQueueMetrics,
141
143
  broadcast: this.deps.eventsManager.broadcast.bind(this.deps.eventsManager),
144
+ broadcastBatch: this.deps.eventsManager.broadcastBatch.bind(this.deps.eventsManager),
142
145
  onJobCompleted: this.callbacks.onJobCompleted,
143
146
  onJobFailed: this.callbacks.onJobFailed,
144
147
  onJobsCompleted: this.callbacks.onJobsCompleted,
@@ -196,6 +196,7 @@ function requeueCompletedJob(job, ctx) {
196
196
  timeline,
197
197
  };
198
198
  setDlqRetryState(requeued, null);
199
+ shard.assignGroupFifoOrder(requeued);
199
200
  // Commit the durable transition before publishing the new in-memory state.
200
201
  // A SQLite failure therefore leaves the completed generation authoritative.
201
202
  ctx.storage?.requeueCompletedJob(requeued);
@@ -44,6 +44,7 @@ function retryEntry(queue, entry, ctx, now, prepareState) {
44
44
  if (!canRetry(queue, entry.job, ctx))
45
45
  return null;
46
46
  const job = prepareRetry(entry.job, now);
47
+ ctx.shards[shardIndex(queue)].assignGroupFifoOrder(job);
47
48
  prepareState(job);
48
49
  ctx.storage?.requeueDlqJob(job);
49
50
  const removed = ctx.shards[shardIndex(queue)].removeFromDlq(queue, entry.job.id);
@@ -7,11 +7,14 @@ import { EventType, type JobEvent } from '../domain/types/queue';
7
7
  import type { WebhookManager } from './webhookManager';
8
8
  /** Event subscriber callback */
9
9
  export type EventSubscriber = (event: JobEvent) => void;
10
+ export type EventBatchSubscriber = (events: readonly JobEvent[]) => void;
10
11
  /** Events manager class */
11
12
  export declare class EventsManager {
12
13
  private readonly webhookManager;
13
14
  /** Use Set for O(1) subscribe/unsubscribe instead of indexOf+splice */
14
15
  private readonly subscribers;
16
+ /** Optional batch path for internal subscribers such as durable telemetry. */
17
+ private readonly batchSubscribers;
15
18
  /** Waiters for specific job completions - for efficient WaitJob implementation */
16
19
  private readonly completionWaiters;
17
20
  constructor(webhookManager: WebhookManager);
@@ -20,7 +23,7 @@ export declare class EventsManager {
20
23
  /** Number of jobs with active completion waiters */
21
24
  get completionWaiterCount(): number;
22
25
  /** Subscribe to job events - O(1) add and remove */
23
- subscribe(callback: EventSubscriber): () => void;
26
+ subscribe(callback: EventSubscriber, batchCallback?: EventBatchSubscriber): () => void;
24
27
  /** Clear all subscribers (for shutdown) */
25
28
  clear(): void;
26
29
  /**
@@ -38,6 +41,10 @@ export declare class EventsManager {
38
41
  timestamp: number;
39
42
  error?: string;
40
43
  }): void;
44
+ /** Broadcast an ordered batch while allowing internal subscribers to persist once. */
45
+ broadcastBatch(events: readonly JobEvent[]): void;
46
+ private notifyCompletionWaiters;
47
+ private triggerWebhook;
41
48
  /** Map internal event type to webhook event */
42
49
  private mapEventToWebhook;
43
50
  }
@@ -8,6 +8,8 @@ export class EventsManager {
8
8
  webhookManager;
9
9
  /** Use Set for O(1) subscribe/unsubscribe instead of indexOf+splice */
10
10
  subscribers = new Set();
11
+ /** Optional batch path for internal subscribers such as durable telemetry. */
12
+ batchSubscribers = new Map();
11
13
  /** Waiters for specific job completions - for efficient WaitJob implementation */
12
14
  completionWaiters = new Map();
13
15
  constructor(webhookManager) {
@@ -22,15 +24,19 @@ export class EventsManager {
22
24
  return this.completionWaiters.size;
23
25
  }
24
26
  /** Subscribe to job events - O(1) add and remove */
25
- subscribe(callback) {
27
+ subscribe(callback, batchCallback) {
26
28
  this.subscribers.add(callback);
29
+ if (batchCallback)
30
+ this.batchSubscribers.set(callback, batchCallback);
27
31
  return () => {
28
32
  this.subscribers.delete(callback); // O(1) instead of O(n)
33
+ this.batchSubscribers.delete(callback);
29
34
  };
30
35
  }
31
36
  /** Clear all subscribers (for shutdown) */
32
37
  clear() {
33
38
  this.subscribers.clear();
39
+ this.batchSubscribers.clear();
34
40
  // Clear all waiters
35
41
  for (const waiters of this.completionWaiters.values()) {
36
42
  for (const waiter of waiters) {
@@ -112,40 +118,81 @@ export class EventsManager {
112
118
  }
113
119
  }
114
120
  }
115
- // Notify completion waiters for WaitJob - O(1) lookup
116
- if (hasWaiters) {
117
- const jobKey = String(event.jobId);
118
- const waiters = this.completionWaiters.get(jobKey);
119
- if (waiters) {
120
- this.completionWaiters.delete(jobKey);
121
- // Only notify non-cancelled waiters
122
- for (const waiter of waiters) {
123
- if (!waiter.cancelled) {
124
- waiter.resolve();
125
- }
121
+ if (hasWaiters)
122
+ this.notifyCompletionWaiters(event);
123
+ if (hasWebhooks)
124
+ this.triggerWebhook(event);
125
+ }
126
+ /** Broadcast an ordered batch while allowing internal subscribers to persist once. */
127
+ broadcastBatch(events) {
128
+ if (events.length === 0)
129
+ return;
130
+ const hasSubscribers = this.subscribers.size > 0;
131
+ const hasWebhooks = this.webhookManager.hasEnabledWebhooks();
132
+ const hasWaiters = this.completionWaiters.size > 0;
133
+ if (!hasSubscribers && !hasWebhooks && !hasWaiters)
134
+ return;
135
+ if (hasSubscribers) {
136
+ for (const [subscriber, batchSubscriber] of this.batchSubscribers) {
137
+ if (!this.subscribers.has(subscriber))
138
+ continue;
139
+ try {
140
+ batchSubscriber(events);
141
+ }
142
+ catch {
143
+ // Ignore subscriber errors.
126
144
  }
127
145
  }
128
146
  }
129
- // Trigger webhooks - only if there are enabled webhooks
130
- if (hasWebhooks) {
131
- const webhookEvent = this.mapEventToWebhook(event.eventType);
132
- if (webhookEvent) {
133
- this.webhookManager
134
- .trigger(webhookEvent, String(event.jobId), event.queue, {
135
- data: event.data,
136
- error: event.error,
137
- })
138
- .catch((err) => {
139
- webhookLog.error('Webhook trigger failed', {
140
- event: webhookEvent,
141
- jobId: String(event.jobId),
142
- queue: event.queue,
143
- error: String(err),
144
- });
145
- });
147
+ for (const event of events) {
148
+ if (hasSubscribers) {
149
+ for (const subscriber of this.subscribers) {
150
+ if (this.batchSubscribers.has(subscriber))
151
+ continue;
152
+ try {
153
+ subscriber(event);
154
+ }
155
+ catch {
156
+ // Ignore subscriber errors and continue with the ordered batch.
157
+ }
158
+ }
146
159
  }
160
+ if (hasWaiters && event.eventType === "completed" /* EventType.Completed */) {
161
+ this.notifyCompletionWaiters(event);
162
+ }
163
+ if (hasWebhooks)
164
+ this.triggerWebhook(event);
165
+ }
166
+ }
167
+ notifyCompletionWaiters(event) {
168
+ const jobKey = String(event.jobId);
169
+ const waiters = this.completionWaiters.get(jobKey);
170
+ if (!waiters)
171
+ return;
172
+ this.completionWaiters.delete(jobKey);
173
+ for (const waiter of waiters) {
174
+ if (!waiter.cancelled)
175
+ waiter.resolve();
147
176
  }
148
177
  }
178
+ triggerWebhook(event) {
179
+ const webhookEvent = this.mapEventToWebhook(event.eventType);
180
+ if (!webhookEvent)
181
+ return;
182
+ this.webhookManager
183
+ .trigger(webhookEvent, String(event.jobId), event.queue, {
184
+ data: event.data,
185
+ error: event.error,
186
+ })
187
+ .catch((err) => {
188
+ webhookLog.error('Webhook trigger failed', {
189
+ event: webhookEvent,
190
+ jobId: String(event.jobId),
191
+ queue: event.queue,
192
+ error: String(err),
193
+ });
194
+ });
195
+ }
149
196
  /** Map internal event type to webhook event */
150
197
  mapEventToWebhook(eventType) {
151
198
  switch (eventType) {
@@ -2,10 +2,7 @@
2
2
  import type { Job, JobId } from '../../domain/types/job';
3
3
  import type { BatchContext, ExtractedJob, FinalizeContext } from '../types/ack';
4
4
  export type { BatchContext, ExtractedJob, FinalizeContext } from '../types/ack';
5
- /**
6
- * Group job IDs by processing shard
7
- * Returns Map<shardIndex, jobIds[]>
8
- */
5
+ /** Group job IDs by processing shard. */
9
6
  export declare function groupByProcShard(jobIds: JobId[]): Map<number, JobId[]>;
10
7
  /**
11
8
  * Group items by processing shard (with results)
@@ -4,10 +4,30 @@ import { withWriteLock } from '../../shared/lock';
4
4
  import { shardIndex, processingShardIndex } from '../../shared/hash';
5
5
  import { throughputTracker } from '../throughputTracker';
6
6
  import { commitRemovedCompletion } from '../dependencyCompletions';
7
- /**
8
- * Group job IDs by processing shard
9
- * Returns Map<shardIndex, jobIds[]>
10
- */
7
+ /** Batch only when no per-job durable result/removal ordering must be preserved. */
8
+ function persistCompletedBatch(extractedJobs, ctx, includeResults, now) {
9
+ const storage = ctx.storage;
10
+ if (!storage || extractedJobs.length === 0)
11
+ return false;
12
+ if (extractedJobs.some(({ job, removeOnComplete }) => job.removeOnComplete || removeOnComplete === true) ||
13
+ (includeResults && extractedJobs.some(({ result }) => result !== undefined))) {
14
+ return false;
15
+ }
16
+ try {
17
+ storage.markCompletedBatch(extractedJobs.map(({ id: jobId, job }) => ({
18
+ jobId,
19
+ completedAt: now,
20
+ timeline: job.timeline.length < MAX_TIMELINE_ENTRIES
21
+ ? [...job.timeline, { state: 'completed', timestamp: now }]
22
+ : job.timeline,
23
+ })));
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Group job IDs by processing shard. */
11
31
  export function groupByProcShard(jobIds) {
12
32
  const byProcShard = new Map();
13
33
  for (const jobId of jobIds) {
@@ -119,6 +139,7 @@ export function finalizeBatchAck(extractedJobs, ctx, includeResults) {
119
139
  const jobCount = extractedJobs.length;
120
140
  const needsBroadcast = ctx.needsBroadcast?.() ?? true;
121
141
  const hasPendingDeps = ctx.hasPendingDeps?.() ?? true;
142
+ const completionBatchPersisted = persistCompletedBatch(extractedJobs, ctx, includeResults, now);
122
143
  // Batch counter update
123
144
  ctx.totalCompleted.value += BigInt(jobCount);
124
145
  // Per-queue completed counter update
@@ -160,8 +181,9 @@ export function finalizeBatchAck(extractedJobs, ctx, includeResults) {
160
181
  // 2. Update job index and store job data for completed listing
161
182
  ctx.jobIndex.set(jobId, { type: 'completed', queueName: job.queue });
162
183
  ctx.completedJobsData.set(jobId, job);
163
- if (hasStorage)
184
+ if (hasStorage && !completionBatchPersisted) {
164
185
  storage.markCompleted(jobId, now, job.timeline);
186
+ }
165
187
  // 3. Mark as completed LAST - this is the signal other threads wait for
166
188
  ctx.completedJobs.add(jobId);
167
189
  }
@@ -180,15 +202,18 @@ export function finalizeBatchAck(extractedJobs, ctx, includeResults) {
180
202
  }
181
203
  // Broadcast events
182
204
  if (needsBroadcast) {
183
- for (let i = 0; i < jobCount; i++) {
184
- const { id: jobId, job, result } = extractedJobs[i];
185
- ctx.broadcast({
186
- eventType: 'completed',
187
- queue: job.queue,
188
- jobId,
189
- timestamp: now,
190
- data: includeResults ? result : undefined,
191
- });
205
+ const events = extractedJobs.map(({ id: jobId, job, result }) => ({
206
+ eventType: 'completed',
207
+ queue: job.queue,
208
+ jobId,
209
+ timestamp: now,
210
+ data: includeResults ? result : undefined,
211
+ }));
212
+ if (ctx.broadcastBatch)
213
+ ctx.broadcastBatch(events);
214
+ else {
215
+ for (const event of events)
216
+ ctx.broadcast(event);
192
217
  }
193
218
  }
194
219
  // Notify completions
@@ -29,6 +29,7 @@ function assertIdsAvailable(batch, ctx) {
29
29
  function prepareJobs(batch, ctx, now) {
30
30
  return batch.jobs.map((planned) => {
31
31
  const job = createJob(planned.id, planned.queue, planned.input, now);
32
+ ctx.shards[shardIndex(planned.queue)].assignGroupFifoOrder(job);
32
33
  job.timeline.push({ state: initialJobState(job, ctx, now), timestamp: now });
33
34
  return job;
34
35
  });
@@ -1,3 +1,4 @@
1
+ import { validateGroupId } from '../../domain/types/group';
1
2
  import { normalizeJobPayload } from '../../domain/types/job';
2
3
  import { validateFlowTopology } from './flowTopologyValidation';
3
4
  const MAX_FLOW_JOBS = 10_000;
@@ -52,10 +53,9 @@ function validateOptions(input) {
52
53
  throw new Error(`${name} must be a boolean`);
53
54
  }
54
55
  }
55
- if (input.groupId !== undefined &&
56
- (typeof input.groupId !== 'string' || input.groupId.length > 256)) {
57
- throw new Error('groupId must be a string of at most 256 characters');
58
- }
56
+ const groupError = validateGroupId(input.groupId);
57
+ if (groupError)
58
+ throw new Error(groupError);
59
59
  if (input.tags !== undefined &&
60
60
  (!Array.isArray(input.tags) ||
61
61
  input.tags.some((tag) => typeof tag !== 'string' || tag.length > 256))) {
@@ -3,13 +3,18 @@
3
3
  * Job pull logic with timeout support
4
4
  */
5
5
  import type { Job } from '../../domain/types/job';
6
+ import { type GroupPullOptions } from '../../domain/types/group';
6
7
  import { type PullContext } from './pullStateTransition';
7
8
  export type { PullContext } from './pullStateTransition';
9
+ interface PullRequestOptions {
10
+ signal?: AbortSignal;
11
+ group?: GroupPullOptions;
12
+ }
8
13
  /**
9
14
  * Pull next job from queue
10
15
  */
11
- export declare function pullJob(queue: string, timeoutMs: number, ctx: PullContext, signal?: AbortSignal): Promise<Job | null>;
16
+ export declare function pullJob(queue: string, timeoutMs: number, ctx: PullContext, signal?: AbortSignal, groupOptions?: GroupPullOptions): Promise<Job | null>;
12
17
  /**
13
18
  * Pull multiple jobs from queue
14
19
  */
15
- export declare function pullJobBatch(queue: string, count: number, timeoutMs: number, ctx: PullContext, signal?: AbortSignal): Promise<Job[]>;
20
+ export declare function pullJobBatch(queue: string, count: number, timeoutMs: number, ctx: PullContext, options?: PullRequestOptions): Promise<Job[]>;
@@ -2,10 +2,11 @@
2
2
  * Pull Operations
3
3
  * Job pull logic with timeout support
4
4
  */
5
+ import { assertGroupPullOptions } from '../../domain/types/group';
5
6
  import { withWriteLock } from '../../shared/lock';
6
- import { shardIndex, processingShardIndex } from '../../shared/hash';
7
+ import { shardIndex } from '../../shared/hash';
7
8
  import { latencyTracker } from '../latencyTracker';
8
- import { throughputTracker } from '../throughputTracker';
9
+ import { finalizeProcessing, finalizeProcessingBatch } from './pullFinalization';
9
10
  import { createDequeueScan, requeueJob, restoreParkedJobs, tryDequeueNextJob, } from './pullStateTransition';
10
11
  /** Wait until either a notification arrives, the next delay matures, or the pull deadline. */
11
12
  async function waitForNextCandidate(options) {
@@ -14,64 +15,18 @@ async function waitForNextCandidate(options) {
14
15
  const untilNextRun = nextRunAt === null ? remaining : Math.max(1, nextRunAt - now);
15
16
  await shard.waitForJob(queue, Math.min(remaining, untilNextRun), signal);
16
17
  }
17
- /**
18
- * Finish the handoff of a dequeued job: persist active state and broadcast.
19
- *
20
- * The job was ALREADY inserted into processingShards and jobIndex was flipped
21
- * to 'processing' inside the dequeue critical section (tryDequeueNextJob),
22
- * atomically with the queue pop, so readers never see a stale 'queue'
23
- * location. Only the bookkeeping that may follow an await happens here.
24
- *
25
- * Returns false when the job is no longer in processingShards: a management
26
- * op (discardJob, moveJobToDelayed, obliterate) claimed it between the
27
- * dequeue and this call. The claimer owns the job now; the pull must NOT
28
- * deliver it to a worker nor mark it active.
29
- */
30
- function finalizeProcessing(job, queue, ctx) {
31
- const procIdx = processingShardIndex(job.id);
32
- if (!ctx.processingShards[procIdx].has(job.id))
33
- return false;
34
- const now = Date.now();
35
- try {
36
- ctx.storage?.markActive(job.id, job.startedAt ?? now, job.timeline);
37
- }
38
- catch {
39
- // Non-fatal: job is already in processingShards (in-memory source of truth).
40
- // On crash, SQLite recovery will handle the stale state.
41
- }
42
- ctx.totalPulled.value++;
43
- throughputTracker.pullRate.increment();
44
- ctx.broadcast({
45
- eventType: 'pulled',
46
- queue,
47
- jobId: job.id,
48
- timestamp: now,
49
- });
50
- return true;
51
- }
52
- /**
53
- * Finish the handoff for a batch of dequeued jobs.
54
- * Returns the jobs actually delivered (claimed jobs are skipped, see above).
55
- */
56
- function finalizeProcessingBatch(jobs, queue, ctx) {
57
- const delivered = [];
58
- for (const job of jobs) {
59
- if (finalizeProcessing(job, queue, ctx))
60
- delivered.push(job);
61
- }
62
- return delivered;
63
- }
64
18
  /**
65
19
  * Pull next job from queue
66
20
  */
67
- export async function pullJob(queue, timeoutMs, ctx, signal) {
21
+ export async function pullJob(queue, timeoutMs, ctx, signal, groupOptions) {
22
+ assertGroupPullOptions(groupOptions);
68
23
  const startNs = Bun.nanoseconds();
69
24
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
70
25
  const idx = shardIndex(queue);
71
26
  while (true) {
72
27
  if (signal?.aborted)
73
28
  return null;
74
- const attempt = await tryPullFromShard(queue, idx, ctx, signal);
29
+ const attempt = await tryPullFromShard(queue, idx, ctx, signal, groupOptions);
75
30
  const job = attempt.job;
76
31
  if (job) {
77
32
  if (signal?.aborted) {
@@ -112,7 +67,7 @@ export async function pullJob(queue, timeoutMs, ctx, signal) {
112
67
  /**
113
68
  * Try to pull a job from a specific shard
114
69
  */
115
- async function tryPullFromShard(queue, idx, ctx, signal) {
70
+ async function tryPullFromShard(queue, idx, ctx, signal, groupOptions) {
116
71
  return await withWriteLock(ctx.shardLocks[idx], () => {
117
72
  if (signal?.aborted)
118
73
  return { job: null, nextRunAt: null };
@@ -122,7 +77,7 @@ async function tryPullFromShard(queue, idx, ctx, signal) {
122
77
  return { job: null, nextRunAt: null };
123
78
  }
124
79
  const now = Date.now();
125
- const scan = createDequeueScan();
80
+ const scan = createDequeueScan(groupOptions);
126
81
  try {
127
82
  const result = tryDequeueNextJob(shard, queue, now, ctx, scan);
128
83
  return result.status === 'job'
@@ -137,14 +92,16 @@ async function tryPullFromShard(queue, idx, ctx, signal) {
137
92
  /**
138
93
  * Pull multiple jobs from queue
139
94
  */
140
- export async function pullJobBatch(queue, count, timeoutMs, ctx, signal) {
95
+ export async function pullJobBatch(queue, count, timeoutMs, ctx, options = {}) {
96
+ assertGroupPullOptions(options.group);
97
+ const { signal } = options;
141
98
  const startNs = Bun.nanoseconds();
142
99
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
143
100
  const idx = shardIndex(queue);
144
101
  while (true) {
145
102
  if (signal?.aborted)
146
103
  return [];
147
- const attempt = await tryPullBatchFromShard(queue, idx, count, ctx, signal);
104
+ const attempt = await tryPullBatchFromShard(queue, idx, count, ctx, options);
148
105
  const jobs = attempt.jobs;
149
106
  if (jobs.length > 0) {
150
107
  if (signal?.aborted) {
@@ -194,7 +151,8 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx, signal) {
194
151
  /**
195
152
  * Try to pull multiple jobs from a shard
196
153
  */
197
- async function tryPullBatchFromShard(queue, idx, count, ctx, signal) {
154
+ async function tryPullBatchFromShard(queue, idx, count, ctx, options) {
155
+ const { signal, group: groupOptions } = options;
198
156
  return await withWriteLock(ctx.shardLocks[idx], () => {
199
157
  if (signal?.aborted)
200
158
  return { jobs: [], nextRunAt: null };
@@ -204,7 +162,7 @@ async function tryPullBatchFromShard(queue, idx, count, ctx, signal) {
204
162
  if (state.paused)
205
163
  return { jobs, nextRunAt: null };
206
164
  const now = Date.now();
207
- const scan = createDequeueScan();
165
+ const scan = createDequeueScan(groupOptions);
208
166
  try {
209
167
  while (jobs.length < count) {
210
168
  const result = tryDequeueNextJob(shard, queue, now, ctx, scan);
@@ -0,0 +1,6 @@
1
+ import type { Job } from '../../domain/types/job';
2
+ import type { PullContext } from './pullStateTransition';
3
+ /** Persist and publish a single processing handoff. */
4
+ export declare function finalizeProcessing(job: Job, queue: string, ctx: PullContext): boolean;
5
+ /** Persist and publish all processing handoffs with one durable state transaction. */
6
+ export declare function finalizeProcessingBatch(jobs: Job[], queue: string, ctx: PullContext): Job[];