bunqueue 2.8.40 → 2.8.42

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.
@@ -2,30 +2,9 @@
2
2
  * Pull Operations
3
3
  * Job pull logic with timeout support
4
4
  */
5
- import { type Job, type JobId } from '../../domain/types/job';
6
- import type { JobLocation, EventType } from '../../domain/types/queue';
7
- import type { Shard } from '../../domain/queue/shard';
8
- import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
9
- import type { RWLock } from '../../shared/lock';
10
- /** Pull operation context */
11
- export interface PullContext {
12
- storage: SqliteStorage | null;
13
- shards: Shard[];
14
- shardLocks: RWLock[];
15
- processingShards: Map<JobId, Job>[];
16
- processingLocks: RWLock[];
17
- jobIndex: Map<JobId, JobLocation>;
18
- totalPulled: {
19
- value: bigint;
20
- };
21
- broadcast: (event: {
22
- eventType: EventType;
23
- queue: string;
24
- jobId: JobId;
25
- timestamp: number;
26
- }) => void;
27
- dashboardEmit?: (event: string, data: Record<string, unknown>) => void;
28
- }
5
+ import type { Job } from '../../domain/types/job';
6
+ import { type PullContext } from './pullStateTransition';
7
+ export type { PullContext } from './pullStateTransition';
29
8
  /**
30
9
  * Pull next job from queue
31
10
  */
@@ -2,125 +2,11 @@
2
2
  * Pull Operations
3
3
  * Job pull logic with timeout support
4
4
  */
5
- import { isExpired, isReady, MAX_TIMELINE_ENTRIES, } from '../../domain/types/job';
6
5
  import { withWriteLock } from '../../shared/lock';
7
6
  import { shardIndex, processingShardIndex } from '../../shared/hash';
8
7
  import { latencyTracker } from '../latencyTracker';
9
8
  import { throughputTracker } from '../throughputTracker';
