bunqueue 2.8.38 → 2.8.40
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/dist/application/backgroundTasks.js +9 -0
- package/dist/application/cleanupTasks.js +31 -11
- package/dist/application/contextFactory.d.ts +3 -0
- package/dist/application/contextFactory.js +6 -0
- package/dist/application/dependencyResultTracker.d.ts +17 -0
- package/dist/application/dependencyResultTracker.js +71 -0
- package/dist/application/operations/ack.d.ts +2 -0
- package/dist/application/operations/ack.js +6 -0
- package/dist/application/operations/ackHelpers.d.ts +2 -1
- package/dist/application/operations/ackHelpers.js +6 -0
- package/dist/application/operations/jobManagement.d.ts +2 -0
- package/dist/application/operations/jobManagement.js +10 -4
- package/dist/application/operations/jobStateTransitions.js +1 -0
- package/dist/application/operations/pull.d.ts +2 -2
- package/dist/application/operations/pull.js +43 -10
- package/dist/application/operations/push.d.ts +2 -0
- package/dist/application/operations/pushInsert.d.ts +4 -0
- package/dist/application/operations/pushInsert.js +6 -0
- package/dist/application/operations/queryOperations.d.ts +2 -0
- package/dist/application/operations/queryOperations.js +5 -1
- package/dist/application/operations/queueControl.d.ts +2 -0
- package/dist/application/operations/queueControl.js +4 -0
- package/dist/application/queueManager.d.ts +5 -4
- package/dist/application/queueManager.js +37 -11
- package/dist/application/types.d.ts +2 -0
- package/dist/cli/client.js +6 -47
- package/dist/cli/commandRegistry.d.ts +27 -0
- package/dist/cli/commandRegistry.js +40 -0
- package/dist/cli/commandRouter.d.ts +3 -0
- package/dist/cli/commandRouter.js +44 -0
- package/dist/cli/commands/backup.js +1 -1
- package/dist/cli/commands/core.js +54 -2
- package/dist/cli/commands/doctor.d.ts +50 -5
- package/dist/cli/commands/doctor.js +125 -108
- package/dist/cli/commands/types.js +5 -1
- package/dist/cli/globalOptions.d.ts +16 -0
- package/dist/cli/globalOptions.js +192 -0
- package/dist/cli/help.d.ts +9 -0
- package/dist/cli/help.js +42 -21
- package/dist/cli/index.d.ts +1 -25
- package/dist/cli/index.js +84 -348
- package/dist/cli/localOutput.d.ts +22 -0
- package/dist/cli/localOutput.js +45 -0
- package/dist/client/queue/dlqOps.js +1 -3
- package/dist/client/tcp/client.js +10 -8
- package/dist/domain/queue/shard.d.ts +2 -2
- package/dist/domain/queue/shard.js +3 -3
- package/dist/domain/queue/waiterManager.d.ts +2 -2
- package/dist/domain/queue/waiterManager.js +26 -5
- package/dist/infrastructure/cloud/wsSender.js +4 -4
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +7 -2
- package/dist/infrastructure/persistence/sqlite.d.ts +5 -1
- package/dist/infrastructure/persistence/sqlite.js +10 -2
- package/dist/infrastructure/persistence/sqliteSerializer.js +1 -1
- package/dist/infrastructure/persistence/statements.d.ts +2 -1
- package/dist/infrastructure/persistence/statements.js +3 -2
- package/dist/infrastructure/server/handlers/core.js +4 -4
- package/dist/infrastructure/server/tcp.d.ts +1 -0
- package/dist/infrastructure/server/tcp.js +23 -12
- package/dist/infrastructure/server/types.d.ts +2 -0
- package/dist/shared/msgpack.d.ts +3 -0
- package/dist/shared/msgpack.js +70 -0
- package/package.json +1 -1
|
@@ -17,10 +17,17 @@ export class WaiterManager {
|
|
|
17
17
|
return;
|
|
18
18
|
this.notifyQueue(queue, count);
|
|
19
19
|
}
|
|
20
|
-
waitForJob(queueOrTimeout,
|
|
20
|
+
waitForJob(queueOrTimeout, maybeTimeoutOrSignal, maybeSignal) {
|
|
21
21
|
const queue = typeof queueOrTimeout === 'string' ? queueOrTimeout : DEFAULT_QUEUE;
|
|
22
|
-
const timeoutMs = typeof queueOrTimeout === 'number'
|
|
23
|
-
|
|
22
|
+
const timeoutMs = typeof queueOrTimeout === 'number'
|
|
23
|
+
? queueOrTimeout
|
|
24
|
+
: typeof maybeTimeoutOrSignal === 'number'
|
|
25
|
+
? maybeTimeoutOrSignal
|
|
26
|
+
: 0;
|
|
27
|
+
const signal = typeof queueOrTimeout === 'number'
|
|
28
|
+
? maybeTimeoutOrSignal
|
|
29
|
+
: maybeSignal;
|
|
30
|
+
if (timeoutMs <= 0 || signal?.aborted)
|
|
24
31
|
return Promise.resolve();
|
|
25
32
|
const state = this.queues.get(queue);
|
|
26
33
|
if (state?.pending) {
|
|
@@ -34,19 +41,30 @@ export class WaiterManager {
|
|
|
34
41
|
resolve,
|
|
35
42
|
cancelled: false,
|
|
36
43
|
timer: undefined,
|
|
44
|
+
signal,
|
|
45
|
+
onAbort: undefined,
|
|
37
46
|
};
|
|
38
|
-
|
|
47
|
+
const settle = () => {
|
|
39
48
|
if (waiter.cancelled)
|
|
40
49
|
return;
|
|
41
50
|
waiter.cancelled = true;
|
|
51
|
+
clearTimeout(waiter.timer);
|
|
52
|
+
if (waiter.signal && waiter.onAbort) {
|
|
53
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
54
|
+
}
|
|
42
55
|
queueState.active--;
|
|
43
56
|
this.activeWaiters--;
|
|
44
57
|
resolve();
|
|
45
58
|
this.compact(queue, queueState);
|
|
46
|
-
}
|
|
59
|
+
};
|
|
60
|
+
waiter.timer = setTimeout(settle, timeoutMs);
|
|
61
|
+
waiter.onAbort = settle;
|
|
62
|
+
signal?.addEventListener('abort', settle, { once: true });
|
|
47
63
|
queueState.entries.push(waiter);
|
|
48
64
|
queueState.active++;
|
|
49
65
|
this.activeWaiters++;
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
settle();
|
|
50
68
|
});
|
|
51
69
|
}
|
|
52
70
|
notifyQueue(queue, count) {
|
|
@@ -62,6 +80,9 @@ export class WaiterManager {
|
|
|
62
80
|
continue;
|
|
63
81
|
waiter.cancelled = true;
|
|
64
82
|
clearTimeout(waiter.timer);
|
|
83
|
+
if (waiter.signal && waiter.onAbort) {
|
|
84
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
85
|
+
}
|
|
65
86
|
state.active--;
|
|
66
87
|
this.activeWaiters--;
|
|
67
88
|
remaining--;
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - Commands only: all data goes via HTTP, WS is for remote commands
|
|
9
9
|
* - Keepalive: server sends ping every 25s, bunqueue responds pong
|
|
10
10
|
*/
|
|
11
|
-
import { pack, unpack } from 'msgpackr';
|
|
12
11
|
import { cloudLog } from './logger';
|
|
12
|
+
import { decodeMessagePack, encodeMessagePack } from '../../shared/msgpack';
|
|
13
13
|
export class WsSender {
|
|
14
14
|
config;
|
|
15
15
|
instanceId;
|
|
@@ -124,7 +124,7 @@ export class WsSender {
|
|
|
124
124
|
}
|
|
125
125
|
/** Encode and send as zstd(msgpack) binary frame */
|
|
126
126
|
async sendBinary(ws, data) {
|
|
127
|
-
ws.send(await Bun.zstdCompress(
|
|
127
|
+
ws.send(await Bun.zstdCompress(encodeMessagePack(data)));
|
|
128
128
|
}
|
|
129
129
|
/** Decode incoming message — supports zstd+msgpack, plain msgpack, and JSON (text) */
|
|
130
130
|
decodeMessage(data) {
|
|
@@ -146,9 +146,9 @@ export class WsSender {
|
|
|
146
146
|
buf[1] === 0xb5 &&
|
|
147
147
|
buf[2] === 0x2f &&
|
|
148
148
|
buf[3] === 0xfd) {
|
|
149
|
-
return
|
|
149
|
+
return decodeMessagePack(Buffer.from(await Bun.zstdDecompress(buf)));
|
|
150
150
|
}
|
|
151
|
-
return
|
|
151
|
+
return decodeMessagePack(buf);
|
|
152
152
|
}
|
|
153
153
|
/** Clean up socket handlers and close */
|
|
154
154
|
cleanup() {
|
|
@@ -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 stall_count INTEGER NOT NULL DEFAULT 0,\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 rate_limit_duration INTEGER,\n rate_limit_expires_at INTEGER,\n stall_enabled INTEGER,\n stall_interval INTEGER,\n max_stalls INTEGER,\n stall_grace_period 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 stall_count INTEGER NOT NULL DEFAULT 0,\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 rate_limit_duration INTEGER,\n rate_limit_expires_at INTEGER,\n stall_enabled INTEGER,\n stall_interval INTEGER,\n max_stalls INTEGER,\n stall_grace_period INTEGER,\n dlq_config BLOB\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 = 22;
|
|
12
12
|
/** All migrations in order */
|
|
13
13
|
export declare const MIGRATIONS: Record<number, string>;
|
|
@@ -134,7 +134,8 @@ CREATE TABLE IF NOT EXISTS queue_state (
|
|
|
134
134
|
stall_enabled INTEGER,
|
|
135
135
|
stall_interval INTEGER,
|
|
136
136
|
max_stalls INTEGER,
|
|
137
|
-
stall_grace_period INTEGER
|
|
137
|
+
stall_grace_period INTEGER,
|
|
138
|
+
dlq_config BLOB
|
|
138
139
|
);
|
|
139
140
|
`;
|
|
140
141
|
/** Migration version table */
|
|
@@ -145,7 +146,7 @@ CREATE TABLE IF NOT EXISTS migrations (
|
|
|
145
146
|
);
|
|
146
147
|
`;
|
|
147
148
|
/** Current schema version */
|
|
148
|
-
export const SCHEMA_VERSION =
|
|
149
|
+
export const SCHEMA_VERSION = 22;
|
|
149
150
|
/** All migrations in order */
|
|
150
151
|
export const MIGRATIONS = {
|
|
151
152
|
1: SCHEMA,
|
|
@@ -235,5 +236,9 @@ ALTER TABLE queue_state ADD COLUMN max_stalls INTEGER;
|
|
|
235
236
|
`,
|
|
236
237
|
21: `
|
|
237
238
|
ALTER TABLE queue_state ADD COLUMN stall_grace_period INTEGER;
|
|
239
|
+
`,
|
|
240
|
+
// Migration 22: Persist the complete per-queue DLQ policy atomically.
|
|
241
|
+
22: `
|
|
242
|
+
ALTER TABLE queue_state ADD COLUMN dlq_config BLOB;
|
|
238
243
|
`,
|
|
239
244
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { Job, JobId, JobTimelineEntry } from '../../domain/types/job';
|
|
7
7
|
import type { StallConfig } from '../../domain/types/stall';
|
|
8
8
|
import type { CronJob } from '../../domain/types/cron';
|
|
9
|
-
import { type DlqEntry } from '../../domain/types/dlq';
|
|
9
|
+
import { type DlqConfig, type DlqEntry } from '../../domain/types/dlq';
|
|
10
10
|
/** Critical-loss callback: invoked when WriteBuffer drops jobs after exhausting retries. */
|
|
11
11
|
export type SqliteCriticalLossCallback = (jobs: Job[], lastError: Error, attempts: number) => void;
|
|
12
12
|
/** Record of a critical job loss event (jobs dropped after max retries). */
|
|
@@ -116,6 +116,8 @@ export declare class SqliteStorage {
|
|
|
116
116
|
updateJobData(jobId: JobId, data: unknown): void;
|
|
117
117
|
/** Persist priority ordering, including its LIFO tie-break, across recovery. */
|
|
118
118
|
updateJobPriority(jobId: JobId, priority: number, lifo: boolean): void;
|
|
119
|
+
/** Persist progress and its heartbeat as one active-job mutation. */
|
|
120
|
+
updateJobProgress(jobId: JobId, progress: number, message: string | null, lastHeartbeat: number): void;
|
|
119
121
|
/**
|
|
120
122
|
* Persist a delay change (moveToDelayed / changeDelay) so the new `run_at`
|
|
121
123
|
* survives a restart. Without this the delay lives only in the in-memory heap:
|
|
@@ -220,6 +222,7 @@ export declare class SqliteStorage {
|
|
|
220
222
|
rateLimitDuration?: number | null;
|
|
221
223
|
rateLimitExpiresAt?: number | null;
|
|
222
224
|
stallConfig?: StallConfig | null;
|
|
225
|
+
dlqConfig?: DlqConfig | null;
|
|
223
226
|
}): void;
|
|
224
227
|
/** Load all persisted queue control-state rows (used by recover() on boot). */
|
|
225
228
|
loadQueueState(): Array<{
|
|
@@ -230,6 +233,7 @@ export declare class SqliteStorage {
|
|
|
230
233
|
rateLimitDuration: number | null;
|
|
231
234
|
rateLimitExpiresAt: number | null;
|
|
232
235
|
stallConfig: StallConfig | null;
|
|
236
|
+
dlqConfig: DlqConfig | null;
|
|
233
237
|
}>;
|
|
234
238
|
/** Drop a queue's persisted control-state (e.g. on obliterate). */
|
|
235
239
|
deleteQueueState(name: string): void;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses MessagePack for ~2-3x faster serialization than JSON
|
|
5
5
|
*/
|
|
6
6
|
import { Database } from 'bun:sqlite';
|
|
7
|
-
import { createDlqEntry } from '../../domain/types/dlq';
|
|
7
|
+
import { createDlqEntry, } from '../../domain/types/dlq';
|
|
8
8
|
import { PRAGMA_SETTINGS, SCHEMA, MIGRATION_TABLE, SCHEMA_VERSION, MIGRATIONS } from './schema';
|
|
9
9
|
import { prepareStatements, } from './statements';
|
|
10
10
|
import { pack, persistedStallCount, reconstructDlqEntry, rowToJob, unpack, } from './sqliteSerializer';
|
|
@@ -403,6 +403,13 @@ export class SqliteStorage {
|
|
|
403
403
|
.run(priority, lifo ? 1 : 0, jobId);
|
|
404
404
|
});
|
|
405
405
|
}
|
|
406
|
+
/** Persist progress and its heartbeat as one active-job mutation. */
|
|
407
|
+
updateJobProgress(jobId, progress, message, lastHeartbeat) {
|
|
408
|
+
this.flushIfBuffered(jobId);
|
|
409
|
+
this.safeWrite(() => {
|
|
410
|
+
this.statements.get('updateJobProgress').run(progress, message, lastHeartbeat, jobId);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
406
413
|
/**
|
|
407
414
|
* Persist a delay change (moveToDelayed / changeDelay) so the new `run_at`
|
|
408
415
|
* survives a restart. Without this the delay lives only in the in-memory heap:
|
|
@@ -697,7 +704,7 @@ export class SqliteStorage {
|
|
|
697
704
|
? null
|
|
698
705
|
: state.stallConfig.enabled
|
|
699
706
|
? 1
|
|
700
|
-
: 0, state.stallConfig?.stallInterval ?? null, state.stallConfig?.maxStalls ?? null, state.stallConfig?.gracePeriod ?? null);
|
|
707
|
+
: 0, state.stallConfig?.stallInterval ?? null, state.stallConfig?.maxStalls ?? null, state.stallConfig?.gracePeriod ?? null, state.dlqConfig ? pack(state.dlqConfig) : null);
|
|
701
708
|
});
|
|
702
709
|
}
|
|
703
710
|
/** Load all persisted queue control-state rows (used by recover() on boot). */
|
|
@@ -721,6 +728,7 @@ export class SqliteStorage {
|
|
|
721
728
|
maxStalls: row.max_stalls,
|
|
722
729
|
gracePeriod: row.stall_grace_period,
|
|
723
730
|
},
|
|
731
|
+
dlqConfig: unpack(row.dlq_config, null, `loadQueueDlqConfig:${row.name}`),
|
|
724
732
|
}));
|
|
725
733
|
}
|
|
726
734
|
/** Drop a queue's persisted control-state (e.g. on obliterate). */
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* MessagePack encoding/decoding and row conversion
|
|
4
4
|
* Uses msgpackr for 2-3x faster serialization
|
|
5
5
|
*/
|
|
6
|
-
import { pack as msgpackEncode, unpack as msgpackDecode } from 'msgpackr';
|
|
7
6
|
import { jobId } from '../../domain/types/job';
|
|
8
7
|
import { storageLog } from '../../shared/logger';
|
|
8
|
+
import { decodeMessagePack as msgpackDecode, encodeMessagePack as msgpackEncode, } from '../../shared/msgpack';
|
|
9
9
|
/** Encode data to MessagePack buffer */
|
|
10
10
|
export function pack(data) {
|
|
11
11
|
return msgpackEncode(data);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Database } from 'bun:sqlite';
|
|
6
6
|
/** Statement names */
|
|
7
|
-
export type StatementName = 'insertJob' | 'updateJobState' | 'completeJob' | 'deleteJob' | 'deleteJobResult' | 'getJob' | 'insertResult' | 'getResult' | 'insertDlq' | 'loadDlq' | 'deleteDlqEntry' | 'deleteDlqEntryForQueue' | 'clearDlqQueue' | 'insertCron' | 'updateCron' | 'upsertQueueState' | 'loadQueueState' | 'deleteQueueState';
|
|
7
|
+
export type StatementName = 'insertJob' | 'updateJobState' | 'completeJob' | 'updateJobProgress' | 'deleteJob' | 'deleteJobResult' | 'getJob' | 'insertResult' | 'getResult' | 'insertDlq' | 'loadDlq' | 'deleteDlqEntry' | 'deleteDlqEntryForQueue' | 'clearDlqQueue' | 'insertCron' | 'updateCron' | 'upsertQueueState' | 'loadQueueState' | 'deleteQueueState';
|
|
8
8
|
/** SQL statements */
|
|
9
9
|
export declare const SQL_STATEMENTS: Record<StatementName, string>;
|
|
10
10
|
/** Prepare all statements */
|
|
@@ -74,4 +74,5 @@ export interface DbQueueState {
|
|
|
74
74
|
stall_interval: number | null;
|
|
75
75
|
max_stalls: number | null;
|
|
76
76
|
stall_grace_period: number | null;
|
|
77
|
+
dlq_config: Uint8Array | null;
|
|
77
78
|
}
|
|
@@ -42,6 +42,7 @@ export const SQL_STATEMENTS = {
|
|
|
42
42
|
`,
|
|
43
43
|
updateJobState: 'UPDATE jobs SET state = ?, started_at = ?, timeline = ? WHERE id = ?',
|
|
44
44
|
completeJob: 'UPDATE jobs SET state = ?, completed_at = ?, progress = 100, timeline = ? WHERE id = ?',
|
|
45
|
+
updateJobProgress: 'UPDATE jobs SET progress = ?, progress_msg = ?, last_heartbeat = ? WHERE id = ?',
|
|
45
46
|
deleteJob: 'DELETE FROM jobs WHERE id = ?',
|
|
46
47
|
deleteJobResult: 'DELETE FROM job_results WHERE job_id = ?',
|
|
47
48
|
getJob: 'SELECT * FROM jobs WHERE id = ?',
|
|
@@ -59,8 +60,8 @@ export const SQL_STATEMENTS = {
|
|
|
59
60
|
`,
|
|
60
61
|
updateCron: 'UPDATE cron_jobs SET executions = ?, next_run = ? WHERE name = ?',
|
|
61
62
|
// Queue control-state persistence (#100): paused / rate-limit / concurrency.
|
|
62
|
-
upsertQueueState: 'INSERT OR REPLACE INTO queue_state (name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at, stall_enabled, stall_interval, max_stalls, stall_grace_period) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
63
|
-
loadQueueState: 'SELECT name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at, stall_enabled, stall_interval, max_stalls, stall_grace_period FROM queue_state',
|
|
63
|
+
upsertQueueState: 'INSERT OR REPLACE INTO queue_state (name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at, stall_enabled, stall_interval, max_stalls, stall_grace_period, dlq_config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
64
|
+
loadQueueState: 'SELECT name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at, stall_enabled, stall_interval, max_stalls, stall_grace_period, dlq_config FROM queue_state',
|
|
64
65
|
deleteQueueState: 'DELETE FROM queue_state WHERE name = ?',
|
|
65
66
|
};
|
|
66
67
|
/** Prepare all statements */
|
|
@@ -106,7 +106,7 @@ export async function handlePull(cmd, ctx, reqId) {
|
|
|
106
106
|
return resp.error(timeoutError, reqId);
|
|
107
107
|
// If owner is provided, use lock-based pull
|
|
108
108
|
if (cmd.owner) {
|
|
109
|
-
const { job, token } = await ctx.queueManager.pullWithLock(cmd.queue, cmd.owner, cmd.timeout, cmd.lockTtl);
|
|
109
|
+
const { job, token } = await ctx.queueManager.pullWithLock(cmd.queue, cmd.owner, cmd.timeout, cmd.lockTtl, ctx.signal);
|
|
110
110
|
// Register job with client for connection-based release
|
|
111
111
|
if (job && ctx.clientId) {
|
|
112
112
|
ctx.queueManager.registerClientJob(ctx.clientId, job.id);
|
|
@@ -114,7 +114,7 @@ export async function handlePull(cmd, ctx, reqId) {
|
|
|
114
114
|
return resp.pulledJob(job, token, reqId);
|
|
115
115
|
}
|
|
116
116
|
// Standard pull (no lock, but still track for client release unless detached)
|
|
117
|
-
const job = await ctx.queueManager.pull(cmd.queue, cmd.timeout);
|
|
117
|
+
const job = await ctx.queueManager.pull(cmd.queue, cmd.timeout, ctx.signal);
|
|
118
118
|
if (job && ctx.clientId && !cmd.detach) {
|
|
119
119
|
ctx.queueManager.registerClientJob(ctx.clientId, job.id);
|
|
120
120
|
}
|
|
@@ -135,7 +135,7 @@ export async function handlePullBatch(cmd, ctx, reqId) {
|
|
|
135
135
|
return resp.error(timeoutError, reqId);
|
|
136
136
|
// If owner is provided, use lock-based pull
|
|
137
137
|
if (cmd.owner) {
|
|
138
|
-
const { jobs, tokens } = await ctx.queueManager.pullBatchWithLock(cmd.queue, cmd.count, cmd.owner, cmd.timeout ?? 0, cmd.lockTtl);
|
|
138
|
+
const { jobs, tokens } = await ctx.queueManager.pullBatchWithLock(cmd.queue, cmd.count, cmd.owner, cmd.timeout ?? 0, cmd.lockTtl, ctx.signal);
|
|
139
139
|
// Register all jobs with client for connection-based release
|
|
140
140
|
if (ctx.clientId) {
|
|
141
141
|
for (const job of jobs) {
|
|
@@ -146,7 +146,7 @@ export async function handlePullBatch(cmd, ctx, reqId) {
|
|
|
146
146
|
}
|
|
147
147
|
// Standard pull (no locks, but still track for client release) — the
|
|
148
148
|
// non-owner branch honors cmd.timeout exactly like the owner branch and PULL.
|
|
149
|
-
const jobs = await ctx.queueManager.pullBatch(cmd.queue, cmd.count, cmd.timeout ?? 0);
|
|
149
|
+
const jobs = await ctx.queueManager.pullBatch(cmd.queue, cmd.count, cmd.timeout ?? 0, ctx.signal);
|
|
150
150
|
if (ctx.clientId) {
|
|
151
151
|
for (const job of jobs) {
|
|
152
152
|
ctx.queueManager.registerClientJob(ctx.clientId, job.id);
|
|
@@ -39,6 +39,7 @@ export interface TcpServerConfig {
|
|
|
39
39
|
/** Per-connection data */
|
|
40
40
|
interface ConnectionData {
|
|
41
41
|
state: ConnectionState;
|
|
42
|
+
abortController: AbortController;
|
|
42
43
|
frameParser: FrameParser;
|
|
43
44
|
ctx: HandlerContext;
|
|
44
45
|
/** Semaphore for limiting concurrent command processing (pipelining) */
|
|
@@ -8,10 +8,10 @@ import { FrameParser, FrameSizeError, createConnectionState, } from './protocol'
|
|
|
8
8
|
import { uuid } from '../../shared/hash';
|
|
9
9
|
import { tcpLog } from '../../shared/logger';
|
|
10
10
|
import { getRateLimiter } from './rateLimiter';
|
|
11
|
-
import { pack, unpack } from 'msgpackr';
|
|
12
11
|
import { Semaphore, withSemaphore } from '../../shared/semaphore';
|
|
13
12
|
import { SocketWriteQueue } from './socketWriteQueue';
|
|
14
13
|
import { loadTlsOptions } from './tls';
|
|
14
|
+
import { decodeMessagePack, encodeMessagePack } from '../../shared/msgpack';
|
|
15
15
|
/** Max concurrent commands per connection for pipelining */
|
|
16
16
|
const MAX_CONCURRENT_PER_CONNECTION = 50;
|
|
17
17
|
/**
|
|
@@ -58,11 +58,11 @@ async function releaseClientJobsWithRetry(queueManager, clientId, maxRetries = 3
|
|
|
58
58
|
}
|
|
59
59
|
/** Serialize response to framed msgpack */
|
|
60
60
|
function serializeResponse(response) {
|
|
61
|
-
return FrameParser.frame(
|
|
61
|
+
return FrameParser.frame(encodeMessagePack(response));
|
|
62
62
|
}
|
|
63
63
|
/** Error response as framed msgpack */
|
|
64
64
|
function errorResponse(message, reqId) {
|
|
65
|
-
return FrameParser.frame(
|
|
65
|
+
return FrameParser.frame(encodeMessagePack({ ok: false, error: message, reqId }));
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* Create and start TCP server
|
|
@@ -118,14 +118,17 @@ export function createTcpServer(queueManager, config) {
|
|
|
118
118
|
return;
|
|
119
119
|
const clientId = uuid();
|
|
120
120
|
const state = createConnectionState(clientId);
|
|
121
|
+
const abortController = new AbortController();
|
|
121
122
|
const ctx = {
|
|
122
123
|
queueManager,
|
|
123
124
|
authTokens,
|
|
124
125
|
authenticated: authTokens.size === 0, // Auto-auth if no tokens
|
|
125
126
|
clientId, // For job ownership tracking
|
|
127
|
+
signal: abortController.signal,
|
|
126
128
|
};
|
|
127
129
|
socket.data = {
|
|
128
130
|
state,
|
|
131
|
+
abortController,
|
|
129
132
|
frameParser: new FrameParser(),
|
|
130
133
|
ctx,
|
|
131
134
|
semaphore: new Semaphore(MAX_CONCURRENT_PER_CONNECTION),
|
|
@@ -144,12 +147,6 @@ export function createTcpServer(queueManager, config) {
|
|
|
144
147
|
initConnection(socket);
|
|
145
148
|
const { frameParser, ctx, state, semaphore, writeQueue } = socket.data;
|
|
146
149
|
const rateLimiter = getRateLimiter();
|
|
147
|
-
// Check rate limit
|
|
148
|
-
if (!rateLimiter.isAllowed(state.clientId)) {
|
|
149
|
-
ctx.queueManager.emitDashboardEvent('ratelimit:hit', { clientId: state.clientId });
|
|
150
|
-
writeQueue.write(socket, errorResponse('Rate limit exceeded'));
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
150
|
let frames;
|
|
154
151
|
try {
|
|
155
152
|
// `data` is a Bun Buffer (a Uint8Array). addData copies it into its own
|
|
@@ -191,14 +188,27 @@ export function createTcpServer(queueManager, config) {
|
|
|
191
188
|
const processFrame = async (frame) => {
|
|
192
189
|
let cmd;
|
|
193
190
|
try {
|
|
194
|
-
cmd =
|
|
191
|
+
cmd = decodeMessagePack(frame);
|
|
195
192
|
}
|
|
196
193
|
catch {
|
|
194
|
+
// Invalid complete frames still consume protocol quota. They cannot
|
|
195
|
+
// carry a trustworthy reqId because msgpack decoding failed.
|
|
196
|
+
}
|
|
197
|
+
const reqId = typeof cmd?.reqId === 'string' ? cmd.reqId : undefined;
|
|
198
|
+
if (!rateLimiter.isAllowed(state.clientId)) {
|
|
199
|
+
ctx.queueManager.emitDashboardEvent('ratelimit:hit', { clientId: state.clientId });
|
|
200
|
+
writeQueue.write(socket, errorResponse('Rate limit exceeded', reqId));
|
|
201
|
+
dropForWriteOverflow();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!cmd) {
|
|
197
205
|
writeQueue.write(socket, errorResponse('Invalid command format'));
|
|
206
|
+
dropForWriteOverflow();
|
|
198
207
|
return;
|
|
199
208
|
}
|
|
200
209
|
if (!cmd?.cmd) {
|
|
201
|
-
writeQueue.write(socket, errorResponse('Invalid command'));
|
|
210
|
+
writeQueue.write(socket, errorResponse('Invalid command', reqId));
|
|
211
|
+
dropForWriteOverflow();
|
|
202
212
|
return;
|
|
203
213
|
}
|
|
204
214
|
// Process with concurrency limit
|
|
@@ -224,6 +234,7 @@ export function createTcpServer(queueManager, config) {
|
|
|
224
234
|
// (e.g. TLS handshake aborted) — nothing to release. #108
|
|
225
235
|
if (!socket.data)
|
|
226
236
|
return;
|
|
237
|
+
socket.data.abortController.abort();
|
|
227
238
|
const clientId = socket.data.state.clientId;
|
|
228
239
|
// Cancel the slowloris stall timer and drop any buffered-but-unwritten
|
|
229
240
|
// bytes; the socket is gone.
|
|
@@ -288,7 +299,7 @@ export function createTcpServer(queueManager, config) {
|
|
|
288
299
|
},
|
|
289
300
|
/** Broadcast to all connections */
|
|
290
301
|
broadcast(message) {
|
|
291
|
-
const frame = FrameParser.frame(
|
|
302
|
+
const frame = FrameParser.frame(encodeMessagePack(message));
|
|
292
303
|
for (const socket of connections.values()) {
|
|
293
304
|
// Each connection writes through its own backpressure-aware queue so a
|
|
294
305
|
// slow reader buffers (in order) instead of dropping frame tail bytes.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { pack, unpack, Unpackr } from 'msgpackr';
|
|
2
|
+
const unpackr = new Unpackr({ mapsAsObjects: false, useRecords: false });
|
|
3
|
+
const PROTO_KEY_BYTES = new TextEncoder().encode('__proto__');
|
|
4
|
+
function containsProtoKey(buffer) {
|
|
5
|
+
for (let index = 0; index <= buffer.length - PROTO_KEY_BYTES.length; index++) {
|
|
6
|
+
if (buffer[index] !== PROTO_KEY_BYTES[0])
|
|
7
|
+
continue;
|
|
8
|
+
let matches = true;
|
|
9
|
+
for (let offset = 1; offset < PROTO_KEY_BYTES.length; offset++) {
|
|
10
|
+
if (buffer[index + offset] !== PROTO_KEY_BYTES[offset]) {
|
|
11
|
+
matches = false;
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (matches)
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function defineSafe(target, key, value) {
|
|
21
|
+
Object.defineProperty(target, key, {
|
|
22
|
+
value,
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
writable: true,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function materialize(value, seen) {
|
|
29
|
+
if (value === null || typeof value !== 'object')
|
|
30
|
+
return value;
|
|
31
|
+
const cached = seen.get(value);
|
|
32
|
+
if (cached !== undefined)
|
|
33
|
+
return cached;
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
const result = [];
|
|
36
|
+
seen.set(value, result);
|
|
37
|
+
for (const item of value)
|
|
38
|
+
result.push(materialize(item, seen));
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Map) {
|
|
42
|
+
const result = {};
|
|
43
|
+
seen.set(value, result);
|
|
44
|
+
for (const [key, item] of value) {
|
|
45
|
+
defineSafe(result, typeof key === 'symbol' ? key : String(key), materialize(item, seen));
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
if (value instanceof Date ||
|
|
50
|
+
value instanceof RegExp ||
|
|
51
|
+
value instanceof ArrayBuffer ||
|
|
52
|
+
ArrayBuffer.isView(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
const result = {};
|
|
56
|
+
seen.set(value, result);
|
|
57
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
58
|
+
defineSafe(result, key, materialize(Reflect.get(value, key), seen));
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export function encodeMessagePack(value) {
|
|
63
|
+
return pack(value);
|
|
64
|
+
}
|
|
65
|
+
/** Decode maps through safe own properties, preserving keys such as `__proto__`. */
|
|
66
|
+
export function decodeMessagePack(buffer) {
|
|
67
|
+
if (!containsProtoKey(buffer))
|
|
68
|
+
return unpack(buffer);
|
|
69
|
+
return materialize(unpackr.unpack(buffer), new WeakMap());
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.40",
|
|
4
4
|
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external infrastructure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|