bunqueue 2.8.31 → 2.8.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -26
- package/dist/application/backgroundTasks.js +17 -9
- package/dist/application/dependencyProcessor.js +6 -1
- package/dist/application/dlqManager.js +1 -1
- package/dist/application/lockManager.js +1 -1
- package/dist/application/operations/ack.js +23 -1
- package/dist/application/operations/jobManagement.js +29 -1
- package/dist/application/operations/jobStateTransitions.js +1 -1
- package/dist/application/operations/pull.js +125 -96
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/queryOperations.js +74 -70
- package/dist/application/queueManager.d.ts +6 -11
- package/dist/application/queueManager.js +16 -3
- package/dist/application/queueStatsAggregator.d.ts +23 -0
- package/dist/application/queueStatsAggregator.js +80 -0
- package/dist/application/statsManager.d.ts +2 -26
- package/dist/application/statsManager.js +8 -124
- package/dist/client/tcpPool.js +8 -1
- package/dist/domain/queue/shard.d.ts +3 -1
- package/dist/domain/queue/shard.js +15 -7
- package/dist/domain/queue/temporalIndex.d.ts +24 -0
- package/dist/domain/queue/temporalIndex.js +100 -0
- package/dist/domain/queue/temporalManager.d.ts +2 -11
- package/dist/domain/queue/temporalManager.js +27 -44
- package/dist/domain/queue/waiterManager.d.ts +11 -13
- package/dist/domain/queue/waiterManager.js +80 -49
- package/dist/infrastructure/cloud/commands.js +5 -2
- package/dist/infrastructure/cloud/snapshotCollector.js +5 -2
- package/dist/infrastructure/cloud/statsRefresh.js +5 -2
- package/dist/infrastructure/cloud/statsUpdate.js +5 -2
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +13 -1
- package/dist/infrastructure/persistence/sqlite.d.ts +18 -2
- package/dist/infrastructure/persistence/sqlite.js +49 -6
- package/dist/infrastructure/server/handlers/core.js +13 -7
- package/dist/infrastructure/server/handlers/pushBatchValidation.d.ts +23 -0
- package/dist/infrastructure/server/handlers/pushBatchValidation.js +70 -0
- package/dist/infrastructure/server/queueCountsScheduler.d.ts +13 -0
- package/dist/infrastructure/server/queueCountsScheduler.js +35 -0
- package/dist/infrastructure/server/sseHandler.d.ts +1 -0
- package/dist/infrastructure/server/sseHandler.js +18 -13
- package/dist/infrastructure/server/wsHandler.d.ts +1 -1
- package/dist/infrastructure/server/wsHandler.js +18 -15
- package/package.json +3 -3
|
@@ -8,65 +8,119 @@ import { shardIndex, processingShardIndex } from '../../shared/hash';
|
|
|
8
8
|
import { latencyTracker } from '../latencyTracker';
|
|
9
9
|
import { throughputTracker } from '../throughputTracker';
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
25
|
*/
|
|
14
26
|
function tryDequeueNextJob(shard, queue, now, ctx) {
|
|
15
27
|
const q = shard.getQueue(queue);
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
q.pop();
|
|
39
|
+
shard.decrementQueued(job.id);
|
|
40
|
+
ctx.jobIndex.delete(job.id);
|
|
41
|
+
ctx.dashboardEmit?.('job:expired', {
|
|
42
|
+
queue,
|
|
43
|
+
jobId: String(job.id),
|
|
44
|
+
ttl: job.ttl,
|
|
45
|
+
age: now - job.createdAt,
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!isReady(job, now)) {
|
|
50
|
+
const delayed = q.pop();
|
|
51
|
+
if (delayed)
|
|
52
|
+
parked.push(delayed);
|
|
53
|
+
nextRunAt = nextRunAt === null ? job.runAt : Math.min(nextRunAt, job.runAt);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (job.groupId && shard.isGroupActive(queue, job.groupId)) {
|
|
57
|
+
const blocked = q.pop();
|
|
58
|
+
if (blocked)
|
|
59
|
+
parked.push(blocked);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Check capacity only after proving that an eligible job exists. Check
|
|
63
|
+
// concurrency first because that slot can be returned if rate limiting
|
|
64
|
+
// rejects; rate-limit tokens have no rollback operation.
|
|
65
|
+
if (!shard.tryAcquireConcurrency(queue)) {
|
|
66
|
+
ctx.dashboardEmit?.('concurrency:rejected', { queue });
|
|
67
|
+
return { status: 'stop', nextRunAt };
|
|
68
|
+
}
|
|
69
|
+
if (!shard.tryAcquireRateLimit(queue)) {
|
|
70
|
+
shard.releaseConcurrency(queue);
|
|
71
|
+
ctx.dashboardEmit?.('ratelimit:rejected', { queue });
|
|
72
|
+
return { status: 'stop', nextRunAt };
|
|
73
|
+
}
|
|
74
|
+
// Dequeue and update. q.peek() and q.pop() are synchronous under the
|
|
75
|
+
// shard write lock, so pop returns the same eligible entry selected above.
|
|
76
|
+
const dequeued = q.pop();
|
|
77
|
+
if (!dequeued) {
|
|
78
|
+
shard.releaseConcurrency(queue);
|
|
79
|
+
return { status: 'stop', nextRunAt };
|
|
80
|
+
}
|
|
81
|
+
shard.decrementQueued(dequeued.id);
|
|
82
|
+
if (dequeued.groupId) {
|
|
83
|
+
shard.activateGroup(queue, dequeued.groupId);
|
|
84
|
+
}
|
|
85
|
+
dequeued.startedAt = now;
|
|
86
|
+
dequeued.lastHeartbeat = now;
|
|
87
|
+
if (dequeued.timeline.length < MAX_TIMELINE_ENTRIES) {
|
|
88
|
+
dequeued.timeline.push({ state: 'active', timestamp: now });
|
|
89
|
+
}
|
|
90
|
+
// Make the queue -> processing transition atomic for observers. From the
|
|
91
|
+
// pop above, the job is no longer findable in the shard queue, so a
|
|
92
|
+
// jobIndex entry still saying 'queue' would make getJob/getJobState return
|
|
93
|
+
// a false null/'unknown' for an existing job (JOB -> null -> JOB(active)
|
|
94
|
+
// flicker), and cleanOrphanedJobIndex would even drop its index entry.
|
|
95
|
+
// Insert into processingShards and flip the index HERE, in the same
|
|
96
|
+
// synchronous critical section as the pop (the caller holds the shard
|
|
97
|
+
// write lock; JS is single-threaded, so no other task can interleave).
|
|
98
|
+
// Writing processingShards without its RWLock is safe: until this
|
|
99
|
+
// statement the index said 'queue', so no id-targeted critical section
|
|
100
|
+
// (ack/fail/discard operate only on ids they find as 'processing') can be
|
|
101
|
+
// mid-operation on this id; a sync Map.set cannot corrupt a paused
|
|
102
|
+
// critical section; and lock-free sync access to processingShards is
|
|
103
|
+
// established practice (stallDetection phases 1/2, collectActiveJobs,
|
|
104
|
+
// getJobProgress). Acquiring the async RWLock here would instead hold the
|
|
105
|
+
// hot shard write lock across an await.
|
|
106
|
+
const procIdx = processingShardIndex(dequeued.id);
|
|
107
|
+
ctx.processingShards[procIdx].set(dequeued.id, dequeued);
|
|
108
|
+
ctx.jobIndex.set(dequeued.id, { type: 'processing', shardIdx: procIdx });
|
|
109
|
+
return { status: 'job', job: dequeued };
|
|
110
|
+
}
|
|
44
111
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
112
|
+
finally {
|
|
113
|
+
// Restore only the priority-queue membership. The logical queue counters,
|
|
114
|
+
// temporal index and global jobIndex never changed for parked entries.
|
|
115
|
+
for (const parkedJob of parked)
|
|
116
|
+
q.push(parkedJob);
|
|
49
117
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// synchronous critical section as the pop (the caller holds the shard
|
|
57
|
-
// write lock; JS is single-threaded, so no other task can interleave).
|
|
58
|
-
// Writing processingShards without its RWLock is safe: until this
|
|
59
|
-
// statement the index said 'queue', so no id-targeted critical section
|
|
60
|
-
// (ack/fail/discard operate only on ids they find as 'processing') can be
|
|
61
|
-
// mid-operation on this id; a sync Map.set cannot corrupt a paused
|
|
62
|
-
// critical section; and lock-free sync access to processingShards is
|
|
63
|
-
// established practice (stallDetection phases 1/2, collectActiveJobs,
|
|
64
|
-
// getJobProgress). Acquiring the async RWLock here would instead hold the
|
|
65
|
-
// hot shard write lock across an await.
|
|
66
|
-
const procIdx = processingShardIndex(job.id);
|
|
67
|
-
ctx.processingShards[procIdx].set(job.id, job);
|
|
68
|
-
ctx.jobIndex.set(job.id, { type: 'processing', shardIdx: procIdx });
|
|
69
|
-
return { status: 'job', job };
|
|
118
|
+
}
|
|
119
|
+
/** Wait until either a notification arrives, the next delay matures, or the pull deadline. */
|
|
120
|
+
async function waitForNextCandidate(shard, queue, deadline, now, nextRunAt) {
|
|
121
|
+
const remaining = deadline - now;
|
|
122
|
+
const untilNextRun = nextRunAt === null ? remaining : Math.max(1, nextRunAt - now);
|
|
123
|
+
await shard.waitForJob(queue, Math.min(remaining, untilNextRun));
|
|
70
124
|
}
|
|
71
125
|
/**
|
|
72
126
|
* Finish the handoff of a dequeued job: persist active state and broadcast.
|
|
@@ -141,7 +195,7 @@ async function requeueJob(job, queue, idx, ctx) {
|
|
|
141
195
|
shard.getQueue(queue).push(job);
|
|
142
196
|
shard.incrementQueued(job.id, false, job.createdAt, queue, job.runAt);
|
|
143
197
|
ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
|
|
144
|
-
shard.notify();
|
|
198
|
+
shard.notify(queue);
|
|
145
199
|
requeued = true;
|
|
146
200
|
});
|
|
147
201
|
if (!requeued)
|
|
@@ -163,7 +217,8 @@ export async function pullJob(queue, timeoutMs, ctx) {
|
|
|
163
217
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
|
|
164
218
|
const idx = shardIndex(queue);
|
|
165
219
|
while (true) {
|
|
166
|
-
const
|
|
220
|
+
const attempt = await tryPullFromShard(queue, idx, ctx);
|
|
221
|
+
const job = attempt.job;
|
|
167
222
|
if (job) {
|
|
168
223
|
let delivered = false;
|
|
169
224
|
try {
|
|
@@ -186,9 +241,7 @@ export async function pullJob(queue, timeoutMs, ctx) {
|
|
|
186
241
|
if (deadline === 0 || now >= deadline) {
|
|
187
242
|
return null;
|
|
188
243
|
}
|
|
189
|
-
|
|
190
|
-
const remaining = deadline - now;
|
|
191
|
-
await ctx.shards[idx].waitForJob(remaining);
|
|
244
|
+
await waitForNextCandidate(ctx.shards[idx], queue, deadline, now, attempt.nextRunAt);
|
|
192
245
|
}
|
|
193
246
|
}
|
|
194
247
|
/**
|
|
@@ -199,27 +252,13 @@ async function tryPullFromShard(queue, idx, ctx) {
|
|
|
199
252
|
const shard = ctx.shards[idx];
|
|
200
253
|
const state = shard.getState(queue);
|
|
201
254
|
if (state.paused) {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
if (!shard.tryAcquireRateLimit(queue)) {
|
|
205
|
-
ctx.dashboardEmit?.('ratelimit:rejected', { queue });
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
if (!shard.tryAcquireConcurrency(queue)) {
|
|
209
|
-
ctx.dashboardEmit?.('concurrency:rejected', { queue });
|
|
210
|
-
return null;
|
|
255
|
+
return { job: null, nextRunAt: null };
|
|
211
256
|
}
|
|
212
257
|
const now = Date.now();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (result.status === 'stop') {
|
|
218
|
-
shard.releaseConcurrency(queue);
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
// status === 'skip': continue loop
|
|
222
|
-
}
|
|
258
|
+
const result = tryDequeueNextJob(shard, queue, now, ctx);
|
|
259
|
+
return result.status === 'job'
|
|
260
|
+
? { job: result.job, nextRunAt: null }
|
|
261
|
+
: { job: null, nextRunAt: result.nextRunAt };
|
|
223
262
|
});
|
|
224
263
|
}
|
|
225
264
|
/**
|
|
@@ -230,7 +269,8 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx) {
|
|
|
230
269
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
|
|
231
270
|
const idx = shardIndex(queue);
|
|
232
271
|
while (true) {
|
|
233
|
-
const
|
|
272
|
+
const attempt = await tryPullBatchFromShard(queue, idx, count, ctx);
|
|
273
|
+
const jobs = attempt.jobs;
|
|
234
274
|
if (jobs.length > 0) {
|
|
235
275
|
let delivered;
|
|
236
276
|
try {
|
|
@@ -260,9 +300,7 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx) {
|
|
|
260
300
|
if (deadline === 0 || now >= deadline) {
|
|
261
301
|
return [];
|
|
262
302
|
}
|
|
263
|
-
|
|
264
|
-
const remaining = deadline - now;
|
|
265
|
-
await ctx.shards[idx].waitForJob(remaining);
|
|
303
|
+
await waitForNextCandidate(ctx.shards[idx], queue, deadline, now, attempt.nextRunAt);
|
|
266
304
|
}
|
|
267
305
|
}
|
|
268
306
|
/**
|
|
@@ -274,28 +312,19 @@ async function tryPullBatchFromShard(queue, idx, count, ctx) {
|
|
|
274
312
|
const state = shard.getState(queue);
|
|
275
313
|
const jobs = [];
|
|
276
314
|
if (state.paused)
|
|
277
|
-
return jobs;
|
|
315
|
+
return { jobs, nextRunAt: null };
|
|
278
316
|
const now = Date.now();
|
|
317
|
+
let nextRunAt = null;
|
|
279
318
|
while (jobs.length < count) {
|
|
280
|
-
// Check limits per job
|
|
281
|
-
if (!shard.tryAcquireRateLimit(queue)) {
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
if (!shard.tryAcquireConcurrency(queue)) {
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
319
|
const result = tryDequeueNextJob(shard, queue, now, ctx);
|
|
288
320
|
if (result.status === 'job') {
|
|
289
321
|
jobs.push(result.job);
|
|
290
322
|
}
|
|
291
323
|
else {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (result.status === 'stop')
|
|
295
|
-
break;
|
|
296
|
-
// 'skip': continue loop to try next job
|
|
324
|
+
nextRunAt = result.nextRunAt;
|
|
325
|
+
break;
|
|
297
326
|
}
|
|
298
327
|
}
|
|
299
|
-
return jobs;
|
|
328
|
+
return { jobs, nextRunAt };
|
|
300
329
|
});
|
|
301
330
|
}
|
|
@@ -212,7 +212,7 @@ export async function pushJob(queue, input, ctx) {
|
|
|
212
212
|
}
|
|
213
213
|
// Insert to shard
|
|
214
214
|
insertJobToShard(job, queue, shard, idx, ctx);
|
|
215
|
-
shard.notify();
|
|
215
|
+
shard.notify(queue);
|
|
216
216
|
result = { job, persisted: true };
|
|
217
217
|
});
|
|
218
218
|
if (!result) {
|
|
@@ -274,7 +274,7 @@ export async function pushJobBatch(queue, inputs, ctx) {
|
|
|
274
274
|
resultIds.push(job.id);
|
|
275
275
|
}
|
|
276
276
|
if (jobsToInsert.length > 0) {
|
|
277
|
-
shard.notifyBatch(jobsToInsert.length);
|
|
277
|
+
shard.notifyBatch(queue, jobsToInsert.length);
|
|
278
278
|
}
|
|
279
279
|
});
|
|
280
280
|
if (jobsToInsert.length > 0) {
|
|
@@ -123,6 +123,17 @@ export function getJobProgress(jobId, ctx) {
|
|
|
123
123
|
return null;
|
|
124
124
|
return { progress: job.progress, message: job.progressMessage };
|
|
125
125
|
}
|
|
126
|
+
/** Stable total order used by both in-memory collection and SQL queries. */
|
|
127
|
+
function compareJobsByCreatedAt(a, b, asc) {
|
|
128
|
+
if (a.createdAt !== b.createdAt) {
|
|
129
|
+
return asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt;
|
|
130
|
+
}
|
|
131
|
+
if (a.id === b.id)
|
|
132
|
+
return 0;
|
|
133
|
+
if (asc)
|
|
134
|
+
return a.id < b.id ? -1 : 1;
|
|
135
|
+
return a.id > b.id ? -1 : 1;
|
|
136
|
+
}
|
|
126
137
|
/** Resolve job state from SQLite when jobIndex has no entry (post-restart recovery). */
|
|
127
138
|
function resolveStateFromStorage(jobId, storage) {
|
|
128
139
|
if (!storage)
|
|
@@ -197,29 +208,25 @@ export async function getJobState(jobId, ctx) {
|
|
|
197
208
|
return 'unknown';
|
|
198
209
|
}
|
|
199
210
|
/** Collect completed jobs for a queue from index + storage */
|
|
200
|
-
function collectCompletedJobs(queue, ctx
|
|
211
|
+
function collectCompletedJobs(queue, ctx) {
|
|
201
212
|
const jobs = [];
|
|
202
213
|
for (const [jId, location] of ctx.jobIndex) {
|
|
203
214
|
if (location.type === 'completed' && location.queueName === queue) {
|
|
204
215
|
const job = ctx.storage?.getJob(jId) ?? ctx.completedJobsData?.get(jId) ?? null;
|
|
205
216
|
if (job) {
|
|
206
217
|
jobs.push(job);
|
|
207
|
-
if (jobs.length >= maxCollect)
|
|
208
|
-
break;
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
}
|
|
212
221
|
return jobs;
|
|
213
222
|
}
|
|
214
223
|
/** Collect active jobs for a queue across all processing shards */
|
|
215
|
-
function collectActiveJobs(queue, shardIdx, ctx
|
|
224
|
+
function collectActiveJobs(queue, shardIdx, ctx) {
|
|
216
225
|
const jobs = [];
|
|
217
226
|
// Own shard first (most likely location)
|
|
218
227
|
for (const job of ctx.processingShards[shardIdx].values()) {
|
|
219
228
|
if (job.queue === queue)
|
|
220
229
|
jobs.push(job);
|
|
221
|
-
if (jobs.length >= maxCollect)
|
|
222
|
-
return jobs;
|
|
223
230
|
}
|
|
224
231
|
// Other shards
|
|
225
232
|
for (let i = 0; i < ctx.shardCount; i++) {
|
|
@@ -228,20 +235,15 @@ function collectActiveJobs(queue, shardIdx, ctx, maxCollect) {
|
|
|
228
235
|
for (const job of ctx.processingShards[i].values()) {
|
|
229
236
|
if (job.queue === queue)
|
|
230
237
|
jobs.push(job);
|
|
231
|
-
if (jobs.length >= maxCollect)
|
|
232
|
-
return jobs;
|
|
233
238
|
}
|
|
234
239
|
}
|
|
235
240
|
return jobs;
|
|
236
241
|
}
|
|
237
242
|
/** Collect waiting/delayed/prioritized jobs in a single pass */
|
|
238
|
-
function collectTemporalJobs(shard, queue, needs,
|
|
243
|
+
function collectTemporalJobs(shard, queue, needs, now = Date.now()) {
|
|
239
244
|
const { waiting: needWaiting, prioritized: needPrioritized, delayed: needDelayed } = needs;
|
|
240
|
-
const now = Date.now();
|
|
241
245
|
const jobs = [];
|
|
242
246
|
for (const j of shard.getQueue(queue).values()) {
|
|
243
|
-
if (jobs.length >= maxCollect)
|
|
244
|
-
break;
|
|
245
247
|
const isDelayed = j.runAt > now;
|
|
246
248
|
if (isDelayed && needDelayed) {
|
|
247
249
|
jobs.push(j);
|
|
@@ -262,8 +264,7 @@ function tagState(jobs, state) {
|
|
|
262
264
|
return jobs;
|
|
263
265
|
}
|
|
264
266
|
/** Tag temporal jobs with their actual state based on runAt/priority */
|
|
265
|
-
function tagTemporalState(jobs) {
|
|
266
|
-
const now = Date.now();
|
|
267
|
+
function tagTemporalState(jobs, now = Date.now()) {
|
|
267
268
|
for (const j of jobs) {
|
|
268
269
|
const isDelayed = j.runAt > now;
|
|
269
270
|
j._state = isDelayed
|
|
@@ -274,19 +275,15 @@ function tagTemporalState(jobs) {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
/** Collect waiting-children jobs from deps and children maps */
|
|
277
|
-
function collectWaitingChildrenFromShard(shard, queue
|
|
278
|
+
function collectWaitingChildrenFromShard(shard, queue) {
|
|
278
279
|
const wcJobs = [];
|
|
279
280
|
for (const job of shard.waitingDeps.values()) {
|
|
280
281
|
if (job.queue === queue)
|
|
281
282
|
wcJobs.push(job);
|
|
282
|
-
if (wcJobs.length >= max)
|
|
283
|
-
return wcJobs;
|
|
284
283
|
}
|
|
285
284
|
for (const job of shard.waitingChildren.values()) {
|
|
286
285
|
if (job.queue === queue)
|
|
287
286
|
wcJobs.push(job);
|
|
288
|
-
if (wcJobs.length >= max)
|
|
289
|
-
return wcJobs;
|
|
290
287
|
}
|
|
291
288
|
return wcJobs;
|
|
292
289
|
}
|
|
@@ -312,34 +309,33 @@ function resolveStateNeeds(states, paused) {
|
|
|
312
309
|
};
|
|
313
310
|
}
|
|
314
311
|
/** When paused, the queue's ready jobs (waiting + prioritized) ARE the paused set (#92). */
|
|
315
|
-
function collectPausedJobs(shard, queue,
|
|
316
|
-
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false },
|
|
312
|
+
function collectPausedJobs(shard, queue, now) {
|
|
313
|
+
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, now);
|
|
317
314
|
return tagState(pausedJobs, 'paused');
|
|
318
315
|
}
|
|
319
|
-
function collectJobsByState(queue, shardIdx, states, ctx,
|
|
316
|
+
function collectJobsByState(queue, shardIdx, states, ctx, now = Date.now()) {
|
|
320
317
|
const shard = ctx.shards[shardIdx];
|
|
321
318
|
const jobs = [];
|
|
322
319
|
const need = resolveStateNeeds(states, shard.getState(queue).paused);
|
|
323
320
|
if (need.waiting || need.prioritized || need.delayed) {
|
|
324
|
-
const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed },
|
|
325
|
-
tagTemporalState(temporal);
|
|
321
|
+
const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed }, now);
|
|
322
|
+
tagTemporalState(temporal, now);
|
|
326
323
|
jobs.push(...temporal);
|
|
327
324
|
}
|
|
328
325
|
if (need.paused) {
|
|
329
|
-
jobs.push(...collectPausedJobs(shard, queue,
|
|
326
|
+
jobs.push(...collectPausedJobs(shard, queue, now));
|
|
330
327
|
}
|
|
331
328
|
if (need.active) {
|
|
332
|
-
jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx
|
|
329
|
+
jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx), 'active'));
|
|
333
330
|
}
|
|
334
331
|
if (need.failed) {
|
|
335
|
-
|
|
336
|
-
jobs.push(...tagState(maxPerSource < dlq.length ? dlq.slice(0, maxPerSource) : dlq, 'failed'));
|
|
332
|
+
jobs.push(...tagState(shard.getDlq(queue), 'failed'));
|
|
337
333
|
}
|
|
338
334
|
if (need.completed) {
|
|
339
|
-
jobs.push(...tagState(collectCompletedJobs(queue, ctx
|
|
335
|
+
jobs.push(...tagState(collectCompletedJobs(queue, ctx), 'completed'));
|
|
340
336
|
}
|
|
341
337
|
if (need.waitingChildren) {
|
|
342
|
-
jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue
|
|
338
|
+
jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue), 'waiting-children'));
|
|
343
339
|
}
|
|
344
340
|
return jobs;
|
|
345
341
|
}
|
|
@@ -356,38 +352,34 @@ function collectWaitingChildrenJobs(shard, queue) {
|
|
|
356
352
|
}
|
|
357
353
|
return jobs;
|
|
358
354
|
}
|
|
359
|
-
/** Query SQLite
|
|
360
|
-
function
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (!hasPrioritized && !hasWaiting) {
|
|
364
|
-
if (sqlFilteredStates.length === 1) {
|
|
365
|
-
return storage.queryJobs(queue, { state: sqlFilteredStates[0], ...opts });
|
|
366
|
-
}
|
|
367
|
-
return storage.queryJobs(queue, { states: sqlFilteredStates, ...opts });
|
|
368
|
-
}
|
|
369
|
-
// Map 'prioritized' to 'waiting' for SQLite, then post-filter by priority
|
|
370
|
-
const sqlStates = sqlFilteredStates
|
|
371
|
-
.map((s) => (s === 'prioritized' ? 'waiting' : s))
|
|
372
|
-
.filter((s, i, arr) => arr.indexOf(s) === i);
|
|
373
|
-
const overFetchOpts = { ...opts, limit: opts.limit * 2 };
|
|
374
|
-
let jobs = sqlStates.length === 1
|
|
375
|
-
? storage.queryJobs(queue, { state: sqlStates[0], ...overFetchOpts })
|
|
376
|
-
: storage.queryJobs(queue, { states: sqlStates, ...overFetchOpts });
|
|
377
|
-
if (hasPrioritized && !hasWaiting) {
|
|
378
|
-
jobs = jobs.filter((j) => j.priority > 0);
|
|
355
|
+
/** Query SQLite after translating persisted pending rows to their logical state. */
|
|
356
|
+
function querySqliteByLogicalState(storage, queue, sqlFilteredStates, opts) {
|
|
357
|
+
if (opts.excludedIds.size === 0) {
|
|
358
|
+
return storage.queryJobsByLogicalStates(queue, sqlFilteredStates, opts);
|
|
379
359
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
360
|
+
// waitingDeps/waitingChildren rows remain persisted as waiting/delayed. Fetch
|
|
361
|
+
// enough rows to account for every possible exclusion, then paginate the
|
|
362
|
+
// logical result so parked jobs cannot create short or shifted pages.
|
|
363
|
+
const pageEnd = opts.offset + opts.limit;
|
|
364
|
+
const jobs = storage.queryJobsByLogicalStates(queue, sqlFilteredStates, {
|
|
365
|
+
limit: pageEnd + opts.excludedIds.size,
|
|
366
|
+
offset: 0,
|
|
367
|
+
asc: opts.asc,
|
|
368
|
+
now: opts.now,
|
|
369
|
+
});
|
|
370
|
+
return jobs.filter((job) => !opts.excludedIds.has(job.id)).slice(opts.offset, pageEnd);
|
|
384
371
|
}
|
|
385
372
|
/** Merge SQL rows with in-memory extras (each gathered from index 0), sort by
|
|
386
373
|
* createdAt, and paginate [start, end) once — so offset-unaware extras don't
|
|
387
374
|
* duplicate or drop rows across pages (#92). */
|
|
388
375
|
function mergePage(sqlJobs, extras, start, end, asc) {
|
|
389
|
-
const
|
|
390
|
-
|
|
376
|
+
const byId = new Map();
|
|
377
|
+
for (const job of sqlJobs)
|
|
378
|
+
byId.set(job.id, job);
|
|
379
|
+
for (const job of extras)
|
|
380
|
+
byId.set(job.id, job);
|
|
381
|
+
const merged = Array.from(byId.values());
|
|
382
|
+
merged.sort((a, b) => compareJobsByCreatedAt(a, b, asc));
|
|
391
383
|
return merged.slice(start, end);
|
|
392
384
|
}
|
|
393
385
|
/** Get jobs from queue with filters */
|
|
@@ -401,10 +393,10 @@ export function getJobs(queue, shardIdx, options, ctx) {
|
|
|
401
393
|
: state
|
|
402
394
|
: [state];
|
|
403
395
|
const limit = end - start;
|
|
396
|
+
const now = Date.now();
|
|
404
397
|
if (ctx.storage) {
|
|
405
398
|
const shard = ctx.shards[shardIdx];
|
|
406
399
|
const isPaused = shard.getState(queue).paused;
|
|
407
|
-
const maxPerSource = end + 1;
|
|
408
400
|
// Derived sources are NOT offset-aware (the DLQ and the paused/waiting-children
|
|
409
401
|
// views come from in-memory maps). When any contributes, we must gather [0, end)
|
|
410
402
|
// from EVERY source, merge, sort, then slice [start, end) exactly once — pushing
|
|
@@ -417,6 +409,19 @@ export function getJobs(queue, shardIdx, options, ctx) {
|
|
|
417
409
|
const all = ctx.storage.queryJobs(queue, { limit: end, offset: 0, asc });
|
|
418
410
|
return mergePage(all, dlq, start, end, asc);
|
|
419
411
|
}
|
|
412
|
+
// jobs-table states. A paused queue reports its waiting/prioritized jobs under
|
|
413
|
+
// 'paused', so they must not also surface in the waiting/prioritized lists (#92).
|
|
414
|
+
const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
|
|
415
|
+
s !== 'waiting-children' &&
|
|
416
|
+
s !== 'paused' &&
|
|
417
|
+
!(isPaused && (s === 'waiting' || s === 'prioritized')));
|
|
418
|
+
// In-memory parked state is authoritative over the persisted row. Initial
|
|
419
|
+
// dependency jobs are stored as waiting/delayed, while parents moved after
|
|
420
|
+
// a pull can still be stored as active.
|
|
421
|
+
const parkedJobs = states.includes('waiting-children') || sqlFilteredStates.length > 0
|
|
422
|
+
? collectWaitingChildrenJobs(shard, queue)
|
|
423
|
+
: [];
|
|
424
|
+
const parkedIds = new Set(parkedJobs.map((job) => job.id));
|
|
420
425
|
// States with no jobs-table row are collected from in-memory sources:
|
|
421
426
|
// - 'failed' -> DLQ (the failed job lives in the dlq table) (#92)
|
|
422
427
|
// - 'waiting-children' -> deps/children maps
|
|
@@ -426,40 +431,39 @@ export function getJobs(queue, shardIdx, options, ctx) {
|
|
|
426
431
|
extras.push(...tagState(shard.getDlq(queue), 'failed'));
|
|
427
432
|
}
|
|
428
433
|
if (states.includes('waiting-children')) {
|
|
429
|
-
extras.push(...
|
|
434
|
+
extras.push(...tagState(parkedJobs, 'waiting-children'));
|
|
430
435
|
}
|
|
431
436
|
if (states.includes('paused') && isPaused) {
|
|
432
|
-
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false },
|
|
437
|
+
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, now);
|
|
433
438
|
extras.push(...tagState(pausedJobs, 'paused'));
|
|
434
439
|
}
|
|
435
|
-
// jobs-table states. A paused queue reports its waiting/prioritized jobs under
|
|
436
|
-
// 'paused', so they must not also surface in the waiting/prioritized lists (#92).
|
|
437
|
-
const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
|
|
438
|
-
s !== 'waiting-children' &&
|
|
439
|
-
s !== 'paused' &&
|
|
440
|
-
!(isPaused && (s === 'waiting' || s === 'prioritized')));
|
|
441
440
|
// Fast path: only jobs-table states, no derived sources — SQL paginates directly.
|
|
442
441
|
if (extras.length === 0) {
|
|
443
442
|
return sqlFilteredStates.length > 0
|
|
444
|
-
?
|
|
443
|
+
? querySqliteByLogicalState(ctx.storage, queue, sqlFilteredStates, {
|
|
445
444
|
limit,
|
|
446
445
|
offset: start,
|
|
447
446
|
asc,
|
|
447
|
+
now,
|
|
448
|
+
excludedIds: parkedIds,
|
|
448
449
|
})
|
|
449
450
|
: [];
|
|
450
451
|
}
|
|
451
452
|
const sqlJobs = sqlFilteredStates.length > 0
|
|
452
|
-
?
|
|
453
|
+
? querySqliteByLogicalState(ctx.storage, queue, sqlFilteredStates, {
|
|
453
454
|
limit: end,
|
|
454
455
|
offset: 0,
|
|
455
456
|
asc,
|
|
457
|
+
now,
|
|
458
|
+
excludedIds: parkedIds,
|
|
456
459
|
})
|
|
457
460
|
: [];
|
|
458
461
|
return mergePage(sqlJobs, extras, start, end, asc);
|
|
459
462
|
}
|
|
460
463
|
// In-memory path (embedded mode only)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
jobs
|
|
464
|
+
// Every source must be filtered before the global order is applied. Limiting
|
|
465
|
+
// an insertion-ordered Map here loses newer rows on descending pages.
|
|
466
|
+
const jobs = collectJobsByState(queue, shardIdx, states, ctx, now);
|
|
467
|
+
jobs.sort((a, b) => compareJobsByCreatedAt(a, b, asc));
|
|
464
468
|
return jobs.slice(start, end);
|
|
465
469
|
}
|
|
@@ -341,24 +341,19 @@ export declare class QueueManager {
|
|
|
341
341
|
paused: boolean;
|
|
342
342
|
counts: {
|
|
343
343
|
waiting: number;
|
|
344
|
+
prioritized: number;
|
|
344
345
|
active: number;
|
|
345
346
|
completed: number;
|
|
346
347
|
failed: number;
|
|
347
348
|
delayed: number;
|
|
348
349
|
};
|
|
349
350
|
}>;
|
|
351
|
+
/** Get counts for every registered queue with one global aggregation pass. */
|
|
352
|
+
getAllQueueJobCounts(): Map<string, statsMgr.QueueJobCounts>;
|
|
353
|
+
/** Aggregate a selected group of queues without repeating global scans. */
|
|
354
|
+
getQueueJobCountsBatch(queueNames: Iterable<string>): Map<string, statsMgr.QueueJobCounts>;
|
|
350
355
|
/** Get job counts for a specific queue */
|
|
351
|
-
getQueueJobCounts(queueName: string):
|
|
352
|
-
waiting: number;
|
|
353
|
-
prioritized: number;
|
|
354
|
-
delayed: number;
|
|
355
|
-
active: number;
|
|
356
|
-
completed: number;
|
|
357
|
-
failed: number;
|
|
358
|
-
'waiting-children': number;
|
|
359
|
-
totalCompleted: number;
|
|
360
|
-
totalFailed: number;
|
|
361
|
-
};
|
|
356
|
+
getQueueJobCounts(queueName: string): statsMgr.QueueJobCounts;
|
|
362
357
|
getMemoryStats(): statsMgr.MemoryStats;
|
|
363
358
|
/** Get storage health status (disk full detection) */
|
|
364
359
|
getStorageStatus(): {
|