10
- /**
11
- * Dequeue the highest-ranked eligible job without letting a delayed or
12
- * group-blocked heap root stall unrelated work.
13
- *
14
- * The priority heap cannot encode time-varying readiness or active-group
15
- * membership in its comparator. Temporarily park ineligible roots, select the
16
- * first eligible entry (therefore the best eligible entry in heap order), and
17
- * restore every parked job before leaving the shard critical section. Parked
18
- * jobs remain logically queued throughout: their shard counters and jobIndex
19
- * entries are deliberately untouched.
20
- *
21
- * Complexity is O((k + 1) log n) time and O(k) scratch space, where k is the
22
- * number of higher-ranked ineligible jobs skipped. A separate delayed heap and
23
- * per-group eligible-head heap can reduce this to O(log n), but this scan keeps
24
- * the existing queue representation and state transitions intact.
25
- */
26
- function tryDequeueNextJob(shard, queue, now, ctx) {
27
- const q = shard.getQueue(queue);
28
- const parked = [];
29
- let nextRunAt = null;
30
- try {
31
- while (true) {
32
- const job = q.peek();
33
- if (!job)
34
- return { status: 'stop', nextRunAt };
35
- // Expired entries are not parked: they are genuinely removed from the
36
- // logical queue and therefore update counters/index exactly once.
37
- if (isExpired(job, now)) {
38
- // Delete persistence before mutating the in-memory indexes. deleteJob
39
- // also removes a pending buffered INSERT, so the expired job cannot be
40
- // flushed after this pull or resurrect during crash recovery. This is
41
- // synchronous and therefore keeps the shard critical section await-free.
42
- ctx.storage?.deleteJob(job.id);
43
- q.pop();
44
- shard.decrementQueued(job.id);
45
- ctx.jobIndex.delete(job.id);
46
- ctx.dashboardEmit?.('job:expired', {
47
- queue,
48
- jobId: String(job.id),
49
- ttl: job.ttl,
50
- age: now - job.createdAt,
51
- });
52
- continue;
53
- }
54
- if (!isReady(job, now)) {
55
- const delayed = q.pop();
56
- if (delayed)
57
- parked.push(delayed);
58
- nextRunAt = nextRunAt === null ? job.runAt : Math.min(nextRunAt, job.runAt);
59
- continue;
60
- }
61
- if (job.groupId && shard.isGroupActive(queue, job.groupId)) {
62
- const blocked = q.pop();
63
- if (blocked)
64
- parked.push(blocked);
65
- continue;
66
- }
67
- // Check capacity only after proving that an eligible job exists. Check
68
- // concurrency first because that slot can be returned if rate limiting
69
- // rejects; rate-limit tokens have no rollback operation.
70
- if (!shard.tryAcquireConcurrency(queue)) {
71
- ctx.dashboardEmit?.('concurrency:rejected', { queue });
72
- return { status: 'stop', nextRunAt };
73
- }
74
- if (!shard.tryAcquireRateLimit(queue)) {
75
- shard.releaseConcurrency(queue);
76
- ctx.dashboardEmit?.('ratelimit:rejected', { queue });
77
- return { status: 'stop', nextRunAt };
78
- }
79
- // Dequeue and update. q.peek() and q.pop() are synchronous under the
80
- // shard write lock, so pop returns the same eligible entry selected above.
81
- const dequeued = q.pop();
82
- if (!dequeued) {
83
- shard.releaseConcurrency(queue);
84
- return { status: 'stop', nextRunAt };
85
- }
86
- shard.decrementQueued(dequeued.id);
87
- if (dequeued.groupId) {
88
- shard.activateGroup(queue, dequeued.groupId);
89
- }
90
- dequeued.startedAt = now;
91
- dequeued.lastHeartbeat = now;
92
- if (dequeued.timeline.length < MAX_TIMELINE_ENTRIES) {
93
- dequeued.timeline.push({ state: 'active', timestamp: now });
94
- }
95
- // Make the queue -> processing transition atomic for observers. From the
96
- // pop above, the job is no longer findable in the shard queue, so a
97
- // jobIndex entry still saying 'queue' would make getJob/getJobState return
98
- // a false null/'unknown' for an existing job (JOB -> null -> JOB(active)
99
- // flicker), and cleanOrphanedJobIndex would even drop its index entry.
100
- // Insert into processingShards and flip the index HERE, in the same
101
- // synchronous critical section as the pop (the caller holds the shard
102
- // write lock; JS is single-threaded, so no other task can interleave).
103
- // Writing processingShards without its RWLock is safe: until this
104
- // statement the index said 'queue', so no id-targeted critical section
105
- // (ack/fail/discard operate only on ids they find as 'processing') can be
106
- // mid-operation on this id; a sync Map.set cannot corrupt a paused
107
- // critical section; and lock-free sync access to processingShards is
108
- // established practice (stallDetection phases 1/2, collectActiveJobs,
109
- // getJobProgress). Acquiring the async RWLock here would instead hold the
110
- // hot shard write lock across an await.
111
- const procIdx = processingShardIndex(dequeued.id);
112
- ctx.processingShards[procIdx].set(dequeued.id, dequeued);
113
- ctx.jobIndex.set(dequeued.id, { type: 'processing', shardIdx: procIdx });
114
- return { status: 'job', job: dequeued };
115
- }
116
- }
117
- finally {
118
- // Restore only the priority-queue membership. The logical queue counters,
119
- // temporal index and global jobIndex never changed for parked entries.
120
- for (const parkedJob of parked)
121
- q.push(parkedJob);
122
- }
123
- }
9
+ import { createDequeueScan, requeueJob, restoreParkedJobs, tryDequeueNextJob, } from './pullStateTransition';
124
10
  /** Wait until either a notification arrives, the next delay matures, or the pull deadline. */
125
11
  async function waitForNextCandidate(options) {
126
12
  const { shard, queue, deadline, now, nextRunAt, signal } = options;
@@ -175,46 +61,6 @@ function finalizeProcessingBatch(jobs, queue, ctx) {
175
61
  }
176
62
  return delivered;
177
63
  }
