bunqueue 2.8.4 → 2.8.5

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.
@@ -80,6 +80,8 @@ export class ContextFactory {
80
80
  shards: this.deps.shards,
81
81
  shardLocks: this.deps.shardLocks,
82
82
  completedJobs: this.deps.completedJobs,
83
+ completedJobsData: this.deps.completedJobsData,
84
+ jobResults: this.deps.jobResults,
83
85
  customIdMap: this.deps.customIdMap,
84
86
  jobIndex: this.deps.jobIndex,
85
87
  totalPushed: this.deps.metrics.totalPushed,
@@ -14,6 +14,8 @@ export interface PushContext {
14
14
  shards: Shard[];
15
15
  shardLocks: RWLock[];
16
16
  completedJobs: SetLike<JobId>;
17
+ completedJobsData: MapLike<JobId, Job>;
18
+ jobResults: MapLike<JobId, unknown>;
17
19
  customIdMap: MapLike<string, JobId>;
18
20
  jobIndex: Map<JobId, JobLocation>;
19
21
  totalPushed: {
@@ -17,19 +17,29 @@ function handleCustomId(input, shard, ctx) {
17
17
  }
18
18
  const id = jobId(input.customId);
19
19
  const existing = ctx.customIdMap.get(input.customId);
20
- // No existing mapping - register and proceed
21
- if (!existing) {
22
- ctx.customIdMap.set(input.customId, id);
23
- return { skip: false, id };
20
+ // If the existing job is still queued, the add is idempotent — return it.
21
+ if (existing) {
22
+ const location = ctx.jobIndex.get(existing);
23
+ const existingJob = location?.type === 'queue' ? shard.getQueue(location.queueName).find(existing) : null;
24
+ if (existingJob) {
25
+ return { skip: true, existingJob };
26
+ }
24
27
  }
25
- // Check if existing job is still in queue
26
- const location = ctx.jobIndex.get(existing);
27
- const existingJob = location?.type === 'queue' ? shard.getQueue(location.queueName).find(existing) : null;
28
- if (existingJob) {
29
- return { skip: true, existingJob };
28
+ // Reuse path: the id is free in the queue (no mapping, or the prior job is
29
+ // processing/completed). If the prior job COMPLETED, its row survives on disk
30
+ // (markCompleted does an UPDATE, not a DELETE) and it is still in completedJobs.
31
+ // Reusing the same deterministic id would then (a) make getJobState return
32
+ // 'completed' for the brand-new job and (b) collide on the `jobs.id` PRIMARY KEY
33
+ // at flush time. Evict the stale completed job so the reused id starts fresh as
34
+ // 'waiting' (#92). Checked regardless of customIdMap state — the mapping may have
35
+ // been cleared on completion, which would otherwise skip this path entirely.
36
+ if (ctx.completedJobs.has(id)) {
37
+ ctx.completedJobs.delete(id);
38
+ ctx.completedJobsData.delete(id);
39
+ ctx.jobResults.delete(id);
40
+ ctx.jobIndex.delete(id);
41
+ ctx.storage?.deleteJob(id); // removes the surviving row + result + any buffered insert
30
42
  }
31
- // Job gone (processing/completed) - allow reuse of customId
32
- ctx.customIdMap.delete(input.customId);
33
43
  ctx.customIdMap.set(input.customId, id);
34
44
  return { skip: false, id };
35
45
  }
@@ -56,11 +56,24 @@ export class SqliteStorage {
56
56
  if (isSqliteFullError(err)) {
57
57
  this.setDiskFull(err.message);
58
58
  }
59
- storageLog.error('Write buffer flush failed', {
60
- jobCount,
61
- error: err.message,
62
- diskFull: this._diskFull,
63
- });
59
+ // A constraint violation (e.g. duplicate jobs.id) is a PERMANENT per-row
60
+ // rejection that the WriteBuffer isolated and dropped — sibling valid jobs
61
+ // in the same flush were still persisted. Log it distinctly from a
62
+ // transient flush failure (and never route it to the DLQ, which would
63
+ // resurrect a duplicate).
64
+ if (/constraint failed/i.test(err.message)) {
65
+ storageLog.error('Write buffer rejected jobs (constraint violation, dropped)', {
66
+ rejectedJobCount: jobCount,
67
+ error: err.message,
68
+ });
69
+ }
70
+ else {
71
+ storageLog.error('Write buffer flush failed', {
72
+ jobCount,
73
+ error: err.message,
74
+ diskFull: this._diskFull,
75
+ });
76
+ }
64
77
  }, (jobs, lastError, attempts) => {
65
78
  this.handleCriticalLoss(jobs, lastError, attempts);
66
79
  });
@@ -4,13 +4,33 @@
4
4
  */
5
5
  import type { Database } from 'bun:sqlite';
6
6
  import type { Job } from '../../domain/types/job';
7
+ /** Outcome of a batch insert after isolating per-row failures. */
8
+ export interface BatchInsertResult {
9
+ /** Jobs that hit a transient (non-constraint) error — caller should retry. */
10
+ transient: Job[];
11
+ /** Jobs rejected by a permanent constraint (e.g. duplicate id) — drop, never retry. */
12
+ conflicts: Job[];
13
+ /** The originating error from the fast-path failure, for logging. */
14
+ error?: Error;
15
+ }
7
16
  /** Batch insert manager with prepared statement caching */
8
17
  export declare class BatchInsertManager {
9
18
  private readonly db;
10
19
  private readonly cache;
11
20
  constructor(db: Database);
12
- /** Insert batch of jobs using multi-row INSERT for 50-100x speedup */
13
- insertJobsBatch(jobs: Job[]): void;
21
+ /**
22
+ * Insert a batch of jobs using a single multi-row INSERT (50-100x speedup).
23
+ * On the common path every row succeeds and an empty result is returned.
24
+ *
25
+ * If the atomic batch fails (e.g. a single duplicate `jobs.id`), the rows are
26
+ * re-inserted ONE AT A TIME so a single bad row can no longer drop the rest:
27
+ * valid jobs persist, constraint violations are isolated as `conflicts` (drop,
28
+ * never retry — they would poison every future flush), and any transient
29
+ * failures are returned so the caller can retry just those. Never throws.
30
+ */
31
+ insertJobsBatch(jobs: Job[]): BatchInsertResult;
32
+ /** Fallback path: insert each job independently, isolating per-row failures. */
33
+ private insertRowByRow;
14
34
  /** Get or create cached prepared statement for batch insert */
15
35
  private getBatchInsertStmt;
16
36
  /** Insert a chunk of jobs with single multi-row INSERT */
@@ -52,7 +72,16 @@ export declare class WriteBuffer {
52
72
  add(job: Job): void;
53
73
  /** Add multiple jobs to buffer */
54
74
  addBatch(jobs: Job[]): void;
55
- /** Flush buffer to disk using double-buffering. Returns number of jobs flushed. */
75
+ /**
76
+ * Flush buffer to disk using double-buffering. Returns number of jobs persisted.
77
+ *
78
+ * Per-row isolation (see BatchInsertManager.insertJobsBatch): valid jobs are
79
+ * persisted even if a sibling row violates a constraint. Constraint conflicts
80
+ * (e.g. duplicate id) are dropped+reported and NEVER re-buffered — re-buffering
81
+ * them would poison every future flush and silently drop unrelated valid jobs
82
+ * (the #92-class data-loss bug). Transient failures are re-buffered and retried
83
+ * with exponential backoff, exactly as before.
84
+ */
56
85
  flush(): number;
57
86
  /** Schedule a retry with exponential backoff */
58
87
  private scheduleBackoffRetry;
@@ -3,6 +3,20 @@
3
3
  * High-performance batch insert with prepared statement caching
4
4
  */
5
5
  import { pack } from './sqliteSerializer';
6
+ const COLS_PER_ROW = 24;
7
+ // SQLite has a limit of ~999 variables, so batch in chunks
8
+ const MAX_ROWS_PER_INSERT = Math.floor(999 / COLS_PER_ROW);
9
+ /**
10
+ * A constraint violation (e.g. a duplicate `jobs.id` PRIMARY KEY) is PERMANENT
11
+ * for that row — retrying never succeeds. It must be isolated from the rest of
12
+ * the batch, otherwise one bad row poisons the whole atomic flush and drops
13
+ * unrelated valid jobs (data loss, #92-class). Everything else (disk I/O, busy,
14
+ * full) is treated as transient and retried.
15
+ */
16
+ function isConstraintError(err) {
17
+ const code = err.code ?? '';
18
+ return code.startsWith('SQLITE_CONSTRAINT') || /constraint failed/i.test(err.message);
19
+ }
6
20
  /** Batch insert manager with prepared statement caching */
7
21
  export class BatchInsertManager {
8
22
  db;
@@ -10,20 +24,52 @@ export class BatchInsertManager {
10
24
  constructor(db) {
11
25
  this.db = db;
12
26
  }
13
- /** Insert batch of jobs using multi-row INSERT for 50-100x speedup */
27
+ /**
28
+ * Insert a batch of jobs using a single multi-row INSERT (50-100x speedup).
29
+ * On the common path every row succeeds and an empty result is returned.
30
+ *
31
+ * If the atomic batch fails (e.g. a single duplicate `jobs.id`), the rows are
32
+ * re-inserted ONE AT A TIME so a single bad row can no longer drop the rest:
33
+ * valid jobs persist, constraint violations are isolated as `conflicts` (drop,
34
+ * never retry — they would poison every future flush), and any transient
35
+ * failures are returned so the caller can retry just those. Never throws.
36
+ */
14
37
  insertJobsBatch(jobs) {
15
38
  if (jobs.length === 0)
16
- return;
39
+ return { transient: [], conflicts: [] };
17
40
  const now = Date.now();
18
- const COLS_PER_ROW = 24;
19
- // SQLite has a limit of ~999 variables, so batch in chunks
20
- const MAX_ROWS_PER_INSERT = Math.floor(999 / COLS_PER_ROW);
21
- this.db.transaction(() => {
22
- for (let offset = 0; offset < jobs.length; offset += MAX_ROWS_PER_INSERT) {
23
- const chunk = jobs.slice(offset, offset + MAX_ROWS_PER_INSERT);
24
- this.insertJobsChunk(chunk, now);
41
+ try {
42
+ this.db.transaction(() => {
43
+ for (let offset = 0; offset < jobs.length; offset += MAX_ROWS_PER_INSERT) {
44
+ const chunk = jobs.slice(offset, offset + MAX_ROWS_PER_INSERT);
45
+ this.insertJobsChunk(chunk, now);
46
+ }
47
+ })();
48
+ return { transient: [], conflicts: [] };
49
+ }
50
+ catch (err) {
51
+ const batchError = err instanceof Error ? err : new Error(String(err));
52
+ return this.insertRowByRow(jobs, now, batchError);
53
+ }
54
+ }
55
+ /** Fallback path: insert each job independently, isolating per-row failures. */
56
+ insertRowByRow(jobs, now, batchError) {
57
+ const transient = [];
58
+ const conflicts = [];
59
+ for (const job of jobs) {
60
+ try {
61
+ // Single-row INSERT, auto-committed: succeeds/fails independently.
62
+ this.insertJobsChunk([job], now);
63
+ }
64
+ catch (e) {
65
+ const err = e instanceof Error ? e : new Error(String(e));
66
+ if (isConstraintError(err))
67
+ conflicts.push(job);
68
+ else
69
+ transient.push(job);
25
70
  }
26
- })();
71
+ }
72
+ return { transient, conflicts, error: batchError };
27
73
  }
28
74
  /** Get or create cached prepared statement for batch insert */
29
75
  getBatchInsertStmt(size) {
@@ -112,7 +158,16 @@ export class WriteBuffer {
112
158
  this.flush();
113
159
  }
114
160
  }
115
- /** Flush buffer to disk using double-buffering. Returns number of jobs flushed. */
161
+ /**
162
+ * Flush buffer to disk using double-buffering. Returns number of jobs persisted.
163
+ *
164
+ * Per-row isolation (see BatchInsertManager.insertJobsBatch): valid jobs are
165
+ * persisted even if a sibling row violates a constraint. Constraint conflicts
166
+ * (e.g. duplicate id) are dropped+reported and NEVER re-buffered — re-buffering
167
+ * them would poison every future flush and silently drop unrelated valid jobs
168
+ * (the #92-class data-loss bug). Transient failures are re-buffered and retried
169
+ * with exponential backoff, exactly as before.
170
+ */
116
171
  flush() {
117
172
  // Prevent flush after stop or concurrent flushes
118
173
  if (this.stopped || this.flushing)
@@ -125,53 +180,68 @@ export class WriteBuffer {
125
180
  this.activeBuffer = [];
126
181
  const jobCount = this.flushBuffer.length;
127
182
  try {
128
- this.batchManager.insertJobsBatch(this.flushBuffer);
129
- this.flushBuffer = []; // Clear after successful write
130
- // Reset retry state on success
131
- this.retryCount = 0;
132
- this.currentBackoffMs = this.initialBackoffMs;
133
- this.lastError = null;
134
- return jobCount;
135
- }
136
- catch (err) {
137
- const error = err instanceof Error ? err : new Error(String(err));
138
- this.lastError = error;
139
- this.retryCount++;
140
- // On failure, prepend failed jobs back to active buffer
141
- // This preserves order: failed jobs first, then new jobs
142
- this.activeBuffer = this.flushBuffer.concat(this.activeBuffer);
183
+ // BatchInsertManager.insertJobsBatch isolates per-row failures and never
184
+ // throws. Stay defensive: a manager that throws (or returns nothing) is
185
+ // treated as a transient failure of the whole batch, preserving the
186
+ // re-buffer/retry/critical-loss semantics.
187
+ let result;
188
+ try {
189
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive against a non-conforming batch manager (e.g. a test double returning undefined)
190
+ result = this.batchManager.insertJobsBatch(this.flushBuffer) ?? {
191
+ transient: [],
192
+ conflicts: [],
193
+ };
194
+ }
195
+ catch (err) {
196
+ result = {
197
+ transient: this.flushBuffer,
198
+ conflicts: [],
199
+ error: err instanceof Error ? err : new Error(String(err)),
200
+ };
201
+ }
202
+ const { transient, conflicts, error } = result;
143
203
  this.flushBuffer = [];
144
- // Check if we've exceeded max retries
145
- if (this.retryCount >= this.maxRetries) {
146
- // Move jobs to dead letter / emit critical error
147
- const lostJobs = [...this.activeBuffer];
148
- this.activeBuffer = [];
149
- if (this.onCriticalError) {
150
- this.onCriticalError(lostJobs, error, this.retryCount);
204
+ // Permanent constraint conflicts: drop + report once, never retry.
205
+ if (conflicts.length > 0) {
206
+ this.onError(error ?? new Error('Constraint violation'), conflicts.length);
207
+ }
208
+ // Transient failures: re-buffer just those and retry with backoff.
209
+ if (transient.length > 0) {
210
+ const error2 = error ?? new Error('Write buffer flush failed');
211
+ this.lastError = error2;
212
+ this.retryCount++;
213
+ // Prepend failed jobs back to active buffer (failed first, then new).
214
+ this.activeBuffer = transient.concat(this.activeBuffer);
215
+ if (this.retryCount >= this.maxRetries) {
216
+ const lostJobs = [...this.activeBuffer];
217
+ this.activeBuffer = [];
218
+ if (this.onCriticalError)
219
+ this.onCriticalError(lostJobs, error2, this.retryCount);
220
+ this.onError(error2, lostJobs.length, {
221
+ retryCount: this.retryCount,
222
+ nextBackoffMs: 0,
223
+ maxRetries: this.maxRetries,
224
+ });
225
+ this.retryCount = 0;
226
+ this.currentBackoffMs = this.initialBackoffMs;
227
+ this.lastError = null;
228
+ }
229
+ else {
230
+ const nextBackoffMs = Math.min(this.currentBackoffMs * 2, this.maxBackoffMs);
231
+ this.onError(error2, transient.length, {
232
+ retryCount: this.retryCount,
233
+ nextBackoffMs,
234
+ maxRetries: this.maxRetries,
235
+ });
236
+ this.scheduleBackoffRetry();
151
237
  }
152
- // Also call onError with retry info for logging
153
- this.onError(error, lostJobs.length, {
154
- retryCount: this.retryCount,
155
- nextBackoffMs: 0, // No more retries
156
- maxRetries: this.maxRetries,
157
- });
158
- // Reset retry state
159
- this.retryCount = 0;
160
- this.currentBackoffMs = this.initialBackoffMs;
161
- this.lastError = null;
162
- throw err;
238
+ return jobCount - transient.length - conflicts.length;
163
239
  }
164
- // Calculate next backoff with exponential increase
165
- const nextBackoffMs = Math.min(this.currentBackoffMs * 2, this.maxBackoffMs);
166
- // Call error callback with retry information
167
- this.onError(error, jobCount, {
168
- retryCount: this.retryCount,
169
- nextBackoffMs: nextBackoffMs,
170
- maxRetries: this.maxRetries,
171
- });
172
- // Schedule backoff retry
173
- this.scheduleBackoffRetry();
174
- throw err;
240
+ // Success (or success-modulo-dropped-conflicts): reset retry state.
241
+ this.retryCount = 0;
242
+ this.currentBackoffMs = this.initialBackoffMs;
243
+ this.lastError = null;
244
+ return jobCount - conflicts.length;
175
245
  }
176
246
  finally {
177
247
  this.flushing = false;
@@ -287,6 +357,10 @@ export class WriteBuffer {
287
357
  const flushed = this.flush();
288
358
  clearTimeout(timeout);
289
359
  this.stopped = true;
360
+ // flush() no longer throws on transient failure (it re-buffers); report
361
+ // anything that could not be persisted so it isn't silently lost.
362
+ if (this.pendingCount > 0)
363
+ this.reportLostJobs();
290
364
  resolve(flushed);
291
365
  }
292
366
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.4",
3
+ "version": "2.8.5",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",