bunqueue 2.8.31 → 2.8.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +29 -26
  2. package/dist/application/backgroundTasks.js +17 -9
  3. package/dist/application/dependencyProcessor.js +6 -1
  4. package/dist/application/dlqManager.js +1 -1
  5. package/dist/application/lockManager.js +1 -1
  6. package/dist/application/operations/ack.js +23 -1
  7. package/dist/application/operations/jobManagement.js +29 -1
  8. package/dist/application/operations/jobStateTransitions.js +1 -1
  9. package/dist/application/operations/pull.js +125 -96
  10. package/dist/application/operations/push.js +2 -2
  11. package/dist/application/operations/queryOperations.js +74 -70
  12. package/dist/application/queueManager.d.ts +6 -11
  13. package/dist/application/queueManager.js +16 -3
  14. package/dist/application/queueStatsAggregator.d.ts +23 -0
  15. package/dist/application/queueStatsAggregator.js +80 -0
  16. package/dist/application/statsManager.d.ts +2 -26
  17. package/dist/application/statsManager.js +8 -124
  18. package/dist/client/tcpPool.js +8 -1
  19. package/dist/domain/queue/shard.d.ts +3 -1
  20. package/dist/domain/queue/shard.js +15 -7
  21. package/dist/domain/queue/temporalIndex.d.ts +24 -0
  22. package/dist/domain/queue/temporalIndex.js +100 -0
  23. package/dist/domain/queue/temporalManager.d.ts +2 -11
  24. package/dist/domain/queue/temporalManager.js +27 -44
  25. package/dist/domain/queue/waiterManager.d.ts +11 -13
  26. package/dist/domain/queue/waiterManager.js +80 -49
  27. package/dist/infrastructure/cloud/commands.js +5 -2
  28. package/dist/infrastructure/cloud/snapshotCollector.js +5 -2
  29. package/dist/infrastructure/cloud/statsRefresh.js +5 -2
  30. package/dist/infrastructure/cloud/statsUpdate.js +5 -2
  31. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  32. package/dist/infrastructure/persistence/schema.js +13 -1
  33. package/dist/infrastructure/persistence/sqlite.d.ts +18 -2
  34. package/dist/infrastructure/persistence/sqlite.js +49 -6
  35. package/dist/infrastructure/server/handlers/core.js +13 -7
  36. package/dist/infrastructure/server/handlers/pushBatchValidation.d.ts +23 -0
  37. package/dist/infrastructure/server/handlers/pushBatchValidation.js +70 -0
  38. package/dist/infrastructure/server/queueCountsScheduler.d.ts +13 -0
  39. package/dist/infrastructure/server/queueCountsScheduler.js +35 -0
  40. package/dist/infrastructure/server/sseHandler.d.ts +1 -0
  41. package/dist/infrastructure/server/sseHandler.js +18 -13
  42. package/dist/infrastructure/server/wsHandler.d.ts +1 -1
  43. package/dist/infrastructure/server/wsHandler.js +18 -15
  44. package/package.json +3 -3
@@ -5,29 +5,15 @@
5
5
  * - Temporal index (SkipList) for efficient cleanQueue range queries
6
6
  * - Delayed heap (MinHeap) for O(k) delayed job refresh
7
7
  */
8
- import { SkipList } from '../../shared/skipList';
9
8
  import { MinHeap } from '../../shared/minHeap';
9
+ import { TemporalIndex } from './temporalIndex';
10
+ const DELAYED_COMPACTION_MIN_STALE = 256;
10
11
  /**
11
12
  * Manages temporal indexing for efficient job cleanup
12
13
  * and delayed job tracking for O(k) refresh
13
14
  */
