bunqueue-client 0.1.3 → 0.1.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.
- package/LICENSE +1 -1
- package/README.md +78 -10
- package/dist/bunqueue/bunqueue.js +3 -1
- package/dist/bunqueue/rate-gate.d.ts +6 -0
- package/dist/bunqueue/rate-gate.js +13 -0
- package/dist/connection.d.ts +8 -0
- package/dist/connection.js +31 -0
- package/dist/flow.js +21 -4
- package/dist/queue-control.js +3 -1
- package/dist/queue-query.d.ts +9 -2
- package/dist/queue-query.js +36 -8
- package/dist/queue.js +12 -4
- package/dist/worker.js +3 -1
- package/package.json +2 -2
- package/src/bunqueue/bunqueue.ts +4 -1
- package/src/bunqueue/rate-gate.ts +14 -0
- package/src/connection.ts +30 -0
- package/src/flow.ts +24 -4
- package/src/queue-control.ts +3 -5
- package/src/queue-query.ts +38 -8
- package/src/queue.ts +12 -4
- package/src/worker.ts +3 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@ The bunqueue server runs on Bun, distributed as a binary or a Docker image. This
|
|
|
8
8
|
|
|
9
9
|
| Runtime | Status | Notes |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| Node.js 20 or later | Supported,
|
|
12
|
-
| Bun | Supported,
|
|
13
|
-
| Deno 2 or later | Supported,
|
|
11
|
+
| Node.js 20 or later | Supported, 81/81 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
|
|
12
|
+
| Bun | Supported, 81/81 e2e and 8/8 integration tests | No additional configuration required |
|
|
13
|
+
| Deno 2 or later | Supported, 81/81 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
|
|
14
14
|
| tsx, ts-node, vitest, jest | Supported | These environments execute on Node.js |
|
|
15
|
-
| Cloudflare Workers | Supported,
|
|
15
|
+
| Cloudflare Workers | Supported, 16/16 e2e tests inside workerd, including Simple Mode and the full API surface | Requires the `nodejs_compat` compatibility flag. The runtime is request scoped, so long lived worker loops are not available: consume in batches from Cron Triggers or Durable Object alarms, a pattern covered by the test suite. TLS connections require a publicly trusted certificate |
|
|
16
16
|
| Browser | Not supported | Raw TCP sockets are unavailable. Use the server HTTP API instead |
|
|
17
17
|
|
|
18
18
|
Portability is guaranteed by design: the client relies exclusively on `node:*` builtins (`net`, `tls`, `events`, `crypto`, `os`), uses no `Bun.*` globals and no runtime specific imports, and carries a single runtime dependency, `msgpackr`.
|
|
@@ -24,15 +24,37 @@ npm install bunqueue-client
|
|
|
24
24
|
# or: bun add bunqueue-client / pnpm add bunqueue-client / deno add npm:bunqueue-client
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
## Quick start
|
|
27
|
+
## Quick start, step by step
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Every step from zero to a production ready queue.
|
|
30
|
+
|
|
31
|
+
### Step 1. Run the bunqueue server
|
|
32
|
+
|
|
33
|
+
The server is the only component that requires [Bun](https://bun.sh). Pick one:
|
|
30
34
|
|
|
31
35
|
```bash
|
|
36
|
+
# Option A: one command, no install (requires Bun)
|
|
32
37
|
bunx bunqueue start
|
|
38
|
+
|
|
39
|
+
# Option B: Docker, with persistent data
|
|
40
|
+
docker run -d --name bunqueue \
|
|
41
|
+
-p 6789:6789 -p 6790:6790 \
|
|
42
|
+
-v bunqueue-data:/app/data \
|
|
43
|
+
ghcr.io/egeominotti/bunqueue:latest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Port 6789 is the TCP protocol (what this client uses), port 6790 is the HTTP API with `/health`, `/metrics`, and dashboard endpoints.
|
|
47
|
+
|
|
48
|
+
### Step 2. Install the client
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install bunqueue-client
|
|
52
|
+
# or: bun add bunqueue-client / pnpm add bunqueue-client / deno add npm:bunqueue-client
|
|
33
53
|
```
|
|
34
54
|
|
|
35
|
-
Step
|
|
55
|
+
### Step 3. Add your first job and process it
|
|
56
|
+
|
|
57
|
+
Create `app.ts`, one file for the sake of the demo:
|
|
36
58
|
|
|
37
59
|
```typescript
|
|
38
60
|
import { Queue, Worker } from 'bunqueue-client';
|
|
@@ -51,7 +73,7 @@ await queue.add('greet', { name: 'world' });
|
|
|
51
73
|
queue.close();
|
|
52
74
|
```
|
|
53
75
|
|
|
54
|
-
|
|
76
|
+
Run it with the runtime you already use:
|
|
55
77
|
|
|
56
78
|
```bash
|
|
57
79
|
node --experimental-strip-types app.ts # Node 22 or later
|
|
@@ -66,7 +88,53 @@ processing { name: 'world' }
|
|
|
66
88
|
completed 019f40a5-... { greeted: 'world' }
|
|
67
89
|
```
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
Defaults are `host: 'localhost'` and `port: 6789`, so constructors need no options on a local setup.
|
|
92
|
+
|
|
93
|
+
### Step 4. Split producer and worker
|
|
94
|
+
|
|
95
|
+
In production the producer and the worker are separate services, often in different languages. The producer is typically an API endpoint:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// api-service: adds jobs, no processing
|
|
99
|
+
import { Queue } from 'bunqueue-client';
|
|
100
|
+
const queue = new Queue('emails', { host: 'queue.internal', port: 6789 });
|
|
101
|
+
await queue.add('welcome', { to: 'user@example.com' }, { attempts: 3 });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The worker is a long running process:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// worker-service: processes jobs, no HTTP
|
|
108
|
+
import { Worker } from 'bunqueue-client';
|
|
109
|
+
new Worker('emails', sendEmail, { host: 'queue.internal', port: 6789, concurrency: 10 });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The [Python client](https://github.com/egeominotti/bunqueue/tree/main/sdk/python) speaks the same protocol against the same queue, so the worker can be a Python service instead.
|
|
113
|
+
|
|
114
|
+
### Step 5. Observe and operate
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
await queue.getJobCounts(); // { waiting, active, completed, failed, delayed, ... }
|
|
118
|
+
await queue.getDlq(); // jobs that exhausted their retries
|
|
119
|
+
await queue.retryDlq(); // send them back to the queue
|
|
120
|
+
await queue.getWorkers(); // connected workers
|
|
121
|
+
await queue.getStats(); // throughput and totals
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or hit the HTTP side: `curl http://localhost:6790/health`.
|
|
125
|
+
|
|
126
|
+
### Step 6. Go to production
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const queue = new Queue('emails', {
|
|
130
|
+
host: 'queue.example.com',
|
|
131
|
+
port: 6789,
|
|
132
|
+
token: process.env.BUNQUEUE_TOKEN, // server started with AUTH_TOKENS=...
|
|
133
|
+
tls: true, // or { caFile: './ca.pem' }
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Checklist: set `AUTH_TOKENS` on the server, enable TLS (`TLS_CERT_FILE`/`TLS_KEY_FILE`), mount a volume for the SQLite data path, monitor `/health` and `/metrics`, and size worker `concurrency` to your workload. Full guide: [bunqueue.dev/guide/deployment](https://bunqueue.dev/guide/deployment/).
|
|
70
138
|
|
|
71
139
|
## Producing jobs
|
|
72
140
|
|
|
@@ -209,7 +277,7 @@ Authentication uses server side tokens (`AUTH_TOKENS`). Transport security uses
|
|
|
209
277
|
| Area | Capabilities |
|
|
210
278
|
|---|---|
|
|
211
279
|
| Queue | `add`, `addBulk`, full `JobOptions`: priority, delay, attempts, backoff, ttl, timeout, jobId, deduplication, dependsOn, tags, groupId, lifo, removeOnComplete, removeOnFail, durable, repeat, debounce |
|
|
212
|
-
| Query | `getJob`, `getJobByCustomId`, `getJobs` with per state helpers, state, result, progress, `waitForJob
|
|
280
|
+
| Query | `getJob`, `getJobByCustomId`, `getJobs` with per state helpers, state, result, progress, `waitForJob` (throws on timeout, BullMQ contract), counts, counts per priority, children values, job logs |
|
|
213
281
|
| Control | pause, resume, drain, obliterate, clean, remove, discard, promote, `retryJob`, `retryJobs`, move to wait or delayed, change priority or delay, update data, extend lock |
|
|
214
282
|
| Dead letter queue | `getDlq`, `retryDlq`, `purgeDlq`, DLQ configuration |
|
|
215
283
|
| Administration | rate limiting, global concurrency, stall configuration, webhooks, stats, metrics, `listQueues`, `getWorkers` |
|
|
@@ -105,8 +105,10 @@ export class Bunqueue {
|
|
|
105
105
|
}
|
|
106
106
|
// ------------------------------------------------- core processing pipeline
|
|
107
107
|
async processJob(job) {
|
|
108
|
-
if (this.rateGate)
|
|
108
|
+
if (this.rateGate) {
|
|
109
|
+
this.rateGate.prune(); // evict fully-expired groups (high-cardinality groupKey)
|
|
109
110
|
await this.rateGate.acquire(this.rateGate.groupFor(job.data));
|
|
111
|
+
}
|
|
110
112
|
// Circuit breaker check
|
|
111
113
|
if (this.cb?.isOpen()) {
|
|
112
114
|
throw new Error('Circuit breaker is open');
|
|
@@ -18,4 +18,10 @@ export declare class RateGate {
|
|
|
18
18
|
groupFor(data: unknown): string;
|
|
19
19
|
/** Wait until the group's window has room, then record the start. */
|
|
20
20
|
acquire(group: string): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Drop groups whose window is fully expired. Called on each acquire cycle
|
|
23
|
+
* boundary by the owner; without it a high-cardinality groupKey (e.g. one
|
|
24
|
+
* group per user id) grows the map forever.
|
|
25
|
+
*/
|
|
26
|
+
prune(): void;
|
|
21
27
|
}
|
|
@@ -45,4 +45,17 @@ export class RateGate {
|
|
|
45
45
|
await sleep(Math.max(oldest + this.duration - now, 10));
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Drop groups whose window is fully expired. Called on each acquire cycle
|
|
50
|
+
* boundary by the owner; without it a high-cardinality groupKey (e.g. one
|
|
51
|
+
* group per user id) grows the map forever.
|
|
52
|
+
*/
|
|
53
|
+
prune() {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
for (const [group, window] of this.windows) {
|
|
56
|
+
if (window.length === 0 || now - window[window.length - 1] >= this.duration) {
|
|
57
|
+
this.windows.delete(group);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
48
61
|
}
|
package/dist/connection.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare class Connection {
|
|
|
25
25
|
private connectGeneration;
|
|
26
26
|
private failedAttempts;
|
|
27
27
|
private nextAttemptAt;
|
|
28
|
+
private readonly maxCommandTimeouts;
|
|
29
|
+
private consecutiveTimeouts;
|
|
28
30
|
constructor(options?: ConnectionOptions);
|
|
29
31
|
get isConnected(): boolean;
|
|
30
32
|
/**
|
|
@@ -48,5 +50,11 @@ export declare class Connection {
|
|
|
48
50
|
/** Close permanently; in-flight commands reject. */
|
|
49
51
|
close(): void;
|
|
50
52
|
private handleData;
|
|
53
|
+
/**
|
|
54
|
+
* A dead/half-open link makes every command time out while the socket still
|
|
55
|
+
* looks connected. After maxCommandTimeouts consecutive timeouts, tear down
|
|
56
|
+
* so the next call() reconnects instead of wedging (mirrors #94).
|
|
57
|
+
*/
|
|
58
|
+
private noteTimeout;
|
|
51
59
|
private teardown;
|
|
52
60
|
}
|
package/dist/connection.js
CHANGED
|
@@ -27,6 +27,10 @@ export class Connection {
|
|
|
27
27
|
connectGeneration = 0;
|
|
28
28
|
failedAttempts = 0;
|
|
29
29
|
nextAttemptAt = 0;
|
|
30
|
+
// Half-open recovery (#94): after this many consecutive command timeouts the
|
|
31
|
+
// socket is presumed dead and torn down so the next call reconnects.
|
|
32
|
+
maxCommandTimeouts = 3;
|
|
33
|
+
consecutiveTimeouts = 0;
|
|
30
34
|
constructor(options = {}) {
|
|
31
35
|
this.host = options.host ?? 'localhost';
|
|
32
36
|
this.port = options.port ?? 6789;
|
|
@@ -79,6 +83,9 @@ export class Connection {
|
|
|
79
83
|
async doConnect() {
|
|
80
84
|
const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
|
|
81
85
|
socket.setNoDelay(true);
|
|
86
|
+
// TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
|
|
87
|
+
// drop with no FIN/RST) in seconds instead of ~tcp_retries2 minutes.
|
|
88
|
+
socket.setKeepAlive(true, 15_000);
|
|
82
89
|
this.parser.clear();
|
|
83
90
|
this.socket = socket;
|
|
84
91
|
socket.on('data', (chunk) => this.handleData(chunk));
|
|
@@ -86,6 +93,13 @@ export class Connection {
|
|
|
86
93
|
socket.on('close', () => this.teardown());
|
|
87
94
|
this.connected = true;
|
|
88
95
|
this.connectGeneration += 1;
|
|
96
|
+
this.consecutiveTimeouts = 0;
|
|
97
|
+
// INVARIANT (H3): connected is flipped true before Auth, which is safe
|
|
98
|
+
// ONLY because call() writes the Auth frame synchronously — there is no
|
|
99
|
+
// `await` between this line and the Auth socket.write, so no concurrent
|
|
100
|
+
// call() can interleave a frame ahead of Auth on the wire. Do NOT insert
|
|
101
|
+
// an await here or before the Auth call, or a command could race ahead of
|
|
102
|
+
// Auth (the Python SDK guards this with a lock; JS relies on this ordering).
|
|
89
103
|
if (this.token) {
|
|
90
104
|
try {
|
|
91
105
|
await this.call({ cmd: 'Auth', token: this.token });
|
|
@@ -111,14 +125,17 @@ export class Connection {
|
|
|
111
125
|
this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
|
|
112
126
|
const reqId = String(this.reqCounter);
|
|
113
127
|
const payload = pack({ ...compact(command), reqId });
|
|
128
|
+
const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
|
|
114
129
|
return new Promise((resolve, reject) => {
|
|
115
130
|
const timer = setTimeout(() => {
|
|
116
131
|
this.pending.delete(reqId);
|
|
132
|
+
this.noteTimeout(gen);
|
|
117
133
|
reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
|
|
118
134
|
}, timeoutMs ?? this.commandTimeoutMs);
|
|
119
135
|
this.pending.set(reqId, {
|
|
120
136
|
resolve: (response) => {
|
|
121
137
|
clearTimeout(timer);
|
|
138
|
+
this.consecutiveTimeouts = 0; // any reply means the link is alive
|
|
122
139
|
if (!response.ok) {
|
|
123
140
|
reject(new CommandError(String(response.error ?? 'unknown server error')));
|
|
124
141
|
}
|
|
@@ -196,6 +213,20 @@ export class Connection {
|
|
|
196
213
|
}
|
|
197
214
|
}
|
|
198
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* A dead/half-open link makes every command time out while the socket still
|
|
218
|
+
* looks connected. After maxCommandTimeouts consecutive timeouts, tear down
|
|
219
|
+
* so the next call() reconnects instead of wedging (mirrors #94).
|
|
220
|
+
*/
|
|
221
|
+
noteTimeout(gen) {
|
|
222
|
+
// A timeout from an already-replaced connection must not tear down (or
|
|
223
|
+
// miscount against) the current one.
|
|
224
|
+
if (gen !== this.connectGeneration)
|
|
225
|
+
return;
|
|
226
|
+
this.consecutiveTimeouts += 1;
|
|
227
|
+
if (this.consecutiveTimeouts >= this.maxCommandTimeouts)
|
|
228
|
+
this.teardown();
|
|
229
|
+
}
|
|
199
230
|
teardown() {
|
|
200
231
|
this.connected = false;
|
|
201
232
|
const socket = this.socket;
|
package/dist/flow.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* UpdateParent fix-up, rollback via Cancel on failure).
|
|
5
5
|
*/
|
|
6
6
|
import { Connection } from './connection.js';
|
|
7
|
+
import { CommandError } from './errors.js';
|
|
7
8
|
import { compact } from './frame.js';
|
|
8
9
|
import { Job } from './job.js';
|
|
9
10
|
import { jobPayload, wireJobOptions } from './types.js';
|
|
@@ -43,7 +44,7 @@ export class FlowProducer {
|
|
|
43
44
|
}
|
|
44
45
|
/** Fetch a flow tree starting from a job id (recursive over childrenIds). */
|
|
45
46
|
getFlow(opts) {
|
|
46
|
-
return this.fetchNode(opts.id, opts.depth ?? Number.POSITIVE_INFINITY, opts.maxChildren);
|
|
47
|
+
return this.fetchNode(opts.id, opts.depth ?? Number.POSITIVE_INFINITY, opts.maxChildren, new Set());
|
|
47
48
|
}
|
|
48
49
|
/** Add a sequential chain: step[0] → step[1] → ... via dependsOn. */
|
|
49
50
|
async addChain(steps) {
|
|
@@ -156,8 +157,24 @@ export class FlowProducer {
|
|
|
156
157
|
}
|
|
157
158
|
return parentJobId;
|
|
158
159
|
}
|
|
159
|
-
async fetchNode(id, depth, maxChildren) {
|
|
160
|
-
|
|
160
|
+
async fetchNode(id, depth, maxChildren, visited) {
|
|
161
|
+
if (visited.has(id))
|
|
162
|
+
return null; // cycle guard: id already on the current path
|
|
163
|
+
visited.add(id);
|
|
164
|
+
// A missing job — the root, or a child removed via removeOnComplete/cancel
|
|
165
|
+
// (childrenIds is a static push-time list, never pruned) — yields null and
|
|
166
|
+
// is skipped, returning the surviving partial tree instead of throwing.
|
|
167
|
+
let response;
|
|
168
|
+
try {
|
|
169
|
+
response = await this.connection.call({ cmd: 'GetJob', id });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
// Only 'Job not found' means a removed node; a real server error must not
|
|
173
|
+
// masquerade as a missing child and yield a misleading partial tree.
|
|
174
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
175
|
+
return null;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
161
178
|
const raw = response.job;
|
|
162
179
|
if (!raw)
|
|
163
180
|
return null;
|
|
@@ -167,7 +184,7 @@ export class FlowProducer {
|
|
|
167
184
|
const limit = maxChildren ?? job.childrenIds.length;
|
|
168
185
|
const children = [];
|
|
169
186
|
for (const childId of job.childrenIds.slice(0, limit)) {
|
|
170
|
-
const child = await this.fetchNode(childId, depth - 1, maxChildren);
|
|
187
|
+
const child = await this.fetchNode(childId, depth - 1, maxChildren, visited);
|
|
171
188
|
if (child)
|
|
172
189
|
children.push(child);
|
|
173
190
|
}
|
package/dist/queue-control.js
CHANGED
|
@@ -48,7 +48,9 @@ export const controlMethods = {
|
|
|
48
48
|
await this.call({ cmd: 'RetryCompleted', queue: this.name });
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
// `count` is accepted for API parity but not sent: the server has no
|
|
52
|
+
// partial RetryDlq — it retries the whole DLQ (BullMQ semantics).
|
|
53
|
+
await this.call({ cmd: 'RetryDlq', queue: this.name });
|
|
52
54
|
},
|
|
53
55
|
async retryCompleted(id) {
|
|
54
56
|
await this.call(compact({ cmd: 'RetryCompleted', queue: this.name, id }));
|
package/dist/queue-query.d.ts
CHANGED
|
@@ -32,7 +32,14 @@ export declare const queryMethods: {
|
|
|
32
32
|
removeChildDependency(this: Ctx, id: string): Promise<void>;
|
|
33
33
|
/** Remove all still-unprocessed children of a parent job. */
|
|
34
34
|
removeUnprocessedChildren(this: Ctx, id: string): Promise<void>;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Block until the job completes; returns its result.
|
|
37
|
+
* The server's WaitJob waiter resolves only on completion, replying
|
|
38
|
+
* `{ok:true, completed:false}` (no result) otherwise — so returning undefined
|
|
39
|
+
* would be indistinguishable from a genuine undefined result. On
|
|
40
|
+
* non-completion we probe the state: a `failed` job throws CommandError (it
|
|
41
|
+
* will not complete), everything else throws CommandTimeoutError.
|
|
42
|
+
*/
|
|
36
43
|
waitForJob<R = unknown>(this: Ctx, id: string, ttlMs?: number): Promise<R>;
|
|
37
44
|
/** BullMQ v5 alias for waitForJob (queueEvents param unused over TCP). */
|
|
38
45
|
waitJobUntilFinished<R = unknown>(this: Ctx, id: string, _queueEvents?: unknown, ttlMs?: number): Promise<R>;
|
|
@@ -50,7 +57,7 @@ export declare const queryMethods: {
|
|
|
50
57
|
getWaitingChildrenCount(this: Ctx): Promise<number>;
|
|
51
58
|
count(this: Ctx): Promise<number>;
|
|
52
59
|
getCountsPerPriority(this: Ctx): Promise<Record<string, number>>;
|
|
53
|
-
addJobLog(this: Ctx, id: string, message: string): Promise<void>;
|
|
60
|
+
addJobLog(this: Ctx, id: string, message: string, level?: "info" | "warn" | "error"): Promise<void>;
|
|
54
61
|
getJobLogs(this: Ctx, id: string, start?: number, end?: number): Promise<string[]>;
|
|
55
62
|
clearJobLogs(this: Ctx, id: string, keepLogs?: number): Promise<void>;
|
|
56
63
|
};
|
package/dist/queue-query.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Queue query surface: job lookup, state, results, counts, logs, children.
|
|
3
3
|
* Methods are merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
|
-
import { CommandError } from './errors.js';
|
|
5
|
+
import { CommandError, CommandTimeoutError } from './errors.js';
|
|
6
6
|
import { compact } from './frame.js';
|
|
7
7
|
import { Job } from './job.js';
|
|
8
8
|
function unwrapValues(response) {
|
|
@@ -53,7 +53,9 @@ export const queryMethods = {
|
|
|
53
53
|
return jobs.map((raw) => new Job(raw, this.connection));
|
|
54
54
|
},
|
|
55
55
|
getWaiting(start, end) {
|
|
56
|
-
|
|
56
|
+
// Only the 'waiting' bucket — prioritized jobs live in a separate bucket
|
|
57
|
+
// (getPrioritized), matching BullMQ and the Python SDK / reference client.
|
|
58
|
+
return this.getJobs({ state: 'waiting', start, end });
|
|
57
59
|
},
|
|
58
60
|
getDelayed(start, end) {
|
|
59
61
|
return this.getJobs({ state: 'delayed', start, end });
|
|
@@ -98,9 +100,28 @@ export const queryMethods = {
|
|
|
98
100
|
async removeUnprocessedChildren(id) {
|
|
99
101
|
await this.call({ cmd: 'RemoveUnprocessedChildren', id });
|
|
100
102
|
},
|
|
101
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Block until the job completes; returns its result.
|
|
105
|
+
* The server's WaitJob waiter resolves only on completion, replying
|
|
106
|
+
* `{ok:true, completed:false}` (no result) otherwise — so returning undefined
|
|
107
|
+
* would be indistinguishable from a genuine undefined result. On
|
|
108
|
+
* non-completion we probe the state: a `failed` job throws CommandError (it
|
|
109
|
+
* will not complete), everything else throws CommandTimeoutError.
|
|
110
|
+
*/
|
|
102
111
|
async waitForJob(id, ttlMs = 30_000) {
|
|
103
112
|
const response = await this.call({ cmd: 'WaitJob', id, timeout: ttlMs }, ttlMs + 5000);
|
|
113
|
+
if (response.completed !== true) {
|
|
114
|
+
let state;
|
|
115
|
+
try {
|
|
116
|
+
state = await this.getJobState(id);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* ignore probe failure; fall through to timeout */
|
|
120
|
+
}
|
|
121
|
+
if (state === 'failed')
|
|
122
|
+
throw new CommandError(`job ${id} failed before completion`);
|
|
123
|
+
throw new CommandTimeoutError(`waitUntilFinished timed out after ${ttlMs}ms`);
|
|
124
|
+
}
|
|
104
125
|
return response.result;
|
|
105
126
|
},
|
|
106
127
|
/** BullMQ v5 alias for waitForJob (queueEvents param unused over TCP). */
|
|
@@ -120,8 +141,9 @@ export const queryMethods = {
|
|
|
120
141
|
return response.counts;
|
|
121
142
|
},
|
|
122
143
|
async getWaitingCount() {
|
|
123
|
-
|
|
124
|
-
|
|
144
|
+
// 'waiting' only — prioritized jobs are counted by getPrioritizedCount,
|
|
145
|
+
// matching BullMQ and the Python SDK / reference client.
|
|
146
|
+
return (await this.getJobCounts()).waiting;
|
|
125
147
|
},
|
|
126
148
|
async getActiveCount() {
|
|
127
149
|
return (await this.getJobCounts()).active;
|
|
@@ -150,14 +172,20 @@ export const queryMethods = {
|
|
|
150
172
|
return (response.counts ?? response.data ?? {});
|
|
151
173
|
},
|
|
152
174
|
// --------------------------------------------------------------------- logs
|
|
153
|
-
async addJobLog(id, message) {
|
|
154
|
-
await this.call({ cmd: 'AddLog', id, message });
|
|
175
|
+
async addJobLog(id, message, level) {
|
|
176
|
+
await this.call(compact({ cmd: 'AddLog', id, message, level }));
|
|
155
177
|
},
|
|
156
178
|
async getJobLogs(id, start, end) {
|
|
157
179
|
const response = await this.call(compact({ cmd: 'GetLogs', id, start, end }));
|
|
158
180
|
const data = (response.data ?? {});
|
|
159
181
|
const logs = (data.logs ?? response.logs ?? []);
|
|
160
|
-
|
|
182
|
+
// Format as `[level] message` (reference client parity); never drop level.
|
|
183
|
+
return logs.map((row) => {
|
|
184
|
+
if (typeof row === 'string')
|
|
185
|
+
return row;
|
|
186
|
+
const r = row;
|
|
187
|
+
return r.level ? `[${r.level}] ${r.message}` : String(r.message ?? row);
|
|
188
|
+
});
|
|
161
189
|
},
|
|
162
190
|
async clearJobLogs(id, keepLogs) {
|
|
163
191
|
await this.call(compact({ cmd: 'ClearLogs', id, keepLogs }));
|
package/dist/queue.js
CHANGED
|
@@ -50,10 +50,18 @@ export class Queue {
|
|
|
50
50
|
}
|
|
51
51
|
/** Add many jobs in one round-trip; returns Job stubs. */
|
|
52
52
|
async addBulk(jobs) {
|
|
53
|
-
const inputs = jobs.map((entry) =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const inputs = jobs.map((entry) => {
|
|
54
|
+
const opts = wireJobOptions(entry.opts);
|
|
55
|
+
// PUSHB entries are JobInput, whose custom-id field is `customId` —
|
|
56
|
+
// unlike single PUSH which renames `jobId`->`customId` server-side.
|
|
57
|
+
// Without this the batch custom id is silently dropped (idempotency /
|
|
58
|
+
// getJobByCustomId broken).
|
|
59
|
+
if (opts.jobId !== undefined) {
|
|
60
|
+
opts.customId = opts.jobId;
|
|
61
|
+
delete opts.jobId;
|
|
62
|
+
}
|
|
63
|
+
return { data: jobPayload(entry.name, entry.data), ...opts };
|
|
64
|
+
});
|
|
57
65
|
const response = await this.call({ cmd: 'PUSHB', queue: this.name, jobs: inputs });
|
|
58
66
|
const ids = (response.ids ?? []);
|
|
59
67
|
return ids.map((id, i) => new Job({ id, queue: this.name, data: inputs[i].data }, this.connection));
|
package/dist/worker.js
CHANGED
|
@@ -107,7 +107,9 @@ export class Worker extends WorkerBase {
|
|
|
107
107
|
catch (err) {
|
|
108
108
|
this.failedCount += 1;
|
|
109
109
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
110
|
-
|
|
110
|
+
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
111
|
+
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
112
|
+
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
111
113
|
await this.safeCall(compact({
|
|
112
114
|
cmd: 'FAIL',
|
|
113
115
|
id: job.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Cross-runtime TypeScript client for the bunqueue job queue server — Node.js, Bun, Deno and Cloudflare Workers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"lint": "biome lint src tests",
|
|
30
30
|
"format": "biome format --write src tests",
|
|
31
31
|
"check": "biome check src tests",
|
|
32
|
-
"test:workers": "bun pm pack --destination tests/workers && cd tests/workers && mv bunqueue-client-*.tgz bunqueue-client.tgz && bun install && node run.mjs"
|
|
32
|
+
"test:workers": "bun pm pack --destination tests/workers && cd tests/workers && mv bunqueue-client-*.tgz bunqueue-client.tgz && rm -rf node_modules/bunqueue-client bun.lock && bun install --force && node run.mjs"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"queue",
|
package/src/bunqueue/bunqueue.ts
CHANGED
|
@@ -120,7 +120,10 @@ export class Bunqueue<T = unknown, R = unknown> {
|
|
|
120
120
|
// ------------------------------------------------- core processing pipeline
|
|
121
121
|
|
|
122
122
|
private async processJob(job: Job<T>): Promise<R> {
|
|
123
|
-
if (this.rateGate)
|
|
123
|
+
if (this.rateGate) {
|
|
124
|
+
this.rateGate.prune(); // evict fully-expired groups (high-cardinality groupKey)
|
|
125
|
+
await this.rateGate.acquire(this.rateGate.groupFor(job.data));
|
|
126
|
+
}
|
|
124
127
|
// Circuit breaker check
|
|
125
128
|
if (this.cb?.isOpen()) {
|
|
126
129
|
throw new Error('Circuit breaker is open');
|
|
@@ -50,4 +50,18 @@ export class RateGate {
|
|
|
50
50
|
await sleep(Math.max(oldest + this.duration - now, 10));
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Drop groups whose window is fully expired. Called on each acquire cycle
|
|
56
|
+
* boundary by the owner; without it a high-cardinality groupKey (e.g. one
|
|
57
|
+
* group per user id) grows the map forever.
|
|
58
|
+
*/
|
|
59
|
+
prune(): void {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
for (const [group, window] of this.windows) {
|
|
62
|
+
if (window.length === 0 || now - window[window.length - 1] >= this.duration) {
|
|
63
|
+
this.windows.delete(group);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
53
67
|
}
|
package/src/connection.ts
CHANGED
|
@@ -40,6 +40,10 @@ export class Connection {
|
|
|
40
40
|
private connectGeneration = 0;
|
|
41
41
|
private failedAttempts = 0;
|
|
42
42
|
private nextAttemptAt = 0;
|
|
43
|
+
// Half-open recovery (#94): after this many consecutive command timeouts the
|
|
44
|
+
// socket is presumed dead and torn down so the next call reconnects.
|
|
45
|
+
private readonly maxCommandTimeouts = 3;
|
|
46
|
+
private consecutiveTimeouts = 0;
|
|
43
47
|
|
|
44
48
|
constructor(options: ConnectionOptions = {}) {
|
|
45
49
|
this.host = options.host ?? 'localhost';
|
|
@@ -96,6 +100,9 @@ export class Connection {
|
|
|
96
100
|
private async doConnect(): Promise<void> {
|
|
97
101
|
const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
|
|
98
102
|
socket.setNoDelay(true);
|
|
103
|
+
// TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
|
|
104
|
+
// drop with no FIN/RST) in seconds instead of ~tcp_retries2 minutes.
|
|
105
|
+
socket.setKeepAlive(true, 15_000);
|
|
99
106
|
this.parser.clear();
|
|
100
107
|
this.socket = socket;
|
|
101
108
|
|
|
@@ -105,7 +112,14 @@ export class Connection {
|
|
|
105
112
|
|
|
106
113
|
this.connected = true;
|
|
107
114
|
this.connectGeneration += 1;
|
|
115
|
+
this.consecutiveTimeouts = 0;
|
|
108
116
|
|
|
117
|
+
// INVARIANT (H3): connected is flipped true before Auth, which is safe
|
|
118
|
+
// ONLY because call() writes the Auth frame synchronously — there is no
|
|
119
|
+
// `await` between this line and the Auth socket.write, so no concurrent
|
|
120
|
+
// call() can interleave a frame ahead of Auth on the wire. Do NOT insert
|
|
121
|
+
// an await here or before the Auth call, or a command could race ahead of
|
|
122
|
+
// Auth (the Python SDK guards this with a lock; JS relies on this ordering).
|
|
109
123
|
if (this.token) {
|
|
110
124
|
try {
|
|
111
125
|
await this.call({ cmd: 'Auth', token: this.token });
|
|
@@ -129,16 +143,19 @@ export class Connection {
|
|
|
129
143
|
this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
|
|
130
144
|
const reqId = String(this.reqCounter);
|
|
131
145
|
const payload = pack({ ...compact(command), reqId });
|
|
146
|
+
const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
|
|
132
147
|
|
|
133
148
|
return new Promise<Response>((resolve, reject) => {
|
|
134
149
|
const timer = setTimeout(() => {
|
|
135
150
|
this.pending.delete(reqId);
|
|
151
|
+
this.noteTimeout(gen);
|
|
136
152
|
reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
|
|
137
153
|
}, timeoutMs ?? this.commandTimeoutMs);
|
|
138
154
|
|
|
139
155
|
this.pending.set(reqId, {
|
|
140
156
|
resolve: (response) => {
|
|
141
157
|
clearTimeout(timer);
|
|
158
|
+
this.consecutiveTimeouts = 0; // any reply means the link is alive
|
|
142
159
|
if (!response.ok) {
|
|
143
160
|
reject(new CommandError(String(response.error ?? 'unknown server error')));
|
|
144
161
|
} else {
|
|
@@ -216,6 +233,19 @@ export class Connection {
|
|
|
216
233
|
}
|
|
217
234
|
}
|
|
218
235
|
|
|
236
|
+
/**
|
|
237
|
+
* A dead/half-open link makes every command time out while the socket still
|
|
238
|
+
* looks connected. After maxCommandTimeouts consecutive timeouts, tear down
|
|
239
|
+
* so the next call() reconnects instead of wedging (mirrors #94).
|
|
240
|
+
*/
|
|
241
|
+
private noteTimeout(gen: number): void {
|
|
242
|
+
// A timeout from an already-replaced connection must not tear down (or
|
|
243
|
+
// miscount against) the current one.
|
|
244
|
+
if (gen !== this.connectGeneration) return;
|
|
245
|
+
this.consecutiveTimeouts += 1;
|
|
246
|
+
if (this.consecutiveTimeouts >= this.maxCommandTimeouts) this.teardown();
|
|
247
|
+
}
|
|
248
|
+
|
|
219
249
|
private teardown(): void {
|
|
220
250
|
this.connected = false;
|
|
221
251
|
const socket = this.socket;
|
package/src/flow.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Connection } from './connection.js';
|
|
8
|
+
import { CommandError } from './errors.js';
|
|
8
9
|
import type {
|
|
9
10
|
FlowJob,
|
|
10
11
|
FlowProducerOptions,
|
|
@@ -54,7 +55,12 @@ export class FlowProducer {
|
|
|
54
55
|
|
|
55
56
|
/** Fetch a flow tree starting from a job id (recursive over childrenIds). */
|
|
56
57
|
getFlow<T = unknown>(opts: GetFlowOptions): Promise<JobNode<T> | null> {
|
|
57
|
-
return this.fetchNode<T>(
|
|
58
|
+
return this.fetchNode<T>(
|
|
59
|
+
opts.id,
|
|
60
|
+
opts.depth ?? Number.POSITIVE_INFINITY,
|
|
61
|
+
opts.maxChildren,
|
|
62
|
+
new Set()
|
|
63
|
+
);
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** Add a sequential chain: step[0] → step[1] → ... via dependsOn. */
|
|
@@ -205,9 +211,23 @@ export class FlowProducer {
|
|
|
205
211
|
private async fetchNode<T>(
|
|
206
212
|
id: string,
|
|
207
213
|
depth: number,
|
|
208
|
-
maxChildren
|
|
214
|
+
maxChildren: number | undefined,
|
|
215
|
+
visited: Set<string>
|
|
209
216
|
): Promise<JobNode<T> | null> {
|
|
210
|
-
|
|
217
|
+
if (visited.has(id)) return null; // cycle guard: id already on the current path
|
|
218
|
+
visited.add(id);
|
|
219
|
+
// A missing job — the root, or a child removed via removeOnComplete/cancel
|
|
220
|
+
// (childrenIds is a static push-time list, never pruned) — yields null and
|
|
221
|
+
// is skipped, returning the surviving partial tree instead of throwing.
|
|
222
|
+
let response: Awaited<ReturnType<Connection['call']>>;
|
|
223
|
+
try {
|
|
224
|
+
response = await this.connection.call({ cmd: 'GetJob', id });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// Only 'Job not found' means a removed node; a real server error must not
|
|
227
|
+
// masquerade as a missing child and yield a misleading partial tree.
|
|
228
|
+
if (err instanceof CommandError && /not found/i.test(err.message)) return null;
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
211
231
|
const raw = response.job as Record<string, unknown> | null;
|
|
212
232
|
if (!raw) return null;
|
|
213
233
|
const job = new Job<T>(raw, this.connection);
|
|
@@ -216,7 +236,7 @@ export class FlowProducer {
|
|
|
216
236
|
const limit = maxChildren ?? job.childrenIds.length;
|
|
217
237
|
const children: JobNode<T>[] = [];
|
|
218
238
|
for (const childId of job.childrenIds.slice(0, limit)) {
|
|
219
|
-
const child = await this.fetchNode<T>(childId, depth - 1, maxChildren);
|
|
239
|
+
const child = await this.fetchNode<T>(childId, depth - 1, maxChildren, visited);
|
|
220
240
|
if (child) children.push(child);
|
|
221
241
|
}
|
|
222
242
|
return { job, children: children.length > 0 ? children : undefined };
|
package/src/queue-control.ts
CHANGED
|
@@ -72,11 +72,9 @@ export const controlMethods = {
|
|
|
72
72
|
await this.call({ cmd: 'RetryCompleted', queue: this.name });
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
);
|
|
75
|
+
// `count` is accepted for API parity but not sent: the server has no
|
|
76
|
+
// partial RetryDlq — it retries the whole DLQ (BullMQ semantics).
|
|
77
|
+
await this.call({ cmd: 'RetryDlq', queue: this.name });
|
|
80
78
|
},
|
|
81
79
|
|
|
82
80
|
async retryCompleted(this: Ctx, id?: string): Promise<void> {
|
package/src/queue-query.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Methods are merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { CommandError } from './errors.js';
|
|
6
|
+
import { CommandError, CommandTimeoutError } from './errors.js';
|
|
7
7
|
import { compact } from './frame.js';
|
|
8
8
|
import { Job } from './job.js';
|
|
9
9
|
import type { Queue } from './queue.js';
|
|
@@ -66,7 +66,9 @@ export const queryMethods = {
|
|
|
66
66
|
},
|
|
67
67
|
|
|
68
68
|
getWaiting<T = unknown>(this: Ctx, start?: number, end?: number): Promise<Job<T>[]> {
|
|
69
|
-
|
|
69
|
+
// Only the 'waiting' bucket — prioritized jobs live in a separate bucket
|
|
70
|
+
// (getPrioritized), matching BullMQ and the Python SDK / reference client.
|
|
71
|
+
return this.getJobs({ state: 'waiting', start, end });
|
|
70
72
|
},
|
|
71
73
|
|
|
72
74
|
getDelayed<T = unknown>(this: Ctx, start?: number, end?: number): Promise<Job<T>[]> {
|
|
@@ -125,9 +127,26 @@ export const queryMethods = {
|
|
|
125
127
|
await this.call({ cmd: 'RemoveUnprocessedChildren', id });
|
|
126
128
|
},
|
|
127
129
|
|
|
128
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Block until the job completes; returns its result.
|
|
132
|
+
* The server's WaitJob waiter resolves only on completion, replying
|
|
133
|
+
* `{ok:true, completed:false}` (no result) otherwise — so returning undefined
|
|
134
|
+
* would be indistinguishable from a genuine undefined result. On
|
|
135
|
+
* non-completion we probe the state: a `failed` job throws CommandError (it
|
|
136
|
+
* will not complete), everything else throws CommandTimeoutError.
|
|
137
|
+
*/
|
|
129
138
|
async waitForJob<R = unknown>(this: Ctx, id: string, ttlMs = 30_000): Promise<R> {
|
|
130
139
|
const response = await this.call({ cmd: 'WaitJob', id, timeout: ttlMs }, ttlMs + 5000);
|
|
140
|
+
if (response.completed !== true) {
|
|
141
|
+
let state: string | undefined;
|
|
142
|
+
try {
|
|
143
|
+
state = await this.getJobState(id);
|
|
144
|
+
} catch {
|
|
145
|
+
/* ignore probe failure; fall through to timeout */
|
|
146
|
+
}
|
|
147
|
+
if (state === 'failed') throw new CommandError(`job ${id} failed before completion`);
|
|
148
|
+
throw new CommandTimeoutError(`waitUntilFinished timed out after ${ttlMs}ms`);
|
|
149
|
+
}
|
|
131
150
|
return response.result as R;
|
|
132
151
|
},
|
|
133
152
|
|
|
@@ -157,8 +176,9 @@ export const queryMethods = {
|
|
|
157
176
|
},
|
|
158
177
|
|
|
159
178
|
async getWaitingCount(this: Ctx): Promise<number> {
|
|
160
|
-
|
|
161
|
-
|
|
179
|
+
// 'waiting' only — prioritized jobs are counted by getPrioritizedCount,
|
|
180
|
+
// matching BullMQ and the Python SDK / reference client.
|
|
181
|
+
return (await this.getJobCounts()).waiting;
|
|
162
182
|
},
|
|
163
183
|
|
|
164
184
|
async getActiveCount(this: Ctx): Promise<number> {
|
|
@@ -197,8 +217,13 @@ export const queryMethods = {
|
|
|
197
217
|
|
|
198
218
|
// --------------------------------------------------------------------- logs
|
|
199
219
|
|
|
200
|
-
async addJobLog(
|
|
201
|
-
|
|
220
|
+
async addJobLog(
|
|
221
|
+
this: Ctx,
|
|
222
|
+
id: string,
|
|
223
|
+
message: string,
|
|
224
|
+
level?: 'info' | 'warn' | 'error'
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
await this.call(compact({ cmd: 'AddLog', id, message, level }) as { cmd: string });
|
|
202
227
|
},
|
|
203
228
|
|
|
204
229
|
async getJobLogs(this: Ctx, id: string, start?: number, end?: number): Promise<string[]> {
|
|
@@ -209,7 +234,12 @@ export const queryMethods = {
|
|
|
209
234
|
);
|
|
210
235
|
const data = (response.data ?? {}) as Raw;
|
|
211
236
|
const logs = (data.logs ?? response.logs ?? []) as unknown[];
|
|
212
|
-
|
|
237
|
+
// Format as `[level] message` (reference client parity); never drop level.
|
|
238
|
+
return logs.map((row) => {
|
|
239
|
+
if (typeof row === 'string') return row;
|
|
240
|
+
const r = row as Raw;
|
|
241
|
+
return r.level ? `[${r.level}] ${r.message}` : String(r.message ?? row);
|
|
242
|
+
});
|
|
213
243
|
},
|
|
214
244
|
|
|
215
245
|
async clearJobLogs(this: Ctx, id: string, keepLogs?: number): Promise<void> {
|
package/src/queue.ts
CHANGED
|
@@ -72,10 +72,18 @@ export class Queue<T = unknown> {
|
|
|
72
72
|
|
|
73
73
|
/** Add many jobs in one round-trip; returns Job stubs. */
|
|
74
74
|
async addBulk(jobs: BulkJobEntry<T>[]): Promise<Job<T>[]> {
|
|
75
|
-
const inputs = jobs.map((entry) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const inputs = jobs.map((entry) => {
|
|
76
|
+
const opts = wireJobOptions(entry.opts);
|
|
77
|
+
// PUSHB entries are JobInput, whose custom-id field is `customId` —
|
|
78
|
+
// unlike single PUSH which renames `jobId`->`customId` server-side.
|
|
79
|
+
// Without this the batch custom id is silently dropped (idempotency /
|
|
80
|
+
// getJobByCustomId broken).
|
|
81
|
+
if (opts.jobId !== undefined) {
|
|
82
|
+
opts.customId = opts.jobId;
|
|
83
|
+
delete opts.jobId;
|
|
84
|
+
}
|
|
85
|
+
return { data: jobPayload(entry.name, entry.data), ...opts };
|
|
86
|
+
});
|
|
79
87
|
const response = await this.call({ cmd: 'PUSHB', queue: this.name, jobs: inputs });
|
|
80
88
|
const ids = (response.ids ?? []) as string[];
|
|
81
89
|
return ids.map(
|
package/src/worker.ts
CHANGED
|
@@ -127,7 +127,9 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
127
127
|
} catch (err) {
|
|
128
128
|
this.failedCount += 1;
|
|
129
129
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
130
|
-
|
|
130
|
+
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
131
|
+
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
132
|
+
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
131
133
|
await this.safeCall(
|
|
132
134
|
compact({
|
|
133
135
|
cmd: 'FAIL',
|