178
- /**
179
- * Requeue a job whose handoff to the worker failed after dequeue.
180
- * The dequeue critical section moved the job to processingShards and flipped
181
- * jobIndex to 'processing'; this restores both, keeping the same atomicity
182
- * rule: the job becomes findable in the queue (push + index flip in ONE
183
- * shard-lock section) BEFORE the processing entry is dropped, so a reader
184
- * never sees a location that misses. Lock order shard -> processing matches
185
- * the documented hierarchy.
186
- */
187
- async function requeueJob(job, queue, idx, ctx) {
188
- const procIdx = processingShardIndex(job.id);
189
- let requeued = false;
190
- await withWriteLock(ctx.shardLocks[idx], () => {
191
- // A management op (discardJob, moveJobToDelayed) may have claimed the job
192
- // from processingShards after the failed handoff; it owns the job now.
193
- if (!ctx.processingShards[procIdx].has(job.id))
194
- return;
195
- const shard = ctx.shards[idx];
196
- if (job.groupId)
197
- shard.releaseGroup(queue, job.groupId);
198
- shard.releaseConcurrency(queue);
199
- // Reset job state (was modified in tryDequeueNextJob)
200
- job.startedAt = null;
201
- shard.getQueue(queue).push(job);
202
- shard.incrementQueued(job.id, false, job.createdAt, queue, job.runAt);
203
- ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
204
- shard.notify(queue);
205
- requeued = true;
206
- });
207
- if (!requeued)
208
- return;
209
- await withWriteLock(ctx.processingLocks[procIdx], () => {
210
- // A concurrent pull may have re-popped the job between the two lock
211
- // sections and flipped the index back to 'processing'; do not delete the
212
- // (re-inserted, same-object) entry out from under it.
213
- if (ctx.jobIndex.get(job.id)?.type !== 'processing') {
214
- ctx.processingShards[procIdx].delete(job.id);
215
- }
216
- });
217
- }
218
64
  /**
219
65
  * Pull next job from queue
220
66
  */
@@ -276,10 +122,16 @@ async function tryPullFromShard(queue, idx, ctx, signal) {
276
122
  return { job: null, nextRunAt: null };
277
123
  }
278
124
  const now = Date.now();
279
- const result = tryDequeueNextJob(shard, queue, now, ctx);
280
- return result.status === 'job'
281
- ? { job: result.job, nextRunAt: null }
282
- : { job: null, nextRunAt: result.nextRunAt };
125
+ const scan = createDequeueScan();
126
+ try {
127
+ const result = tryDequeueNextJob(shard, queue, now, ctx, scan);
128
+ return result.status === 'job'
129
+ ? { job: result.job, nextRunAt: null }
130
+ : { job: null, nextRunAt: scan.nextRunAt };
131
+ }
132
+ finally {
133
+ restoreParkedJobs(shard, queue, scan);
134
+ }
283
135
  });
284
136
  }
