bunqueue 2.8.32 → 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 +4 -3
- 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 +2 -2
- 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/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/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
|
@@ -1,69 +1,100 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WaiterManager - Manages job availability notifications
|
|
3
|
-
* Handles worker polling with timeout-based waiting
|
|
2
|
+
* WaiterManager - Manages queue-scoped job availability notifications.
|
|
3
|
+
* Handles worker polling with timeout-based waiting.
|
|
4
4
|
*/
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
const DEFAULT_QUEUE = '';
|
|
6
|
+
const COMPACT_MIN_HEAD = 1024;
|
|
7
7
|
export class WaiterManager {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/** Notify that multiple jobs are available - wakes up to `count` waiters */
|
|
13
|
-
notifyBatch(count) {
|
|
14
|
-
for (let i = 0; i < count; i++) {
|
|
15
|
-
this.notify();
|
|
16
|
-
}
|
|
8
|
+
queues = new Map();
|
|
9
|
+
activeWaiters = 0;
|
|
10
|
+
notify(queue) {
|
|
11
|
+
this.notifyQueue(queue ?? DEFAULT_QUEUE, 1);
|
|
17
12
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// Wake the first active waiter
|
|
25
|
-
const waiter = this.waiters.shift();
|
|
26
|
-
if (waiter && !waiter.cancelled) {
|
|
27
|
-
waiter.resolve();
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
// No active waiter - increment pending counter so next waitForJob returns immediately
|
|
31
|
-
this.pendingNotifications++;
|
|
32
|
-
}
|
|
33
|
-
// Periodic full cleanup when array grows too large
|
|
34
|
-
if (this.waiters.length > WAITERS_CLEANUP_THRESHOLD) {
|
|
35
|
-
this.cleanupWaiters();
|
|
36
|
-
}
|
|
13
|
+
notifyBatch(queueOrCount, maybeCount) {
|
|
14
|
+
const queue = typeof queueOrCount === 'string' ? queueOrCount : DEFAULT_QUEUE;
|
|
15
|
+
const count = typeof queueOrCount === 'number' ? queueOrCount : (maybeCount ?? 0);
|
|
16
|
+
if (count <= 0)
|
|
17
|
+
return;
|
|
18
|
+
this.notifyQueue(queue, count);
|
|
37
19
|
}
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
waitForJob(queueOrTimeout, maybeTimeout) {
|
|
21
|
+
const queue = typeof queueOrTimeout === 'string' ? queueOrTimeout : DEFAULT_QUEUE;
|
|
22
|
+
const timeoutMs = typeof queueOrTimeout === 'number' ? queueOrTimeout : (maybeTimeout ?? 0);
|
|
40
23
|
if (timeoutMs <= 0)
|
|
41
24
|
return Promise.resolve();
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
25
|
+
const state = this.queues.get(queue);
|
|
26
|
+
if (state?.pending) {
|
|
27
|
+
state.pending = false;
|
|
28
|
+
this.deleteIdleQueue(queue, state);
|
|
45
29
|
return Promise.resolve();
|
|
46
30
|
}
|
|
47
31
|
return new Promise((resolve) => {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
32
|
+
const queueState = state ?? this.createQueue(queue);
|
|
33
|
+
const waiter = {
|
|
34
|
+
resolve,
|
|
35
|
+
cancelled: false,
|
|
36
|
+
timer: undefined,
|
|
37
|
+
};
|
|
38
|
+
waiter.timer = setTimeout(() => {
|
|
50
39
|
if (waiter.cancelled)
|
|
51
40
|
return;
|
|
52
41
|
waiter.cancelled = true;
|
|
42
|
+
queueState.active--;
|
|
43
|
+
this.activeWaiters--;
|
|
53
44
|
resolve();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
this.compact(queue, queueState);
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
queueState.entries.push(waiter);
|
|
48
|
+
queueState.active++;
|
|
49
|
+
this.activeWaiters++;
|
|
57
50
|
});
|
|
58
51
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
notifyQueue(queue, count) {
|
|
53
|
+
const state = this.queues.get(queue);
|
|
54
|
+
if (!state) {
|
|
55
|
+
this.createQueue(queue).pending = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let remaining = count;
|
|
59
|
+
while (remaining > 0 && state.active > 0) {
|
|
60
|
+
const waiter = state.entries[state.head++];
|
|
61
|
+
if (!waiter || waiter.cancelled)
|
|
62
|
+
continue;
|
|
63
|
+
waiter.cancelled = true;
|
|
64
|
+
clearTimeout(waiter.timer);
|
|
65
|
+
state.active--;
|
|
66
|
+
this.activeWaiters--;
|
|
67
|
+
remaining--;
|
|
68
|
+
waiter.resolve();
|
|
69
|
+
}
|
|
70
|
+
// Availability is edge-triggered: surplus notifications coalesce into one
|
|
71
|
+
// retry hint instead of accumulating an unbounded notification debt.
|
|
72
|
+
if (remaining > 0)
|
|
73
|
+
state.pending = true;
|
|
74
|
+
this.compact(queue, state);
|
|
75
|
+
}
|
|
76
|
+
createQueue(queue) {
|
|
77
|
+
const state = { entries: [], head: 0, active: 0, pending: false };
|
|
78
|
+
this.queues.set(queue, state);
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
compact(queue, state) {
|
|
82
|
+
if (state.active === 0) {
|
|
83
|
+
state.entries = [];
|
|
84
|
+
state.head = 0;
|
|
85
|
+
this.deleteIdleQueue(queue, state);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (state.head >= COMPACT_MIN_HEAD && state.head * 2 >= state.entries.length) {
|
|
89
|
+
state.entries = state.entries.slice(state.head);
|
|
90
|
+
state.head = 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
deleteIdleQueue(queue, state) {
|
|
94
|
+
if (state.active === 0 && !state.pending)
|
|
95
|
+
this.queues.delete(queue);
|
|
64
96
|
}
|
|
65
|
-
/** Current number of waiters */
|
|
66
97
|
get length() {
|
|
67
|
-
return this.
|
|
98
|
+
return this.activeWaiters;
|
|
68
99
|
}
|
|
69
100
|
}
|
|
@@ -311,8 +311,11 @@ export const COMMANDS = {
|
|
|
311
311
|
// --- Queue list ---
|
|
312
312
|
'queue:list': (qm) => {
|
|
313
313
|
const queueNames = qm.listQueues();
|
|
314
|
-
const
|
|
315
|
-
|
|
314
|
+
const countsByQueue = qm.getAllQueueJobCounts();
|
|
315
|
+
const queues = queueNames.flatMap((name) => {
|
|
316
|
+
const counts = countsByQueue.get(name);
|
|
317
|
+
if (!counts)
|
|
318
|
+
return [];
|
|
316
319
|
return {
|
|
317
320
|
name,
|
|
318
321
|
waiting: counts.waiting,
|
|
@@ -85,8 +85,11 @@ export async function collectSnapshot(params) {
|
|
|
85
85
|
const storage = queueManager.getStorageStatus();
|
|
86
86
|
const mem = process.memoryUsage();
|
|
87
87
|
const queueNames = queueManager.listQueues();
|
|
88
|
-
const
|
|
89
|
-
|
|
88
|
+
const countsByQueue = queueManager.getAllQueueJobCounts();
|
|
89
|
+
const queues = queueNames.flatMap((name) => {
|
|
90
|
+
const counts = countsByQueue.get(name);
|
|
91
|
+
if (!counts)
|
|
92
|
+
return [];
|
|
90
93
|
return {
|
|
91
94
|
name,
|
|
92
95
|
waiting: counts.waiting,
|
|
@@ -26,8 +26,11 @@ export function buildStatsRefresh(qm) {
|
|
|
26
26
|
const mem = process.memoryUsage();
|
|
27
27
|
const workerStats = qm.workerManager.getStats();
|
|
28
28
|
const queueNames = qm.listQueues();
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const countsByQueue = qm.getAllQueueJobCounts();
|
|
30
|
+
const queues = queueNames.flatMap((name) => {
|
|
31
|
+
const counts = countsByQueue.get(name);
|
|
32
|
+
if (!counts)
|
|
33
|
+
return [];
|
|
31
34
|
return {
|
|
32
35
|
name,
|
|
33
36
|
waiting: counts.waiting,
|
|
@@ -11,8 +11,11 @@ export function buildStatsUpdate(qm) {
|
|
|
11
11
|
const s = qm.getStats();
|
|
12
12
|
const rates = throughputTracker.getRates();
|
|
13
13
|
const queueNames = qm.listQueues();
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
const countsByQueue = qm.getAllQueueJobCounts();
|
|
15
|
+
const queues = queueNames.flatMap((name) => {
|
|
16
|
+
const c = countsByQueue.get(name);
|
|
17
|
+
if (!c)
|
|
18
|
+
return [];
|
|
16
19
|
return {
|
|
17
20
|
name,
|
|
18
21
|
waiting: c.waiting,
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
/** SQLite PRAGMA settings for optimal performance */
|
|
5
5
|
export declare const PRAGMA_SETTINGS = "\nPRAGMA journal_mode = WAL;\nPRAGMA synchronous = NORMAL;\nPRAGMA cache_size = -64000;\nPRAGMA temp_store = MEMORY;\nPRAGMA mmap_size = 268435456;\nPRAGMA page_size = 4096;\nPRAGMA busy_timeout = 5000;\n";
|
|
6
6
|
/** Main schema creation */
|
|
7
|
-
export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
|
|
7
|
+
export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\n-- Stable createdAt/id pagination for unfiltered and logical-state queue views\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_created\n ON jobs(queue, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created\n ON jobs(queue, state, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
|
|
8
8
|
/** Migration version table */
|
|
9
9
|
export declare const MIGRATION_TABLE = "\nCREATE TABLE IF NOT EXISTS migrations (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n";
|
|
10
10
|
/** Current schema version */
|
|
11
|
-
export declare const SCHEMA_VERSION =
|
|
11
|
+
export declare const SCHEMA_VERSION = 14;
|
|
12
12
|
/** All migrations in order */
|
|
13
13
|
export declare const MIGRATIONS: Record<number, string>;
|
|
@@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
51
51
|
-- Indexes for common queries
|
|
52
52
|
CREATE INDEX IF NOT EXISTS idx_jobs_queue_state
|
|
53
53
|
ON jobs(queue, state);
|
|
54
|
+
-- Stable createdAt/id pagination for unfiltered and logical-state queue views
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_created
|
|
56
|
+
ON jobs(queue, created_at, id);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created
|
|
58
|
+
ON jobs(queue, state, created_at, id);
|
|
54
59
|
CREATE INDEX IF NOT EXISTS idx_jobs_run_at
|
|
55
60
|
ON jobs(run_at) WHERE state IN ('waiting', 'delayed');
|
|
56
61
|
CREATE INDEX IF NOT EXISTS idx_jobs_unique
|
|
@@ -133,7 +138,7 @@ CREATE TABLE IF NOT EXISTS migrations (
|
|
|
133
138
|
);
|
|
134
139
|
`;
|
|
135
140
|
/** Current schema version */
|
|
136
|
-
export const SCHEMA_VERSION =
|
|
141
|
+
export const SCHEMA_VERSION = 14;
|
|
137
142
|
/** All migrations in order */
|
|
138
143
|
export const MIGRATIONS = {
|
|
139
144
|
1: SCHEMA,
|
|
@@ -187,5 +192,12 @@ ALTER TABLE cron_jobs ADD COLUMN job_options BLOB;
|
|
|
187
192
|
// Migration 13: Persist the last failure's stacktrace on jobs (issue #74)
|
|
188
193
|
13: `
|
|
189
194
|
ALTER TABLE jobs ADD COLUMN stacktrace BLOB;
|
|
195
|
+
`,
|
|
196
|
+
// Migration 14: Stable, index-backed getJobs ordering and pagination
|
|
197
|
+
14: `
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_created
|
|
199
|
+
ON jobs(queue, created_at, id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created
|
|
201
|
+
ON jobs(queue, state, created_at, id);
|
|
190
202
|
`,
|
|
191
203
|
};
|
|
@@ -142,7 +142,8 @@ export declare class SqliteStorage {
|
|
|
142
142
|
insertJobsBatch(jobs: Job[], durable?: boolean): void;
|
|
143
143
|
/**
|
|
144
144
|
* Query jobs by queue with optional state filter and pagination.
|
|
145
|
-
*
|
|
145
|
+
* This is the raw persisted-state query. Logical waiting/prioritized/delayed
|
|
146
|
+
* views should use queryJobsByLogicalStates().
|
|
146
147
|
*/
|
|
147
148
|
queryJobs(queue: string, options: {
|
|
148
149
|
state?: string;
|
|
@@ -151,9 +152,24 @@ export declare class SqliteStorage {
|
|
|
151
152
|
offset: number;
|
|
152
153
|
asc: boolean;
|
|
153
154
|
}): Job[];
|
|
155
|
+
/**
|
|
156
|
+
* Query public/logical job states before applying pagination.
|
|
157
|
+
*
|
|
158
|
+
* SQLite persists pending jobs as waiting/delayed, while the public state is
|
|
159
|
+
* time-dependent: a delayed row becomes waiting or prioritized once run_at
|
|
160
|
+
* is reached. Keeping these predicates in SQL makes OFFSET count the logical
|
|
161
|
+
* result rather than the unfiltered persisted rows.
|
|
162
|
+
*/
|
|
163
|
+
queryJobsByLogicalStates(queue: string, states: string[], options: {
|
|
164
|
+
limit: number;
|
|
165
|
+
offset: number;
|
|
166
|
+
asc: boolean;
|
|
167
|
+
now: number;
|
|
168
|
+
}): Job[];
|
|
154
169
|
/**
|
|
155
170
|
* Load pending jobs with pagination for efficient recovery.
|
|
156
|
-
* Orders by priority (desc)
|
|
171
|
+
* Orders by priority (desc), run_at (asc), and id (asc) so page boundaries
|
|
172
|
+
* remain deterministic when scheduling fields compare equal.
|
|
157
173
|
* @param limit Max jobs to return (default: 10000)
|
|
158
174
|
* @param offset Skip first N jobs (default: 0)
|
|
159
175
|
*/
|
|
@@ -482,7 +482,8 @@ export class SqliteStorage {
|
|
|
482
482
|
// ============ Query Operations ============
|
|
483
483
|
/**
|
|
484
484
|
* Query jobs by queue with optional state filter and pagination.
|
|
485
|
-
*
|
|
485
|
+
* This is the raw persisted-state query. Logical waiting/prioritized/delayed
|
|
486
|
+
* views should use queryJobsByLogicalStates().
|
|
486
487
|
*/
|
|
487
488
|
queryJobs(queue, options) {
|
|
488
489
|
const order = options.asc ? 'ASC' : 'DESC';
|
|
@@ -490,30 +491,72 @@ export class SqliteStorage {
|
|
|
490
491
|
if (options.states && options.states.length > 0) {
|
|
491
492
|
const placeholders = options.states.map(() => '?').join(',');
|
|
492
493
|
rows = this.db
|
|
493
|
-
.query(`SELECT * FROM jobs WHERE queue = ? AND state IN (${placeholders}) ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
|
|
494
|
+
.query(`SELECT * FROM jobs WHERE queue = ? AND state IN (${placeholders}) ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
|
|
494
495
|
.all(queue, ...options.states, options.limit, options.offset);
|
|
495
496
|
}
|
|
496
497
|
else if (options.state) {
|
|
497
498
|
rows = this.db
|
|
498
|
-
.query(`SELECT * FROM jobs WHERE queue = ? AND state = ? ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
|
|
499
|
+
.query(`SELECT * FROM jobs WHERE queue = ? AND state = ? ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
|
|
499
500
|
.all(queue, options.state, options.limit, options.offset);
|
|
500
501
|
}
|
|
501
502
|
else {
|
|
502
503
|
rows = this.db
|
|
503
|
-
.query(`SELECT * FROM jobs WHERE queue = ? ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
|
|
504
|
+
.query(`SELECT * FROM jobs WHERE queue = ? ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
|
|
504
505
|
.all(queue, options.limit, options.offset);
|
|
505
506
|
}
|
|
506
507
|
return rows.map((row) => rowToJob(row));
|
|
507
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Query public/logical job states before applying pagination.
|
|
511
|
+
*
|
|
512
|
+
* SQLite persists pending jobs as waiting/delayed, while the public state is
|
|
513
|
+
* time-dependent: a delayed row becomes waiting or prioritized once run_at
|
|
514
|
+
* is reached. Keeping these predicates in SQL makes OFFSET count the logical
|
|
515
|
+
* result rather than the unfiltered persisted rows.
|
|
516
|
+
*/
|
|
517
|
+
queryJobsByLogicalStates(queue, states, options) {
|
|
518
|
+
const uniqueStates = Array.from(new Set(states));
|
|
519
|
+
if (uniqueStates.length === 0)
|
|
520
|
+
return [];
|
|
521
|
+
const predicates = [];
|
|
522
|
+
const predicateParams = [];
|
|
523
|
+
const requested = new Set(uniqueStates);
|
|
524
|
+
if (requested.has('waiting')) {
|
|
525
|
+
predicates.push("(state IN ('waiting', 'delayed') AND run_at <= ? AND priority <= 0)");
|
|
526
|
+
predicateParams.push(options.now);
|
|
527
|
+
}
|
|
528
|
+
if (requested.has('prioritized')) {
|
|
529
|
+
predicates.push("(state IN ('waiting', 'delayed') AND run_at <= ? AND priority > 0)");
|
|
530
|
+
predicateParams.push(options.now);
|
|
531
|
+
}
|
|
532
|
+
if (requested.has('delayed')) {
|
|
533
|
+
predicates.push("(state IN ('waiting', 'delayed') AND run_at > ?)");
|
|
534
|
+
predicateParams.push(options.now);
|
|
535
|
+
}
|
|
536
|
+
const persistedStates = uniqueStates.filter((state) => state !== 'waiting' && state !== 'prioritized' && state !== 'delayed');
|
|
537
|
+
if (persistedStates.length > 0) {
|
|
538
|
+
predicates.push(`state IN (${persistedStates.map(() => '?').join(',')})`);
|
|
539
|
+
predicateParams.push(...persistedStates);
|
|
540
|
+
}
|
|
541
|
+
const order = options.asc ? 'ASC' : 'DESC';
|
|
542
|
+
const rows = this.db
|
|
543
|
+
.query(`SELECT * FROM jobs INDEXED BY idx_jobs_queue_created
|
|
544
|
+
WHERE queue = ? AND (${predicates.join(' OR ')})
|
|
545
|
+
ORDER BY created_at ${order}, id ${order}
|
|
546
|
+
LIMIT ? OFFSET ?`)
|
|
547
|
+
.all(queue, ...predicateParams, options.limit, options.offset);
|
|
548
|
+
return rows.map((row) => rowToJob(row));
|
|
549
|
+
}
|
|
508
550
|
/**
|
|
509
551
|
* Load pending jobs with pagination for efficient recovery.
|
|
510
|
-
* Orders by priority (desc)
|
|
552
|
+
* Orders by priority (desc), run_at (asc), and id (asc) so page boundaries
|
|
553
|
+
* remain deterministic when scheduling fields compare equal.
|
|
511
554
|
* @param limit Max jobs to return (default: 10000)
|
|
512
555
|
* @param offset Skip first N jobs (default: 0)
|
|
513
556
|
*/
|
|
514
557
|
loadPendingJobs(limit = 10000, offset = 0) {
|
|
515
558
|
const rows = this.db
|
|
516
|
-
.query("SELECT * FROM jobs WHERE state IN ('waiting', 'delayed') ORDER BY priority DESC, run_at ASC LIMIT ? OFFSET ?")
|
|
559
|
+
.query("SELECT * FROM jobs WHERE state IN ('waiting', 'delayed') ORDER BY priority DESC, run_at ASC, id ASC LIMIT ? OFFSET ?")
|
|
517
560
|
.all(limit, offset);
|
|
518
561
|
return rows.map((row) => rowToJob(row));
|
|
519
562
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueueManager } from '../../application/queueManager';
|
|
2
|
+
import type { QueueJobCounts } from '../../application/queueStatsAggregator';
|
|
3
|
+
/** Coalesce high-volume job events into one queue-count aggregation per tick. */
|
|
4
|
+
export declare class QueueCountsScheduler {
|
|
5
|
+
private readonly queueManager;
|
|
6
|
+
private readonly emit;
|
|
7
|
+
private readonly pendingQueues;
|
|
8
|
+
private timer;
|
|
9
|
+
constructor(queueManager: QueueManager, emit: (queue: string, counts: QueueJobCounts) => void);
|
|
10
|
+
schedule(queue: string): void;
|
|
11
|
+
stop(): void;
|
|
12
|
+
private flush;
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const COUNTS_COALESCE_MS = 10;
|
|
2
|
+
/** Coalesce high-volume job events into one queue-count aggregation per tick. */
|
|
3
|
+
export class QueueCountsScheduler {
|
|
4
|
+
queueManager;
|
|
5
|
+
emit;
|
|
6
|
+
pendingQueues = new Set();
|
|
7
|
+
timer = null;
|
|
8
|
+
constructor(queueManager, emit) {
|
|
9
|
+
this.queueManager = queueManager;
|
|
10
|
+
this.emit = emit;
|
|
11
|
+
}
|
|
12
|
+
schedule(queue) {
|
|
13
|
+
this.pendingQueues.add(queue);
|
|
14
|
+
this.timer ??= setTimeout(() => this.flush(), COUNTS_COALESCE_MS);
|
|
15
|
+
}
|
|
16
|
+
stop() {
|
|
17
|
+
if (this.timer)
|
|
18
|
+
clearTimeout(this.timer);
|
|
19
|
+
this.timer = null;
|
|
20
|
+
this.pendingQueues.clear();
|
|
21
|
+
}
|
|
22
|
+
flush() {
|
|
23
|
+
this.timer = null;
|
|
24
|
+
if (this.pendingQueues.size === 0)
|
|
25
|
+
return;
|
|
26
|
+
const queues = Array.from(this.pendingQueues);
|
|
27
|
+
this.pendingQueues.clear();
|
|
28
|
+
const countsByQueue = this.queueManager.getQueueJobCountsBatch(queues);
|
|
29
|
+
for (const queue of queues) {
|
|
30
|
+
const counts = countsByQueue.get(queue);
|
|
31
|
+
if (counts)
|
|
32
|
+
this.emit(queue, counts);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { throughputTracker } from '../../application/throughputTracker';
|
|
15
15
|
import { uuid } from '../../shared/hash';
|
|
16
|
+
import { QueueCountsScheduler } from './queueCountsScheduler';
|
|
16
17
|
const textEncoder = new TextEncoder();
|
|
17
18
|
/** Tuning constants */
|
|
18
19
|
const MAX_CLIENTS = 1000;
|
|
@@ -45,6 +46,7 @@ export class SseHandler {
|
|
|
45
46
|
healthInterval = null;
|
|
46
47
|
storageInterval = null;
|
|
47
48
|
queueManager = null;
|
|
49
|
+
queueCountsScheduler = null;
|
|
48
50
|
eventBuffer = [];
|
|
49
51
|
/** Get client count */
|
|
50
52
|
get size() {
|
|
@@ -54,6 +56,17 @@ export class SseHandler {
|
|
|
54
56
|
/** Start heartbeat + periodic broadcasts (stats, health, storage) */
|
|
55
57
|
startBroadcasts(qm) {
|
|
56
58
|
this.queueManager = qm;
|
|
59
|
+
this.queueCountsScheduler ??= new QueueCountsScheduler(qm, (queue, counts) => {
|
|
60
|
+
this.sendTypedEvent('queue:counts', {
|
|
61
|
+
queue,
|
|
62
|
+
waiting: counts.waiting,
|
|
63
|
+
prioritized: counts.prioritized,
|
|
64
|
+
active: counts.active,
|
|
65
|
+
completed: counts.completed,
|
|
66
|
+
failed: counts.failed,
|
|
67
|
+
delayed: counts.delayed,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
57
70
|
this.heartbeatTimer ??= setInterval(() => {
|
|
58
71
|
this.sendHeartbeat();
|
|
59
72
|
}, HEARTBEAT_MS);
|
|
@@ -78,6 +91,8 @@ export class SseHandler {
|
|
|
78
91
|
}
|
|
79
92
|
/** Stop all timers */
|
|
80
93
|
stopTimers() {
|
|
94
|
+
this.queueCountsScheduler?.stop();
|
|
95
|
+
this.queueCountsScheduler = null;
|
|
81
96
|
if (this.heartbeatTimer) {
|
|
82
97
|
clearInterval(this.heartbeatTimer);
|
|
83
98
|
this.heartbeatTimer = null;
|
|
@@ -154,19 +169,9 @@ export class SseHandler {
|
|
|
154
169
|
}
|
|
155
170
|
for (const clientId of disconnected)
|
|
156
171
|
this.clients.delete(clientId);
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.sendTypedEvent('queue:counts', {
|
|
161
|
-
queue: event.queue,
|
|
162
|
-
waiting: counts.waiting,
|
|
163
|
-
prioritized: counts.prioritized,
|
|
164
|
-
active: counts.active,
|
|
165
|
-
completed: counts.completed,
|
|
166
|
-
failed: counts.failed,
|
|
167
|
-
delayed: counts.delayed,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
172
|
+
// Counts are eventually exact, but coalesced so a PUSHB does not perform
|
|
173
|
+
// one full queue scan per inserted job.
|
|
174
|
+
this.queueCountsScheduler?.schedule(event.queue);
|
|
170
175
|
}
|
|
171
176
|
// ── Dashboard / system event broadcasting ──────────────────
|
|
172
177
|
/** Emit a typed event (dashboard events: worker, queue control, DLQ, etc.) */
|
|
@@ -28,7 +28,7 @@ export declare class WsHandler {
|
|
|
28
28
|
private statsInterval;
|
|
29
29
|
private healthInterval;
|
|
30
30
|
private storageInterval;
|
|
31
|
-
private
|
|
31
|
+
private queueCountsScheduler;
|
|
32
32
|
droppedMessages: number;
|
|
33
33
|
get size(): number;
|
|
34
34
|
/** Check if a new connection can be accepted */
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { handleCommand } from './handler';
|
|
15
15
|
import { parseCommand, serializeResponse, errorResponse } from './protocol';
|
|
16
16
|
import { throughputTracker } from '../../application/throughputTracker';
|
|
17
|
+
import { QueueCountsScheduler } from './queueCountsScheduler';
|
|
17
18
|
const textDecoder = new TextDecoder();
|
|
18
19
|
/** Old eventType → new event name */
|
|
19
20
|
const EVENT_MAP = {
|
|
@@ -170,7 +171,7 @@ export class WsHandler {
|
|
|
170
171
|
statsInterval = null;
|
|
171
172
|
healthInterval = null;
|
|
172
173
|
storageInterval = null;
|
|
173
|
-
|
|
174
|
+
queueCountsScheduler = null;
|
|
174
175
|
droppedMessages = 0;
|
|
175
176
|
get size() {
|
|
176
177
|
return this.clients.size;
|
|
@@ -196,7 +197,17 @@ export class WsHandler {
|
|
|
196
197
|
}
|
|
197
198
|
/** Start periodic broadcasts */
|
|
198
199
|
startBroadcasts(qm) {
|
|
199
|
-
this.
|
|
200
|
+
this.queueCountsScheduler ??= new QueueCountsScheduler(qm, (queue, counts) => {
|
|
201
|
+
this.emit('queue:counts', {
|
|
202
|
+
queue,
|
|
203
|
+
waiting: counts.waiting,
|
|
204
|
+
prioritized: counts.prioritized,
|
|
205
|
+
active: counts.active,
|
|
206
|
+
completed: counts.completed,
|
|
207
|
+
failed: counts.failed,
|
|
208
|
+
delayed: counts.delayed,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
200
211
|
this.statsInterval ??= setInterval(() => {
|
|
201
212
|
if (this.clients.size > 0)
|
|
202
213
|
this.broadcastStats(qm);
|
|
@@ -212,6 +223,8 @@ export class WsHandler {
|
|
|
212
223
|
}
|
|
213
224
|
/** Stop periodic broadcasts */
|
|
214
225
|
stopBroadcasts() {
|
|
226
|
+
this.queueCountsScheduler?.stop();
|
|
227
|
+
this.queueCountsScheduler = null;
|
|
215
228
|
if (this.statsInterval) {
|
|
216
229
|
clearInterval(this.statsInterval);
|
|
217
230
|
this.statsInterval = null;
|
|
@@ -338,19 +351,9 @@ export class WsHandler {
|
|
|
338
351
|
}
|
|
339
352
|
for (const id of dead)
|
|
340
353
|
this.clients.delete(id);
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
this.emit('queue:counts', {
|
|
345
|
-
queue: event.queue,
|
|
346
|
-
waiting: counts.waiting,
|
|
347
|
-
prioritized: counts.prioritized,
|
|
348
|
-
active: counts.active,
|
|
349
|
-
completed: counts.completed,
|
|
350
|
-
failed: counts.failed,
|
|
351
|
-
delayed: counts.delayed,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
+
// Counts are eventually exact, but coalesced so a PUSHB does not perform
|
|
355
|
+
// one full queue scan per inserted job.
|
|
356
|
+
this.queueCountsScheduler?.schedule(event.queue);
|
|
354
357
|
}
|
|
355
358
|
// ── Connection lifecycle ─────────────────────────────────
|
|
356
359
|
onOpen(ws) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
4
|
-
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external
|
|
3
|
+
"version": "2.8.33",
|
|
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",
|
|
7
7
|
"types": "dist/main.d.ts",
|
|
@@ -146,4 +146,4 @@
|
|
|
146
146
|
"engines": {
|
|
147
147
|
"bun": ">=1.3.9"
|
|
148
148
|
}
|
|
149
|
-
}
|
|
149
|
+
}
|