Skip to content

Commit 999b6f0

Browse files
egeominotticlaude
andcommitted
fix(stability): close 8 audit + destruction-test bugs (RED→GREEN repros)
End-to-end audit + adversarial destruction test of v2.8.13. The data plane was already bulletproof (exactly-once through a SIGKILL flood, zero corruption, lossless crash recovery, bad-input isolation); these are feature-conditional control-plane / resource-hygiene defects. Each fix ships a committed reproduction test. - concurrency slot leak on lock expiry -> setConcurrency(N) queue wedged to 0 (lockManager: releaseJobResources on requeue/DLQ) - dependency children orphaned for undefined-result (across restart) and removeOnComplete parents (depCompletions set + state='completed' recovery) - addBulk/PUSHB ignored durable -> batch durable now written immediately in one transaction (push.ts, sqlite.insertJobsBatch) - pool socket drop re-dispatched in-flight jobs (double exec) -> releaseClientJobs skips renewalCount>0; worker renews just-pulled locks immediately - Worker.close() hang on group-limited buffered jobs -> requeue buffered, drain on activeJobs only, force-close pre-empts - worker not re-registered after TCP reconnect -> tcpPool.onReconnect + worker re-register (visibility only) - moveToWaitingChildren stranded job -> getJob/getJobByCustomId/cancelJob consult waitingChildren - perQueueMetrics unbounded -> LRU-bounded + freed by obliterate Verified: tsc clean; unit 5640/5641, TCP 59/59, embedded 272/273 (2 pre-existing timing flakes pass in isolation); 9 repro files (20 tests) green; 161/161 disconnect-related suites; skeptic PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8d77a01 commit 999b6f0

26 files changed

Lines changed: 1732 additions & 51 deletions

docs/src/content/docs/changelog.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ head:
1010

1111
All notable changes to bunqueue are documented here.
1212

13+
## [2.8.14] - 2026-06-15
14+
15+
### Fixed — 8 stability bugs from an end-to-end audit + destruction test (each with a RED→GREEN reproduction test)
16+
17+
The data plane was already bulletproof under the destruction test (exactly-once held through a SIGKILL flood, zero corruption, lossless crash recovery, bad-input isolation). These fixes close feature-conditional defects in the control plane and resource hygiene. No change to data correctness or process stability for the default producer/consumer path.
18+
19+
- **Concurrency slot leak on lock expiry** (`lockManager.ts`): `requeueExpiredJob` / `handleMaxStallsExceeded` now call `shard.releaseJobResources()` before re-queue/DLQ, mirroring the stall-detection paths. Previously a queue with `setConcurrency(N)` permanently wedged (throughput → 0) after N lock expiries under worker churn.
20+
- **Dependency children orphaned** (`ack.ts`, `ackHelpers.ts`, `dependencyProcessor.ts`, `push.ts`, `backgroundTasks.ts`, `sqlite.ts`): a child `dependsOn` a parent that returned `undefined` (across a restart) or had `removeOnComplete: true` was silently never run and dropped after 1h. Added a bounded `depCompletions` set for removeOnComplete parents and made dependency recovery recognize `state='completed'` rows (not only `job_results`). Fixes late-dependent ordering too.
21+
- **`addBulk` / PUSHB ignored `durable`** (`push.ts`, `sqlite.ts`): durable batch jobs sat in the 10ms write buffer instead of being written immediately like a single durable push. `insertJobsBatch(jobs, durable)` now writes the durable subset to disk atomically (single transaction), bypassing the buffer.
22+
- **Pool socket drop re-dispatched in-flight jobs** (`clientTracking.ts`, `worker.ts`): with `poolSize > 1`, dropping the connection that pulled a job re-queued a job a live worker was still running (double execution). `releaseClientJobs` now skips jobs whose lock was renewed (`renewalCount > 0`); the worker renews just-pulled locks immediately so the window cannot open.
23+
- **`Worker.close()` hang on buffered jobs** (`worker.ts`): a graceful close with group-limited buffered jobs hung forever; `close(true)` could not pre-empt it. Buffered (pulled-but-unstarted) jobs are now requeued on close, the drain waits only on genuinely in-flight jobs, and a force close pre-empts an in-progress graceful close.
24+
- **Worker not re-registered after a TCP reconnect** (`tcpPool.ts`, `worker.ts`): after a transient drop the worker vanished from `ListWorkers` / `getForQueue` while still consuming jobs. The pool now exposes `onReconnect()` and the worker re-registers on reconnect. (Visibility only — no data loss.)
25+
- **`moveToWaitingChildren` stranded the job** (`queryOperations.ts`, `jobManagement.ts`): a job moved to waiting-children was invisible to `getJob` and uncancellable. `getJob` / `getJobByCustomId` / `cancelJob` now consult `waitingChildren`.
26+
- **`perQueueMetrics` unbounded growth** (`queueManager.ts`, `cleanupTasks.ts`): the per-queue metrics map grew one permanent entry per distinct queue name and was not freed by `obliterate()`. It is now LRU-bounded and freed by `obliterate()`; cumulative counters survive a transient drain.
27+
1328
## [2.8.13] - 2026-06-15
1429

