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.
Files changed (64) hide show
  1. package/dist/application/backgroundTasks.js +9 -0
  2. package/dist/application/cleanupTasks.js +31 -11
  3. package/dist/application/contextFactory.d.ts +3 -0
  4. package/dist/application/contextFactory.js +6 -0
  5. package/dist/application/dependencyResultTracker.d.ts +17 -0
  6. package/dist/application/dependencyResultTracker.js +71 -0
  7. package/dist/application/operations/ack.d.ts +2 -0
  8. package/dist/application/operations/ack.js +6 -0
  9. package/dist/application/operations/ackHelpers.d.ts +2 -1
  10. package/dist/application/operations/ackHelpers.js +6 -0
  11. package/dist/application/operations/jobManagement.d.ts +2 -0
  12. package/dist/application/operations/jobManagement.js +10 -4
  13. package/dist/application/operations/jobStateTransitions.js +1 -0
  14. package/dist/application/operations/pull.d.ts +2 -2
  15. package/dist/application/operations/pull.js +43 -10
  16. package/dist/application/operations/push.d.ts +2 -0
  17. package/dist/application/operations/pushInsert.d.ts +4 -0
  18. package/dist/application/operations/pushInsert.js +6 -0
  19. package/dist/application/operations/queryOperations.d.ts +2 -0
  20. package/dist/application/operations/queryOperations.js +5 -1
  21. package/dist/application/operations/queueControl.d.ts +2 -0
  22. package/dist/application/operations/queueControl.js +4 -0
  23. package/dist/application/queueManager.d.ts +5 -4
  24. package/dist/application/queueManager.js +37 -11
  25. package/dist/application/types.d.ts +2 -0
  26. package/dist/cli/client.js +6 -47
  27. package/dist/cli/commandRegistry.d.ts +27 -0
  28. package/dist/cli/commandRegistry.js +40 -0
  29. package/dist/cli/commandRouter.d.ts +3 -0
  30. package/dist/cli/commandRouter.js +44 -0
  31. package/dist/cli/commands/backup.js +1 -1
  32. package/dist/cli/commands/core.js +54 -2
  33. package/dist/cli/commands/doctor.d.ts +50 -5
  34. package/dist/cli/commands/doctor.js +125 -108
  35. package/dist/cli/commands/types.js +5 -1
  36. package/dist/cli/globalOptions.d.ts +16 -0
  37. package/dist/cli/globalOptions.js +192 -0
  38. package/dist/cli/help.d.ts +9 -0
  39. package/dist/cli/help.js +42 -21
  40. package/dist/cli/index.d.ts +1 -25
  41. package/dist/cli/index.js +84 -348
  42. package/dist/cli/localOutput.d.ts +22 -0
  43. package/dist/cli/localOutput.js +45 -0
  44. package/dist/client/queue/dlqOps.js +1 -3
  45. package/dist/client/tcp/client.js +10 -8
  46. package/dist/domain/queue/shard.d.ts +2 -2
  47. package/dist/domain/queue/shard.js +3 -3
  48. package/dist/domain/queue/waiterManager.d.ts +2 -2
  49. package/dist/domain/queue/waiterManager.js +26 -5
  50. package/dist/infrastructure/cloud/wsSender.js +4 -4
  51. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  52. package/dist/infrastructure/persistence/schema.js +7 -2
  53. package/dist/infrastructure/persistence/sqlite.d.ts +5 -1
  54. package/dist/infrastructure/persistence/sqlite.js +10 -2
  55. package/dist/infrastructure/persistence/sqliteSerializer.js +1 -1
  56. package/dist/infrastructure/persistence/statements.d.ts +2 -1
  57. package/dist/infrastructure/persistence/statements.js +3 -2
  58. package/dist/infrastructure/server/handlers/core.js +4 -4
  59. package/dist/infrastructure/server/tcp.d.ts +1 -0
  60. package/dist/infrastructure/server/tcp.js +23 -12
  61. package/dist/infrastructure/server/types.d.ts +2 -0
  62. package/dist/shared/msgpack.d.ts +3 -0
  63. package/dist/shared/msgpack.js +70 -0
  64. 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, maybeTimeout) {
20
+ waitForJob(queueOrTimeout, maybeTimeoutOrSignal, maybeSignal) {
21
21
  const queue = typeof queueOrTimeout === 'string' ? queueOrTimeout : DEFAULT_QUEUE;
22
- const timeoutMs = typeof queueOrTimeout === 'number' ? queueOrTimeout : (maybeTimeout ?? 0);
23
- if (timeoutMs <= 0)
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
- waiter.timer = setTimeout(() => {
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
- }, timeoutMs);
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(pack(data)));
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 unpack(Buffer.from(await Bun.zstdDecompress(buf)));
149
+ return decodeMessagePack(Buffer.from(await Bun.zstdDecompress(buf)));
150
150
  }
151
- return unpack(buf);
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 = 21;
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 = 21;
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(pack(response));
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(pack({ ok: false, error: message, reqId }));
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 = unpack(frame);
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(pack(message));
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.
@@ -9,4 +9,6 @@ export interface HandlerContext {
9
9
  authenticated: boolean;
10
10
  /** Client ID for job ownership tracking */
11
11
  clientId?: string;
12
+ /** Aborted when the transport disconnects, cancelling connection-scoped waits. */
13
+ signal?: AbortSignal;
12
14
  }
@@ -0,0 +1,3 @@
1
+ export declare function encodeMessagePack(value: unknown): Uint8Array;
2
+ /** Decode maps through safe own properties, preserving keys such as `__proto__`. */
3
+ export declare function decodeMessagePack<T = unknown>(buffer: Uint8Array): T;
@@ -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.38",
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",