bunqueue 2.8.50 → 2.8.51

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 (40) hide show
  1. package/README.md +43 -0
  2. package/dist/application/backgroundTasks.js +21 -4
  3. package/dist/application/cleanupTasks.js +7 -3
  4. package/dist/application/contextFactory.d.ts +2 -1
  5. package/dist/application/contextFactory.js +5 -0
  6. package/dist/application/dependencyCompletions.d.ts +53 -0
  7. package/dist/application/dependencyCompletions.js +123 -0
  8. package/dist/application/dependencyProcessor.d.ts +5 -0
  9. package/dist/application/dependencyProcessor.js +25 -13
  10. package/dist/application/flowFailureRecovery.d.ts +3 -0
  11. package/dist/application/flowFailureRecovery.js +3 -1
  12. package/dist/application/flowParentBackpatch.d.ts +27 -0
  13. package/dist/application/flowParentBackpatch.js +120 -0
  14. package/dist/application/operations/ack.d.ts +3 -1
  15. package/dist/application/operations/ack.js +2 -2
  16. package/dist/application/operations/ackHelpers.d.ts +5 -4
  17. package/dist/application/operations/ackHelpers.js +4 -10
  18. package/dist/application/operations/customId.d.ts +3 -0
  19. package/dist/application/operations/customId.js +10 -0
  20. package/dist/application/operations/jobManagement.d.ts +3 -0
  21. package/dist/application/operations/jobManagement.js +10 -4
  22. package/dist/application/operations/push.d.ts +3 -1
  23. package/dist/application/operations/pushInsert.d.ts +4 -1
  24. package/dist/application/operations/pushInsert.js +2 -0
  25. package/dist/application/queueManager.d.ts +2 -0
  26. package/dist/application/queueManager.js +134 -75
  27. package/dist/application/types.d.ts +3 -1
  28. package/dist/client/flowPlan.js +8 -0
  29. package/dist/client/flowTypes.d.ts +2 -2
  30. package/dist/infrastructure/persistence/dependencyCompletionSchema.d.ts +6 -0
  31. package/dist/infrastructure/persistence/dependencyCompletionSchema.js +16 -0
  32. package/dist/infrastructure/persistence/dependencyCompletionStore.d.ts +38 -0
  33. package/dist/infrastructure/persistence/dependencyCompletionStore.js +105 -0
  34. package/dist/infrastructure/persistence/schema.d.ts +2 -5
  35. package/dist/infrastructure/persistence/schema.js +6 -1
  36. package/dist/infrastructure/persistence/sqlite.d.ts +20 -1
  37. package/dist/infrastructure/persistence/sqlite.js +119 -15
  38. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +3 -0
  39. package/dist/infrastructure/persistence/sqliteSerializer.js +10 -0
  40. package/package.json +1 -1
@@ -13,6 +13,7 @@ import type { WebhookManager } from './webhookManager';
13
13
  import type { WorkerManager } from './workerManager';
14
14
  import type { MonitoringState } from './monitoringChecks';
15
15
  import type { DependencyResultTracker } from './dependencyResultTracker';
16
+ import type { DependencyCompletionTracker } from './dependencyCompletions';
16
17
  /** Queue Manager configuration */
17
18
  export interface QueueManagerConfig {
18
19
  dataPath?: string;
@@ -108,7 +109,8 @@ export interface BackgroundContext extends QueueManagerState {
108
109
  workerManager: WorkerManager;
109
110
  monitoringState: MonitoringState;
110
111
  completedJobsData: BoundedMap<JobId, Job>;
111
- depCompletions?: BoundedSet<JobId>;
112
+ depCompletions?: DependencyCompletionTracker;
113
+ maxDependencyCompletions: number;
112
114
  timedOutJobs?: BoundedSet<JobId>;
113
115
  }
114
116
  /** Context for stats operations */
@@ -36,8 +36,16 @@ function plannedId(node) {
36
36
  }
37
37
  return jobId(custom);
38
38
  }
39
+ function validateQueueDefaults(options) {
40
+ for (const defaults of Object.values(options?.queuesOptions ?? {})) {
41
+ if (defaults.jobId !== undefined) {
42
+ throw new Error('jobId cannot be a queue default');
43
+ }
44
+ }
45
+ }
39
46
  /** Compile one or more trees into a fully-resolved graph before contacting the broker. */