1530
### Fixed — explicit `job.moveToFailed(err)` now carries the stacktrace (#74 follow-up)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bunqueue",
3-
"version": "2.8.13",
3+
"version": "2.8.14",
44
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
55
"type": "module",
66
"main": "dist/main.js",

src/application/cleanupTasks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ function cleanEmptyQueues(ctx: BackgroundContext): void {
247247
shard.clearQueueLimiters(queueName);
248248
shard.stallConfig.delete(queueName);
249249
shard.dlqConfig.delete(queueName);
250+
// NOTE: perQueueMetrics is intentionally NOT pruned here — it is an
251+
// LRU-bounded map and these counters are cumulative, so they must survive
252+
// a transient drain (a busy queue momentarily empty must not reset to 0).
253+
// obliterate() reclaims it explicitly; the LRU cap bounds growth for
254+
// ephemeral/dynamically-named queues.
250255
ctx.dashboardEmit?.('queue:removed', { queue: queueName });
251256
ctx.unregisterQueueName(queueName);
252257
}

src/application/clientTracking.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ export async function releaseClientJobs(clientId: string, ctx: LockContext): Pro
6262
const loc = ctx.jobIndex.get(jobId);
6363
if (loc?.type !== 'processing') continue;
6464

65+
// A job whose lock has been renewed since pull (renewalCount > 0) is being
66+
// actively heartbeated by a live worker. With a pooled client, heartbeats
67+
// travel on a DIFFERENT connection than the one that pulled, so THIS socket
68+
// closing does not mean the worker died — re-queuing here would re-dispatch
69+
// (double-execute) a job the worker still holds. Leave it; lock expiry /
70+
// stall detection reclaims it if the worker truly stops heartbeating.
71+
// A never-renewed lock (renewalCount === 0) keeps the original fast-recovery
72+
// behavior: requeue immediately on disconnect.
73+
const lock = ctx.jobLocks.get(jobId);
74+
if (lock && lock.renewalCount > 0) continue;
75+
6576
const procIdx = loc.shardIdx;
6677
const job = ctx.processingShards[procIdx].get(jobId);
6778
if (!job) continue;

src/application/contextFactory.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { JobLogEntry } from '../domain/types/worker';
88
import type { Shard } from '../domain/queue/shard';
99
import type { SqliteStorage } from '../infrastructure/persistence/sqlite';
1010
import type { RWLock } from '../shared/lock';
11-
import type { LRUMap, BoundedSet, BoundedMap } from '../shared/lru';
11+
import type { LRUMap, BoundedSet, BoundedMap, MapLike } from '../shared/lru';
1212
import type { WebhookManager } from './webhookManager';
1313
import type { WorkerManager } from './workerManager';
1414
import type { EventsManager } from './eventsManager';
@@ -34,6 +34,7 @@ export interface ContextDependencies {
3434
jobIndex: Map<JobId, JobLocation>;
3535
completedJobs: BoundedSet<JobId>;
3636
completedJobsData: BoundedMap<JobId, Job>;
37+
depCompletions?: BoundedSet<JobId>;
3738
jobResults: LRUMap<JobId, unknown>;
3839
customIdMap: LRUMap<string, JobId>;
3940
jobLogs: LRUMap<JobId, JobLogEntry[]>;
@@ -55,7 +56,7 @@ export interface ContextDependencies {
5556
};
5657
startTime: number;
5758
maxLogsPerJob: number;
58-
perQueueMetrics: Map<string, { totalCompleted: bigint; totalFailed: bigint }>;
59+
perQueueMetrics: MapLike<string, { totalCompleted: bigint; totalFailed: bigint }>;
5960
}
6061