285
137
  /**
@@ -352,17 +204,21 @@ async function tryPullBatchFromShard(queue, idx, count, ctx, signal) {
352
204
  if (state.paused)
353
205
  return { jobs, nextRunAt: null };
354
206
  const now = Date.now();
355
- let nextRunAt = null;
356
- while (jobs.length < count) {
357
- const result = tryDequeueNextJob(shard, queue, now, ctx);
358
- if (result.status === 'job') {
359
- jobs.push(result.job);
360
- }
361
- else {
362
- nextRunAt = result.nextRunAt;
363
- break;
207
+ const scan = createDequeueScan();
208
+ try {
209
+ while (jobs.length < count) {
210
+ const result = tryDequeueNextJob(shard, queue, now, ctx, scan);
211
+ if (result.status === 'job') {
212
+ jobs.push(result.job);
213
+ }
214
+ else {
215
+ break;
216
+ }
364
217
  }
218
+ return { jobs, nextRunAt: scan.nextRunAt };
219
+ }
220
+ finally {
221
+ restoreParkedJobs(shard, queue, scan);
365
222
  }
366
- return { jobs, nextRunAt };
367
223
  });
368
224
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Atomic queue-state transitions used by single and batch pull orchestration.
3
+ */
4
+ import type { Shard } from '../../domain/queue/shard';
5
+ import { type Job, type JobId } from '../../domain/types/job';
6
+ import type { EventType, JobLocation } from '../../domain/types/queue';
7
+ import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
8
+ import type { RWLock } from '../../shared/lock';
9
+ /** Dependencies shared by the single and batch pull paths. */
10
+ export interface PullContext {
11
+ storage: SqliteStorage | null;
12
+ shards: Shard[];
13
+ shardLocks: RWLock[];
14
+ processingShards: Map<JobId, Job>[];
15
+ processingLocks: RWLock[];
16
+ jobIndex: Map<JobId, JobLocation>;
17
+ totalPulled: {
18
+ value: bigint;
19
+ };
20
+ broadcast: (event: {
21
+ eventType: EventType;
22
+ queue: string;
23
+ jobId: JobId;
24
+ timestamp: number;
25
+ }) => void;
26
+ dashboardEmit?: (event: string, data: Record<string, unknown>) => void;
27
+ }
28
+ export interface DequeueScan {
29
+ parked: Job[];
30
+ nextRunAt: number | null;
31
+ }
32
+ export type DequeueResult = {
33
+ status: 'job';
34
+ job: Job;
35
+ } | {
36
+ status: 'stop';
37
+ };
38
+ export declare function createDequeueScan(): DequeueScan;
39
+ /** Restore physical heap membership without changing logical queue state. */
40
+ export declare function restoreParkedJobs(shard: Shard, queue: string, scan: DequeueScan): void;
41
+ /**
42
+ * Remove the best eligible job while retaining ineligible roots in `scan`.
43
+ *
44
+ * A batch reuses one scan, because readiness cannot change while the synchronous
45
+ * shard critical section is held: time is fixed and active FIFO groups only
46
+ * grow. Its parked jobs therefore need to be restored only once after the batch.
47
+ */
48
+ export declare function tryDequeueNextJob(shard: Shard, queue: string, now: number, ctx: PullContext, scan: DequeueScan): DequeueResult;
49
+ /** Restore a dequeued job when its handoff to the worker cannot complete. */
50
+ export declare function requeueJob(job: Job, queue: string, idx: number, ctx: PullContext): Promise<void>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Atomic queue-state transitions used by single and batch pull orchestration.
3
+ */
4
+ import { isExpired, isReady, MAX_TIMELINE_ENTRIES, } from '../../domain/types/job';
5
+ import { processingShardIndex } from '../../shared/hash';
6
+ import { withWriteLock } from '../../shared/lock';
7
+ export function createDequeueScan() {
8
+ return { parked: [], nextRunAt: null };
9
+ }
10
+ /** Restore physical heap membership without changing logical queue state. */
11
+ export function restoreParkedJobs(shard, queue, scan) {
12
+ const priorityQueue = shard.getQueue(queue);
13
+ for (const job of scan.parked)
14
+ priorityQueue.push(job);
15
+ scan.parked.length = 0;
16
+ }
17
+ /**
18
+ * Remove the best eligible job while retaining ineligible roots in `scan`.
19
+ *
20
+ * A batch reuses one scan, because readiness cannot change while the synchronous
21
+ * shard critical section is held: time is fixed and active FIFO groups only
22
+ * grow. Its parked jobs therefore need to be restored only once after the batch.
23
+ */
24
+ export function tryDequeueNextJob(shard, queue, now, ctx, scan) {
25
+ const priorityQueue = shard.getQueue(queue);
26
+ while (true) {
27
+ const job = priorityQueue.peek();
28
+ if (!job)
29
+ return { status: 'stop' };
30
+ if (isExpired(job, now)) {
31
+ // Persistence is removed first so a failed durable delete leaves the
32
+ // in-memory job available for a later attempt.
33
+ ctx.storage?.deleteJob(job.id);
34
+ priorityQueue.pop();
35
+ shard.decrementQueued(job.id);
36
+ ctx.jobIndex.delete(job.id);
37
+ ctx.dashboardEmit?.('job:expired', {
38
+ queue,
39
+ jobId: String(job.id),
40
+ ttl: job.ttl,
41
+ age: now - job.createdAt,
42
+ });
43
+ continue;
44
+ }
45
+ if (!isReady(job, now)) {
46
+ const delayed = priorityQueue.pop();
47
+ if (delayed)
48
+ scan.parked.push(delayed);
49
+ scan.nextRunAt = scan.nextRunAt === null ? job.runAt : Math.min(scan.nextRunAt, job.runAt);
50
+ continue;
51
+ }
52
+ if (job.groupId && shard.isGroupActive(queue, job.groupId)) {
53
+ const blocked = priorityQueue.pop();
54
+ if (blocked)
55
+ scan.parked.push(blocked);
56
+ continue;
57
+ }
58
+ // Capacity is consumed only after an eligible job has been found.
59
+ if (!shard.tryAcquireConcurrency(queue)) {
60
+ ctx.dashboardEmit?.('concurrency:rejected', { queue });
61
+ return { status: 'stop' };
62
+ }
63
+ if (!shard.tryAcquireRateLimit(queue)) {
64
+ shard.releaseConcurrency(queue);
65
+ ctx.dashboardEmit?.('ratelimit:rejected', { queue });
66
+ return { status: 'stop' };
67
+ }
68
+ const dequeued = priorityQueue.pop();
69
+ if (!dequeued) {
70
+ shard.releaseConcurrency(queue);
71
+ return { status: 'stop' };
72
+ }
73
+ shard.decrementQueued(dequeued.id);
74
+ if (dequeued.groupId)
75
+ shard.activateGroup(queue, dequeued.groupId);
76
+ dequeued.startedAt = now;
77
+ dequeued.lastHeartbeat = now;
78
+ if (dequeued.timeline.length < MAX_TIMELINE_ENTRIES) {
79
+ dequeued.timeline.push({ state: 'active', timestamp: now });
80
+ }
81
+ // Queue -> processing is atomic for observers while the shard lock is held.
82
+ const procIdx = processingShardIndex(dequeued.id);
83
+ ctx.processingShards[procIdx].set(dequeued.id, dequeued);
84
+ ctx.jobIndex.set(dequeued.id, { type: 'processing', shardIdx: procIdx });
85
+ return { status: 'job', job: dequeued };
86
+ }
87
+ }
88
+ /** Restore a dequeued job when its handoff to the worker cannot complete. */
89
+ export async function requeueJob(job, queue, idx, ctx) {
90
+ const procIdx = processingShardIndex(job.id);
91
+ let requeued = false;
92
+ await withWriteLock(ctx.shardLocks[idx], () => {
93
+ if (!ctx.processingShards[procIdx].has(job.id))
94
+ return;
95
+ const shard = ctx.shards[idx];
96
+ if (job.groupId)
97
+ shard.releaseGroup(queue, job.groupId);
98
+ shard.releaseConcurrency(queue);
99
+ job.startedAt = null;
100
+ shard.getQueue(queue).push(job);
101
+ shard.incrementQueued(job.id, false, job.createdAt, queue, job.runAt);
102
+ ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
103
+ shard.notify(queue);
104
+ requeued = true;
105
+ });
106
+ if (!requeued)
107
+ return;
108
+ await withWriteLock(ctx.processingLocks[procIdx], () => {
109
+ // A concurrent pull may already have moved the same object back to active.
110
+ if (ctx.jobIndex.get(job.id)?.type !== 'processing') {
111
+ ctx.processingShards[procIdx].delete(job.id);
112
+ }
113
+ });
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.40",
3
+ "version": "2.8.42",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external infrastructure.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",