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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Egeo Minotti
3
+ Copyright (c) 2026 Egeo Minotti
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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, 72/72 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
12
- | Bun | Supported, 72/72 e2e and 8/8 integration tests | No additional configuration required |
13
- | Deno 2 or later | Supported, 72/72 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
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, 11/11 e2e tests inside workerd | 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 |
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
- Sixty seconds from zero to a working queue. Step 1, start the server (requires [Bun](https://bun.sh), or use the Docker image):
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 2, create `app.ts`: add a job and process it, in the same file for the sake of the demo:
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
- Step 3, run it with the runtime you already use:
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
- That is the whole model: the server owns state, retries, and scheduling, your code only adds and processes. In production the producer and the worker are separate services, often in different languages: the [Python client](https://github.com/egeominotti/bunqueue/tree/main/sdk/python) speaks the same protocol against the same queue. Defaults are `host: 'localhost'`, `port: 6789`, so constructors need no options on a local setup.
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`, counts, counts per priority, children values, job logs |
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
  }
@@ -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
  }
@@ -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
- const response = await this.connection.call({ cmd: 'GetJob', id });
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
  }
@@ -48,7 +48,9 @@ export const controlMethods = {
48
48
  await this.call({ cmd: 'RetryCompleted', queue: this.name });
49
49
  return;
50
50
  }
51
- await this.call(compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }));
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 }));
@@ -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
- /** Block until the job finishes; returns its result. */
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
  };
@@ -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
- return this.getJobs({ state: ['waiting', 'prioritized'], start, end });
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
- /** Block until the job finishes; returns its result. */
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
- const counts = await this.getJobCounts();
124
- return counts.waiting + counts.prioritized;
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
- return logs.map((row) => (typeof row === 'string' ? row : String(row.message ?? row)));
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
- data: jobPayload(entry.name, entry.data),
55
- ...wireJobOptions(entry.opts),
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
- const stack = (error.stack ?? error.message).split('\n').slice(-MAX_STACK_LINES);
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",
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",
@@ -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) await this.rateGate.acquire(this.rateGate.groupFor(job.data));
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>(opts.id, opts.depth ?? Number.POSITIVE_INFINITY, opts.maxChildren);
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?: number
214
+ maxChildren: number | undefined,
215
+ visited: Set<string>
209
216
  ): Promise<JobNode<T> | null> {
210
- const response = await this.connection.call({ cmd: 'GetJob', id });
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 };
@@ -72,11 +72,9 @@ export const controlMethods = {
72
72
  await this.call({ cmd: 'RetryCompleted', queue: this.name });
73
73
  return;
74
74
  }
75
- await this.call(
76
- compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }) as {
77
- cmd: string;
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> {
@@ -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
- return this.getJobs({ state: ['waiting', 'prioritized'], start, end });
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
- /** Block until the job finishes; returns its result. */
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
- const counts = await this.getJobCounts();
161
- return counts.waiting + counts.prioritized;
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(this: Ctx, id: string, message: string): Promise<void> {
201
- await this.call({ cmd: 'AddLog', id, message });
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
- return logs.map((row) => (typeof row === 'string' ? row : String((row as Raw).message ?? row)));
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
- data: jobPayload(entry.name, entry.data),
77
- ...wireJobOptions(entry.opts),
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
- const stack = (error.stack ?? error.message).split('\n').slice(-MAX_STACK_LINES);
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',