6162
/** Callbacks needed for some contexts */
@@ -105,6 +106,7 @@ export class ContextFactory {
105106
processingLocks: this.deps.processingLocks,
106107
jobIndex: this.deps.jobIndex,
107108
completedJobs: this.deps.completedJobs,
109+
depCompletions: this.deps.depCompletions,
108110
jobResults: this.deps.jobResults,
109111
customIdMap: this.deps.customIdMap,
110112
jobLogs: this.deps.jobLogs,
@@ -154,6 +156,7 @@ export class ContextFactory {
154156
shardLocks: this.deps.shardLocks,
155157
completedJobs: this.deps.completedJobs,
156158
completedJobsData: this.deps.completedJobsData,
159+
depCompletions: this.deps.depCompletions,
157160
jobResults: this.deps.jobResults,
158161
customIdMap: this.deps.customIdMap,
159162
jobIndex: this.deps.jobIndex,
@@ -186,6 +189,7 @@ export class ContextFactory {
186189
processingLocks: this.deps.processingLocks,
187190
completedJobs: this.deps.completedJobs,
188191
completedJobsData: this.deps.completedJobsData,
192+
depCompletions: this.deps.depCompletions,
189193
jobResults: this.deps.jobResults,
190194
jobIndex: this.deps.jobIndex,
191195
customIdMap: this.deps.customIdMap,

src/application/dependencyProcessor.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,16 @@ export async function processPendingDependencies(ctx: BackgroundContext): Promis
4646
const shard = ctx.shards[i];
4747
const jobsToPromote: Job[] = [];
4848

49-
// Check which jobs have all dependencies satisfied (inside lock)
49+
// Check which jobs have all dependencies satisfied (inside lock).
50+
// A removeOnComplete parent is not in completedJobs (its full record was
51+
// dropped to bound memory), so also honor its bare-id depCompletions entry.
5052
for (const jobId of jobIdsToCheck) {
5153
const job = shard.waitingDeps.get(jobId);
52-
if (job?.dependsOn.every((dep) => ctx.completedJobs.has(dep))) {
54+
if (
55+
job?.dependsOn.every(
56+
(dep) => ctx.completedJobs.has(dep) || (ctx.depCompletions?.has(dep) ?? false)
57+
)
58+
) {
5359
jobsToPromote.push(job);
5460
}
5561
}
@@ -61,6 +67,12 @@ export async function processPendingDependencies(ctx: BackgroundContext): Promis
6167
});
6268
})
6369
);
70+
71+
// NOTE: depCompletions is intentionally NOT pruned here. It is a FIFO
72+
// BoundedSet (same cap as completedJobs), so it self-bounds. Pruning eagerly
73+
// once "no waiters remain" would orphan a dependent pushed AFTER a
74+
// removeOnComplete parent completed — exactly the symmetry completedJobs
75+
// provides for normal parents (readiness holds for the whole bounded window).
6476
}
6577

6678
/** Move jobs from waitingDeps to the active queue */

