bunqueue 2.8.30 â 2.8.32
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/README.md +25 -23
- package/dist/application/operations/jobManagement.js +29 -1
- package/dist/application/operations/pull.js +85 -63
- package/dist/application/operations/queryOperations.js +110 -76
- package/dist/client/tcpPool.js +8 -1
- package/dist/infrastructure/server/handlers/core.js +13 -7
- package/dist/infrastructure/server/handlers/pushBatchValidation.d.ts +23 -0
- package/dist/infrastructure/server/handlers/pushBatchValidation.js +70 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,15 +63,15 @@ That's it. Queue + Worker in one object. No Redis, no config, no setup.
|
|
|
63
63
|
|
|
64
64
|
## ðŠķ Featherweight install
|
|
65
65
|
|
|
66
|
-
`bun add bunqueue` pulls **7 packages and 5.
|
|
66
|
+
`bun add bunqueue` pulls **7 packages and 5.5 MB** â and that includes the binary
|
|
67
67
|
queue server, the CLI, and the MCP server. Most queue libraries pull a Redis
|
|
68
68
|
client and its dependency tree before you've queued a single job.
|
|
69
69
|
|
|
70
70
|
| | Before (2.7.x) | Now | |
|
|
71
71
|
| --- | --- | --- | --- |
|
|
72
|
-
| **`node_modules`** | 93 MB | **5.
|
|
72
|
+
| **`node_modules`** | 93 MB | **5.5 MB** | **â94%** |
|
|
73
73
|
| **packages** | 117 | **7** | **â110** |
|
|
74
|
-
| **cold install** | ~6 s | **~1.
|
|
74
|
+
| **cold install** | ~6 s | **~1.7 s** | **~3.5Ã faster** |
|
|
75
75
|
|
|
76
76
|
Just **2 runtime dependencies** (`croner` + `msgpackr`). SQLite, S3, HTTP and
|
|
77
77
|
WebSocket are all Bun built-ins. The MCP SDK is an optional peer dependency â
|
|
@@ -336,7 +336,7 @@ const app = new Bunqueue('tasks', {
|
|
|
336
336
|
});
|
|
337
337
|
```
|
|
338
338
|
|
|
339
|
-
A job with priority 1
|
|
339
|
+
A job with priority 1 becomes eligible after 5 min, then gains +2 on every 60s tick: 3, 5, 7, âĶ capped at 100.
|
|
340
340
|
|
|
341
341
|
### Deduplication
|
|
342
342
|
|
|
@@ -362,7 +362,7 @@ Override per-job: `await app.add('task', data, { deduplication: { id: 'my-id', t
|
|
|
362
362
|
|
|
363
363
|
### Debouncing
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
Attach a default debounce id to same-name jobs. Each job gets `debounce: { id: jobName, ttl }` unless the per-job options already set one:
|
|
366
366
|
|
|
367
367
|
```typescript
|
|
368
368
|
const app = new Bunqueue('search', {
|
|
@@ -373,9 +373,11 @@ const app = new Bunqueue('search', {
|
|
|
373
373
|
|
|
374
374
|
await app.add('search', { query: 'h' });
|
|
375
375
|
await app.add('search', { query: 'he' });
|
|
376
|
-
await app.add('search', { query: 'hello' }); //
|
|
376
|
+
await app.add('search', { query: 'hello' }); // all three share the debounce id 'search'
|
|
377
377
|
```
|
|
378
378
|
|
|
379
|
+
The debounce id and TTL are stored on the job (BullMQ v5 compatible metadata, visible via `job.opts.debounce`), but they do not suppress duplicates by themselves in the current engine. To actually coalesce rapid duplicates, use [Deduplication](#deduplication) with `replace: true`: a duplicate arriving within the TTL window replaces the pending job, giving last-write-wins semantics.
|
|
380
|
+
|
|
379
381
|
### Rate Limiting
|
|
380
382
|
|
|
381
383
|
```typescript
|
|
@@ -385,15 +387,13 @@ const app = new Bunqueue('api', {
|
|
|
385
387
|
rateLimit: { max: 100, duration: 1000 },
|
|
386
388
|
});
|
|
387
389
|
|
|
388
|
-
// Per-group
|
|
390
|
+
// Per-group cap (e.g., per customer): with groupKey set, max limits
|
|
391
|
+
// concurrently active jobs per group (duration is not applied in this mode)
|
|
389
392
|
const app2 = new Bunqueue('api', {
|
|
390
393
|
embedded: true,
|
|
391
394
|
processor: async (job) => callAPI(job.data),
|
|
392
395
|
rateLimit: { max: 10, duration: 1000, groupKey: 'customerId' },
|
|
393
396
|
});
|
|
394
|
-
|
|
395
|
-
app.setGlobalRateLimit(50, 1000);
|
|
396
|
-
app.removeGlobalRateLimit();
|
|
397
397
|
```
|
|
398
398
|
|
|
399
399
|
### DLQ (Dead Letter Queue)
|
|
@@ -418,7 +418,7 @@ app.retryDlq(); // retry all
|
|
|
418
418
|
app.purgeDlq(); // clear all
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
Failure reasons: `explicit_fail`, `max_attempts_exceeded`, `timeout`, `stalled`, `ttl_expired`, `worker_lost`.
|
|
421
|
+
Failure reasons: `explicit_fail`, `max_attempts_exceeded`, `timeout`, `stalled`, `ttl_expired`, `worker_lost`, `unknown`.
|
|
422
422
|
|
|
423
423
|
### Cron Jobs
|
|
424
424
|
|
|
@@ -662,9 +662,9 @@ bunx bunqueue-dashboard
|
|
|
662
662
|
- **MCP server included** â 73 tools, 5 resources, 3 prompts. AI agents get full control out of the box
|
|
663
663
|
- **BullMQ-compatible API** â Same `Queue`, `Worker`, `QueueEvents`
|
|
664
664
|
- **Zero dependencies** â No Redis, no MongoDB
|
|
665
|
-
- **Tiny footprint** â 5.
|
|
665
|
+
- **Tiny footprint** â 5.5 MB installed, 7 packages (just 2 runtime deps: `croner` + `msgpackr`)
|
|
666
666
|
- **SQLite persistence** â Survives restarts, WAL mode for concurrent access
|
|
667
|
-
- **Up to
|
|
667
|
+
- **Up to 630K ops/sec** â [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
|
|
668
668
|
|
|
669
669
|
## Built for AI Agents (MCP Server)
|
|
670
670
|
|
|
@@ -690,8 +690,8 @@ bunqueue is the **first job queue with native MCP support**. AI agents get a ful
|
|
|
690
690
|
# bunqueue-mcp is a binary bundled inside the bunqueue package (not a standalone npm package),
|
|
691
691
|
# so install bunqueue first, then connect Claude Code:
|
|
692
692
|
bun add bunqueue
|
|
693
|
-
# Since v2.8.
|
|
694
|
-
# a clean install drops from 93 MB / 117 packages to 5.
|
|
693
|
+
# Since v2.8.1 the MCP SDK is an optional peer dependency. Queue-only users skip it entirely:
|
|
694
|
+
# a clean install drops from 93 MB / 117 packages to 5.5 MB / 7 packages (â94%, ~3.5Ã faster on a cold install).
|
|
695
695
|
# bunx --package=bunqueue does NOT auto-install optional peers, so install the SDK once to run the MCP server:
|
|
696
696
|
bun add @modelcontextprotocol/sdk
|
|
697
697
|
claude mcp add bunqueue -- bunx bunqueue-mcp
|
|
@@ -731,7 +731,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
|
|
|
731
731
|
- **Agentic workflows** â agents push jobs, workers process, agents monitor results
|
|
732
732
|
- Single-server deployments
|
|
733
733
|
- Prototypes and MVPs
|
|
734
|
-
- Moderate to high workloads (up to
|
|
734
|
+
- Moderate to high workloads (up to 630K ops/sec)
|
|
735
735
|
- Teams that want to avoid Redis operational overhead
|
|
736
736
|
- Embedded use cases (CLI tools, edge functions, serverless)
|
|
737
737
|
|
|
@@ -745,7 +745,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
|
|
|
745
745
|
|
|
746
746
|
If you're already running Redis, BullMQ is great â battle-tested and feature-rich.
|
|
747
747
|
|
|
748
|
-
bunqueue is for when you **don't want to run Redis**. SQLite with WAL mode handles surprisingly high throughput for single-node deployments (tested up to
|
|
748
|
+
bunqueue is for when you **don't want to run Redis**. SQLite with WAL mode handles surprisingly high throughput for single-node deployments (tested up to 630K ops/sec on bulk push). You get persistence, priorities, delays, retries, cron jobs, and DLQ â without the operational overhead of another service.
|
|
749
749
|
|
|
750
750
|
## Install
|
|
751
751
|
|
|
@@ -765,13 +765,15 @@ bunqueue runs in two modes depending on your architecture:
|
|
|
765
765
|
| ---------------- | ------------------------------------- | -------------------------------------------- |
|
|
766
766
|
| **How it works** | Queue runs inside your process | Standalone server, clients connect via TCP |
|
|
767
767
|
| **Setup** | `bun add bunqueue` | `docker run` or `bunqueue start` |
|
|
768
|
-
| **Performance** |
|
|
768
|
+
| **Performance** | 630K ops/sec (bulk push) | 90K ops/sec (push) |
|
|
769
769
|
| **Best for** | Single-process apps, CLIs, serverless | Multiple workers, separate producer/consumer |
|
|
770
770
|
| **Scaling** | Same process only | Multiple clients across machines |
|
|
771
771
|
|
|
772
772
|
### Embedded Mode
|
|
773
773
|
|
|
774
|
-
Everything runs in your process. No server, no network, no setup.
|
|
774
|
+
Everything runs in your process. No server, no network, no setup. Without a
|
|
775
|
+
data path the queue is in-memory and lost on restart: pass
|
|
776
|
+
`dataPath: './data/app.db'` (or set `BUNQUEUE_DATA_PATH`) to persist jobs.
|
|
775
777
|
|
|
776
778
|
```typescript
|
|
777
779
|
import { Queue, Worker } from 'bunqueue/client';
|
|
@@ -827,8 +829,8 @@ full feature parity, so producers and workers can live anywhere in your stack:
|
|
|
827
829
|
|
|
828
830
|
| Where your code runs | Install | Status |
|
|
829
831
|
| -------------------- | ------- | ------ |
|
|
830
|
-
| **Node.js âĨ 20, Deno âĨ 2, Bun, Cloudflare Workers** | [`npm install bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) |
|
|
831
|
-
| **Python âĨ 3.9** | PyPI coming soon â today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` |
|
|
832
|
+
| **Node.js âĨ 20, Deno âĨ 2, Bun, Cloudflare Workers** | [`npm install bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) | 100/100 e2e per runtime + 16/16 inside workerd |
|
|
833
|
+
| **Python âĨ 3.9** | PyPI coming soon â today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` | 78/78 e2e |
|
|
832
834
|
|
|
833
835
|
```typescript
|
|
834
836
|
// Node.js / Deno / Cloudflare Workers
|
|
@@ -909,8 +911,8 @@ SQLite handles surprisingly high throughput for single-node deployments:
|
|
|
909
911
|
|
|
910
912
|
| Mode | Peak Throughput | Use Case |
|
|
911
913
|
| -------- | --------------- | ------------------- |
|
|
912
|
-
| Embedded |
|
|
913
|
-
| TCP |
|
|
914
|
+
| Embedded | 630K ops/sec (bulk push) | Same process |
|
|
915
|
+
| TCP | 90K ops/sec (push) | Distributed workers |
|
|
914
916
|
|
|
915
917
|
> Run `bun run bench` to verify on your hardware. [Full benchmark methodology â](https://bunqueue.dev/guide/benchmarks/)
|
|
916
918
|
|
|
@@ -200,11 +200,19 @@ export async function moveJobToDelayed(jobId, delay, ctx) {
|
|
|
200
200
|
const queueName = job.queue;
|
|
201
201
|
await withWriteLock(ctx.shardLocks[idx], () => {
|
|
202
202
|
const shard = ctx.shards[idx];
|
|
203
|
+
// Release the concurrency slot (+group+uniqueKey) acquired at pull before
|
|
204
|
+
// re-queueing, mirroring moveActiveToWait (jobStateTransitions.ts) --
|
|
205
|
+
// otherwise the slot leaks and setConcurrency(N) wedges after N moves.
|
|
206
|
+
// Dropping the uniqueKey reservation on requeue matches the sibling
|
|
207
|
+
// requeue paths (failJob retry, moveActiveToWait, requeueExpiredJob).
|
|
208
|
+
shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
|
|
203
209
|
shard.getQueue(job.queue).push(job);
|
|
204
210
|
// Update running counters for O(1) stats and temporal index (job is delayed since delay > 0)
|
|
205
211
|
const isDelayed = job.runAt > now;
|
|
206
212
|
shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
|
|
207
213
|
ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
|
|
214
|
+
// The freed slot may unblock waiting jobs for long-pollers.
|
|
215
|
+
shard.notify();
|
|
208
216
|
});
|
|
209
217
|
// Persist the move so the delay survives a restart: the on-disk row was
|
|
210
218
|
// `state='active'` with the old run_at, which recovery would otherwise treat as a
|
|
@@ -250,9 +258,29 @@ export async function discardJob(jobId, ctx) {
|
|
|
250
258
|
if (job) {
|
|
251
259
|
const validJob = job; // Local reference for closure
|
|
252
260
|
const idx = shardIndex(validJob.queue);
|
|
261
|
+
const fromProcessing = location.type === 'processing';
|
|
253
262
|
const entry = await withWriteLock(ctx.shardLocks[idx], () => {
|
|
263
|
+
const shard = ctx.shards[idx];
|
|
264
|
+
if (fromProcessing) {
|
|
265
|
+
// The pull that activated this job acquired a concurrency slot (and
|
|
266
|
+
// possibly a FIFO group); return them before the DLQ move or the slot
|
|
267
|
+
// leaks and setConcurrency(N) wedges after N discards. The uniqueKey
|
|
268
|
+
// is freed too: every sibling terminal DLQ path releases the full
|
|
269
|
+
// reservation on entry (failJob, ack.ts; handleMaxStallsExceeded,
|
|
270
|
+
// lockManager.ts), so the DLQ entry never keeps it.
|
|
271
|
+
shard.releaseJobResources(validJob.queue, validJob.uniqueKey, validJob.groupId);
|
|
272
|
+
// The freed slot may unblock waiting jobs for long-pollers.
|
|
273
|
+
shard.notify();
|
|
274
|
+
}
|
|
275
|
+
else if (validJob.uniqueKey) {
|
|
276
|
+
// Queue branch: the waiting job never acquired a slot or group at
|
|
277
|
+
// pull, so a full releaseJobResources would free a slot legitimately
|
|
278
|
+
// held by ANOTHER active job. Release only the uniqueKey reservation,
|
|
279
|
+
// matching cancelJob and the DLQ-entry semantics above.
|
|
280
|
+
shard.releaseUniqueKey(validJob.queue, validJob.uniqueKey);
|
|
281
|
+
}
|
|
254
282
|
// addToDlq already updates dlq counter and returns entry
|
|
255
|
-
const dlqEntry =
|
|
283
|
+
const dlqEntry = shard.addToDlq(validJob);
|
|
256
284
|
ctx.jobIndex.set(jobId, { type: 'dlq', queueName: validJob.queue });
|
|
257
285
|
return dlqEntry;
|
|
258
286
|
});
|
|
@@ -47,18 +47,45 @@ function tryDequeueNextJob(shard, queue, now, ctx) {
|
|
|
47
47
|
if (job.timeline.length < MAX_TIMELINE_ENTRIES) {
|
|
48
48
|
job.timeline.push({ state: 'active', timestamp: now });
|
|
49
49
|
}
|
|
50
|
+
// Make the queue -> processing transition atomic for observers. From the
|
|
51
|
+
// pop above, the job is no longer findable in the shard queue, so a
|
|
52
|
+
// jobIndex entry still saying 'queue' would make getJob/getJobState return
|
|
53
|
+
// a false null/'unknown' for an existing job (JOB -> null -> JOB(active)
|
|
54
|
+
// flicker), and cleanOrphanedJobIndex would even drop its index entry.
|
|
55
|
+
// Insert into processingShards and flip the index HERE, in the same
|
|
56
|
+
// synchronous critical section as the pop (the caller holds the shard
|
|
57
|
+
// write lock; JS is single-threaded, so no other task can interleave).
|
|
58
|
+
// Writing processingShards without its RWLock is safe: until this
|
|
59
|
+
// statement the index said 'queue', so no id-targeted critical section
|
|
60
|
+
// (ack/fail/discard operate only on ids they find as 'processing') can be
|
|
61
|
+
// mid-operation on this id; a sync Map.set cannot corrupt a paused
|
|
62
|
+
// critical section; and lock-free sync access to processingShards is
|
|
63
|
+
// established practice (stallDetection phases 1/2, collectActiveJobs,
|
|
64
|
+
// getJobProgress). Acquiring the async RWLock here would instead hold the
|
|
65
|
+
// hot shard write lock across an await.
|
|
66
|
+
const procIdx = processingShardIndex(job.id);
|
|
67
|
+
ctx.processingShards[procIdx].set(job.id, job);
|
|
68
|
+
ctx.jobIndex.set(job.id, { type: 'processing', shardIdx: procIdx });
|
|
50
69
|
return { status: 'job', job };
|
|
51
70
|
}
|
|
52
71
|
/**
|
|
53
|
-
*
|
|
72
|
+
* Finish the handoff of a dequeued job: persist active state and broadcast.
|
|
73
|
+
*
|
|
74
|
+
* The job was ALREADY inserted into processingShards and jobIndex was flipped
|
|
75
|
+
* to 'processing' inside the dequeue critical section (tryDequeueNextJob),
|
|
76
|
+
* atomically with the queue pop, so readers never see a stale 'queue'
|
|
77
|
+
* location. Only the bookkeeping that may follow an await happens here.
|
|
78
|
+
*
|
|
79
|
+
* Returns false when the job is no longer in processingShards: a management
|
|
80
|
+
* op (discardJob, moveJobToDelayed, obliterate) claimed it between the
|
|
81
|
+
* dequeue and this call. The claimer owns the job now; the pull must NOT
|
|
82
|
+
* deliver it to a worker nor mark it active.
|
|
54
83
|
*/
|
|
55
|
-
|
|
84
|
+
function finalizeProcessing(job, queue, ctx) {
|
|
56
85
|
const procIdx = processingShardIndex(job.id);
|
|
86
|
+
if (!ctx.processingShards[procIdx].has(job.id))
|
|
87
|
+
return false;
|
|
57
88
|
const now = Date.now();
|
|
58
|
-
await withWriteLock(ctx.processingLocks[procIdx], () => {
|
|
59
|
-
ctx.processingShards[procIdx].set(job.id, job);
|
|
60
|
-
});
|
|
61
|
-
ctx.jobIndex.set(job.id, { type: 'processing', shardIdx: procIdx });
|
|
62
89
|
try {
|
|
63
90
|
ctx.storage?.markActive(job.id, job.startedAt ?? now, job.timeline);
|
|
64
91
|
}
|
|
@@ -74,74 +101,59 @@ async function moveToProcessing(job, queue, ctx) {
|
|
|
74
101
|
jobId: job.id,
|
|
75
102
|
timestamp: now,
|
|
76
103
|
});
|
|
104
|
+
return true;
|
|
77
105
|
}
|
|
78
106
|
/**
|
|
79
|
-
*
|
|
107
|
+
* Finish the handoff for a batch of dequeued jobs.
|
|
108
|
+
* Returns the jobs actually delivered (claimed jobs are skipped, see above).
|
|
80
109
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const byProcShard = new Map();
|
|
110
|
+
function finalizeProcessingBatch(jobs, queue, ctx) {
|
|
111
|
+
const delivered = [];
|
|
84
112
|
for (const job of jobs) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (shardJobs.length === 0)
|
|
88
|
-
byProcShard.set(procIdx, shardJobs);
|
|
89
|
-
shardJobs.push(job);
|
|
90
|
-
}
|
|
91
|
-
// Add to processing shards in parallel
|
|
92
|
-
const lockPromises = [];
|
|
93
|
-
for (const [procIdx, shardJobs] of byProcShard) {
|
|
94
|
-
lockPromises.push(withWriteLock(ctx.processingLocks[procIdx], () => {
|
|
95
|
-
for (const job of shardJobs) {
|
|
96
|
-
ctx.processingShards[procIdx].set(job.id, job);
|
|
97
|
-
}
|
|
98
|
-
}));
|
|
99
|
-
}
|
|
100
|
-
await Promise.all(lockPromises);
|
|
101
|
-
// Update indexes and broadcast
|
|
102
|
-
const now = Date.now();
|
|
103
|
-
for (const job of jobs) {
|
|
104
|
-
const procIdx = processingShardIndex(job.id);
|
|
105
|
-
ctx.jobIndex.set(job.id, { type: 'processing', shardIdx: procIdx });
|
|
106
|
-
try {
|
|
107
|
-
ctx.storage?.markActive(job.id, job.startedAt ?? now, job.timeline);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
// Non-fatal: job is already in processingShards (in-memory source of truth).
|
|
111
|
-
}
|
|
112
|
-
ctx.totalPulled.value++;
|
|
113
|
-
throughputTracker.pullRate.increment();
|
|
114
|
-
ctx.broadcast({
|
|
115
|
-
eventType: 'pulled',
|
|
116
|
-
queue,
|
|
117
|
-
jobId: job.id,
|
|
118
|
-
timestamp: now,
|
|
119
|
-
});
|
|
113
|
+
if (finalizeProcessing(job, queue, ctx))
|
|
114
|
+
delivered.push(job);
|
|
120
115
|
}
|
|
116
|
+
return delivered;
|
|
121
117
|
}
|
|
122
118
|
/**
|
|
123
|
-
* Requeue a job
|
|
124
|
-
*
|
|
119
|
+
* Requeue a job whose handoff to the worker failed after dequeue.
|
|
120
|
+
* The dequeue critical section moved the job to processingShards and flipped
|
|
121
|
+
* jobIndex to 'processing'; this restores both, keeping the same atomicity
|
|
122
|
+
* rule: the job becomes findable in the queue (push + index flip in ONE
|
|
123
|
+
* shard-lock section) BEFORE the processing entry is dropped, so a reader
|
|
124
|
+
* never sees a location that misses. Lock order shard -> processing matches
|
|
125
|
+
* the documented hierarchy.
|
|
125
126
|
*/
|
|
126
127
|
async function requeueJob(job, queue, idx, ctx) {
|
|
127
|
-
// Remove from processingShards (may have been added before the failure)
|
|
128
128
|
const procIdx = processingShardIndex(job.id);
|
|
129
|
-
|
|
130
|
-
ctx.processingShards[procIdx].delete(job.id);
|
|
131
|
-
});
|
|
132
|
-
// Reset job state (was modified in tryDequeueNextJob)
|
|
133
|
-
job.startedAt = null;
|
|
134
|
-
// Push back to queue
|
|
129
|
+
let requeued = false;
|
|
135
130
|
await withWriteLock(ctx.shardLocks[idx], () => {
|
|
131
|
+
// A management op (discardJob, moveJobToDelayed) may have claimed the job
|
|
132
|
+
// from processingShards after the failed handoff; it owns the job now.
|
|
133
|
+
if (!ctx.processingShards[procIdx].has(job.id))
|
|
134
|
+
return;
|
|
136
135
|
const shard = ctx.shards[idx];
|
|
137
136
|
if (job.groupId)
|
|
138
137
|
shard.releaseGroup(queue, job.groupId);
|
|
139
138
|
shard.releaseConcurrency(queue);
|
|
139
|
+
// Reset job state (was modified in tryDequeueNextJob)
|
|
140
|
+
job.startedAt = null;
|
|
140
141
|
shard.getQueue(queue).push(job);
|
|
141
142
|
shard.incrementQueued(job.id, false, job.createdAt, queue, job.runAt);
|
|
143
|
+
ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
|
|
142
144
|
shard.notify();
|
|
145
|
+
requeued = true;
|
|
146
|
+
});
|
|
147
|
+
if (!requeued)
|
|
148
|
+
return;
|
|
149
|
+
await withWriteLock(ctx.processingLocks[procIdx], () => {
|
|
150
|
+
// A concurrent pull may have re-popped the job between the two lock
|
|
151
|
+
// sections and flipped the index back to 'processing'; do not delete the
|
|
152
|
+
// (re-inserted, same-object) entry out from under it.
|
|
153
|
+
if (ctx.jobIndex.get(job.id)?.type !== 'processing') {
|
|
154
|
+
ctx.processingShards[procIdx].delete(job.id);
|
|
155
|
+
}
|
|
143
156
|
});
|
|
144
|
-
ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
|
|
145
157
|
}
|
|
146
158
|
/**
|
|
147
159
|
* Pull next job from queue
|
|
@@ -153,14 +165,18 @@ export async function pullJob(queue, timeoutMs, ctx) {
|
|
|
153
165
|
while (true) {
|
|
154
166
|
const job = await tryPullFromShard(queue, idx, ctx);
|
|
155
167
|
if (job) {
|
|
168
|
+
let delivered = false;
|
|
156
169
|
try {
|
|
157
|
-
|
|
170
|
+
delivered = finalizeProcessing(job, queue, ctx);
|
|
158
171
|
}
|
|
159
172
|
catch {
|
|
160
|
-
// Safety net: if
|
|
173
|
+
// Safety net: if the handoff fails, push job back to prevent loss
|
|
161
174
|
await requeueJob(job, queue, idx, ctx);
|
|
162
175
|
return null;
|
|
163
176
|
}
|
|
177
|
+
// Claimed by a management op during the handoff: try the next job
|
|
178
|
+
if (!delivered)
|
|
179
|
+
continue;
|
|
164
180
|
latencyTracker.pull.observe((Bun.nanoseconds() - startNs) / 1e6);
|
|
165
181
|
return job;
|
|
166
182
|
}
|
|
@@ -216,21 +232,27 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx) {
|
|
|
216
232
|
while (true) {
|
|
217
233
|
const jobs = await tryPullBatchFromShard(queue, idx, count, ctx);
|
|
218
234
|
if (jobs.length > 0) {
|
|
235
|
+
let delivered;
|
|
219
236
|
try {
|
|
220
|
-
|
|
237
|
+
delivered = finalizeProcessingBatch(jobs, queue, ctx);
|
|
221
238
|
}
|
|
222
239
|
catch {
|
|
223
|
-
// Safety net: push all jobs back to prevent loss
|
|
240
|
+
// Safety net: push all jobs back to prevent loss (requeueJob skips
|
|
241
|
+
// jobs a management op already claimed)
|
|
224
242
|
for (const job of jobs) {
|
|
225
243
|
await requeueJob(job, queue, idx, ctx);
|
|
226
244
|
}
|
|
227
245
|
return [];
|
|
228
246
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
247
|
+
if (delivered.length > 0) {
|
|
248
|
+
if (delivered.length > 1) {
|
|
249
|
+
ctx.dashboardEmit?.('batch:pulled', { queue, count: delivered.length });
|
|
250
|
+
}
|
|
251
|
+
latencyTracker.pull.observe((Bun.nanoseconds() - startNs) / 1e6);
|
|
252
|
+
return delivered;
|
|
231
253
|
}
|
|
232
|
-
|
|
233
|
-
|
|
254
|
+
// Every dequeued job was claimed by a management op: fall through to
|
|
255
|
+
// the timeout/wait logic below (nothing to deliver this round)
|
|
234
256
|
}
|
|
235
257
|
// No jobs available, check timeout
|
|
236
258
|
// Cache Date.now() to avoid multiple syscalls per iteration
|
|
@@ -6,51 +6,75 @@ import { withReadLock } from '../../shared/lock';
|
|
|
6
6
|
import { shardIndex } from '../../shared/hash';
|
|
7
7
|
/** Get job by ID */
|
|
8
8
|
export async function getJob(jobId, ctx) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return await withReadLock(ctx.shardLocks[location.shardIdx], () => {
|
|
26
|
-
const shard = ctx.shards[location.shardIdx];
|
|
27
|
-
return (shard.getQueue(location.queueName).find(jobId) ??
|
|
28
|
-
shard.waitingDeps.get(jobId) ??
|
|
29
|
-
shard.waitingChildren.get(jobId) ??
|
|
30
|
-
null);
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
case 'processing': {
|
|
34
|
-
return await withReadLock(ctx.processingLocks[location.shardIdx], () => {
|
|
35
|
-
return ctx.processingShards[location.shardIdx].get(jobId) ?? null;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
case 'completed':
|
|
39
|
-
return ctx.storage?.getJob(jobId) ?? ctx.completedJobsData.get(jobId) ?? null;
|
|
40
|
-
case 'dlq': {
|
|
9
|
+
// The location snapshot is only valid until the first await: while this
|
|
10
|
+
// reader waits on the shard read lock (writers have priority), a concurrent
|
|
11
|
+
// pull can pop the job and move it queue -> processing. The pull updates
|
|
12
|
+
// jobIndex atomically with the pop (tryDequeueNextJob), so on a miss the
|
|
13
|
+
// index re-read below is authoritative: an identity change means the job
|
|
14
|
+
// MOVED, and we chase the new location instead of returning a false null
|
|
15
|
+
// for a job that still exists (getJob(id) === null must be permanent for
|
|
16
|
+
// never-reused uuidv7 ids). Bounded: every extra pass requires a further
|
|
17
|
+
// state transition of this very job (queue -> active -> completed/removed),
|
|
18
|
+
// so 4 passes cover any realistic chase; index entries are always replaced
|
|
19
|
+
// (never mutated), which makes the identity comparison exact.
|
|
20
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
21
|
+
const location = ctx.jobIndex.get(jobId);
|
|
22
|
+
if (!location) {
|
|
23
|
+
// Fallback: jobIndex may not be populated after restart for completed/DLQ jobs.
|
|
24
|
+
// Consult SQLite directly so getJob survives recovery.
|
|
41
25
|
if (ctx.storage) {
|
|
42
|
-
const dlqEntry = ctx.storage.getDlqEntry(jobId);
|
|
43
|
-
if (dlqEntry)
|
|
44
|
-
return dlqEntry.job;
|
|
45
26
|
const job = ctx.storage.getJob(jobId);
|
|
46
27
|
if (job)
|
|
47
28
|
return job;
|
|
29
|
+
const dlqEntry = ctx.storage.getDlqEntry(jobId);
|
|
30
|
+
if (dlqEntry)
|
|
31
|
+
return dlqEntry.job;
|
|
48
32
|
}
|
|
49
|
-
|
|
50
|
-
const dlqJobs = ctx.shards[dlqShardIdx].getDlq(location.queueName);
|
|
51
|
-
return dlqJobs.find((j) => j.id === jobId) ?? null;
|
|
33
|
+
return ctx.completedJobsData.get(jobId) ?? null;
|
|
52
34
|
}
|
|
35
|
+
let found = null;
|
|
36
|
+
switch (location.type) {
|
|
37
|
+
case 'queue': {
|
|
38
|
+
found = await withReadLock(ctx.shardLocks[location.shardIdx], () => {
|
|
39
|
+
const shard = ctx.shards[location.shardIdx];
|
|
40
|
+
return (shard.getQueue(location.queueName).find(jobId) ??
|
|
41
|
+
shard.waitingDeps.get(jobId) ??
|
|
42
|
+
shard.waitingChildren.get(jobId) ??
|
|
43
|
+
null);
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'processing': {
|
|
48
|
+
found = await withReadLock(ctx.processingLocks[location.shardIdx], () => {
|
|
49
|
+
return ctx.processingShards[location.shardIdx].get(jobId) ?? null;
|
|
50
|
+
});
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'completed':
|
|
54
|
+
found = ctx.storage?.getJob(jobId) ?? ctx.completedJobsData.get(jobId) ?? null;
|
|
55
|
+
break;
|
|
56
|
+
case 'dlq': {
|
|
57
|
+
if (ctx.storage) {
|
|
58
|
+
const dlqEntry = ctx.storage.getDlqEntry(jobId);
|
|
59
|
+
if (dlqEntry)
|
|
60
|
+
return dlqEntry.job;
|
|
61
|
+
const job = ctx.storage.getJob(jobId);
|
|
62
|
+
if (job)
|
|
63
|
+
return job;
|
|
64
|
+
}
|
|
65
|
+
const dlqShardIdx = shardIndex(location.queueName);
|
|
66
|
+
const dlqJobs = ctx.shards[dlqShardIdx].getDlq(location.queueName);
|
|
67
|
+
found = dlqJobs.find((j) => j.id === jobId) ?? null;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (found)
|
|
72
|
+
return found;
|
|
73
|
+
if (ctx.jobIndex.get(jobId) === location)
|
|
74
|
+
return null; // no move: genuine miss
|
|
75
|
+
// The job moved while we held the stale snapshot: chase it.
|
|
53
76
|
}
|
|
77
|
+
return null;
|
|
54
78
|
}
|
|
55
79
|
/** Get job result */
|
|
56
80
|
export function getJobResult(jobId, ctx) {
|
|
@@ -121,46 +145,56 @@ function resolveStateFromStorage(jobId, storage) {
|
|
|
121
145
|
}
|
|
122
146
|
/** Get job state by ID */
|
|
123
147
|
export async function getJobState(jobId, ctx) {
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
switch (location.type) {
|
|
133
|
-
case 'queue': {
|
|
134
|
-
// Check if job is delayed, waiting, or waiting for children/deps
|
|
135
|
-
const result = await withReadLock(ctx.shardLocks[location.shardIdx], () => {
|
|
136
|
-
const shard = ctx.shards[location.shardIdx];
|
|
137
|
-
const queueJob = shard.getQueue(location.queueName).find(jobId);
|
|
138
|
-
if (queueJob)
|
|
139
|
-
return { job: queueJob, waitingDeps: false, waitingChildren: false };
|
|
140
|
-
const depsJob = shard.waitingDeps.get(jobId);
|
|
141
|
-
if (depsJob)
|
|
142
|
-
return { job: depsJob, waitingDeps: true, waitingChildren: false };
|
|
143
|
-
const childrenJob = shard.waitingChildren.get(jobId);
|
|
144
|
-
if (childrenJob)
|
|
145
|
-
return { job: childrenJob, waitingDeps: false, waitingChildren: true };
|
|
146
|
-
return null;
|
|
147
|
-
});
|
|
148
|
-
if (!result)
|
|
149
|
-
return 'unknown';
|
|
150
|
-
if (result.waitingDeps || result.waitingChildren)
|
|
151
|
-
return 'waiting-children';
|
|
152
|
-
const now = Date.now();
|
|
153
|
-
if (result.job.runAt > now)
|
|
154
|
-
return "delayed" /* JobState.Delayed */;
|
|
155
|
-
return result.job.priority > 0 ? "prioritized" /* JobState.Prioritized */ : "waiting" /* JobState.Waiting */;
|
|
156
|
-
}
|
|
157
|
-
case 'processing':
|
|
158
|
-
return "active" /* JobState.Active */;
|
|
159
|
-
case 'completed':
|
|
148
|
+
// Same stale-snapshot chase as getJob: a 'queue' location read before the
|
|
149
|
+
// read-lock await may be outdated by a concurrent pull (queue -> processing)
|
|
150
|
+
// by the time the lookup runs. On a miss with a changed index entry, retry
|
|
151
|
+
// with the fresh location instead of reporting a false 'unknown'.
|
|
152
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
153
|
+
const location = ctx.jobIndex.get(jobId);
|
|
154
|
+
// Check completed set first (fast path)
|
|
155
|
+
if (ctx.completedJobs.has(jobId)) {
|
|
160
156
|
return "completed" /* JobState.Completed */;
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
}
|
|
158
|
+
if (!location) {
|
|
159
|
+
return resolveStateFromStorage(jobId, ctx.storage);
|
|
160
|
+
}
|
|
161
|
+
switch (location.type) {
|
|
162
|
+
case 'queue': {
|
|
163
|
+
// Check if job is delayed, waiting, or waiting for children/deps
|
|
164
|
+
const result = await withReadLock(ctx.shardLocks[location.shardIdx], () => {
|
|
165
|
+
const shard = ctx.shards[location.shardIdx];
|
|
166
|
+
const queueJob = shard.getQueue(location.queueName).find(jobId);
|
|
167
|
+
if (queueJob)
|
|
168
|
+
return { job: queueJob, waitingDeps: false, waitingChildren: false };
|
|
169
|
+
const depsJob = shard.waitingDeps.get(jobId);
|
|
170
|
+
if (depsJob)
|
|
171
|
+
return { job: depsJob, waitingDeps: true, waitingChildren: false };
|
|
172
|
+
const childrenJob = shard.waitingChildren.get(jobId);
|
|
173
|
+
if (childrenJob)
|
|
174
|
+
return { job: childrenJob, waitingDeps: false, waitingChildren: true };
|
|
175
|
+
return null;
|
|
176
|
+
});
|
|
177
|
+
if (!result) {
|
|
178
|
+
if (ctx.jobIndex.get(jobId) === location)
|
|
179
|
+
return 'unknown'; // no move
|
|
180
|
+
break; // moved mid-lookup: chase the new location
|
|
181
|
+
}
|
|
182
|
+
if (result.waitingDeps || result.waitingChildren)
|
|
183
|
+
return 'waiting-children';
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
if (result.job.runAt > now)
|
|
186
|
+
return "delayed" /* JobState.Delayed */;
|
|
187
|
+
return result.job.priority > 0 ? "prioritized" /* JobState.Prioritized */ : "waiting" /* JobState.Waiting */;
|
|
188
|
+
}
|
|
189
|
+
case 'processing':
|
|
190
|
+
return "active" /* JobState.Active */;
|
|
191
|
+
case 'completed':
|
|
192
|
+
return "completed" /* JobState.Completed */;
|
|
193
|
+
case 'dlq':
|
|
194
|
+
return "failed" /* JobState.Failed */;
|
|
195
|
+
}
|
|
163
196
|
}
|
|
197
|
+
return 'unknown';
|
|
164
198
|
}
|
|
165
199
|
/** Collect completed jobs for a queue from index + storage */
|
|
166
200
|
function collectCompletedJobs(queue, ctx, maxCollect) {
|
package/dist/client/tcpPool.js
CHANGED
|
@@ -50,6 +50,8 @@ export class TcpConnectionPool {
|
|
|
50
50
|
pingInterval: this.options.pingInterval,
|
|
51
51
|
maxPingFailures: this.options.maxPingFailures,
|
|
52
52
|
maxCommandTimeouts: this.options.maxCommandTimeouts,
|
|
53
|
+
pipelining: this.options.pipelining,
|
|
54
|
+
maxInFlight: this.options.maxInFlight,
|
|
53
55
|
});
|
|
54
56
|
// Socket-level errors (e.g. TLS handshake failures, protocol garbage) are
|
|
55
57
|
// emitted as 'error' events; without a listener EventEmitter would throw
|
|
@@ -190,7 +192,12 @@ function getPoolKey(options) {
|
|
|
190
192
|
// TLS config must differentiate pools too: a TLS pool and a plaintext pool
|
|
191
193
|
// to the same host:port are NOT interchangeable.
|
|
192
194
|
const tlsKey = options?.tls ? JSON.stringify(options.tls) : '0';
|
|
193
|
-
|
|
195
|
+
// pipelining/maxInFlight shape per-connection behavior: without them in the
|
|
196
|
+
// key, two Queues with different windows would silently share whichever
|
|
197
|
+
// pool was created first.
|
|
198
|
+
const pipelining = (options?.pipelining ?? true) ? '1' : '0';
|
|
199
|
+
const maxInFlight = options?.maxInFlight ?? 100;
|
|
200
|
+
return `${host}:${port}:${poolSize}:${tokenHash}:${tlsKey}:${pipelining}:${maxInFlight}`;
|
|
194
201
|
}
|
|
195
202
|
/** Get or create shared connection pool */
|
|
196
203
|
export function getSharedPool(options) {
|
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { validateJobData, validateJobOptions } from '../protocol';
|
|
8
|
+
/**
|
|
9
|
+
* Validate every job of a PUSHB batch. Returns an error message naming the
|
|
10
|
+
* offending index, or null when the whole batch is valid.
|
|
11
|
+
*
|
|
12
|
+
* The dependsOn gate is EXTENDED beyond the PUSH one: a dependency may also
|
|
13
|
+
* reference ANY OTHER job of the same batch via its custom id (the only ids
|
|
14
|
+
* a client can know before the batch is applied). Order within the batch is
|
|
15
|
+
* deliberately irrelevant: the default-on auto-batcher groups concurrent
|
|
16
|
+
* add() calls in arbitrary order, so a child can legitimately precede its
|
|
17
|
+
* parent in the array, and the readiness layer (waitingDeps) resolves the
|
|
18
|
+
* chain once the whole batch is applied. Self-references are rejected, they
|
|
19
|
+
* can never resolve. jobIndex/completedJobs/depCompletions are hoisted out
|
|
20
|
+
* of the loop because PUSHB is the bulk hot path.
|
|
21
|
+
*/
|
|
22
|
+
export function validatePushBatchJobs(jobs, ctx) {
|
|
23
|
+
const jobIndex = ctx.queueManager.getJobIndex();
|
|
24
|
+
const completedJobs = ctx.queueManager.getCompletedJobs();
|
|
25
|
+
const depCompletions = ctx.queueManager.getDepCompletions();
|
|
26
|
+
// Custom ids of ALL jobs in this batch: they become real job ids the
|
|
27
|
+
// moment the batch is applied. Allocated lazily (most batches have neither
|
|
28
|
+
// custom ids nor dependencies).
|
|
29
|
+
let batchIds = null;
|
|
30
|
+
for (const job of jobs) {
|
|
31
|
+
if (job.customId) {
|
|
32
|
+
batchIds ??= new Set();
|
|
33
|
+
batchIds.add(job.customId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
37
|
+
const job = jobs[i];
|
|
38
|
+
const dataError = validateJobData(job.data);
|
|
39
|
+
if (dataError)
|
|
40
|
+
return `jobs[${i}]: ${dataError}`;
|
|
41
|
+
const optionsError = validateJobOptions({
|
|
42
|
+
priority: job.priority,
|
|
43
|
+
delay: job.delay,
|
|
44
|
+
timeout: job.timeout,
|
|
45
|
+
maxAttempts: job.maxAttempts,
|
|
46
|
+
backoff: job.backoff,
|
|
47
|
+
ttl: job.ttl,
|
|
48
|
+
});
|
|
49
|
+
if (optionsError)
|
|
50
|
+
return `jobs[${i}]: ${optionsError}`;
|
|
51
|
+
if (job.dependsOn && job.dependsOn.length > 0) {
|
|
52
|
+
for (const depId of job.dependsOn) {
|
|
53
|
+
const key = String(depId);
|
|
54
|
+
if (key === job.customId) {
|
|
55
|
+
return `jobs[${i}]: Job cannot depend on itself: ${key}`;
|
|
56
|
+
}
|
|
57
|
+
const exists = jobIndex.has(depId) ||
|
|
58
|
+
completedJobs.has(depId) ||
|
|
59
|
+
// removeOnComplete parents leave only a bare completion id behind;
|
|
60
|
+
// the gate must honor it exactly like PUSH does.
|
|
61
|
+
depCompletions.has(depId) ||
|
|
62
|
+
(batchIds?.has(key) ?? false);
|
|
63
|
+
if (!exists) {
|
|
64
|
+
return `jobs[${i}]: Dependency job not found: ${key}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.32",
|
|
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",
|