bunqueue 2.8.4 → 2.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/contextFactory.js +2 -0
- package/dist/application/operations/push.d.ts +2 -0
- package/dist/application/operations/push.js +21 -11
- package/dist/client/queue/queue.js +2 -0
- package/dist/client/tcp/client.d.ts +9 -0
- package/dist/client/tcp/client.js +38 -3
- package/dist/client/tcp/connection.js +11 -0
- package/dist/client/tcp/health.d.ts +14 -0
- package/dist/client/tcp/health.js +24 -0
- package/dist/client/tcp/types.d.ts +10 -0
- package/dist/client/tcp/types.js +1 -0
- package/dist/client/tcpPool.js +2 -0
- package/dist/client/types.d.ts +6 -0
- package/dist/client/worker/worker.js +1 -0
- package/dist/infrastructure/persistence/sqlite.js +18 -5
- package/dist/infrastructure/persistence/sqliteBatch.d.ts +32 -3
- package/dist/infrastructure/persistence/sqliteBatch.js +129 -55
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
21
|
-
if (
|
|
22
|
-
ctx.
|
|
23
|
-
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
|
@@ -63,6 +63,7 @@ export class Queue {
|
|
|
63
63
|
poolSize,
|
|
64
64
|
pingInterval: connOpts.pingInterval,
|
|
65
65
|
commandTimeout: connOpts.commandTimeout,
|
|
66
|
+
maxCommandTimeouts: connOpts.maxCommandTimeouts,
|
|
66
67
|
pipelining: connOpts.pipelining,
|
|
67
68
|
maxInFlight: connOpts.maxInFlight,
|
|
68
69
|
});
|
|
@@ -76,6 +77,7 @@ export class Queue {
|
|
|
76
77
|
poolSize,
|
|
77
78
|
pingInterval: connOpts.pingInterval,
|
|
78
79
|
commandTimeout: connOpts.commandTimeout,
|
|
80
|
+
maxCommandTimeouts: connOpts.maxCommandTimeouts,
|
|
79
81
|
pipelining: connOpts.pipelining,
|
|
80
82
|
maxInFlight: connOpts.maxInFlight,
|
|
81
83
|
});
|
|
@@ -73,6 +73,15 @@ export declare class TcpClient extends EventEmitter {
|
|
|
73
73
|
/** Send ping to check connection health */
|
|
74
74
|
ping(): Promise<boolean>;
|
|
75
75
|
private handlePingFailure;
|
|
76
|
+
/**
|
|
77
|
+
* A command timed out. On a half-open socket (peer gone, no FIN/RST) writes
|
|
78
|
+
* keep succeeding but no response ever returns — every command times out
|
|
79
|
+
* while the socket still looks "connected". The health-check ping is one way
|
|
80
|
+
* to notice, but it can be disabled or slower than real traffic, leaving a
|
|
81
|
+
* worker's PULL loop to time out forever without ever reconnecting (#94).
|
|
82
|
+
* Treat a sustained run of timeouts as a dead link and force a reconnect.
|
|
83
|
+
*/
|
|
84
|
+
private handleCommandTimeout;
|
|
76
85
|
private forceReconnect;
|
|
77
86
|
/** Get connection health metrics */
|
|
78
87
|
getHealth(): ConnectionHealth;
|
|
@@ -79,6 +79,7 @@ export class TcpClient extends EventEmitter {
|
|
|
79
79
|
this.health = new HealthTracker({
|
|
80
80
|
pingInterval: this.options.pingInterval,
|
|
81
81
|
maxPingFailures: this.options.maxPingFailures,
|
|
82
|
+
maxCommandTimeouts: this.options.maxCommandTimeouts,
|
|
82
83
|
});
|
|
83
84
|
this.reconnect = new ReconnectManager({
|
|
84
85
|
maxReconnectAttempts: this.options.maxReconnectAttempts,
|
|
@@ -258,15 +259,44 @@ export class TcpClient extends EventEmitter {
|
|
|
258
259
|
this.emit('health', { type: 'ping_failed' });
|
|
259
260
|
}
|
|
260
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* A command timed out. On a half-open socket (peer gone, no FIN/RST) writes
|
|
264
|
+
* keep succeeding but no response ever returns — every command times out
|
|
265
|
+
* while the socket still looks "connected". The health-check ping is one way
|
|
266
|
+
* to notice, but it can be disabled or slower than real traffic, leaving a
|
|
267
|
+
* worker's PULL loop to time out forever without ever reconnecting (#94).
|
|
268
|
+
* Treat a sustained run of timeouts as a dead link and force a reconnect.
|
|
269
|
+
*/
|
|
270
|
+
handleCommandTimeout() {
|
|
271
|
+
if (this.health.recordCommandTimeout()) {
|
|
272
|
+
this.emit('health', { type: 'unhealthy', reason: 'max_command_timeouts' });
|
|
273
|
+
this.forceReconnect();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
261
276
|
forceReconnect() {
|
|
262
277
|
if (this.reconnect.isClosed())
|
|
263
278
|
return;
|
|
264
279
|
if (this.socket) {
|
|
265
|
-
|
|
280
|
+
// end() can throw on an already-errored/half-dead socket. Swallow it:
|
|
281
|
+
// failing to close the corpse must NOT abort the reconnect path below,
|
|
282
|
+
// or the connection would stay wedged forever (the #94 failure mode).
|
|
283
|
+
try {
|
|
284
|
+
this.socket.end();
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
/* socket already torn down */
|
|
288
|
+
}
|
|
266
289
|
this.socket = null;
|
|
267
290
|
}
|
|
268
291
|
this.connected = false;
|
|
269
292
|
this.health.stopPing();
|
|
293
|
+
// Settle every in-flight/queued command NOW. Otherwise their per-command
|
|
294
|
+
// timeouts keep ticking and fire AFTER the fresh socket is up — each stale
|
|
295
|
+
// timeout bumps the new connection's dead-link counter and can re-trigger
|
|
296
|
+
// forceReconnect in a loop (a reconnect storm that never stabilises). It
|
|
297
|
+
// also unblocks awaiting callers (e.g. a Worker's PULL) immediately instead
|
|
298
|
+
// of making them wait out the full commandTimeout on a corpse.
|
|
299
|
+
this.commands.rejectAll(new Error('Connection lost'));
|
|
270
300
|
if (this.reconnect.canReconnect())
|
|
271
301
|
this.reconnect.scheduleReconnect(() => this.connect());
|
|
272
302
|
}
|
|
@@ -343,6 +373,8 @@ export class TcpClient extends EventEmitter {
|
|
|
343
373
|
if (removed) {
|
|
344
374
|
this.health.recordError();
|
|
345
375
|
next.reject(new Error('Command timeout'));
|
|
376
|
+
// In-flight command got no response: count it toward dead-link detection.
|
|
377
|
+
this.handleCommandTimeout();
|
|
346
378
|
}
|
|
347
379
|
}, this.options.commandTimeout);
|
|
348
380
|
next.timeout = newTimeout;
|
|
@@ -365,17 +397,20 @@ export class TcpClient extends EventEmitter {
|
|
|
365
397
|
let pendingRef;
|
|
366
398
|
const promise = new Promise((resolve, reject) => {
|
|
367
399
|
const timeout = setTimeout(() => {
|
|
368
|
-
// Try to remove from queue first
|
|
400
|
+
// Try to remove from queue first. A still-queued command never reached
|
|
401
|
+
// the socket (e.g. waiting on connect), so it is NOT evidence of a dead
|
|
402
|
+
// link — reject it but don't count it toward dead-link detection.
|
|
369
403
|
if (this.commands.remove(id)) {
|
|
370
404
|
this.health.recordError();
|
|
371
405
|
reject(new Error('Command timeout'));
|
|
372
406
|
return;
|
|
373
407
|
}
|
|
374
|
-
// Try to remove from in-flight
|
|
408
|
+
// Try to remove from in-flight: this one WAS sent and got no response.
|
|
375
409
|
const removed = this.commands.removeByReqId(reqId);
|
|
376
410
|
if (removed) {
|
|
377
411
|
this.health.recordError();
|
|
378
412
|
reject(new Error('Command timeout'));
|
|
413
|
+
this.handleCommandTimeout();
|
|
379
414
|
}
|
|
380
415
|
}, this.options.commandTimeout);
|
|
381
416
|
pendingRef = {
|
|
@@ -42,6 +42,17 @@ export async function createConnection(target, connectTimeout, events) {
|
|
|
42
42
|
},
|
|
43
43
|
open(sock) {
|
|
44
44
|
cleanup();
|
|
45
|
+
// Enable TCP keepalive so the OS probes idle connections and surfaces a
|
|
46
|
+
// dead peer (suspended host, NAT/LB drop) via an error/close event,
|
|
47
|
+
// instead of a half-open socket lingering until tcp_retries2 (~15 min).
|
|
48
|
+
// Best-effort: not all platforms honor the delay, and older Bun builds
|
|
49
|
+
// may lack the method — never let it abort connection setup. See #94.
|
|
50
|
+
try {
|
|
51
|
+
sock.setKeepAlive?.(true, 15000);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* keepalive unsupported on this platform/runtime */
|
|
55
|
+
}
|
|
45
56
|
socketData.write = (d) => sock.write(d);
|
|
46
57
|
socketData.end = () => sock.end();
|
|
47
58
|
connectionResolved = true;
|
|
@@ -7,6 +7,11 @@ import type { ConnectionHealth } from './types';
|
|
|
7
7
|
export interface HealthConfig {
|
|
8
8
|
pingInterval: number;
|
|
9
9
|
maxPingFailures: number;
|
|
10
|
+
/**
|
|
11
|
+
* Consecutive command timeouts before the link is concluded dead (0 = off).
|
|
12
|
+
* Optional for backward compatibility; defaults to 3 when omitted.
|
|
13
|
+
*/
|
|
14
|
+
maxCommandTimeouts?: number;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
17
|
* Tracks connection health metrics
|
|
@@ -14,6 +19,7 @@ export interface HealthConfig {
|
|
|
14
19
|
export declare class HealthTracker {
|
|
15
20
|
private readonly config;
|
|
16
21
|
private consecutivePingFailures;
|
|
22
|
+
private consecutiveCommandTimeouts;
|
|
17
23
|
private lastSuccessAt;
|
|
18
24
|
private lastErrorAt;
|
|
19
25
|
private connectedAt;
|
|
@@ -35,6 +41,14 @@ export declare class HealthTracker {
|
|
|
35
41
|
recordPingSuccess(latencyMs: number): void;
|
|
36
42
|
/** Record ping failure, returns true if max failures reached */
|
|
37
43
|
recordPingFailure(): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Record a command timeout. Returns true when the configured consecutive
|
|
46
|
+
* threshold is reached (and the feature is enabled), signalling the caller to
|
|
47
|
+
* force a reconnect. Any intervening success resets the counter, so this only
|
|
48
|
+
* fires on a sustained run of timeouts — the signature of a dead/half-open
|
|
49
|
+
* socket where writes succeed but no response ever comes back.
|
|
50
|
+
*/
|
|
51
|
+
recordCommandTimeout(): boolean;
|
|
38
52
|
/** Get current health metrics */
|
|
39
53
|
getHealth(state: 'connected' | 'connecting' | 'disconnected' | 'closed'): ConnectionHealth;
|
|
40
54
|
/** Start ping timer */
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* TCP Health Tracker
|
|
3
3
|
* Monitors connection health with ping and latency tracking
|
|
4
4
|
*/
|
|
5
|
+
/** Default consecutive command-timeout threshold when not configured. */
|
|
6
|
+
const DEFAULT_MAX_COMMAND_TIMEOUTS = 3;
|
|
5
7
|
/**
|
|
6
8
|
* Tracks connection health metrics
|
|
7
9
|
*/
|
|
8
10
|
export class HealthTracker {
|
|
9
11
|
config;
|
|
10
12
|
consecutivePingFailures = 0;
|
|
13
|
+
consecutiveCommandTimeouts = 0;
|
|
11
14
|
lastSuccessAt = null;
|
|
12
15
|
lastErrorAt = null;
|
|
13
16
|
connectedAt = null;
|
|
@@ -23,6 +26,9 @@ export class HealthTracker {
|
|
|
23
26
|
recordSuccess(latencyMs) {
|
|
24
27
|
this.lastSuccessAt = Date.now();
|
|
25
28
|
this.totalCommands++;
|
|
29
|
+
// A real response proves the link is alive: the prior timeouts were not a
|
|
30
|
+
// sustained run, so reset the dead-link counter ("consecutive" must mean it).
|
|
31
|
+
this.consecutiveCommandTimeouts = 0;
|
|
26
32
|
this.recordLatency(latencyMs);
|
|
27
33
|
}
|
|
28
34
|
/** Record command error */
|
|
@@ -38,10 +44,13 @@ export class HealthTracker {
|
|
|
38
44
|
recordConnected() {
|
|
39
45
|
this.connectedAt = Date.now();
|
|
40
46
|
this.consecutivePingFailures = 0;
|
|
47
|
+
this.consecutiveCommandTimeouts = 0;
|
|
41
48
|
}
|
|
42
49
|
/** Record ping success */
|
|
43
50
|
recordPingSuccess(latencyMs) {
|
|
51
|
+
// A successful ping is also proof the link is alive — clear both suspicions.
|
|
44
52
|
this.consecutivePingFailures = 0;
|
|
53
|
+
this.consecutiveCommandTimeouts = 0;
|
|
45
54
|
this.recordLatency(latencyMs);
|
|
46
55
|
}
|
|
47
56
|
/** Record ping failure, returns true if max failures reached */
|
|
@@ -51,6 +60,20 @@ export class HealthTracker {
|
|
|
51
60
|
this.totalErrors++;
|
|
52
61
|
return this.consecutivePingFailures >= this.config.maxPingFailures;
|
|
53
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Record a command timeout. Returns true when the configured consecutive
|
|
65
|
+
* threshold is reached (and the feature is enabled), signalling the caller to
|
|
66
|
+
* force a reconnect. Any intervening success resets the counter, so this only
|
|
67
|
+
* fires on a sustained run of timeouts — the signature of a dead/half-open
|
|
68
|
+
* socket where writes succeed but no response ever comes back.
|
|
69
|
+
*/
|
|
70
|
+
recordCommandTimeout() {
|
|
71
|
+
const max = this.config.maxCommandTimeouts ?? DEFAULT_MAX_COMMAND_TIMEOUTS;
|
|
72
|
+
if (max <= 0)
|
|
73
|
+
return false;
|
|
74
|
+
this.consecutiveCommandTimeouts++;
|
|
75
|
+
return this.consecutiveCommandTimeouts >= max;
|
|
76
|
+
}
|
|
54
77
|
/** Get current health metrics */
|
|
55
78
|
getHealth(state) {
|
|
56
79
|
const avgLatency = this.latencyHistory.length > 0
|
|
@@ -63,6 +86,7 @@ export class HealthTracker {
|
|
|
63
86
|
lastErrorAt: this.lastErrorAt,
|
|
64
87
|
avgLatencyMs: Math.round(avgLatency * 100) / 100,
|
|
65
88
|
consecutivePingFailures: this.consecutivePingFailures,
|
|
89
|
+
consecutiveCommandTimeouts: this.consecutiveCommandTimeouts,
|
|
66
90
|
totalCommands: this.totalCommands,
|
|
67
91
|
totalErrors: this.totalErrors,
|
|
68
92
|
uptimeMs: this.connectedAt ? Date.now() - this.connectedAt : 0,
|
|
@@ -26,6 +26,14 @@ export interface ConnectionOptions {
|
|
|
26
26
|
pingInterval?: number;
|
|
27
27
|
/** Max consecutive ping failures before forcing reconnect (default: 3) */
|
|
28
28
|
maxPingFailures?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Max consecutive command timeouts (with no intervening success) before the
|
|
31
|
+
* connection is concluded dead and reconnect is forced (default: 3, 0 to
|
|
32
|
+
* disable). This is the recovery path for a half-open socket when the
|
|
33
|
+
* health-check ping is disabled or slower than real traffic — a worker whose
|
|
34
|
+
* PULLs keep timing out no longer stalls forever waiting on the ping. See #94.
|
|
35
|
+
*/
|
|
36
|
+
maxCommandTimeouts?: number;
|
|
29
37
|
/** Enable pipelining - multiple commands in flight (default: true) */
|
|
30
38
|
pipelining?: boolean;
|
|
31
39
|
/** Max commands in flight when pipelining (default: 100) */
|
|
@@ -45,6 +53,8 @@ export interface ConnectionHealth {
|
|
|
45
53
|
avgLatencyMs: number;
|
|
46
54
|
/** Consecutive ping failures */
|
|
47
55
|
consecutivePingFailures: number;
|
|
56
|
+
/** Consecutive command timeouts with no intervening success */
|
|
57
|
+
consecutiveCommandTimeouts: number;
|
|
48
58
|
/** Total commands sent */
|
|
49
59
|
totalCommands: number;
|
|
50
60
|
/** Total errors */
|
package/dist/client/tcp/types.js
CHANGED
package/dist/client/tcpPool.js
CHANGED
|
@@ -29,6 +29,7 @@ export class TcpConnectionPool {
|
|
|
29
29
|
autoReconnect: options.autoReconnect ?? true,
|
|
30
30
|
pingInterval: options.pingInterval ?? 30000,
|
|
31
31
|
maxPingFailures: options.maxPingFailures ?? 3,
|
|
32
|
+
maxCommandTimeouts: options.maxCommandTimeouts ?? 3,
|
|
32
33
|
pipelining: options.pipelining ?? true,
|
|
33
34
|
maxInFlight: options.maxInFlight ?? 100,
|
|
34
35
|
};
|
|
@@ -46,6 +47,7 @@ export class TcpConnectionPool {
|
|
|
46
47
|
autoReconnect: this.options.autoReconnect,
|
|
47
48
|
pingInterval: this.options.pingInterval,
|
|
48
49
|
maxPingFailures: this.options.maxPingFailures,
|
|
50
|
+
maxCommandTimeouts: this.options.maxCommandTimeouts,
|
|
49
51
|
});
|
|
50
52
|
this.clients.push(client);
|
|
51
53
|
}
|
package/dist/client/types.d.ts
CHANGED
|
@@ -376,6 +376,12 @@ export interface ConnectionOptions {
|
|
|
376
376
|
pingInterval?: number;
|
|
377
377
|
/** Command timeout in ms (default: 30000) */
|
|
378
378
|
commandTimeout?: number;
|
|
379
|
+
/**
|
|
380
|
+
* Consecutive command timeouts (no intervening success) before the connection
|
|
381
|
+
* is concluded dead and a reconnect is forced (default: 3, 0 to disable).
|
|
382
|
+
* Recovery path for a half-open socket independent of the health-check ping. See #94.
|
|
383
|
+
*/
|
|
384
|
+
maxCommandTimeouts?: number;
|
|
379
385
|
/** Enable TCP pipelining (default: true) */
|
|
380
386
|
pipelining?: boolean;
|
|
381
387
|
/** Max commands in flight per connection (default: 100) */
|
|
@@ -47,6 +47,7 @@ function createTcpPool(opts, concurrency) {
|
|
|
47
47
|
poolSize,
|
|
48
48
|
pingInterval: connOpts.pingInterval,
|
|
49
49
|
commandTimeout: connOpts.commandTimeout,
|
|
50
|
+
maxCommandTimeouts: connOpts.maxCommandTimeouts,
|
|
50
51
|
pipelining: connOpts.pipelining,
|
|
51
52
|
maxInFlight: connOpts.maxInFlight,
|
|
52
53
|
});
|
|
@@ -56,11 +56,24 @@ export class SqliteStorage {
|
|
|
56
56
|
if (isSqliteFullError(err)) {
|
|
57
57
|
this.setDiskFull(err.message);
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
13
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
|
|
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.
|
|
3
|
+
"version": "2.8.6",
|
|
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",
|