src/application/lockManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ interface MaxStallsOptions {
166166
/** Move job to DLQ when max stalls exceeded */
167167
function handleMaxStallsExceeded(opts: MaxStallsOptions): void {
168168
const { jobId, job, lock, shard, ctx, now } = opts;
169+
// Release the concurrency slot (+group+uniqueKey) acquired at pull before
170+
// moving to DLQ — otherwise the slot leaks (mirrors
171+
// stallDetection.moveStalliedJobToDlq).
172+
shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
169173
shard.addToDlq(job, FailureReason.Stalled, `Lock expired after ${lock.renewalCount} renewals`);
170174
ctx.jobIndex.set(jobId, { type: 'dlq', queueName: job.queue });
171175

@@ -193,6 +197,10 @@ interface RequeueOptions {
193197
function requeueExpiredJob(opts: RequeueOptions): void {
194198
const { jobId, job, queue, idx, ctx, now } = opts;
195199
const shard = ctx.shards[idx];
200+
// Release the concurrency slot (+group+uniqueKey) acquired at pull before
201+
// re-pushing — otherwise the slot leaks and the queue wedges (mirrors
202+
// stallDetection.retryStalliedJob).
203+
shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
196204
queue.push(job);
197205
const isDelayed = job.runAt > now;
198206
shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);

src/application/operations/ack.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ export interface AckContext {
4040
processingLocks: RWLock[];
4141
completedJobs: SetLike<JobId>;
4242
completedJobsData: MapLike<JobId, Job>;
43+
/** Bare completion ids for removeOnComplete jobs so dependents can unblock */
44+
depCompletions?: SetLike<JobId>;
4345
jobResults: MapLike<JobId, unknown>;
4446
jobIndex: Map<JobId, JobLocation>;
4547
customIdMap?: MapLike<string, JobId>;
4648
totalCompleted: { value: bigint };
4749
totalFailed: { value: bigint };
48-
perQueueMetrics?: Map<string, { totalCompleted: bigint; totalFailed: bigint }>;
50+
perQueueMetrics?: MapLike<string, { totalCompleted: bigint; totalFailed: bigint }>;
4951
broadcast: (event: {
5052
eventType: EventType;
5153
queue: string;
@@ -113,6 +115,11 @@ export async function ackJob(jobId: JobId, result: unknown, ctx: AckContext): Pr
113115
} else {
114116
ctx.jobIndex.delete(jobId);
115117
ctx.storage?.deleteJob(jobId);
118+
// removeOnComplete drops the full job (index + data + persisted row) to bound
119+
// memory, but dependent jobs gate readiness on completedJobs.has(parentId).
120+
// Record the bare completion id (no payload) so dependents still unblock,
121+
// without making the job appear in state/stats queries.
122+
ctx.depCompletions?.add(jobId);
116123
}
117124

118125
ctx.totalCompleted.value++;

src/application/operations/ackHelpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,13 @@ export interface FinalizeContext {
157157
storage: SqliteStorage | null;
158158
completedJobs: SetLike<JobId>;
159159
completedJobsData: MapLike<JobId, Job>;
160+
/** Bare completion ids for removeOnComplete jobs so dependents can unblock */
161+
depCompletions?: SetLike<JobId>;
160162
jobResults: MapLike<JobId, unknown>;
161163
jobIndex: Map<JobId, JobLocation>;
162164
customIdMap?: MapLike<string, JobId>;
163165
totalCompleted: { value: bigint };
164-
perQueueMetrics?: Map<string, { totalCompleted: bigint; totalFailed: bigint }>;
166+
perQueueMetrics?: MapLike<string, { totalCompleted: bigint; totalFailed: bigint }>;
165167
broadcast: (event: {
166168
eventType: EventType;
167169
queue: string;
@@ -241,6 +243,11 @@ export function finalizeBatchAck<T>(
241243
} else {
242244
ctx.jobIndex.delete(jobId);
243245
if (hasStorage) storage.deleteJob(jobId);
246+
// removeOnComplete drops the full job to bound memory, but dependents gate
247+
// readiness on completedJobs.has(parentId). Record the bare completion id
248+
// (no payload) so dependent jobs still unblock, without surfacing the job
249+
// in state/stats queries.
250+
ctx.depCompletions?.add(jobId);
244251
}
245252
}
246253

src/application/operations/jobManagement.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ export async function cancelJob(jobId: JobId, ctx: JobManagementContext): Promis
4343
ctx.storage?.deleteJob(jobId);
4444
return { success: true, queueName: location.queueName };
4545
}
46+
// Not in the run queue — it may be parked in waitingChildren (moved via
47+
// moveToWaitingChildren, which already released its resources and does not
48+
// track it in the queued counter, so do NOT decrement/release here).
49+
const parked = shard.waitingChildren.get(jobId);
50+
if (parked) {
51+
shard.waitingChildren.delete(jobId);
52+
ctx.jobIndex.delete(jobId);
53+
ctx.storage?.deleteJob(jobId);
54+
return { success: true, queueName: location.queueName };
55+
}
4656
return { success: false, queueName: location.queueName };
4757
});
4858

0 commit comments

Comments
 (0)