14
15
  export class TemporalManager {
15
- /**
16
- * Temporal index: Skip List for O(log n) insert/delete
17
- * Ordered by createdAt for efficient cleanQueue range queries
18
- * Uses equality check on jobId to prevent duplicate entries for the same job
19
- */
20
- // Total-order comparator: createdAt first, then jobId as a tie-break. Without
21
- // the tie-break, a batch of jobs sharing one createdAt (the addBulk case —
22
- // `now` is captured once) makes every node compare-equal, which (a) turns
23
- // SkipList.insert's duplicate-check scan into O(n) per insert => O(n²) for the
24
- // batch, and (b) makes SkipList.delete remove the WRONG same-createdAt node
25
- // (it stops at the first compare-equal node). A total order fixes both: the
26
- // dedup scan and delete both resolve to the exact (createdAt, jobId) node in
27
- // O(log n). jobId is a string (UUIDv7 by default, or any custom id), so its
28
- // lexicographic comparison is a valid total order in every case. Equality is
29
- // still by jobId (now reached only for a true duplicate).
30
- temporalIndex = new SkipList((a, b) => a.createdAt - b.createdAt || (a.jobId < b.jobId ? -1 : a.jobId > b.jobId ? 1 : 0), 16, 0.5, (a, b) => a.jobId === b.jobId);
16
+ temporalIndex = new TemporalIndex();
31
17
  /** Set of delayed job IDs for tracking when they become ready */
32
18
  delayedJobIds = new Set();
33
19
  /**
@@ -40,7 +26,7 @@ export class TemporalManager {
40
26
  // ============ Temporal Index Operations ============
41
27
  /** Add job to temporal index - O(log n) */
42
28
  addToIndex(createdAt, jobId, queue) {
43
- this.temporalIndex.insert({ createdAt, jobId, queue });
29
+ this.temporalIndex.add(createdAt, jobId, queue);
44
30
  }
45
31
  /**
46
32
  * Get old jobs from temporal index - O(log n + k)
@@ -49,36 +35,22 @@ export class TemporalManager {
49
35
  getOldJobs(queue, thresholdMs, limit) {
50
36
  const now = Date.now();
51
37
  const threshold = now - thresholdMs;
52
- const result = [];
53
- for (const entry of this.temporalIndex.values()) {
54
- if (entry.createdAt > threshold)
55
- break;
56
- if (entry.queue === queue) {
57
- result.push({ jobId: entry.jobId, createdAt: entry.createdAt });
58
- if (result.length >= limit)
59
- break;
60
- }
61
- }
62
- return result;
38
+ return this.temporalIndex.getOldJobs(queue, threshold, limit);
63
39
  }
64
40
  /** Remove job from temporal index */
65
41
  removeFromIndex(jobId) {
66
- this.temporalIndex.deleteWhere((e) => e.jobId === jobId);
42
+ this.temporalIndex.remove(jobId);
67
43
  }
68
44
  /** Clear temporal index for a queue */
69
45
  clearIndexForQueue(queue) {
70
- this.temporalIndex.removeAll((e) => e.queue === queue);
46
+ this.temporalIndex.clearQueue(queue);
71
47
  }
72
48
  /**
73
49
  * Clean orphaned temporal index entries.
74
50
  * Removes entries for jobs that no longer exist.
75
51
  */
76
52
  cleanOrphaned(validJobIds) {
77
- if (this.temporalIndex.size === 0)
78
- return 0;
79
- const beforeSize = this.temporalIndex.size;
80
- this.temporalIndex.removeAll((e) => !validJobIds.has(e.jobId));
81
- return beforeSize - this.temporalIndex.size;
53
+ return this.temporalIndex.cleanOrphaned(validJobIds);
82
54
  }
83
55
  /** Get temporal index size */
84
56
  get indexSize() {
@@ -94,15 +66,15 @@ export class TemporalManager {
94
66
  this.delayedJobIds.add(jobId);
95
67
  this.delayedHeap.push({ jobId, runAt });
96
68
  this.delayedRunAt.set(jobId, runAt);
69
+ this.maybeCompactDelayedHeap();
97
70
  }
98
71
  /** Remove a delayed job (lazy removal from heap) */
99
72
  removeDelayed(jobId) {
100
- if (this.delayedJobIds.has(jobId)) {
101
- this.delayedJobIds.delete(jobId);
102
- this.delayedRunAt.delete(jobId);
103
- return true;
104
- }
105
- return false;
73
+ if (!this.delayedJobIds.delete(jobId))
74
+ return false;
75
+ this.delayedRunAt.delete(jobId);
76
+ this.maybeCompactDelayedHeap();
77
+ return true;
106
78
  }
107
79
  /**
108
80
  * Refresh delayed jobs that have become ready
@@ -127,8 +99,20 @@ export class TemporalManager {
127
99
  this.delayedRunAt.delete(top.jobId);
128
100
  count++;
129
101
  }
102
+ this.maybeCompactDelayedHeap();
130
103
  return count;
131
104
  }
105
+ maybeCompactDelayedHeap() {
106
+ const liveCount = this.delayedRunAt.size;
107
+ if (liveCount === 0) {
108
+ this.delayedHeap.clear();
109
+ return;
110
+ }
111
+ const staleCount = this.delayedHeap.size - liveCount;
112
+ if (staleCount < DELAYED_COMPACTION_MIN_STALE || staleCount < liveCount)
113
+ return;
114
+ this.delayedHeap.buildFrom(Array.from(this.delayedRunAt, ([jobId, runAt]) => ({ jobId, runAt })));
115
+ }
132
116
  /** Get delayed job count */
133
117
  get delayedCount() {
134
118
  return this.delayedJobIds.size;
@@ -143,8 +127,7 @@ export class TemporalManager {
143
127
  /** Clear all data */
144
128
  clear() {
145
129
  this.clearDelayed();
146
- // Note: SkipList doesn't have a clear method, so we recreate by removing all
147
- this.temporalIndex.removeAll(() => true);
130
+ this.temporalIndex.clear();
148
131
  }
149
132
  // ============ Debug Info ============
150
133
  /** Get internal structure sizes for memory debugging */
@@ -1,20 +1,18 @@
1
1
  /**
2
- * WaiterManager - Manages job availability notifications
3
- * Handles worker polling with timeout-based waiting
2
+ * WaiterManager - Manages queue-scoped job availability notifications.
3
+ * Handles worker polling with timeout-based waiting.
4
4
  */
5
5
  export declare class WaiterManager {
6
- /** Waiter entries with cancellation flag for O(1) cleanup */
7
- private readonly waiters;
8
- /** Pending notification counter - incremented when notify() is called with no waiters */
9
- private pendingNotifications;
10
- /** Notify that multiple jobs are available - wakes up to `count` waiters */
6
+ private readonly queues;
7
+ private activeWaiters;
8
+ notify(queue?: string): void;
11
9
  notifyBatch(count: number): void;
12
- /** Notify that jobs are available - wakes first non-cancelled waiter */
13
- notify(): void;
14
- /** Wait for a job to become available (with timeout) */
10
+ notifyBatch(queue: string, count: number): void;
15
11
  waitForJob(timeoutMs: number): Promise<void>;
16
- /** Remove all cancelled waiters from the array */
17
- private cleanupWaiters;
18
- /** Current number of waiters */
12
+ waitForJob(queue: string, timeoutMs: number): Promise<void>;
13
+ private notifyQueue;
14
+ private createQueue;
15
+ private compact;
16
+ private deleteIdleQueue;
19
17
  get length(): number;
20
18
  }
@@ -1,69 +1,100 @@
1
1
  /**
2
- * WaiterManager - Manages job availability notifications
3
- * Handles worker polling with timeout-based waiting
2
+ * WaiterManager - Manages queue-scoped job availability notifications.
3
+ * Handles worker polling with timeout-based waiting.
4
4
  */
5
- /** Threshold for triggering full waiters cleanup */
6
- const WAITERS_CLEANUP_THRESHOLD = 1000;
5
+ const DEFAULT_QUEUE = '';
6
+ const COMPACT_MIN_HEAD = 1024;
7
7
  export class WaiterManager {
8
- /** Waiter entries with cancellation flag for O(1) cleanup */
9
- waiters = [];
10
- /** Pending notification counter - incremented when notify() is called with no waiters */
11
- pendingNotifications = 0;
12
- /** Notify that multiple jobs are available - wakes up to `count` waiters */
13
- notifyBatch(count) {
14
- for (let i = 0; i < count; i++) {
15
- this.notify();
16
- }
8
+ queues = new Map();
9
+ activeWaiters = 0;
10
+ notify(queue) {
11
+ this.notifyQueue(queue ?? DEFAULT_QUEUE, 1);
17
12
  }
18
- /** Notify that jobs are available - wakes first non-cancelled waiter */
19
- notify() {
20
- // Clean up leading cancelled waiters first
21
- while (this.waiters.length > 0 && this.waiters[0].cancelled) {
22
- this.waiters.shift();
23
- }
24
- // Wake the first active waiter
25
- const waiter = this.waiters.shift();
26
- if (waiter && !waiter.cancelled) {
27
- waiter.resolve();
28
- }
29
- else {
30
- // No active waiter - increment pending counter so next waitForJob returns immediately
31
- this.pendingNotifications++;
32
- }
33
- // Periodic full cleanup when array grows too large
34
- if (this.waiters.length > WAITERS_CLEANUP_THRESHOLD) {
35
- this.cleanupWaiters();
36
- }
13
+ notifyBatch(queueOrCount, maybeCount) {
14
+ const queue = typeof queueOrCount === 'string' ? queueOrCount : DEFAULT_QUEUE;
15
+ const count = typeof queueOrCount === 'number' ? queueOrCount : (maybeCount ?? 0);
16
+ if (count <= 0)
17
+ return;
18
+ this.notifyQueue(queue, count);
37
19
  }
38
- /** Wait for a job to become available (with timeout) */
39
- waitForJob(timeoutMs) {
20
+ waitForJob(queueOrTimeout, maybeTimeout) {
21
+ const queue = typeof queueOrTimeout === 'string' ? queueOrTimeout : DEFAULT_QUEUE;
22
+ const timeoutMs = typeof queueOrTimeout === 'number' ? queueOrTimeout : (maybeTimeout ?? 0);
40
23
  if (timeoutMs <= 0)
41
24
  return Promise.resolve();
42
- // Check for pending notifications - if any, decrement and return immediately
43
- if (this.pendingNotifications > 0) {
44
- this.pendingNotifications--;
25
+ const state = this.queues.get(queue);
26
+ if (state?.pending) {
27
+ state.pending = false;
28
+ this.deleteIdleQueue(queue, state);
45
29
  return Promise.resolve();
46
30
  }
47
31
  return new Promise((resolve) => {
48
- const waiter = { resolve, cancelled: false };
49
- const cleanup = () => {
32
+ const queueState = state ?? this.createQueue(queue);
33
+ const waiter = {
34
+ resolve,
35
+ cancelled: false,
36
+ timer: undefined,
37
+ };
38
+ waiter.timer = setTimeout(() => {
50
39
  if (waiter.cancelled)
51
40
  return;
52
41
  waiter.cancelled = true;
42
+ queueState.active--;
43
+ this.activeWaiters--;
53
44
  resolve();
54
- };
55
- this.waiters.push(waiter);
56
- setTimeout(cleanup, timeoutMs);
45
+ this.compact(queue, queueState);
46
+ }, timeoutMs);
47
+ queueState.entries.push(waiter);
48
+ queueState.active++;
49
+ this.activeWaiters++;
57
50
  });
58
51
  }
59
- /** Remove all cancelled waiters from the array */
60
- cleanupWaiters() {
61
- const active = this.waiters.filter((w) => !w.cancelled);
62
- this.waiters.length = 0;
63
- this.waiters.push(...active);
52
+ notifyQueue(queue, count) {
53
+ const state = this.queues.get(queue);
54
+ if (!state) {
55
+ this.createQueue(queue).pending = true;
56
+ return;
57
+ }
58
+ let remaining = count;
59
+ while (remaining > 0 && state.active > 0) {
60
+ const waiter = state.entries[state.head++];
61
+ if (!waiter || waiter.cancelled)
62
+ continue;
63
+ waiter.cancelled = true;
64
+ clearTimeout(waiter.timer);
65
+ state.active--;
66
+ this.activeWaiters--;
67
+ remaining--;
68
+ waiter.resolve();
69
+ }
70
+ // Availability is edge-triggered: surplus notifications coalesce into one
71
+ // retry hint instead of accumulating an unbounded notification debt.
72
+ if (remaining > 0)
73
+ state.pending = true;
74
+ this.compact(queue, state);
75
+ }
76
+ createQueue(queue) {
77
+ const state = { entries: [], head: 0, active: 0, pending: false };
78
+ this.queues.set(queue, state);
79
+ return state;
80
+ }
81
+ compact(queue, state) {
82
+ if (state.active === 0) {
83
+ state.entries = [];
84
+ state.head = 0;
85
+ this.deleteIdleQueue(queue, state);
86
+ return;
87
+ }
88
+ if (state.head >= COMPACT_MIN_HEAD && state.head * 2 >= state.entries.length) {
89
+ state.entries = state.entries.slice(state.head);
90
+ state.head = 0;
91
+ }
92
+ }
93
+ deleteIdleQueue(queue, state) {
94
+ if (state.active === 0 && !state.pending)
95
+ this.queues.delete(queue);
64
96
  }
65
- /** Current number of waiters */
66
97
  get length() {
67
- return this.waiters.length;
98
+ return this.activeWaiters;
68
99
  }
69
100
  }
@@ -311,8 +311,11 @@ export const COMMANDS = {
311
311
  // --- Queue list ---
312
312
  'queue:list': (qm) => {
313
313
  const queueNames = qm.listQueues();
314
- const queues = queueNames.map((name) => {
315
- const counts = qm.getQueueJobCounts(name);
314
+ const countsByQueue = qm.getAllQueueJobCounts();
315
+ const queues = queueNames.flatMap((name) => {
316
+ const counts = countsByQueue.get(name);
317
+ if (!counts)
318
+ return [];
316
319
  return {
317
320
  name,
318
321
  waiting: counts.waiting,
@@ -85,8 +85,11 @@ export async function collectSnapshot(params) {
85
85
  const storage = queueManager.getStorageStatus();
86
86
  const mem = process.memoryUsage();
87
87
  const queueNames = queueManager.listQueues();
88
- const queues = queueNames.map((name) => {
89
- const counts = queueManager.getQueueJobCounts(name);
88
+ const countsByQueue = queueManager.getAllQueueJobCounts();
89
+ const queues = queueNames.flatMap((name) => {
90
+ const counts = countsByQueue.get(name);
91
+ if (!counts)
92
+ return [];
90
93
  return {
91
94
  name,
92
95
  waiting: counts.waiting,
@@ -26,8 +26,11 @@ export function buildStatsRefresh(qm) {
26
26
  const mem = process.memoryUsage();
27
27
  const workerStats = qm.workerManager.getStats();
28
28
  const queueNames = qm.listQueues();
29
- const queues = queueNames.map((name) => {
30
- const counts = qm.getQueueJobCounts(name);
29
+ const countsByQueue = qm.getAllQueueJobCounts();
30
+ const queues = queueNames.flatMap((name) => {
31
+ const counts = countsByQueue.get(name);
32
+ if (!counts)
33
+ return [];
31
34
  return {
32
35
  name,
33
36
  waiting: counts.waiting,
@@ -11,8 +11,11 @@ export function buildStatsUpdate(qm) {
11
11
  const s = qm.getStats();
12
12
  const rates = throughputTracker.getRates();
13
13
  const queueNames = qm.listQueues();
14
- const queues = queueNames.map((name) => {
15
- const c = qm.getQueueJobCounts(name);
14
+ const countsByQueue = qm.getAllQueueJobCounts();
15
+ const queues = queueNames.flatMap((name) => {
16
+ const c = countsByQueue.get(name);
17
+ if (!c)
18
+ return [];
16
19
  return {
17
20
  name,
18
21
  waiting: c.waiting,
@@ -4,10 +4,10 @@
4
4
  /** SQLite PRAGMA settings for optimal performance */
5
5
  export declare const PRAGMA_SETTINGS = "\nPRAGMA journal_mode = WAL;\nPRAGMA synchronous = NORMAL;\nPRAGMA cache_size = -64000;\nPRAGMA temp_store = MEMORY;\nPRAGMA mmap_size = 268435456;\nPRAGMA page_size = 4096;\nPRAGMA busy_timeout = 5000;\n";
6
6
  /** Main schema creation */
7
- export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
7
+ export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\n-- Stable createdAt/id pagination for unfiltered and logical-state queue views\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_created\n ON jobs(queue, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created\n ON jobs(queue, state, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
8
8
  /** Migration version table */
9
9
  export declare const MIGRATION_TABLE = "\nCREATE TABLE IF NOT EXISTS migrations (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n";
10
10
  /** Current schema version */
11
- export declare const SCHEMA_VERSION = 13;
11
+ export declare const SCHEMA_VERSION = 14;
12
12
  /** All migrations in order */
13
13
  export declare const MIGRATIONS: Record<number, string>;
@@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS jobs (
51
51
  -- Indexes for common queries
52
52
  CREATE INDEX IF NOT EXISTS idx_jobs_queue_state
53
53
  ON jobs(queue, state);
54
+ -- Stable createdAt/id pagination for unfiltered and logical-state queue views
55
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_created
56
+ ON jobs(queue, created_at, id);
57
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created
58
+ ON jobs(queue, state, created_at, id);
54
59
  CREATE INDEX IF NOT EXISTS idx_jobs_run_at
55
60
  ON jobs(run_at) WHERE state IN ('waiting', 'delayed');
56
61
  CREATE INDEX IF NOT EXISTS idx_jobs_unique
@@ -133,7 +138,7 @@ CREATE TABLE IF NOT EXISTS migrations (
133
138
  );
134
139
  `;
135
140
  /** Current schema version */
136
- export const SCHEMA_VERSION = 13;
141
+ export const SCHEMA_VERSION = 14;
137
142
  /** All migrations in order */
138
143
  export const MIGRATIONS = {
139
144
  1: SCHEMA,
@@ -187,5 +192,12 @@ ALTER TABLE cron_jobs ADD COLUMN job_options BLOB;
187
192
  // Migration 13: Persist the last failure's stacktrace on jobs (issue #74)
188
193
  13: `
189
194
  ALTER TABLE jobs ADD COLUMN stacktrace BLOB;
195
+ `,
196
+ // Migration 14: Stable, index-backed getJobs ordering and pagination
197
+ 14: `
198
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_created
199
+ ON jobs(queue, created_at, id);
200
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created
201
+ ON jobs(queue, state, created_at, id);
190
202
  `,
191
203
  };
@@ -142,7 +142,8 @@ export declare class SqliteStorage {
142
142
  insertJobsBatch(jobs: Job[], durable?: boolean): void;
143
143
  /**
144
144
  * Query jobs by queue with optional state filter and pagination.
145
- * Uses idx_jobs_queue_state index for O(log n) lookups.
145
+ * This is the raw persisted-state query. Logical waiting/prioritized/delayed
146
+ * views should use queryJobsByLogicalStates().
146
147
  */
147
148
  queryJobs(queue: string, options: {
148
149
  state?: string;
@@ -151,9 +152,24 @@ export declare class SqliteStorage {
151
152
  offset: number;
152
153
  asc: boolean;
153
154
  }): Job[];
155
+ /**
156
+ * Query public/logical job states before applying pagination.
157
+ *
158
+ * SQLite persists pending jobs as waiting/delayed, while the public state is
159
+ * time-dependent: a delayed row becomes waiting or prioritized once run_at
160
+ * is reached. Keeping these predicates in SQL makes OFFSET count the logical
161
+ * result rather than the unfiltered persisted rows.
162
+ */
163
+ queryJobsByLogicalStates(queue: string, states: string[], options: {
164
+ limit: number;
165
+ offset: number;
166
+ asc: boolean;
167
+ now: number;
168
+ }): Job[];
154
169
  /**
155
170
  * Load pending jobs with pagination for efficient recovery.
156
- * Orders by priority (desc) and run_at (asc) to process urgent jobs first.
171
+ * Orders by priority (desc), run_at (asc), and id (asc) so page boundaries
172
+ * remain deterministic when scheduling fields compare equal.
157
173
  * @param limit Max jobs to return (default: 10000)
158
174
  * @param offset Skip first N jobs (default: 0)
159
175
  */
@@ -482,7 +482,8 @@ export class SqliteStorage {
482
482
  // ============ Query Operations ============
483
483
  /**
484
484
  * Query jobs by queue with optional state filter and pagination.
485
- * Uses idx_jobs_queue_state index for O(log n) lookups.
485
+ * This is the raw persisted-state query. Logical waiting/prioritized/delayed
486
+ * views should use queryJobsByLogicalStates().
486
487
  */
487
488
  queryJobs(queue, options) {
488
489
  const order = options.asc ? 'ASC' : 'DESC';
@@ -490,30 +491,72 @@ export class SqliteStorage {
490
491
  if (options.states && options.states.length > 0) {
491
492
  const placeholders = options.states.map(() => '?').join(',');
492
493
  rows = this.db
493
- .query(`SELECT * FROM jobs WHERE queue = ? AND state IN (${placeholders}) ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
494
+ .query(`SELECT * FROM jobs WHERE queue = ? AND state IN (${placeholders}) ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
494
495
  .all(queue, ...options.states, options.limit, options.offset);
495
496
  }
496
497
  else if (options.state) {
497
498
  rows = this.db
498
- .query(`SELECT * FROM jobs WHERE queue = ? AND state = ? ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
499
+ .query(`SELECT * FROM jobs WHERE queue = ? AND state = ? ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
499
500
  .all(queue, options.state, options.limit, options.offset);
500
501
  }
501
502
  else {
502
503
  rows = this.db
503
- .query(`SELECT * FROM jobs WHERE queue = ? ORDER BY created_at ${order} LIMIT ? OFFSET ?`)
504
+ .query(`SELECT * FROM jobs WHERE queue = ? ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`)
504
505
  .all(queue, options.limit, options.offset);
505
506
  }
506
507
  return rows.map((row) => rowToJob(row));
507
508
  }
509
+ /**
510
+ * Query public/logical job states before applying pagination.
511
+ *
512
+ * SQLite persists pending jobs as waiting/delayed, while the public state is
513
+ * time-dependent: a delayed row becomes waiting or prioritized once run_at
514
+ * is reached. Keeping these predicates in SQL makes OFFSET count the logical
515
+ * result rather than the unfiltered persisted rows.
516
+ */
517
+ queryJobsByLogicalStates(queue, states, options) {
518
+ const uniqueStates = Array.from(new Set(states));
519
+ if (uniqueStates.length === 0)
520
+ return [];
521
+ const predicates = [];
522
+ const predicateParams = [];
523
+ const requested = new Set(uniqueStates);
524
+ if (requested.has('waiting')) {
525
+ predicates.push("(state IN ('waiting', 'delayed') AND run_at <= ? AND priority <= 0)");
526
+ predicateParams.push(options.now);
527
+ }
528
+ if (requested.has('prioritized')) {
529
+ predicates.push("(state IN ('waiting', 'delayed') AND run_at <= ? AND priority > 0)");
530
+ predicateParams.push(options.now);
531
+ }
532
+ if (requested.has('delayed')) {
533
+ predicates.push("(state IN ('waiting', 'delayed') AND run_at > ?)");
534
+ predicateParams.push(options.now);
535
+ }
536
+ const persistedStates = uniqueStates.filter((state) => state !== 'waiting' && state !== 'prioritized' && state !== 'delayed');
537
+ if (persistedStates.length > 0) {
538
+ predicates.push(`state IN (${persistedStates.map(() => '?').join(',')})`);
539
+ predicateParams.push(...persistedStates);
540
+ }
541
+ const order = options.asc ? 'ASC' : 'DESC';
542
+ const rows = this.db
543
+ .query(`SELECT * FROM jobs INDEXED BY idx_jobs_queue_created
544
+ WHERE queue = ? AND (${predicates.join(' OR ')})
545
+ ORDER BY created_at ${order}, id ${order}
546
+ LIMIT ? OFFSET ?`)
547
+ .all(queue, ...predicateParams, options.limit, options.offset);
548
+ return rows.map((row) => rowToJob(row));
549
+ }
508
550
  /**
509
551
  * Load pending jobs with pagination for efficient recovery.
510
- * Orders by priority (desc) and run_at (asc) to process urgent jobs first.
552
+ * Orders by priority (desc), run_at (asc), and id (asc) so page boundaries
553
+ * remain deterministic when scheduling fields compare equal.
511
554
  * @param limit Max jobs to return (default: 10000)
512
555
  * @param offset Skip first N jobs (default: 0)
513
556
  */
514
557
  loadPendingJobs(limit = 10000, offset = 0) {
515
558
  const rows = this.db
516
- .query("SELECT * FROM jobs WHERE state IN ('waiting', 'delayed') ORDER BY priority DESC, run_at ASC LIMIT ? OFFSET ?")
559
+ .query("SELECT * FROM jobs WHERE state IN ('waiting', 'delayed') ORDER BY priority DESC, run_at ASC, id ASC LIMIT ? OFFSET ?")
517
560
  .all(limit, offset);
518
561
  return rows.map((row) => rowToJob(row));
519
562
  }
@@ -5,6 +5,7 @@
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
7
  import { validateQueueName, validateJobData, validateJobOptions, validateNumericField, } from '../protocol';
8
+ import { validatePushBatchJobs } from './pushBatchValidation';
8
9
  /** Handle PUSH command */
9
10
  export async function handlePush(cmd, ctx, reqId) {
10
11
  const queueError = validateQueueName(cmd.queue);
@@ -86,11 +87,11 @@ export async function handlePushBatch(cmd, ctx, reqId) {
86
87
  const queueError = validateQueueName(cmd.queue);
87
88
  if (queueError)
88
89
  return resp.error(queueError, reqId);
89
- for (const job of cmd.jobs) {
90
- const dataError = validateJobData(job.data);
91
- if (dataError)
92
- return resp.error(dataError, reqId);
93
- }
90
+ // PUSH parity: per-job data/option bounds + dependsOn existence gate
91
+ // (extended to earlier same-batch custom ids for intra-batch chains).
92
+ const jobsError = validatePushBatchJobs(cmd.jobs, ctx);
93
+ if (jobsError)
94
+ return resp.error(jobsError, reqId);
94
95
  const ids = await ctx.queueManager.pushBatch(cmd.queue, cmd.jobs);
95
96
  return resp.batch(ids, reqId);
96
97
  }
@@ -128,6 +129,10 @@ export async function handlePullBatch(cmd, ctx, reqId) {
128
129
  const countError = validateNumericField(cmd.count, 'count', { min: 1, max: 1000 });
129
130
  if (countError)
130
131
  return resp.error(countError, reqId);
132
+ // Validate timeout (same bound as PULL)
133
+ const timeoutError = validateNumericField(cmd.timeout, 'timeout', { min: 0, max: 60000 });
134
+ if (timeoutError)
135
+ return resp.error(timeoutError, reqId);
131
136
  // If owner is provided, use lock-based pull
132
137
  if (cmd.owner) {
133
138
  const { jobs, tokens } = await ctx.queueManager.pullBatchWithLock(cmd.queue, cmd.count, cmd.owner, cmd.timeout ?? 0, cmd.lockTtl);
@@ -139,8 +144,9 @@ export async function handlePullBatch(cmd, ctx, reqId) {
139
144
  }
140
145
  return resp.pulledJobs(jobs, tokens, reqId);
141
146
  }
142
- // Standard pull (no locks, but still track for client release)
143
- const jobs = await ctx.queueManager.pullBatch(cmd.queue, cmd.count, 0);
147
+ // Standard pull (no locks, but still track for client release) — the
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);
144
150
  if (ctx.clientId) {
145
151
  for (const job of jobs) {
146
152
  ctx.queueManager.registerClientJob(ctx.clientId, job.id);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * PUSHB Validation
3
+ * Per-job validation for batch pushes with PUSH parity: the same
4
+ * validateJobData/validateJobOptions bounds and the same dependsOn
5
+ * existence gate handlePush enforces (core.ts).
6
+ */
7
+ import type { JobInput } from '../../../domain/types/job';
8
+ import type { HandlerContext } from '../types';
9
+ /**
10
+ * Validate every job of a PUSHB batch. Returns an error message naming the
11
+ * offending index, or null when the whole batch is valid.
12
+ *
13
+ * The dependsOn gate is EXTENDED beyond the PUSH one: a dependency may also
14
+ * reference ANY OTHER job of the same batch via its custom id (the only ids
15
+ * a client can know before the batch is applied). Order within the batch is
16
+ * deliberately irrelevant: the default-on auto-batcher groups concurrent
17
+ * add() calls in arbitrary order, so a child can legitimately precede its
18
+ * parent in the array, and the readiness layer (waitingDeps) resolves the
19
+ * chain once the whole batch is applied. Self-references are rejected, they
20
+ * can never resolve. jobIndex/completedJobs/depCompletions are hoisted out
21
+ * of the loop because PUSHB is the bulk hot path.
22
+ */
23
+ export declare function validatePushBatchJobs(jobs: JobInput[], ctx: HandlerContext): string | null;