bunqueue 2.8.3 → 2.8.5
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 -0
- package/dist/application/contextFactory.js +2 -0
- package/dist/application/operations/push.d.ts +2 -0
- package/dist/application/operations/push.js +21 -11
- package/dist/application/operations/queryOperations.js +91 -25
- package/dist/bun-only.d.ts +15 -0
- package/dist/bun-only.js +19 -0
- package/dist/cli/index.js +0 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +2 -0
- package/dist/client/queue/operations/counts.js +7 -3
- package/dist/client/workflow/index.d.ts +1 -0
- package/dist/client/workflow/index.js +2 -0
- package/dist/infrastructure/cloud/snapshotHelpers.js +6 -1
- package/dist/infrastructure/persistence/sqlite.js +31 -6
- package/dist/infrastructure/persistence/sqliteBatch.d.ts +32 -3
- package/dist/infrastructure/persistence/sqliteBatch.js +129 -55
- package/dist/infrastructure/server/handlers/dashboard.js +17 -1
- package/dist/infrastructure/server/handlers/query.js +7 -3
- package/dist/infrastructure/server/httpEndpoints.js +15 -1
- package/dist/mcp/index.js +0 -0
- package/dist/require-bun.d.ts +13 -0
- package/dist/require-bun.js +17 -0
- package/dist/shared/pausedView.d.ts +19 -0
- package/dist/shared/pausedView.js +7 -0
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
bunqueue is **Bun-only** (`bun >= 1.3.9`). The client, TCP transport and persistence
|
|
31
|
+
rely on Bun's runtime APIs (`Bun.connect`, `Bun.file`, `Bun.hash`, …) and ship as
|
|
32
|
+
ESM with extensionless specifiers, so they do **not** run under Node.js. Importing
|
|
33
|
+
the package from Node fails fast with a clear error pointing here rather than a
|
|
34
|
+
cryptic resolver crash. Install Bun from [bun.sh](https://bun.sh) and run with `bun`.
|
|
35
|
+
|
|
28
36
|
## Quickstart
|
|
29
37
|
|
|
30
38
|
```bash
|
|
@@ -453,6 +461,27 @@ await app.close(); // graceful shutdown
|
|
|
453
461
|
await app.close(true); // force shutdown
|
|
454
462
|
```
|
|
455
463
|
|
|
464
|
+
### Inspecting jobs & counts
|
|
465
|
+
|
|
466
|
+
`getJobCounts()` and the per-state lists follow BullMQ semantics:
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
const counts = await queue.getJobCountsAsync();
|
|
470
|
+
// { waiting, prioritized, active, completed, failed, delayed, paused }
|
|
471
|
+
|
|
472
|
+
// Failed jobs (those that exhausted their attempts) are enumerable by state —
|
|
473
|
+
// the failed list reflects the same jobs that `failed` counts.
|
|
474
|
+
const failed = await queue.getFailedAsync(0, 50);
|
|
475
|
+
const alsoFailed = await queue.getJobsAsync({ state: 'failed', start: 0, end: 50 });
|
|
476
|
+
|
|
477
|
+
// Paused queue: ready jobs are reported under `paused`, never double-counted as
|
|
478
|
+
// `waiting`. While paused, getJobCounts() returns waiting:0 / paused:N, and the
|
|
479
|
+
// jobs are listed by `getJobsAsync({ state: 'paused' })` (not by `waiting`).
|
|
480
|
+
queue.pause();
|
|
481
|
+
const c = await queue.getJobCountsAsync(); // waiting: 0, paused: N
|
|
482
|
+
const pausedJobs = await queue.getJobsAsync({ state: 'paused', start: 0, end: 50 });
|
|
483
|
+
```
|
|
484
|
+
|
|
456
485
|
### Full Example
|
|
457
486
|
|
|
458
487
|
```typescript
|
|
@@ -80,6 +80,8 @@ export class ContextFactory {
|
|
|
80
80
|
shards: this.deps.shards,
|
|
81
81
|
shardLocks: this.deps.shardLocks,
|
|
82
82
|
completedJobs: this.deps.completedJobs,
|
|
83
|
+
completedJobsData: this.deps.completedJobsData,
|
|
84
|
+
jobResults: this.deps.jobResults,
|
|
83
85
|
customIdMap: this.deps.customIdMap,
|
|
84
86
|
jobIndex: this.deps.jobIndex,
|
|
85
87
|
totalPushed: this.deps.metrics.totalPushed,
|
|
@@ -14,6 +14,8 @@ export interface PushContext {
|
|
|
14
14
|
shards: Shard[];
|
|
15
15
|
shardLocks: RWLock[];
|
|
16
16
|
completedJobs: SetLike<JobId>;
|
|
17
|
+
completedJobsData: MapLike<JobId, Job>;
|
|
18
|
+
jobResults: MapLike<JobId, unknown>;
|
|
17
19
|
customIdMap: MapLike<string, JobId>;
|
|
18
20
|
jobIndex: Map<JobId, JobLocation>;
|
|
19
21
|
totalPushed: {
|
|
@@ -17,19 +17,29 @@ function handleCustomId(input, shard, ctx) {
|
|
|
17
17
|
}
|
|
18
18
|
const id = jobId(input.customId);
|
|
19
19
|
const existing = ctx.customIdMap.get(input.customId);
|
|
20
|
-
//
|
|
21
|
-
if (
|
|
22
|
-
ctx.
|
|
23
|
-
|
|
20
|
+
// If the existing job is still queued, the add is idempotent — return it.
|
|
21
|
+
if (existing) {
|
|
22
|
+
const location = ctx.jobIndex.get(existing);
|
|
23
|
+
const existingJob = location?.type === 'queue' ? shard.getQueue(location.queueName).find(existing) : null;
|
|
24
|
+
if (existingJob) {
|
|
25
|
+
return { skip: true, existingJob };
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// Reuse path: the id is free in the queue (no mapping, or the prior job is
|
|
29
|
+
// processing/completed). If the prior job COMPLETED, its row survives on disk
|
|
30
|
+
// (markCompleted does an UPDATE, not a DELETE) and it is still in completedJobs.
|
|
31
|
+
// Reusing the same deterministic id would then (a) make getJobState return
|
|
32
|
+
// 'completed' for the brand-new job and (b) collide on the `jobs.id` PRIMARY KEY
|
|
33
|
+
// at flush time. Evict the stale completed job so the reused id starts fresh as
|
|
34
|
+
// 'waiting' (#92). Checked regardless of customIdMap state — the mapping may have
|
|
35
|
+
// been cleared on completion, which would otherwise skip this path entirely.
|
|
36
|
+
if (ctx.completedJobs.has(id)) {
|
|
37
|
+
ctx.completedJobs.delete(id);
|
|
38
|
+
ctx.completedJobsData.delete(id);
|
|
39
|
+
ctx.jobResults.delete(id);
|
|
40
|
+
ctx.jobIndex.delete(id);
|
|
41
|
+
ctx.storage?.deleteJob(id); // removes the surviving row + result + any buffered insert
|
|
30
42
|
}
|
|
31
|
-
// Job gone (processing/completed) - allow reuse of customId
|
|
32
|
-
ctx.customIdMap.delete(input.customId);
|
|
33
43
|
ctx.customIdMap.set(input.customId, id);
|
|
34
44
|
return { skip: false, id };
|
|
35
45
|
}
|
|
@@ -250,28 +250,55 @@ function collectWaitingChildrenFromShard(shard, queue, max) {
|
|
|
250
250
|
}
|
|
251
251
|
return wcJobs;
|
|
252
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Resolve which sources to collect for a state filter.
|
|
255
|
+
*
|
|
256
|
+
* When the queue is paused, an EXPLICIT waiting/prioritized query returns nothing:
|
|
257
|
+
* those jobs are reported under 'paused' instead (BullMQ semantics, #92). An
|
|
258
|
+
* unfiltered query (states === null) still lists them by their temporal state.
|
|
259
|
+
*/
|
|
260
|
+
function resolveStateNeeds(states, paused) {
|
|
261
|
+
const want = (s) => !states || states.includes(s);
|
|
262
|
+
const suppressReady = !!states && paused;
|
|
263
|
+
return {
|
|
264
|
+
waiting: want('waiting') && !suppressReady,
|
|
265
|
+
prioritized: want('prioritized') && !suppressReady,
|
|
266
|
+
delayed: want('delayed'),
|
|
267
|
+
paused: !!states && states.includes('paused') && paused,
|
|
268
|
+
active: want('active'),
|
|
269
|
+
failed: want('failed'),
|
|
270
|
+
completed: want('completed'),
|
|
271
|
+
waitingChildren: want('waiting-children'),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** When paused, the queue's ready jobs (waiting + prioritized) ARE the paused set (#92). */
|
|
275
|
+
function collectPausedJobs(shard, queue, maxPerSource) {
|
|
276
|
+
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
|
|
277
|
+
return tagState(pausedJobs, 'paused');
|
|
278
|
+
}
|
|
253
279
|
function collectJobsByState(queue, shardIdx, states, ctx, maxPerSource = Infinity) {
|
|
254
280
|
const shard = ctx.shards[shardIdx];
|
|
255
281
|
const jobs = [];
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (needWaiting || needPrioritized || needDelayed) {
|
|
260
|
-
const temporal = collectTemporalJobs(shard, queue, { waiting: needWaiting, prioritized: needPrioritized, delayed: needDelayed }, maxPerSource);
|
|
282
|
+
const need = resolveStateNeeds(states, shard.getState(queue).paused);
|
|
283
|
+
if (need.waiting || need.prioritized || need.delayed) {
|
|
284
|
+
const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed }, maxPerSource);
|
|
261
285
|
tagTemporalState(temporal);
|
|
262
286
|
jobs.push(...temporal);
|
|
263
287
|
}
|
|
264
|
-
if (
|
|
288
|
+
if (need.paused) {
|
|
289
|
+
jobs.push(...collectPausedJobs(shard, queue, maxPerSource));
|
|
290
|
+
}
|
|
291
|
+
if (need.active) {
|
|
265
292
|
jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx, maxPerSource), 'active'));
|
|
266
293
|
}
|
|
267
|
-
if (
|
|
294
|
+
if (need.failed) {
|
|
268
295
|
const dlq = shard.getDlq(queue);
|
|
269
296
|
jobs.push(...tagState(maxPerSource < dlq.length ? dlq.slice(0, maxPerSource) : dlq, 'failed'));
|
|
270
297
|
}
|
|
271
|
-
if (
|
|
298
|
+
if (need.completed) {
|
|
272
299
|
jobs.push(...tagState(collectCompletedJobs(queue, ctx, maxPerSource), 'completed'));
|
|
273
300
|
}
|
|
274
|
-
if (
|
|
301
|
+
if (need.waitingChildren) {
|
|
275
302
|
jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue, maxPerSource), 'waiting-children'));
|
|
276
303
|
}
|
|
277
304
|
return jobs;
|
|
@@ -315,6 +342,14 @@ function querySqliteWithPriority(storage, queue, sqlFilteredStates, opts) {
|
|
|
315
342
|
}
|
|
316
343
|
return jobs.slice(0, opts.limit);
|
|
317
344
|
}
|
|
345
|
+
/** Merge SQL rows with in-memory extras (each gathered from index 0), sort by
|
|
346
|
+
* createdAt, and paginate [start, end) once — so offset-unaware extras don't
|
|
347
|
+
* duplicate or drop rows across pages (#92). */
|
|
348
|
+
function mergePage(sqlJobs, extras, start, end, asc) {
|
|
349
|
+
const merged = sqlJobs.concat(extras);
|
|
350
|
+
merged.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
|
|
351
|
+
return merged.slice(start, end);
|
|
352
|
+
}
|
|
318
353
|
/** Get jobs from queue with filters */
|
|
319
354
|
export function getJobs(queue, shardIdx, options, ctx) {
|
|
320
355
|
const { state, start = 0, end = 100, asc = true } = options;
|
|
@@ -327,29 +362,60 @@ export function getJobs(queue, shardIdx, options, ctx) {
|
|
|
327
362
|
: [state];
|
|
328
363
|
const limit = end - start;
|
|
329
364
|
if (ctx.storage) {
|
|
365
|
+
const shard = ctx.shards[shardIdx];
|
|
366
|
+
const isPaused = shard.getState(queue).paused;
|
|
367
|
+
const maxPerSource = end + 1;
|
|
368
|
+
// Derived sources are NOT offset-aware (the DLQ and the paused/waiting-children
|
|
369
|
+
// views come from in-memory maps). When any contributes, we must gather [0, end)
|
|
370
|
+
// from EVERY source, merge, sort, then slice [start, end) exactly once — pushing
|
|
371
|
+
// `offset` into the SQL query would drop rows and duplicate across pages (#92).
|
|
330
372
|
if (!states) {
|
|
331
|
-
|
|
373
|
+
const dlq = tagState(shard.getDlq(queue), 'failed');
|
|
374
|
+
if (dlq.length === 0) {
|
|
375
|
+
return ctx.storage.queryJobs(queue, { limit, offset: start, asc });
|
|
376
|
+
}
|
|
377
|
+
const all = ctx.storage.queryJobs(queue, { limit: end, offset: 0, asc });
|
|
378
|
+
return mergePage(all, dlq, start, end, asc);
|
|
379
|
+
}
|
|
380
|
+
// States with no jobs-table row are collected from in-memory sources:
|
|
381
|
+
// - 'failed' -> DLQ (the failed job lives in the dlq table) (#92)
|
|
382
|
+
// - 'waiting-children' -> deps/children maps
|
|
383
|
+
// - 'paused' -> when paused, the would-be-waiting jobs ARE the paused set (#92)
|
|
384
|
+
const extras = [];
|
|
385
|
+
if (states.includes('failed')) {
|
|
386
|
+
extras.push(...tagState(shard.getDlq(queue), 'failed'));
|
|
387
|
+
}
|
|
388
|
+
if (states.includes('waiting-children')) {
|
|
389
|
+
extras.push(...collectWaitingChildrenJobs(shard, queue));
|
|
332
390
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (hasWaitingChildren && sqlFilteredStates.length === 0) {
|
|
337
|
-
const jobs = collectWaitingChildrenJobs(ctx.shards[shardIdx], queue);
|
|
338
|
-
jobs.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
|
|
339
|
-
return jobs.slice(start, end);
|
|
391
|
+
if (states.includes('paused') && isPaused) {
|
|
392
|
+
const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
|
|
393
|
+
extras.push(...tagState(pausedJobs, 'paused'));
|
|
340
394
|
}
|
|
341
|
-
|
|
395
|
+
// jobs-table states. A paused queue reports its waiting/prioritized jobs under
|
|
396
|
+
// 'paused', so they must not also surface in the waiting/prioritized lists (#92).
|
|
397
|
+
const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
|
|
398
|
+
s !== 'waiting-children' &&
|
|
399
|
+
s !== 'paused' &&
|
|
400
|
+
!(isPaused && (s === 'waiting' || s === 'prioritized')));
|
|
401
|
+
// Fast path: only jobs-table states, no derived sources — SQL paginates directly.
|
|
402
|
+
if (extras.length === 0) {
|
|
403
|
+
return sqlFilteredStates.length > 0
|
|
404
|
+
? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
|
|
405
|
+
limit,
|
|
406
|
+
offset: start,
|
|
407
|
+
asc,
|
|
408
|
+
})
|
|
409
|
+
: [];
|
|
410
|
+
}
|
|
411
|
+
const sqlJobs = sqlFilteredStates.length > 0
|
|
342
412
|
? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
|
|
343
|
-
limit,
|
|
344
|
-
offset:
|
|
413
|
+
limit: end,
|
|
414
|
+
offset: 0,
|
|
345
415
|
asc,
|
|
346
416
|
})
|
|
347
417
|
: [];
|
|
348
|
-
|
|
349
|
-
return jobs;
|
|
350
|
-
const merged = jobs.concat(collectWaitingChildrenJobs(ctx.shards[shardIdx], queue));
|
|
351
|
-
merged.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
|
|
352
|
-
return merged.slice(0, limit);
|
|
418
|
+
return mergePage(sqlJobs, extras, start, end, asc);
|
|
353
419
|
}
|
|
354
420
|
// In-memory path (embedded mode only)
|
|
355
421
|
const maxPerSource = end + 1;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-only entry stub.
|
|
3
|
+
*
|
|
4
|
+
* bunqueue is built for the Bun runtime: its TCP transport, persistence and
|
|
5
|
+
* client rely on Bun globals (Bun.connect, Bun.file, Bun.write, Bun.hash, ...)
|
|
6
|
+
* with no Node fallback, and the published ESM uses directory/extensionless
|
|
7
|
+
* relative specifiers that Node's ESM resolver rejects (ERR_UNSUPPORTED_DIR_IMPORT).
|
|
8
|
+
*
|
|
9
|
+
* The package.json `exports` map points the `"node"` condition at this single,
|
|
10
|
+
* self-contained file so a Node import fails fast with a clear, actionable error
|
|
11
|
+
* instead of a cryptic resolver crash or a deep `Bun is not defined`. Bun resolves
|
|
12
|
+
* the real entry via the higher-priority `"bun"` condition and never loads this.
|
|
13
|
+
*
|
|
14
|
+
* Keep this file free of relative imports so it stays resolvable on its own.
|
|
15
|
+
*/
|
package/dist/bun-only.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bun-only entry stub.
|
|
4
|
+
*
|
|
5
|
+
* bunqueue is built for the Bun runtime: its TCP transport, persistence and
|
|
6
|
+
* client rely on Bun globals (Bun.connect, Bun.file, Bun.write, Bun.hash, ...)
|
|
7
|
+
* with no Node fallback, and the published ESM uses directory/extensionless
|
|
8
|
+
* relative specifiers that Node's ESM resolver rejects (ERR_UNSUPPORTED_DIR_IMPORT).
|
|
9
|
+
*
|
|
10
|
+
* The package.json `exports` map points the `"node"` condition at this single,
|
|
11
|
+
* self-contained file so a Node import fails fast with a clear, actionable error
|
|
12
|
+
* instead of a cryptic resolver crash or a deep `Bun is not defined`. Bun resolves
|
|
13
|
+
* the real entry via the higher-priority `"bun"` condition and never loads this.
|
|
14
|
+
*
|
|
15
|
+
* Keep this file free of relative imports so it stays resolvable on its own.
|
|
16
|
+
*/
|
|
17
|
+
throw new Error('bunqueue is Bun-only and requires the Bun runtime (https://bun.sh). ' +
|
|
18
|
+
'Node.js is not supported: install Bun and run your program with `bun`. ' +
|
|
19
|
+
'See https://github.com/egeominotti/bunqueue#requirements');
|
package/dist/cli/index.js
CHANGED
|
File without changes
|
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* worker.on('progress', (job, progress) => console.log(progress));
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
|
+
// Bun-only runtime guard — must evaluate before any module touching Bun.* globals.
|
|
22
|
+
import '../require-bun';
|
|
21
23
|
export { defineConfig } from '../config';
|
|
22
24
|
export { Queue } from './queue';
|
|
23
25
|
export { Worker } from './worker';
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* getJobCounts, getWaitingCount, getDelayedCount, etc.
|
|
5
5
|
*/
|
|
6
6
|
import { getSharedManager } from '../../manager';
|
|
7
|
+
import { pausedView } from '../../../shared/pausedView';
|
|
7
8
|
/** Get job counts (sync, embedded only) */
|
|
8
9
|
export function getJobCounts(ctx) {
|
|
9
10
|
if (!ctx.embedded) {
|
|
@@ -21,14 +22,17 @@ export function getJobCounts(ctx) {
|
|
|
21
22
|
// Use queue-specific counts
|
|
22
23
|
const counts = manager.getQueueJobCounts(ctx.name);
|
|
23
24
|
const isPaused = manager.isPaused(ctx.name);
|
|
25
|
+
// When paused, ready jobs (waiting + prioritized) are reported under `paused` —
|
|
26
|
+
// not in their own buckets, which would double-count (#92, BullMQ semantics).
|
|
27
|
+
const pv = pausedView(counts.waiting, counts.prioritized, isPaused);
|
|
24
28
|
return {
|
|
25
|
-
waiting:
|
|
26
|
-
prioritized:
|
|
29
|
+
waiting: pv.waiting,
|
|
30
|
+
prioritized: pv.prioritized,
|
|
27
31
|
active: counts.active,
|
|
28
32
|
completed: counts.completed,
|
|
29
33
|
failed: counts.failed,
|
|
30
34
|
delayed: counts.delayed,
|
|
31
|
-
paused:
|
|
35
|
+
paused: pv.paused,
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
/** Get job counts (async, works with TCP) */
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* const run = await engine.start('onboarding', { email: 'user@test.com' });
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
|
+
// Bun-only runtime guard — must evaluate before any module touching Bun.* globals.
|
|
20
|
+
import '../../require-bun';
|
|
19
21
|
export { Workflow } from './workflow';
|
|
20
22
|
export { Engine } from './engine';
|
|
21
23
|
export { WorkflowEmitter } from './emitter';
|
|
@@ -9,7 +9,11 @@ const prevQueueTotals = new Map();
|
|
|
9
9
|
/** Per-queue previous waiting count for backlog velocity */
|
|
10
10
|
const prevQueueWaiting = new Map();
|
|
11
11
|
// ─── Heavy data collectors (called every ~90s) ───
|
|
12
|
-
/** All job states (BullMQ v5 compatible)
|
|
12
|
+
/** All job states (BullMQ v5 compatible).
|
|
13
|
+
* 'paused' yields jobs only when the queue is actually paused (where 'waiting' /
|
|
14
|
+
* 'prioritized' are suppressed), so a paused queue's ready jobs still appear in
|
|
15
|
+
* the snapshot — under `paused` — instead of vanishing (#92). No double-collect:
|
|
16
|
+
* a job is returned by exactly one of waiting/prioritized/paused. */
|
|
13
17
|
const ALL_STATES = [
|
|
14
18
|
'waiting',
|
|
15
19
|
'active',
|
|
@@ -17,6 +21,7 @@ const ALL_STATES = [
|
|
|
17
21
|
'failed',
|
|
18
22
|
'completed',
|
|
19
23
|
'prioritized',
|
|
24
|
+
'paused',
|
|
20
25
|
'waiting-children',
|
|
21
26
|
];
|
|
22
27
|
/** Convert falsy/empty values to undefined for compact serialization */
|
|
@@ -34,7 +34,19 @@ export class SqliteStorage {
|
|
|
34
34
|
static MAX_RETAINED_LOSSES = 100;
|
|
35
35
|
constructor(config) {
|
|
36
36
|
this.db = new Database(config.path, { create: true });
|
|
37
|
-
|
|
37
|
+
// PRAGMA settings are performance/optimization hints, not correctness
|
|
38
|
+
// requirements. A transient filesystem IOERR (e.g. SQLITE_IOERR_FSTAT from
|
|
39
|
+
// `mmap_size` calling fstat() on the fd during a restart/cleanup race) must
|
|
40
|
+
// NOT propagate out of the constructor — in a deferred/async context Bun
|
|
41
|
+
// reports it as an "Unhandled error between tests" and tears down CI.
|
|
42
|
+
try {
|
|
43
|
+
this.db.run(PRAGMA_SETTINGS);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
storageLog.error('Failed to apply PRAGMA settings', {
|
|
47
|
+
error: err instanceof Error ? err.message : String(err),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
38
50
|
this.migrate();
|
|
39
51
|
this.statements = prepareStatements(this.db);
|
|
40
52
|
this._onCriticalLoss = config.onCriticalLoss;
|
|
@@ -44,11 +56,24 @@ export class SqliteStorage {
|
|
|
44
56
|
if (isSqliteFullError(err)) {
|
|
45
57
|
this.setDiskFull(err.message);
|
|
46
58
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
// A constraint violation (e.g. duplicate jobs.id) is a PERMANENT per-row
|
|
60
|
+
// rejection that the WriteBuffer isolated and dropped — sibling valid jobs
|
|
61
|
+
// in the same flush were still persisted. Log it distinctly from a
|
|
62
|
+
// transient flush failure (and never route it to the DLQ, which would
|
|
63
|
+
// resurrect a duplicate).
|
|
64
|
+
if (/constraint failed/i.test(err.message)) {
|
|
65
|
+
storageLog.error('Write buffer rejected jobs (constraint violation, dropped)', {
|
|
66
|
+
rejectedJobCount: jobCount,
|
|
67
|
+
error: err.message,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
storageLog.error('Write buffer flush failed', {
|
|
72
|
+
jobCount,
|
|
73
|
+
error: err.message,
|
|
74
|
+
diskFull: this._diskFull,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
52
77
|
}, (jobs, lastError, attempts) => {
|
|
53
78
|
this.handleCriticalLoss(jobs, lastError, attempts);
|
|
54
79
|
});
|
|
@@ -4,13 +4,33 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Database } from 'bun:sqlite';
|
|
6
6
|
import type { Job } from '../../domain/types/job';
|
|
7
|
+
/** Outcome of a batch insert after isolating per-row failures. */
|
|
8
|
+
export interface BatchInsertResult {
|
|
9
|
+
/** Jobs that hit a transient (non-constraint) error — caller should retry. */
|
|
10
|
+
transient: Job[];
|
|
11
|
+
/** Jobs rejected by a permanent constraint (e.g. duplicate id) — drop, never retry. */
|
|
12
|
+
conflicts: Job[];
|
|
13
|
+
/** The originating error from the fast-path failure, for logging. */
|
|
14
|
+
error?: Error;
|
|
15
|
+
}
|
|
7
16
|
/** Batch insert manager with prepared statement caching */
|
|
8
17
|
export declare class BatchInsertManager {
|
|
9
18
|
private readonly db;
|
|
10
19
|
private readonly cache;
|
|
11
20
|
constructor(db: Database);
|
|
12
|
-
/**
|
|
13
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Insert a batch of jobs using a single multi-row INSERT (50-100x speedup).
|
|
23
|
+
* On the common path every row succeeds and an empty result is returned.
|
|
24
|
+
*
|
|
25
|
+
* If the atomic batch fails (e.g. a single duplicate `jobs.id`), the rows are
|
|
26
|
+
* re-inserted ONE AT A TIME so a single bad row can no longer drop the rest:
|
|
27
|
+
* valid jobs persist, constraint violations are isolated as `conflicts` (drop,
|
|
28
|
+
* never retry — they would poison every future flush), and any transient
|
|
29
|
+
* failures are returned so the caller can retry just those. Never throws.
|
|
30
|
+
*/
|
|
31
|
+
insertJobsBatch(jobs: Job[]): BatchInsertResult;
|
|
32
|
+
/** Fallback path: insert each job independently, isolating per-row failures. */
|
|
33
|
+
private insertRowByRow;
|
|
14
34
|
/** Get or create cached prepared statement for batch insert */
|
|
15
35
|
private getBatchInsertStmt;
|
|
16
36
|
/** Insert a chunk of jobs with single multi-row INSERT */
|
|
@@ -52,7 +72,16 @@ export declare class WriteBuffer {
|
|
|
52
72
|
add(job: Job): void;
|
|
53
73
|
/** Add multiple jobs to buffer */
|
|
54
74
|
addBatch(jobs: Job[]): void;
|
|
55
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Flush buffer to disk using double-buffering. Returns number of jobs persisted.
|
|
77
|
+
*
|
|
78
|
+
* Per-row isolation (see BatchInsertManager.insertJobsBatch): valid jobs are
|
|
79
|
+
* persisted even if a sibling row violates a constraint. Constraint conflicts
|
|
80
|
+
* (e.g. duplicate id) are dropped+reported and NEVER re-buffered — re-buffering
|
|
81
|
+
* them would poison every future flush and silently drop unrelated valid jobs
|
|
82
|
+
* (the #92-class data-loss bug). Transient failures are re-buffered and retried
|
|
83
|
+
* with exponential backoff, exactly as before.
|
|
84
|
+
*/
|
|
56
85
|
flush(): number;
|
|
57
86
|
/** Schedule a retry with exponential backoff */
|
|
58
87
|
private scheduleBackoffRetry;
|
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
* High-performance batch insert with prepared statement caching
|
|
4
4
|
*/
|
|
5
5
|
import { pack } from './sqliteSerializer';
|
|
6
|
+
const COLS_PER_ROW = 24;
|
|
7
|
+
// SQLite has a limit of ~999 variables, so batch in chunks
|
|
8
|
+
const MAX_ROWS_PER_INSERT = Math.floor(999 / COLS_PER_ROW);
|
|
9
|
+
/**
|
|
10
|
+
* A constraint violation (e.g. a duplicate `jobs.id` PRIMARY KEY) is PERMANENT
|
|
11
|
+
* for that row — retrying never succeeds. It must be isolated from the rest of
|
|
12
|
+
* the batch, otherwise one bad row poisons the whole atomic flush and drops
|
|
13
|
+
* unrelated valid jobs (data loss, #92-class). Everything else (disk I/O, busy,
|
|
14
|
+
* full) is treated as transient and retried.
|
|
15
|
+
*/
|
|
16
|
+
function isConstraintError(err) {
|
|
17
|
+
const code = err.code ?? '';
|
|
18
|
+
return code.startsWith('SQLITE_CONSTRAINT') || /constraint failed/i.test(err.message);
|
|
19
|
+
}
|
|
6
20
|
/** Batch insert manager with prepared statement caching */
|
|
7
21
|
export class BatchInsertManager {
|
|
8
22
|
db;
|
|
@@ -10,20 +24,52 @@ export class BatchInsertManager {
|
|
|
10
24
|
constructor(db) {
|
|
11
25
|
this.db = db;
|
|
12
26
|
}
|
|
13
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Insert a batch of jobs using a single multi-row INSERT (50-100x speedup).
|
|
29
|
+
* On the common path every row succeeds and an empty result is returned.
|
|
30
|
+
*
|
|
31
|
+
* If the atomic batch fails (e.g. a single duplicate `jobs.id`), the rows are
|
|
32
|
+
* re-inserted ONE AT A TIME so a single bad row can no longer drop the rest:
|
|
33
|
+
* valid jobs persist, constraint violations are isolated as `conflicts` (drop,
|
|
34
|
+
* never retry — they would poison every future flush), and any transient
|
|
35
|
+
* failures are returned so the caller can retry just those. Never throws.
|
|
36
|
+
*/
|
|
14
37
|
insertJobsBatch(jobs) {
|
|
15
38
|
if (jobs.length === 0)
|
|
16
|
-
return;
|
|
39
|
+
return { transient: [], conflicts: [] };
|
|
17
40
|
const now = Date.now();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
try {
|
|
42
|
+
this.db.transaction(() => {
|
|
43
|
+
for (let offset = 0; offset < jobs.length; offset += MAX_ROWS_PER_INSERT) {
|
|
44
|
+
const chunk = jobs.slice(offset, offset + MAX_ROWS_PER_INSERT);
|
|
45
|
+
this.insertJobsChunk(chunk, now);
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return { transient: [], conflicts: [] };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const batchError = err instanceof Error ? err : new Error(String(err));
|
|
52
|
+
return this.insertRowByRow(jobs, now, batchError);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Fallback path: insert each job independently, isolating per-row failures. */
|
|
56
|
+
insertRowByRow(jobs, now, batchError) {
|
|
57
|
+
const transient = [];
|
|
58
|
+
const conflicts = [];
|
|
59
|
+
for (const job of jobs) {
|
|
60
|
+
try {
|
|
61
|
+
// Single-row INSERT, auto-committed: succeeds/fails independently.
|
|
62
|
+
this.insertJobsChunk([job], now);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
66
|
+
if (isConstraintError(err))
|
|
67
|
+
conflicts.push(job);
|
|
68
|
+
else
|
|
69
|
+
transient.push(job);
|
|
25
70
|
}
|
|
26
|
-
}
|
|
71
|
+
}
|
|
72
|
+
return { transient, conflicts, error: batchError };
|
|
27
73
|
}
|
|
28
74
|
/** Get or create cached prepared statement for batch insert */
|
|
29
75
|
getBatchInsertStmt(size) {
|
|
@@ -112,7 +158,16 @@ export class WriteBuffer {
|
|
|
112
158
|
this.flush();
|
|
113
159
|
}
|
|
114
160
|
}
|
|
115
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Flush buffer to disk using double-buffering. Returns number of jobs persisted.
|
|
163
|
+
*
|
|
164
|
+
* Per-row isolation (see BatchInsertManager.insertJobsBatch): valid jobs are
|
|
165
|
+
* persisted even if a sibling row violates a constraint. Constraint conflicts
|
|
166
|
+
* (e.g. duplicate id) are dropped+reported and NEVER re-buffered — re-buffering
|
|
167
|
+
* them would poison every future flush and silently drop unrelated valid jobs
|
|
168
|
+
* (the #92-class data-loss bug). Transient failures are re-buffered and retried
|
|
169
|
+
* with exponential backoff, exactly as before.
|
|
170
|
+
*/
|
|
116
171
|
flush() {
|
|
117
172
|
// Prevent flush after stop or concurrent flushes
|
|
118
173
|
if (this.stopped || this.flushing)
|
|
@@ -125,53 +180,68 @@ export class WriteBuffer {
|
|
|
125
180
|
this.activeBuffer = [];
|
|
126
181
|
const jobCount = this.flushBuffer.length;
|
|
127
182
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
183
|
+
// BatchInsertManager.insertJobsBatch isolates per-row failures and never
|
|
184
|
+
// throws. Stay defensive: a manager that throws (or returns nothing) is
|
|
185
|
+
// treated as a transient failure of the whole batch, preserving the
|
|
186
|
+
// re-buffer/retry/critical-loss semantics.
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive against a non-conforming batch manager (e.g. a test double returning undefined)
|
|
190
|
+
result = this.batchManager.insertJobsBatch(this.flushBuffer) ?? {
|
|
191
|
+
transient: [],
|
|
192
|
+
conflicts: [],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
result = {
|
|
197
|
+
transient: this.flushBuffer,
|
|
198
|
+
conflicts: [],
|
|
199
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const { transient, conflicts, error } = result;
|
|
143
203
|
this.flushBuffer = [];
|
|
144
|
-
//
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
// Permanent constraint conflicts: drop + report once, never retry.
|
|
205
|
+
if (conflicts.length > 0) {
|
|
206
|
+
this.onError(error ?? new Error('Constraint violation'), conflicts.length);
|
|
207
|
+
}
|
|
208
|
+
// Transient failures: re-buffer just those and retry with backoff.
|
|
209
|
+
if (transient.length > 0) {
|
|
210
|
+
const error2 = error ?? new Error('Write buffer flush failed');
|
|
211
|
+
this.lastError = error2;
|
|
212
|
+
this.retryCount++;
|
|
213
|
+
// Prepend failed jobs back to active buffer (failed first, then new).
|
|
214
|
+
this.activeBuffer = transient.concat(this.activeBuffer);
|
|
215
|
+
if (this.retryCount >= this.maxRetries) {
|
|
216
|
+
const lostJobs = [...this.activeBuffer];
|
|
217
|
+
this.activeBuffer = [];
|
|
218
|
+
if (this.onCriticalError)
|
|
219
|
+
this.onCriticalError(lostJobs, error2, this.retryCount);
|
|
220
|
+
this.onError(error2, lostJobs.length, {
|
|
221
|
+
retryCount: this.retryCount,
|
|
222
|
+
nextBackoffMs: 0,
|
|
223
|
+
maxRetries: this.maxRetries,
|
|
224
|
+
});
|
|
225
|
+
this.retryCount = 0;
|
|
226
|
+
this.currentBackoffMs = this.initialBackoffMs;
|
|
227
|
+
this.lastError = null;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const nextBackoffMs = Math.min(this.currentBackoffMs * 2, this.maxBackoffMs);
|
|
231
|
+
this.onError(error2, transient.length, {
|
|
232
|
+
retryCount: this.retryCount,
|
|
233
|
+
nextBackoffMs,
|
|
234
|
+
maxRetries: this.maxRetries,
|
|
235
|
+
});
|
|
236
|
+
this.scheduleBackoffRetry();
|
|
151
237
|
}
|
|
152
|
-
|
|
153
|
-
this.onError(error, lostJobs.length, {
|
|
154
|
-
retryCount: this.retryCount,
|
|
155
|
-
nextBackoffMs: 0, // No more retries
|
|
156
|
-
maxRetries: this.maxRetries,
|
|
157
|
-
});
|
|
158
|
-
// Reset retry state
|
|
159
|
-
this.retryCount = 0;
|
|
160
|
-
this.currentBackoffMs = this.initialBackoffMs;
|
|
161
|
-
this.lastError = null;
|
|
162
|
-
throw err;
|
|
238
|
+
return jobCount - transient.length - conflicts.length;
|
|
163
239
|
}
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
|
|
169
|
-
nextBackoffMs: nextBackoffMs,
|
|
170
|
-
maxRetries: this.maxRetries,
|
|
171
|
-
});
|
|
172
|
-
// Schedule backoff retry
|
|
173
|
-
this.scheduleBackoffRetry();
|
|
174
|
-
throw err;
|
|
240
|
+
// Success (or success-modulo-dropped-conflicts): reset retry state.
|
|
241
|
+
this.retryCount = 0;
|
|
242
|
+
this.currentBackoffMs = this.initialBackoffMs;
|
|
243
|
+
this.lastError = null;
|
|
244
|
+
return jobCount - conflicts.length;
|
|
175
245
|
}
|
|
176
246
|
finally {
|
|
177
247
|
this.flushing = false;
|
|
@@ -287,6 +357,10 @@ export class WriteBuffer {
|
|
|
287
357
|
const flushed = this.flush();
|
|
288
358
|
clearTimeout(timeout);
|
|
289
359
|
this.stopped = true;
|
|
360
|
+
// flush() no longer throws on transient failure (it re-buffers); report
|
|
361
|
+
// anything that could not be persisted so it isn't silently lost.
|
|
362
|
+
if (this.pendingCount > 0)
|
|
363
|
+
this.reportLostJobs();
|
|
290
364
|
resolve(flushed);
|
|
291
365
|
}
|
|
292
366
|
catch {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import * as resp from '../../../domain/types/response';
|
|
7
7
|
import { throughputTracker } from '../../../application/throughputTracker';
|
|
8
8
|
import { latencyTracker } from '../../../application/latencyTracker';
|
|
9
|
+
import { pausedView } from '../../../shared/pausedView';
|
|
9
10
|
/** Dashboard overview - single call for all dashboard data */
|
|
10
11
|
export function handleDashboardOverview(_cmd, ctx, reqId) {
|
|
11
12
|
const stats = ctx.queueManager.getStats();
|
|
@@ -92,8 +93,17 @@ export function handleDashboardQueues(_cmd, ctx, reqId) {
|
|
|
92
93
|
/** Dashboard single queue detail */
|
|
93
94
|
export function handleDashboardQueue(cmd, ctx, reqId) {
|
|
94
95
|
const queue = cmd.queue;
|
|
95
|
-
const
|
|
96
|
+
const rawCounts = ctx.queueManager.getQueueJobCounts(queue);
|
|
96
97
|
const paused = ctx.queueManager.isPaused(queue);
|
|
98
|
+
// Keep the counts consistent with the per-state lists below (#92): a paused
|
|
99
|
+
// queue reports ready jobs under `paused`, not `waiting`/`prioritized`.
|
|
100
|
+
const pv = pausedView(rawCounts.waiting, rawCounts.prioritized, paused);
|
|
101
|
+
const counts = {
|
|
102
|
+
...rawCounts,
|
|
103
|
+
waiting: pv.waiting,
|
|
104
|
+
prioritized: pv.prioritized,
|
|
105
|
+
paused: pv.paused,
|
|
106
|
+
};
|
|
97
107
|
const dlqJobs = ctx.queueManager.getDlq(queue, 10);
|
|
98
108
|
const priorityCounts = ctx.queueManager.getCountsPerPriority(queue);
|
|
99
109
|
const result = {
|
|
@@ -111,13 +121,19 @@ export function handleDashboardQueue(cmd, ctx, reqId) {
|
|
|
111
121
|
};
|
|
112
122
|
if (cmd.includeJobs) {
|
|
113
123
|
const limit = Math.min(cmd.jobsLimit ?? 10, 50);
|
|
124
|
+
// When paused, ready jobs surface under `paused` (waiting/prioritized are
|
|
125
|
+
// empty) — match the counts above (#92).
|
|
114
126
|
const waiting = ctx.queueManager.getJobs(queue, { state: 'waiting', end: limit });
|
|
115
127
|
const active = ctx.queueManager.getJobs(queue, { state: 'active', end: limit });
|
|
116
128
|
const delayed = ctx.queueManager.getJobs(queue, { state: 'delayed', end: limit });
|
|
129
|
+
const pausedJobs = paused
|
|
130
|
+
? ctx.queueManager.getJobs(queue, { state: 'paused', end: limit })
|
|
131
|
+
: [];
|
|
117
132
|
result.jobs = {
|
|
118
133
|
waiting: waiting.map(jobSummary),
|
|
119
134
|
active: active.map(jobSummary),
|
|
120
135
|
delayed: delayed.map(jobSummary),
|
|
136
|
+
paused: pausedJobs.map(jobSummary),
|
|
121
137
|
};
|
|
122
138
|
}
|
|
123
139
|
return resp.data(result, reqId);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as resp from '../../../domain/types/response';
|
|
6
6
|
import { jobId } from '../../../domain/types/job';
|
|
7
|
+
import { pausedView } from '../../../shared/pausedView';
|
|
7
8
|
/** Handle GetJob command */
|
|
8
9
|
export async function handleGetJob(cmd, ctx, reqId) {
|
|
9
10
|
const jid = jobId(cmd.id);
|
|
@@ -28,15 +29,18 @@ export function handleGetJobCounts(cmd, ctx, reqId) {
|
|
|
28
29
|
// Get queue-specific counts
|
|
29
30
|
const counts = ctx.queueManager.getQueueJobCounts(cmd.queue);
|
|
30
31
|
const isPaused = ctx.queueManager.isPaused(cmd.queue);
|
|
32
|
+
// When paused, ready jobs (waiting + prioritized) are reported under `paused` —
|
|
33
|
+
// not in their own buckets, which would double-count (#92, BullMQ semantics).
|
|
34
|
+
const pv = pausedView(counts.waiting, counts.prioritized, isPaused);
|
|
31
35
|
return resp.counts({
|
|
32
|
-
waiting:
|
|
33
|
-
prioritized:
|
|
36
|
+
waiting: pv.waiting,
|
|
37
|
+
prioritized: pv.prioritized,
|
|
34
38
|
delayed: counts.delayed,
|
|
35
39
|
active: counts.active,
|
|
36
40
|
completed: counts.completed,
|
|
37
41
|
failed: counts.failed,
|
|
38
42
|
'waiting-children': counts['waiting-children'],
|
|
39
|
-
paused:
|
|
43
|
+
paused: pv.paused,
|
|
40
44
|
}, reqId);
|
|
41
45
|
}
|
|
42
46
|
/** Handle GetCountsPerPriority command */
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { VERSION } from '../../shared/version';
|
|
5
5
|
import { throughputTracker } from '../../application/throughputTracker';
|
|
6
6
|
import { latencyTracker } from '../../application/latencyTracker';
|
|
7
|
+
import { pausedView } from '../../shared/pausedView';
|
|
7
8
|
/** JSON response helper */
|
|
8
9
|
export function jsonResponse(data, status = 200, corsOrigins) {
|
|
9
10
|
const headers = {
|
|
@@ -256,8 +257,17 @@ export function dashboardQueuesEndpoint(queueManager, limit, offset, corsOrigins
|
|
|
256
257
|
}
|
|
257
258
|
/** Dashboard single queue detail endpoint */
|
|
258
259
|
export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, corsOrigins) {
|
|
259
|
-
const
|
|
260
|
+
const rawCounts = queueManager.getQueueJobCounts(queue);
|
|
260
261
|
const paused = queueManager.isPaused(queue);
|
|
262
|
+
// Keep counts consistent with the per-state lists below (#92): a paused queue
|
|
263
|
+
// reports ready jobs under `paused`, not `waiting`/`prioritized`.
|
|
264
|
+
const pv = pausedView(rawCounts.waiting, rawCounts.prioritized, paused);
|
|
265
|
+
const counts = {
|
|
266
|
+
...rawCounts,
|
|
267
|
+
waiting: pv.waiting,
|
|
268
|
+
prioritized: pv.prioritized,
|
|
269
|
+
paused: pv.paused,
|
|
270
|
+
};
|
|
261
271
|
const dlqJobs = queueManager.getDlq(queue, 10);
|
|
262
272
|
const priorityCounts = queueManager.getCountsPerPriority(queue);
|
|
263
273
|
const result = {
|
|
@@ -275,9 +285,12 @@ export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, c
|
|
|
275
285
|
timestamp: Date.now(),
|
|
276
286
|
};
|
|
277
287
|
if (includeJobs) {
|
|
288
|
+
// When paused, ready jobs surface under `paused` (waiting is empty) — match
|
|
289
|
+
// the counts above (#92).
|
|
278
290
|
const waiting = queueManager.getJobs(queue, { state: 'waiting', end: 10 });
|
|
279
291
|
const active = queueManager.getJobs(queue, { state: 'active', end: 10 });
|
|
280
292
|
const delayed = queueManager.getJobs(queue, { state: 'delayed', end: 10 });
|
|
293
|
+
const pausedJobs = paused ? queueManager.getJobs(queue, { state: 'paused', end: 10 }) : [];
|
|
281
294
|
const toSummary = (j) => ({
|
|
282
295
|
id: j.id,
|
|
283
296
|
priority: j.priority,
|
|
@@ -290,6 +303,7 @@ export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, c
|
|
|
290
303
|
waiting: waiting.map(toSummary),
|
|
291
304
|
active: active.map(toSummary),
|
|
292
305
|
delayed: delayed.map(toSummary),
|
|
306
|
+
paused: pausedJobs.map(toSummary),
|
|
293
307
|
};
|
|
294
308
|
}
|
|
295
309
|
return jsonResponse(result, 200, corsOrigins);
|
package/dist/mcp/index.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-only runtime guard (defense-in-depth for the bundled path).
|
|
3
|
+
*
|
|
4
|
+
* The `"node"` export condition (-> bun-only.ts) already fails fast for a plain
|
|
5
|
+
* `node` import and for node-target bundlers. This guard covers the remaining
|
|
6
|
+
* case: a browser/neutral-target bundler that inlines the real client, whose
|
|
7
|
+
* output is then run on Node. Without it, the failure surfaces deep inside as a
|
|
8
|
+
* cryptic `Bun is not defined` / `Bun.connect is not a function`.
|
|
9
|
+
*
|
|
10
|
+
* Imported FIRST by the client entrypoints so it evaluates before any module
|
|
11
|
+
* that touches `Bun.*` at top level. No-op under Bun. Keep relative-import-free.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-only runtime guard (defense-in-depth for the bundled path).
|
|
3
|
+
*
|
|
4
|
+
* The `"node"` export condition (-> bun-only.ts) already fails fast for a plain
|
|
5
|
+
* `node` import and for node-target bundlers. This guard covers the remaining
|
|
6
|
+
* case: a browser/neutral-target bundler that inlines the real client, whose
|
|
7
|
+
* output is then run on Node. Without it, the failure surfaces deep inside as a
|
|
8
|
+
* cryptic `Bun is not defined` / `Bun.connect is not a function`.
|
|
9
|
+
*
|
|
10
|
+
* Imported FIRST by the client entrypoints so it evaluates before any module
|
|
11
|
+
* that touches `Bun.*` at top level. No-op under Bun. Keep relative-import-free.
|
|
12
|
+
*/
|
|
13
|
+
if (typeof globalThis.Bun === 'undefined') {
|
|
14
|
+
throw new Error('bunqueue is Bun-only and requires the Bun runtime (https://bun.sh). ' +
|
|
15
|
+
'Node.js is not supported: install Bun and run your program with `bun`.');
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BullMQ paused-view counts (#92).
|
|
3
|
+
*
|
|
4
|
+
* When a queue is paused, none of its ready jobs (waiting + prioritized) can be
|
|
5
|
+
* pulled, so they are reported under `paused` — and NOT in their own buckets, to
|
|
6
|
+
* avoid double-counting a single job. This mirrors the per-state lists, where a
|
|
7
|
+
* paused queue lists those jobs under `paused` and returns nothing for `waiting`
|
|
8
|
+
* / `prioritized`. Delayed and active jobs keep their own state.
|
|
9
|
+
*
|
|
10
|
+
* Single source of truth shared by every count surface that exposes the BullMQ
|
|
11
|
+
* `getJobCounts` shape (client SDK, TCP handler, dashboard detail), so they can
|
|
12
|
+
* never drift apart.
|
|
13
|
+
*/
|
|
14
|
+
export interface PausedDerivedCounts {
|
|
15
|
+
waiting: number;
|
|
16
|
+
prioritized: number;
|
|
17
|
+
paused: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function pausedView(waiting: number, prioritized: number, isPaused: boolean): PausedDerivedCounts;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.5",
|
|
4
4
|
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -12,22 +12,32 @@
|
|
|
12
12
|
"exports": {
|
|
13
13
|
".": {
|
|
14
14
|
"types": "./dist/main.d.ts",
|
|
15
|
+
"bun": "./dist/main.js",
|
|
16
|
+
"node": "./dist/bun-only.js",
|
|
15
17
|
"import": "./dist/main.js"
|
|
16
18
|
},
|
|
17
19
|
"./client": {
|
|
18
20
|
"types": "./dist/client/index.d.ts",
|
|
21
|
+
"bun": "./dist/client/index.js",
|
|
22
|
+
"node": "./dist/bun-only.js",
|
|
19
23
|
"import": "./dist/client/index.js"
|
|
20
24
|
},
|
|
21
25
|
"./queue": {
|
|
22
26
|
"types": "./dist/application/queueManager.d.ts",
|
|
27
|
+
"bun": "./dist/application/queueManager.js",
|
|
28
|
+
"node": "./dist/bun-only.js",
|
|
23
29
|
"import": "./dist/application/queueManager.js"
|
|
24
30
|
},
|
|
25
31
|
"./mcp": {
|
|
26
32
|
"types": "./dist/mcp/index.d.ts",
|
|
33
|
+
"bun": "./dist/mcp/index.js",
|
|
34
|
+
"node": "./dist/bun-only.js",
|
|
27
35
|
"import": "./dist/mcp/index.js"
|
|
28
36
|
},
|
|
29
37
|
"./workflow": {
|
|
30
38
|
"types": "./dist/client/workflow/index.d.ts",
|
|
39
|
+
"bun": "./dist/client/workflow/index.js",
|
|
40
|
+
"node": "./dist/bun-only.js",
|
|
31
41
|
"import": "./dist/client/workflow/index.js"
|
|
32
42
|
},
|
|
33
43
|
"./package.json": "./package.json"
|
|
@@ -137,7 +147,6 @@
|
|
|
137
147
|
},
|
|
138
148
|
"homepage": "https://bunqueue.dev",
|
|
139
149
|
"engines": {
|
|
140
|
-
"node": ">=18.0.0",
|
|
141
150
|
"bun": ">=1.3.9"
|
|
142
151
|
}
|
|
143
152
|
}
|