40
47
  export function planFlows(flows, options) {
48
+ validateQueueDefaults(options);
41
49
  const jobs = [];
42
50
  const ids = new Set();
43
51
  const seen = new WeakSet();
@@ -72,6 +72,6 @@ export interface GetFlowOpts {
72
72
  * Allows setting default job options per queue when adding flows.
73
73
  */
74
74
  export interface FlowOpts {
75
- /** Default job options per queue name */
76
- queuesOptions?: Record<string, Partial<JobOptions>>;
75
+ /** Default job options per queue name. Set `jobId` on the individual flow node. */
76
+ queuesOptions?: Record<string, Omit<Partial<JobOptions>, 'jobId'>>;
77
77
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Durable, payload-free evidence for completed jobs removed by
3
+ * `removeOnComplete`. Unreferenced rows follow the bounded FIFO retention
4
+ * window; rows owned by live dependency edges remain pinned until checkpoint.
5
+ */
6
+ export declare const DEPENDENCY_COMPLETION_SCHEMA = "\nCREATE TABLE IF NOT EXISTS dependency_completions (\n sequence INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL UNIQUE,\n queue TEXT NOT NULL,\n completed_at INTEGER NOT NULL,\n pinned INTEGER NOT NULL DEFAULT 0\n);\nCREATE INDEX IF NOT EXISTS idx_dependency_completions_queue\n ON dependency_completions(queue);\n";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Durable, payload-free evidence for completed jobs removed by
3
+ * `removeOnComplete`. Unreferenced rows follow the bounded FIFO retention
4
+ * window; rows owned by live dependency edges remain pinned until checkpoint.
5
+ */
6
+ export const DEPENDENCY_COMPLETION_SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS dependency_completions (
8
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ job_id TEXT NOT NULL UNIQUE,
10
+ queue TEXT NOT NULL,
11
+ completed_at INTEGER NOT NULL,
12
+ pinned INTEGER NOT NULL DEFAULT 0
13
+ );
14
+ CREATE INDEX IF NOT EXISTS idx_dependency_completions_queue
15
+ ON dependency_completions(queue);
16
+ `;
@@ -0,0 +1,38 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { JobId } from '../../domain/types/job';
3
+ export interface DependencyCompletionRecord {
4
+ jobId: JobId;
5
+ queue: string;
6
+ completedAt: number;
7
+ pinned: boolean;
8
+ }
9
+ /**
10
+ * SQLite operations for payload-free `removeOnComplete` completion evidence.
11
+ * The owner supplies write-error handling and buffered-job coordination.
12
+ */
13
+ export declare class DependencyCompletionStore {
14
+ private readonly db;
15
+ private readonly insert;
16
+ private readonly deleteJob;
17
+ private readonly deleteResult;
18
+ private readonly deleteFlowFailures;
19
+ private readonly pruneThrough;
20
+ private readonly maxSequence;
21
+ private readonly loadAll;
22
+ private readonly deleteOne;
23
+ private readonly pinOne;
24
+ private readonly unpinOne;
25
+ private readonly unpinAll;
26
+ private readonly loadQueue;
27
+ private readonly deleteQueue;
28
+ constructor(db: Database);
29
+ commit(record: DependencyCompletionRecord, retentionLimit: number): void;
30
+ load(): DependencyCompletionRecord[];
31
+ private loadRecords;
32
+ pin(jobIds: Iterable<JobId>): void;
33
+ unpin(jobIds: Iterable<JobId>, retentionLimit: number): DependencyCompletionRecord[];
34
+ reconcilePins(referenced: ReadonlySet<JobId>, retentionLimit: number): DependencyCompletionRecord[];
35
+ private prune;
36
+ delete(jobId: JobId): boolean;
37
+ deleteForQueue(queue: string): JobId[];
38
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SQLite operations for payload-free `removeOnComplete` completion evidence.
3
+ * The owner supplies write-error handling and buffered-job coordination.
4
+ */
5
+ export class DependencyCompletionStore {
6
+ db;
7
+ insert;
8
+ deleteJob;
9
+ deleteResult;
10
+ deleteFlowFailures;
11
+ pruneThrough;
12
+ maxSequence;
13
+ loadAll;
14
+ deleteOne;
15
+ pinOne;
16
+ unpinOne;
17
+ unpinAll;
18
+ loadQueue;
19
+ deleteQueue;
20
+ constructor(db) {
21
+ this.db = db;
22
+ this.insert = db.prepare(`INSERT INTO dependency_completions (job_id, queue, completed_at, pinned)
23
+ VALUES (?, ?, ?, ?)
24
+ ON CONFLICT(job_id) DO UPDATE SET
25
+ queue=excluded.queue,
26
+ completed_at=excluded.completed_at,
27
+ pinned=MAX(dependency_completions.pinned, excluded.pinned)
28
+ RETURNING sequence`);
29
+ this.deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?');
30
+ this.deleteResult = db.prepare('DELETE FROM job_results WHERE job_id = ?');
31
+ this.deleteFlowFailures = db.prepare('DELETE FROM flow_failures WHERE parent_id = ?');
32
+ this.pruneThrough = db.prepare('DELETE FROM dependency_completions WHERE pinned = 0 AND sequence <= ?');
33
+ this.maxSequence = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM dependency_completions');
34
+ this.loadAll = db.prepare('SELECT job_id, queue, completed_at, pinned FROM dependency_completions ORDER BY sequence');
35
+ this.deleteOne = db.prepare('DELETE FROM dependency_completions WHERE job_id = ? AND pinned = 0');
36
+ this.pinOne = db.prepare('UPDATE dependency_completions SET pinned = 1 WHERE job_id = ?');
37
+ this.unpinOne = db.prepare('UPDATE dependency_completions SET pinned = 0 WHERE job_id = ?');
38
+ this.unpinAll = db.prepare('UPDATE dependency_completions SET pinned = 0');
39
+ this.loadQueue = db.prepare('SELECT job_id FROM dependency_completions WHERE queue = ? ORDER BY sequence');
40
+ this.deleteQueue = db.prepare('DELETE FROM dependency_completions WHERE queue = ?');
41
+ }
42
+ commit(record, retentionLimit) {
43
+ const limit = Math.max(1, Math.trunc(retentionLimit));
44
+ this.db.transaction(() => {
45
+ const inserted = this.insert.get(record.jobId, record.queue, record.completedAt, record.pinned ? 1 : 0);
46
+ if (!inserted)
47
+ throw new Error(`Failed to persist completion for ${String(record.jobId)}`);
48
+ this.pruneThrough.run(inserted.sequence - limit);
49
+ this.deleteJob.run(record.jobId);
50
+ this.deleteResult.run(record.jobId);
51
+ this.deleteFlowFailures.run(record.jobId);
52
+ })();
53
+ }
54
+ load() {
55
+ return this.loadRecords();
56
+ }
57
+ loadRecords() {
58
+ const rows = this.loadAll.all();
59
+ return rows.map((row) => ({
60
+ jobId: row.job_id,
61
+ queue: row.queue,
62
+ completedAt: row.completed_at,
63
+ pinned: row.pinned === 1,
64
+ }));
65
+ }
66
+ pin(jobIds) {
67
+ this.db.transaction(() => {
68
+ for (const jobId of jobIds)
69
+ this.pinOne.run(jobId);
70
+ })();
71
+ }
72
+ unpin(jobIds, retentionLimit) {
73
+ return this.db.transaction(() => {
74
+ for (const jobId of jobIds)
75
+ this.unpinOne.run(jobId);
76
+ this.prune(retentionLimit);
77
+ return this.loadRecords();
78
+ })();
79
+ }
80
+ reconcilePins(referenced, retentionLimit) {
81
+ return this.db.transaction(() => {
82
+ this.unpinAll.run();
83
+ for (const jobId of referenced)
84
+ this.pinOne.run(jobId);
85
+ this.prune(retentionLimit);
86
+ return this.loadRecords();
87
+ })();
88
+ }
89
+ prune(retentionLimit) {
90
+ const limit = Math.max(1, Math.trunc(retentionLimit));
91
+ const latest = this.maxSequence.get();
92
+ this.pruneThrough.run(latest.sequence - limit);
93
+ }
94
+ delete(jobId) {
95
+ const result = this.deleteOne.run(jobId);
96
+ return result.changes > 0;
97
+ }
98
+ deleteForQueue(queue) {
99
+ return this.db.transaction(() => {
100
+ const rows = this.loadQueue.all(queue);
101
+ this.deleteQueue.run(queue);
102
+ return rows.map((row) => row.job_id);
103
+ })();
104
+ }
105
+ }
@@ -1,13 +1,10 @@
1
- /**
2
- * SQLite schema and migrations
3
- */
4
1
  /** SQLite PRAGMA settings for optimal performance */
5
2
  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
3
  /** 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 fail_parent_on_failure INTEGER NOT NULL DEFAULT 0,\n remove_dependency_on_failure INTEGER NOT NULL DEFAULT 0,\n continue_parent_on_failure INTEGER NOT NULL DEFAULT 0,\n ignore_dependency_on_failure INTEGER NOT NULL 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', 'prioritized', 'waiting-children', '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-- Durable outbox/state for terminal child failure propagation.\nCREATE TABLE IF NOT EXISTS flow_failures (\n parent_id TEXT NOT NULL,\n child_id TEXT NOT NULL,\n child_queue TEXT NOT NULL,\n mode TEXT NOT NULL,\n error TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n PRIMARY KEY (parent_id, child_id)\n);\nCREATE INDEX IF NOT EXISTS idx_flow_failures_parent ON flow_failures(parent_id);\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', 'prioritized', 'waiting-children', '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";
4
+ 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 fail_parent_on_failure INTEGER NOT NULL DEFAULT 0,\n remove_dependency_on_failure INTEGER NOT NULL DEFAULT 0,\n continue_parent_on_failure INTEGER NOT NULL DEFAULT 0,\n ignore_dependency_on_failure INTEGER NOT NULL 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', 'prioritized', 'waiting-children', '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-- Durable outbox/state for terminal child failure propagation.\nCREATE TABLE IF NOT EXISTS flow_failures (\n parent_id TEXT NOT NULL,\n child_id TEXT NOT NULL,\n child_queue TEXT NOT NULL,\n mode TEXT NOT NULL,\n error TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n PRIMARY KEY (parent_id, child_id)\n);\nCREATE INDEX IF NOT EXISTS idx_flow_failures_parent ON flow_failures(parent_id);\n\n\nCREATE TABLE IF NOT EXISTS dependency_completions (\n sequence INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL UNIQUE,\n queue TEXT NOT NULL,\n completed_at INTEGER NOT NULL,\n pinned INTEGER NOT NULL DEFAULT 0\n);\nCREATE INDEX IF NOT EXISTS idx_dependency_completions_queue\n ON dependency_completions(queue);\n\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', 'prioritized', 'waiting-children', '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
5
  /** Migration version table */
9
6
  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
7
  /** Current schema version */
11
- export declare const SCHEMA_VERSION = 27;
8
+ export declare const SCHEMA_VERSION = 29;
12
9
  /** All migrations in order */
13
10
  export declare const MIGRATIONS: Record<number, string>;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SQLite schema and migrations
3
3
  */
4
+ import { DEPENDENCY_COMPLETION_SCHEMA } from './dependencyCompletionSchema';
4
5
  /** SQLite PRAGMA settings for optimal performance */
5
6
  export const PRAGMA_SETTINGS = `
6
7
  PRAGMA journal_mode = WAL;
@@ -82,6 +83,8 @@ CREATE TABLE IF NOT EXISTS flow_failures (
82
83
  );
83
84
  CREATE INDEX IF NOT EXISTS idx_flow_failures_parent ON flow_failures(parent_id);
84
85
 
86
+ ${DEPENDENCY_COMPLETION_SCHEMA}
87
+
85
88
  -- Job results storage (BLOB for MessagePack)
86
89
  CREATE TABLE IF NOT EXISTS job_results (
87
90
  job_id TEXT PRIMARY KEY,
@@ -162,7 +165,7 @@ CREATE TABLE IF NOT EXISTS migrations (
162
165
  );
163
166
  `;
164
167
  /** Current schema version */
165
- export const SCHEMA_VERSION = 27;
168
+ export const SCHEMA_VERSION = 29;
166
169
  /** All migrations in order */
167
170
  export const MIGRATIONS = {
168
171
  1: SCHEMA,
@@ -290,4 +293,6 @@ DROP INDEX IF EXISTS idx_jobs_pending_priority;
290
293
  CREATE INDEX idx_jobs_pending_priority
291
294
  ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'prioritized', 'waiting-children', 'delayed');
292
295
  `,
296
+ 28: DEPENDENCY_COMPLETION_SCHEMA,
297
+ 29: 'ALTER TABLE dependency_completions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;',
293
298
  };
@@ -8,6 +8,7 @@ import type { FlowFailureRecord } from '../../domain/types/flow';
8
8
  import type { StallConfig } from '../../domain/types/stall';
9
9
  import type { CronJob } from '../../domain/types/cron';
10
10
  import { type DlqConfig, type DlqEntry } from '../../domain/types/dlq';
11
+ import { type DependencyCompletionRecord } from './dependencyCompletionStore';
11
12
  /** Critical-loss callback: invoked when WriteBuffer drops jobs after exhausting retries. */
12
13
  export type SqliteCriticalLossCallback = (jobs: Job[], lastError: Error, attempts: number) => void;
13
14
  /** Record of a critical job loss event (jobs dropped after max retries). */
@@ -38,6 +39,7 @@ export declare class SqliteStorage {
38
39
  private readonly statements;
39
40
  private readonly batchManager;
40
41
  private readonly writeBuffer;
42
+ private readonly dependencyCompletionStore;
41
43
  private _diskFull;
42
44
  private _lastDiskFullError;
43
45
  private _lastDiskFullAt;
@@ -137,6 +139,15 @@ export declare class SqliteStorage {
137
139
  updateJobChildrenIds(jobId: JobId, childrenIds: JobId[]): void;
138
140
  /** Persist both sides of a legacy parent link in one SQLite transaction. */
139
141
  updateFlowLink(child: Pick<Job, 'id' | 'parentId' | 'data'>, parent: Pick<Job, 'id' | 'childrenIds' | 'dependsOn' | 'data'>, parentState: 'waiting-children' | 'waiting' | 'prioritized' | 'delayed'): void;
142
+ /**
143
+ * Persist a legacy SDK parent-id backpatch without changing parent state.
144
+ *
145
+ * A fast child can finish before the SDK sends UpdateParent. Completed jobs
146
+ * remain in `jobs`, while failed jobs live only in the serialized DLQ entry.
147
+ * The failure outbox is re-keyed in the same transaction so a crash between
148
+ * this write and in-memory propagation still targets the real parent.
149
+ */
150
+ backpatchFlowChild(child: Pick<Job, 'id' | 'parentId' | 'data'>, previousParentId: JobId | null): void;
140
151
  /** Persist both sides of a removed parent/child relationship atomically. */
141
152
  removeFlowLink(child: Pick<Job, 'id' | 'parentId' | 'data'>, parent: Pick<Job, 'id' | 'childrenIds' | 'dependsOn' | 'data' | 'runAt'>, parentState: 'waiting-children' | 'waiting' | 'prioritized'): void;
142
153
  /** Record terminal child propagation before applying its parent-side effect. */
@@ -144,7 +155,7 @@ export declare class SqliteStorage {
144
155
  loadFlowFailures(): FlowFailureRecord[];
145
156
  deleteFlowFailure(parentId: JobId, childId?: JobId): void;
146
157
  /** Persist dependency removal/promotion as one parent-side recovery checkpoint. */
147
- updateFlowParentResolution(job: Pick<Job, 'id' | 'dependsOn' | 'runAt' | 'priority'>): void;
158
+ updateFlowParentResolution(job: Pick<Job, 'id' | 'dependsOn' | 'runAt' | 'priority' | 'timeline'>, stateOverride?: 'waiting-children' | 'waiting' | 'prioritized' | 'delayed'): void;
148
159
  getJob(id: JobId): Job | null;
149
160
  storeResult(jobId: JobId, result: unknown): void;
150
161
  getResult(jobId: JobId): unknown;
@@ -160,6 +171,14 @@ export declare class SqliteStorage {
160
171
  getJobStateRaw(jobId: JobId): string | null;
161
172
  /** Load all completed job IDs (for dependency recovery) */
162
173
  loadCompletedJobIds(): Set<JobId>;
174
+ /** Atomically replace a removed completed job with payload-free dependency evidence. */
175
+ commitRemovedCompletion(job: Pick<Job, 'id' | 'queue'>, retentionLimit: number, pinned: boolean, completedAt?: number): void;
176
+ loadDependencyCompletions(): DependencyCompletionRecord[];
177
+ pinDependencyCompletions(jobIds: Iterable<JobId>): void;
178
+ unpinDependencyCompletions(jobIds: Iterable<JobId>, retentionLimit: number): DependencyCompletionRecord[];
179
+ reconcileDependencyCompletionPins(referenced: ReadonlySet<JobId>, retentionLimit: number): DependencyCompletionRecord[];
180
+ deleteDependencyCompletion(jobId: JobId): boolean;
181
+ deleteDependencyCompletionsForQueue(queue: string): JobId[];
163
182
  /**
164
183
  * Insert batch of jobs. By default the jobs go through the write buffer.
165
184
  * When `durable` is true they bypass the buffer and are written to disk
@@ -10,6 +10,7 @@ import { prepareStatements, } from './statements';
10
10
  import { pack, persistedInitialState, persistedStallCount, reconstructDlqEntry, rowToJob, unpack, } from './sqliteSerializer';
11
11
  import { BatchInsertManager, WriteBuffer } from './sqliteBatch';
12
12
  import { storageLog } from '../../shared/logger';
13
+ import { DependencyCompletionStore, } from './dependencyCompletionStore';
13
14
  /** Check if an error is a SQLITE_FULL (disk full) error */
14
15
  function isSqliteFullError(err) {
15
16
  if (!(err instanceof Error))
@@ -25,6 +26,7 @@ export class SqliteStorage {
25
26
  statements;
26
27
  batchManager;
27
28
  writeBuffer;
29
+ dependencyCompletionStore;
28
30
  _diskFull = false;
29
31
  _lastDiskFullError = null;
30
32
  _lastDiskFullAt = null;
@@ -48,6 +50,7 @@ export class SqliteStorage {
48
50
  });
49
51
  }
50
52
  this.migrate();
53
+ this.dependencyCompletionStore = new DependencyCompletionStore(this.db);
51
54
  this.statements = prepareStatements(this.db);
52
55
  this._onCriticalLoss = config.onCriticalLoss;
53
56
  // Initialize batch manager and write buffer
@@ -491,6 +494,62 @@ export class SqliteStorage {
491
494
  tx();
492
495
  });
493
496
  }
497
+ /**
498
+ * Persist a legacy SDK parent-id backpatch without changing parent state.
499
+ *
500
+ * A fast child can finish before the SDK sends UpdateParent. Completed jobs
501
+ * remain in `jobs`, while failed jobs live only in the serialized DLQ entry.
502
+ * The failure outbox is re-keyed in the same transaction so a crash between
503
+ * this write and in-memory propagation still targets the real parent.
504
+ */
505
+ backpatchFlowChild(child, previousParentId) {
506
+ this.flushIfBuffered(child.id);
507
+ this.safeWrite(() => {
508
+ const tx = this.db.transaction(() => {
509
+ let persisted = this.db
510
+ .prepare('UPDATE jobs SET parent_id = ?, data = ? WHERE id = ?')
511
+ .run(child.parentId, pack(child.data), child.id).changes > 0;
512
+ const dlqRows = this.db
513
+ .query('SELECT id, entry FROM dlq WHERE job_id = ?')
514
+ .all(String(child.id));
515
+ for (const row of dlqRows) {
516
+ const entry = unpack(row.entry, null, `backpatchFlowChild:${String(child.id)}`);
517
+ if (!entry?.job)
518
+ continue;
519
+ const updated = {
520
+ ...entry,
521
+ job: { ...entry.job, parentId: child.parentId, data: child.data },
522
+ };
523
+ this.db.prepare('UPDATE dlq SET entry = ? WHERE id = ?').run(pack(updated), row.id);
524
+ persisted = true;
525
+ }
526
+ if (!persisted) {
527
+ throw new Error(`Flow child is no longer persisted: ${String(child.id)}`);
528
+ }
529
+ if (previousParentId && child.parentId && previousParentId !== child.parentId) {
530
+ const failure = this.db
531
+ .query(`SELECT child_queue, mode, error, created_at
532
+ FROM flow_failures
533
+ WHERE parent_id = ? AND child_id = ?`)
534
+ .get(String(previousParentId), String(child.id));
535
+ if (failure) {
536
+ this.db
537
+ .prepare(`INSERT INTO flow_failures
538
+ (parent_id, child_id, child_queue, mode, error, created_at)
539
+ VALUES (?, ?, ?, ?, ?, ?)
540
+ ON CONFLICT(parent_id, child_id) DO UPDATE SET
541
+ child_queue=excluded.child_queue, mode=excluded.mode,
542
+ error=excluded.error, created_at=excluded.created_at`)
543
+ .run(child.parentId, child.id, failure.child_queue, failure.mode, failure.error, failure.created_at);
544
+ this.db
545
+ .prepare('DELETE FROM flow_failures WHERE parent_id = ? AND child_id = ?')
546
+ .run(previousParentId, child.id);
547
+ }
548
+ }
549
+ });
550
+ tx();
551
+ });
552
+ }
494
553
  /** Persist both sides of a removed parent/child relationship atomically. */
495
554
  removeFlowLink(child, parent, parentState) {
496
555
  this.flushIfBuffered(child.id);
@@ -551,19 +610,20 @@ export class SqliteStorage {
551
610
  });
552
611
  }
553
612
  /** Persist dependency removal/promotion as one parent-side recovery checkpoint. */
554
- updateFlowParentResolution(job) {
613
+ updateFlowParentResolution(job, stateOverride) {
555
614
  this.flushIfBuffered(job.id);
556
- const state = job.dependsOn.length > 0
557
- ? 'waiting-children'
558
- : job.runAt > Date.now()
559
- ? 'delayed'
560
- : job.priority > 0
561
- ? 'prioritized'
562
- : 'waiting';
615
+ const state = stateOverride ??
616
+ (job.dependsOn.length > 0
617
+ ? 'waiting-children'
618
+ : job.runAt > Date.now()
619
+ ? 'delayed'
620
+ : job.priority > 0
621
+ ? 'prioritized'
622
+ : 'waiting');
563
623
  this.safeWrite(() => {
564
624
  this.db
565
- .prepare('UPDATE jobs SET depends_on = ?, run_at = ?, state = ? WHERE id = ?')
566
- .run(job.dependsOn.length > 0 ? pack(job.dependsOn) : null, job.runAt, state, job.id);
625
+ .prepare('UPDATE jobs SET depends_on = ?, run_at = ?, state = ?, timeline = ? WHERE id = ?')
626
+ .run(job.dependsOn.length > 0 ? pack(job.dependsOn) : null, job.runAt, state, job.timeline.length > 0 ? pack(job.timeline) : null, job.id);
567
627
  });
568
628
  }
569
629
  getJob(id) {
@@ -617,18 +677,62 @@ export class SqliteStorage {
617
677
  }
618
678
  /** Load all completed job IDs (for dependency recovery) */
619
679
  loadCompletedJobIds() {
620
- const rows = this.db.query('SELECT job_id FROM job_results').all();
621
- const ids = new Set(rows.map((r) => r.job_id));
622
- // A job acked with no/undefined result has state='completed' but NO job_results
623
- // row. Include state='completed' ids so dependency recovery still sees it as
624
- // done and unblocks dependents (instead of parking them forever).
680
+ // `jobs.state` is the completion authority. A result row can exist while its
681
+ // job is still active if a process dies between the two legacy writes, so it
682
+ // must never release a dependency by itself.
683
+ const ids = new Set();
625
684
  const stateRows = this.db
626
685
  .query("SELECT id FROM jobs WHERE state = 'completed'")
627
686
  .all();
628
687
  for (const r of stateRows)
629
688
  ids.add(r.id);
689
+ for (const record of this.dependencyCompletionStore.load())
690
+ ids.add(record.jobId);
630
691
  return ids;
631
692
  }
693
+ /** Atomically replace a removed completed job with payload-free dependency evidence. */
694
+ commitRemovedCompletion(job, retentionLimit, pinned, completedAt = Date.now()) {
695
+ this.writeBuffer.removePending(job.id);
696
+ this.safeWrite(() => {
697
+ this.dependencyCompletionStore.commit({ jobId: job.id, queue: job.queue, completedAt, pinned }, retentionLimit);
698
+ });
699
+ }
700
+ loadDependencyCompletions() {
701
+ return this.dependencyCompletionStore.load();
702
+ }
703
+ pinDependencyCompletions(jobIds) {
704
+ this.safeWrite(() => {
705
+ this.dependencyCompletionStore.pin(jobIds);
706
+ });
707
+ }
708
+ unpinDependencyCompletions(jobIds, retentionLimit) {
709
+ let records = [];
710
+ this.safeWrite(() => {
711
+ records = this.dependencyCompletionStore.unpin(jobIds, retentionLimit);
712
+ });
713
+ return records;
714
+ }
715
+ reconcileDependencyCompletionPins(referenced, retentionLimit) {
716
+ let records = [];
717
+ this.safeWrite(() => {
718
+ records = this.dependencyCompletionStore.reconcilePins(referenced, retentionLimit);
719
+ });
720
+ return records;
721
+ }
722
+ deleteDependencyCompletion(jobId) {
723
+ let deleted = false;
724
+ this.safeWrite(() => {
725
+ deleted = this.dependencyCompletionStore.delete(jobId);
726
+ });
727
+ return deleted;
728
+ }
729
+ deleteDependencyCompletionsForQueue(queue) {
730
+ let deleted = [];
731
+ this.safeWrite(() => {
732
+ deleted = this.dependencyCompletionStore.deleteForQueue(queue);
733
+ });
734
+ return deleted;
735
+ }
632
736
  // ============ Bulk Operations ============
633
737
  /**
634
738
  * Insert batch of jobs. By default the jobs go through the write buffer.
@@ -32,6 +32,9 @@ export declare function persistedInitialState(job: Job, now?: number): string;
32
32
  * so it only exists on the in-memory Job for the duration of recovery.
33
33
  */
34
34
  export declare const CORRUPT_DEPENDS_ON: unique symbol;
35
+ export type PersistedJobState = 'active' | 'completed' | 'delayed' | 'prioritized' | 'waiting' | 'waiting-children';
36
+ /** Read the authoritative SQLite state carried by a recovered job. */
37
+ export declare function persistedJobState(job: Job): PersistedJobState | undefined;
35
38
  /** True if a Job was recovered with a corrupt `depends_on` blob. */
36
39
  export declare function isCorruptDependsOn(job: Job): boolean;
37
40
  /** Convert database row to Job object */
@@ -57,6 +57,11 @@ export function persistedInitialState(job, now = Date.now()) {
57
57
  * so it only exists on the in-memory Job for the duration of recovery.
58
58
  */
59
59
  export const CORRUPT_DEPENDS_ON = Symbol('bunqueue.corruptDependsOn');
60
+ const PERSISTED_JOB_STATE = Symbol('bunqueue.persistedJobState');
61
+ /** Read the authoritative SQLite state carried by a recovered job. */
62
+ export function persistedJobState(job) {
63
+ return job[PERSISTED_JOB_STATE];
64
+ }
60
65
  /** True if a Job was recovered with a corrupt `depends_on` blob. */
61
66
  export function isCorruptDependsOn(job) {
62
67
  return job[CORRUPT_DEPENDS_ON] === true;
@@ -146,6 +151,11 @@ export function rowToJob(row) {
146
151
  ? unpack(row.stacktrace, null, `${jobContext}:stacktrace`)
147
152
  : null,
148
153
  };
154
+ Object.defineProperty(job, PERSISTED_JOB_STATE, {
155
+ value: row.state,
156
+ enumerable: false,
157
+ configurable: false,
158
+ });
149
159
  // Stamp a collision-proof corruption marker (non-enumerable Symbol, never
150
160
  // persisted) so the recovery path routes this job to the DLQ rather than
151
161
  // enqueuing it as ready. We keep dependsOn: [] here — the real deps are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.50",
3
+ "version": "2.8.51",
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",