bunqueue-client 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +44 -1
  3. package/dist/ack-batcher.d.ts +28 -0
  4. package/dist/ack-batcher.js +67 -0
  5. package/dist/backpressure.d.ts +22 -0
  6. package/dist/backpressure.js +39 -0
  7. package/dist/connection-pool.d.ts +30 -0
  8. package/dist/connection-pool.js +60 -0
  9. package/dist/connection-types.d.ts +21 -1
  10. package/dist/connection.d.ts +20 -3
  11. package/dist/connection.js +70 -3
  12. package/dist/flow-types.d.ts +2 -1
  13. package/dist/flow.js +33 -7
  14. package/dist/index.d.ts +7 -2
  15. package/dist/index.js +3 -1
  16. package/dist/job.d.ts +2 -2
  17. package/dist/observability.d.ts +95 -0
  18. package/dist/observability.js +110 -0
  19. package/dist/queue-control.js +6 -5
  20. package/dist/queue-query.d.ts +9 -2
  21. package/dist/queue-query.js +44 -26
  22. package/dist/queue.d.ts +13 -4
  23. package/dist/queue.js +26 -11
  24. package/dist/responses.d.ts +79 -0
  25. package/dist/responses.js +10 -0
  26. package/dist/worker-base.d.ts +2 -0
  27. package/dist/worker-base.js +5 -0
  28. package/dist/worker-types.d.ts +15 -1
  29. package/dist/worker.d.ts +4 -0
  30. package/dist/worker.js +46 -7
  31. package/package.json +2 -1
  32. package/src/ack-batcher.ts +76 -0
  33. package/src/backpressure.ts +40 -0
  34. package/src/connection-pool.ts +71 -0
  35. package/src/connection-types.ts +23 -1
  36. package/src/connection.ts +72 -6
  37. package/src/flow-types.ts +2 -1
  38. package/src/flow.ts +46 -18
  39. package/src/index.ts +28 -2
  40. package/src/job.ts +3 -3
  41. package/src/observability.ts +158 -0
  42. package/src/queue-control.ts +9 -11
  43. package/src/queue-query.ts +73 -35
  44. package/src/queue.ts +42 -15
  45. package/src/responses.ts +96 -0
  46. package/src/worker-base.ts +6 -0
  47. package/src/worker-types.ts +16 -1
  48. package/src/worker.ts +48 -7
package/src/connection.ts CHANGED
@@ -6,8 +6,10 @@
6
6
  * builtins (net/tls), which Node, Bun and Deno all support.
7
7
  */
8
8
 
9
+ import { EventEmitter } from 'node:events';
9
10
  import type { Socket } from 'node:net';
10
11
  import { pack, unpack } from 'msgpackr';
12
+ import { Backpressure } from './backpressure.js';
11
13
  import type {
12
14
  Command,
13
15
  ConnectionOptions,
@@ -17,12 +19,19 @@ import type {
17
19
  } from './connection-types.js';
18
20
  import { AuthError, CommandError, CommandTimeoutError, ConnectionClosedError } from './errors.js';
19
21
  import { compact, FrameParser, frame, PROTOCOL_VERSION } from './frame.js';
22
+ import { nowMs, Telemetry } from './observability.js';
20
23
  import { openSocket } from './socket-factory.js';
21
24
 
22
25
  export type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
23
26
 
24
- /** A single pipelined TCP connection to a bunqueue server. */
25
- export class Connection {
27
+ /**
28
+ * A single pipelined TCP connection to a bunqueue server.
29
+ *
30
+ * Emits typed lifecycle events for observability: `connect`, `disconnect` and
31
+ * `reconnect_scheduled` (payloads mirror the telemetry events). Attach a
32
+ * telemetry sink via the `onTelemetry` option for per-command latency metrics.
33
+ */
34
+ export class Connection extends EventEmitter {
26
35
  readonly host: string;
27
36
  readonly port: number;
28
37
  readonly token: string | undefined;
@@ -40,14 +49,26 @@ export class Connection {
40
49
  private connectGeneration = 0;
41
50
  private failedAttempts = 0;
42
51
  private nextAttemptAt = 0;
52
+ // Half-open recovery (#94): after this many consecutive command timeouts the
53
+ // socket is presumed dead and torn down so the next call reconnects.
54
+ private readonly maxCommandTimeouts = 3;
55
+ private consecutiveTimeouts = 0;
56
+ private readonly telemetry: Telemetry;
57
+ private readonly backpressure: Backpressure;
43
58
 
44
59
  constructor(options: ConnectionOptions = {}) {
60
+ super();
45
61
  this.host = options.host ?? 'localhost';
46
62
  this.port = options.port ?? 6789;
47
63
  this.token = options.token;
48
64
  this.tls = options.tls;
49
65
  this.connectTimeoutMs = options.connectTimeoutMs ?? 5000;
50
66
  this.commandTimeoutMs = options.commandTimeoutMs ?? 10_000;
67
+ this.telemetry = new Telemetry(options, (event, payload) => this.emit(event, payload));
68
+ const maxInFlight = options.maxInFlight ?? 0;
69
+ this.backpressure = new Backpressure(maxInFlight, () =>
70
+ this.telemetry.backpressure(this.pending.size, maxInFlight)
71
+ );
51
72
  }
52
73
 
53
74
  get isConnected(): boolean {
@@ -85,6 +106,7 @@ export class Connection {
85
106
  this.failedAttempts += 1;
86
107
  const backoff = Math.min(500 * 2 ** (this.failedAttempts - 1), 5000);
87
108
  this.nextAttemptAt = Date.now() + backoff;
109
+ this.telemetry.reconnectScheduled(this.host, this.port, this.failedAttempts, backoff);
88
110
  throw err;
89
111
  })
90
112
  .finally(() => {
@@ -94,8 +116,12 @@ export class Connection {
94
116
  }
95
117
 
96
118
  private async doConnect(): Promise<void> {
119
+ const startMs = nowMs();
97
120
  const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
98
121
  socket.setNoDelay(true);
122
+ // TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
123
+ // drop with no FIN/RST) in seconds instead of ~tcp_retries2 minutes.
124
+ socket.setKeepAlive(true, 15_000);
99
125
  this.parser.clear();
100
126
  this.socket = socket;
101
127
 
@@ -105,11 +131,21 @@ export class Connection {
105
131
 
106
132
  this.connected = true;
107
133
  this.connectGeneration += 1;
134
+ this.consecutiveTimeouts = 0;
135
+ this.telemetry.connected(this.host, this.port, this.connectGeneration, startMs);
108
136
 
137
+ // INVARIANT (H3): connected is flipped true before Auth, which is safe
138
+ // ONLY because call() writes the Auth frame synchronously — there is no
139
+ // `await` between this line and the Auth socket.write, so no concurrent
140
+ // call() can interleave a frame ahead of Auth on the wire. Do NOT insert
141
+ // an await here or before the Auth call, or a command could race ahead of
142
+ // Auth (the Python SDK guards this with a lock; JS relies on this ordering).
109
143
  if (this.token) {
110
144
  try {
111
145
  await this.call({ cmd: 'Auth', token: this.token });
146
+ this.telemetry.auth(true);
112
147
  } catch (err) {
148
+ this.telemetry.auth(false);
113
149
  this.teardown();
114
150
  if (err instanceof CommandError) throw new AuthError(err.message);
115
151
  throw err;
@@ -121,28 +157,39 @@ export class Connection {
121
157
  * Send a command and await its response. Rejects with CommandError when
122
158
  * the server answers ok=false. Reconnects lazily if the link was lost.
123
159
  */
124
- async call(command: Command, timeoutMs?: number): Promise<Response> {
160
+ async call<R = Response>(command: Command, timeoutMs?: number): Promise<R> {
125
161
  if (!this.connected) await this.connect();
162
+ // Backpressure: park here if too many commands are already in flight. The
163
+ // socket may be torn down while parked, so re-check after the gate.
164
+ const gate = this.backpressure.acquire(this.pending.size);
165
+ if (gate) await gate;
126
166
  const socket = this.socket;
127
- if (!socket) throw new ConnectionClosedError('not connected');
167
+ if (!this.connected || !socket) throw new ConnectionClosedError('not connected');
128
168
 
129
169
  this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
130
170
  const reqId = String(this.reqCounter);
131
171
  const payload = pack({ ...compact(command), reqId });
172
+ const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
173
+ const startMs = nowMs();
132
174
 
133
- return new Promise<Response>((resolve, reject) => {
175
+ return new Promise<R>((resolve, reject) => {
134
176
  const timer = setTimeout(() => {
135
177
  this.pending.delete(reqId);
178
+ this.backpressure.release();
179
+ this.telemetry.timeout(command.cmd, reqId);
180
+ this.noteTimeout(gen);
136
181
  reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
137
182
  }, timeoutMs ?? this.commandTimeoutMs);
138
183
 
139
184
  this.pending.set(reqId, {
140
185
  resolve: (response) => {
141
186
  clearTimeout(timer);
187
+ this.consecutiveTimeouts = 0; // any reply means the link is alive
188
+ this.telemetry.command(command.cmd, reqId, startMs, response.ok);
142
189
  if (!response.ok) {
143
190
  reject(new CommandError(String(response.error ?? 'unknown server error')));
144
191
  } else {
145
- resolve(response);
192
+ resolve(response as R);
146
193
  }
147
194
  },
148
195
  reject: (err) => {
@@ -156,6 +203,7 @@ export class Connection {
156
203
  if (err) {
157
204
  const entry = this.pending.get(reqId);
158
205
  this.pending.delete(reqId);
206
+ this.backpressure.release();
159
207
  entry?.reject(new ConnectionClosedError(`send failed: ${err.message}`));
160
208
  this.teardown();
161
209
  }
@@ -211,12 +259,28 @@ export class Connection {
211
259
  const entry = this.pending.get(String(reqId));
212
260
  if (entry) {
213
261
  this.pending.delete(String(reqId));
262
+ this.backpressure.release();
214
263
  entry.resolve(response);
215
264
  }
216
265
  }
217
266
  }
218
267
 
268
+ /**
269
+ * A dead/half-open link makes every command time out while the socket still
270
+ * looks connected. After maxCommandTimeouts consecutive timeouts, tear down
271
+ * so the next call() reconnects instead of wedging (mirrors #94).
272
+ */
273
+ private noteTimeout(gen: number): void {
274
+ // A timeout from an already-replaced connection must not tear down (or
275
+ // miscount against) the current one.
276
+ if (gen !== this.connectGeneration) return;
277
+ this.consecutiveTimeouts += 1;
278
+ if (this.consecutiveTimeouts >= this.maxCommandTimeouts) this.teardown();
279
+ }
280
+
219
281
  private teardown(): void {
282
+ const wasConnected = this.socket !== null;
283
+ const gen = this.connectGeneration;
220
284
  this.connected = false;
221
285
  const socket = this.socket;
222
286
  this.socket = null;
@@ -230,5 +294,7 @@ export class Connection {
230
294
  clearTimeout(entry.timer);
231
295
  entry.reject(new ConnectionClosedError('connection lost'));
232
296
  }
297
+ this.backpressure.clear(); // release parked callers; they re-check + fail/reconnect
298
+ if (wasConnected) this.telemetry.disconnected(this.host, this.port, gen);
233
299
  }
234
300
  }
package/src/flow-types.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { Connection, TlsOption } from './connection.js';
4
4
  import type { Job } from './job.js';
5
+ import type { Observability } from './observability.js';
5
6
  import type { JobOptions } from './types.js';
6
7
 
7
8
  export interface FlowJob<T = unknown> {
@@ -24,7 +25,7 @@ export interface FlowStep<T = unknown> {
24
25
  opts?: JobOptions;
25
26
  }
26
27
 
27
- export interface FlowProducerOptions {
28
+ export interface FlowProducerOptions extends Observability {
28
29
  host?: string;
29
30
  port?: number;
30
31
  token?: string;
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,
@@ -14,6 +15,7 @@ import type {
14
15
  } from './flow-types.js';
15
16
  import { compact } from './frame.js';
16
17
  import { Job } from './job.js';
18
+ import type { JobResponse, OkResponse } from './responses.js';
17
19
  import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
18
20
 
19
21
  export class FlowProducer {
@@ -23,7 +25,14 @@ export class FlowProducer {
23
25
  constructor(opts: FlowProducerOptions = {}) {
24
26
  this.connection =
25
27
  opts.connection ??
26
- new Connection({ host: opts.host, port: opts.port, token: opts.token, tls: opts.tls });
28
+ new Connection({
29
+ host: opts.host,
30
+ port: opts.port,
31
+ token: opts.token,
32
+ tls: opts.tls,
33
+ logger: opts.logger,
34
+ onTelemetry: opts.onTelemetry,
35
+ });
27
36
  this.ownsConnection = opts.connection === undefined;
28
37
  }
29
38
 
@@ -54,7 +63,12 @@ export class FlowProducer {
54
63
 
55
64
  /** Fetch a flow tree starting from a job id (recursive over childrenIds). */
56
65
  getFlow<T = unknown>(opts: GetFlowOptions): Promise<JobNode<T> | null> {
57
- return this.fetchNode<T>(opts.id, opts.depth ?? Number.POSITIVE_INFINITY, opts.maxChildren);
66
+ return this.fetchNode<T>(
67
+ opts.id,
68
+ opts.depth ?? Number.POSITIVE_INFINITY,
69
+ opts.maxChildren,
70
+ new Set()
71
+ );
58
72
  }
59
73
 
60
74
  /** Add a sequential chain: step[0] → step[1] → ... via dependsOn. */
@@ -64,19 +78,19 @@ export class FlowProducer {
64
78
  let prevId: string | null = null;
65
79
  try {
66
80
  for (const step of steps) {
67
- const data = compact({
81
+ const data: Record<string, unknown> = compact({
68
82
  ...jobPayload(step.name, step.data),
69
83
  __flowParentId: prevId ?? undefined,
70
84
  });
71
- const response = await this.connection.call(
72
- compact({
73
- cmd: 'PUSH',
74
- queue: step.queueName,
75
- data,
76
- ...wireJobOptions(step.opts),
77
- dependsOn: prevId ? [prevId] : undefined,
78
- }) as { cmd: string }
79
- );
85
+ // Connection.call compacts internally, so no outer compact() is needed
86
+ // (nesting it confused generic inference of `data`/`response`).
87
+ const response: OkResponse = await this.connection.call<OkResponse>({
88
+ cmd: 'PUSH',
89
+ queue: step.queueName,
90
+ data,
91
+ ...wireJobOptions(step.opts),
92
+ dependsOn: prevId ? [prevId] : undefined,
93
+ });
80
94
  const id = String(response.id);
81
95
  jobIds.push(id);
82
96
  prevId = id;
@@ -97,7 +111,7 @@ export class FlowProducer {
97
111
  try {
98
112
  const results = await Promise.allSettled(
99
113
  parallel.map(async (step) => {
100
- const response = await this.connection.call(
114
+ const response = await this.connection.call<OkResponse>(
101
115
  compact({
102
116
  cmd: 'PUSH',
103
117
  queue: step.queueName,
@@ -182,7 +196,7 @@ export class FlowProducer {
182
196
  parentRef: { id: string; queue: string } | null,
183
197
  childIds: string[]
184
198
  ): Promise<string> {
185
- const response = await this.connection.call(
199
+ const response = await this.connection.call<OkResponse>(
186
200
  compact({
187
201
  cmd: 'PUSH',
188
202
  queue,
@@ -205,10 +219,24 @@ export class FlowProducer {
205
219
  private async fetchNode<T>(
206
220
  id: string,
207
221
  depth: number,
208
- maxChildren?: number
222
+ maxChildren: number | undefined,
223
+ visited: Set<string>
209
224
  ): Promise<JobNode<T> | null> {
210
- const response = await this.connection.call({ cmd: 'GetJob', id });
211
- const raw = response.job as Record<string, unknown> | null;
225
+ if (visited.has(id)) return null; // cycle guard: id already on the current path
226
+ visited.add(id);
227
+ // A missing job — the root, or a child removed via removeOnComplete/cancel
228
+ // (childrenIds is a static push-time list, never pruned) — yields null and
229
+ // is skipped, returning the surviving partial tree instead of throwing.
230
+ let response: JobResponse;
231
+ try {
232
+ response = await this.connection.call<JobResponse>({ cmd: 'GetJob', id });
233
+ } catch (err) {
234
+ // Only 'Job not found' means a removed node; a real server error must not
235
+ // masquerade as a missing child and yield a misleading partial tree.
236
+ if (err instanceof CommandError && /not found/i.test(err.message)) return null;
237
+ throw err;
238
+ }
239
+ const raw = response.job;
212
240
  if (!raw) return null;
213
241
  const job = new Job<T>(raw, this.connection);
214
242
  if (depth <= 0 || job.childrenIds.length === 0) return { job };
@@ -216,7 +244,7 @@ export class FlowProducer {
216
244
  const limit = maxChildren ?? job.childrenIds.length;
217
245
  const children: JobNode<T>[] = [];
218
246
  for (const childId of job.childrenIds.slice(0, limit)) {
219
- const child = await this.fetchNode<T>(childId, depth - 1, maxChildren);
247
+ const child = await this.fetchNode<T>(childId, depth - 1, maxChildren, visited);
220
248
  if (child) children.push(child);
221
249
  }
222
250
  return { job, children: children.length > 0 ? children : undefined };
package/src/index.ts CHANGED
@@ -26,6 +26,8 @@ export type {
26
26
  } from './bunqueue/types.js';
27
27
  export type { Command, ConnectionOptions, Response, TlsOption } from './connection.js';
28
28
  export { Connection } from './connection.js';
29
+ export { ConnectionPool } from './connection-pool.js';
30
+ export type { ConnectionLike } from './connection-types.js';
29
31
  export {
30
32
  AuthError,
31
33
  BunqueueError,
@@ -45,9 +47,33 @@ export type {
45
47
  export { MAX_FRAME_SIZE, PROTOCOL_VERSION } from './frame.js';
46
48
  export type { JobRaw } from './job.js';
47
49
  export { Job } from './job.js';
50
+ export type {
51
+ Logger,
52
+ LogLevel,
53
+ Observability,
54
+ TelemetryEvent,
55
+ TelemetryHandler,
56
+ } from './observability.js';
57
+ export { consoleLogger, noopLogger } from './observability.js';
48
58
  export type { BulkJobEntry, QueueOptions } from './queue.js';
49
59
  export { Queue } from './queue.js';
50
60
  export type { SchedulerOptions } from './queue-admin.js';
61
+ export type {
62
+ BatchResponse,
63
+ CountResponse,
64
+ DataResponse,
65
+ JobCountsResponse,
66
+ JobResponse,
67
+ JobsResponse,
68
+ OkResponse,
69
+ PausedResponse,
70
+ ProgressResponse,
71
+ PulledJobResponse,
72
+ PulledJobsResponse,
73
+ ResultResponse,
74
+ StateResponse,
75
+ WaitJobResponse,
76
+ } from './responses.js';
51
77
  export type {
52
78
  BackoffOptions,
53
79
  DeduplicationOptions,
@@ -57,6 +83,6 @@ export type {
57
83
  RepeatOptions,
58
84
  } from './types.js';
59
85
  export { Worker } from './worker.js';
60
- export type { Processor, WorkerOptions } from './worker-types.js';
86
+ export type { AckBatchOptions, Processor, WorkerOptions } from './worker-types.js';
61
87
 
62
- export const __version__ = '0.1.2';
88
+ export const __version__ = '0.1.6';
package/src/job.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Mirrors the official TS client's Job surface (TCP mode).
4
4
  */
5
5
 
6
- import type { Connection, Response } from './connection.js';
6
+ import type { ConnectionLike, Response } from './connection-types.js';
7
7
 
8
8
  export type JobRaw = Record<string, unknown>;
9
9
 
@@ -12,10 +12,10 @@ export type ProgressHook = (job: Job, progress: number) => void;
12
12
  export class Job<T = unknown> {
13
13
  readonly raw: JobRaw;
14
14
  readonly token: string | undefined;
15
- private readonly conn: Connection | undefined;
15
+ private readonly conn: ConnectionLike | undefined;
16
16
  private readonly onProgress: ProgressHook | undefined;
17
17
 
18
- constructor(raw: JobRaw, connection?: Connection, token?: string, onProgress?: ProgressHook) {
18
+ constructor(raw: JobRaw, connection?: ConnectionLike, token?: string, onProgress?: ProgressHook) {
19
19
  this.raw = raw;
20
20
  this.conn = connection;
21
21
  this.token = token;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Observability primitives: a pluggable structured logger and a telemetry
3
+ * sink. Zero hard dependencies — wire your own OpenTelemetry / Prometheus /
4
+ * logging stack through these interfaces. Defaults are no-ops, so the SDK is
5
+ * silent unless you opt in.
6
+ */
7
+
8
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
9
+
10
+ export interface Logger {
11
+ debug(message: string, meta?: Record<string, unknown>): void;
12
+ info(message: string, meta?: Record<string, unknown>): void;
13
+ warn(message: string, meta?: Record<string, unknown>): void;
14
+ error(message: string, meta?: Record<string, unknown>): void;
15
+ }
16
+
17
+ /** Silent logger (the default). */
18
+ export const noopLogger: Logger = {
19
+ debug() {},
20
+ info() {},
21
+ warn() {},
22
+ error() {},
23
+ };
24
+
25
+ const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
26
+
27
+ /**
28
+ * A console-backed logger. Levels below `min` are dropped. Opt-in — pass it as
29
+ * `logger` when you want the SDK to log to the console.
30
+ */
31
+ export function consoleLogger(min: LogLevel = 'info'): Logger {
32
+ const floor = LEVELS[min];
33
+ const line = (lvl: LogLevel, msg: string, meta?: Record<string, unknown>) => {
34
+ if (LEVELS[lvl] < floor) return;
35
+ const suffix = meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
36
+ const sink = lvl === 'debug' ? console.log : console[lvl];
37
+ sink(`[bunqueue] ${lvl} ${msg}${suffix}`);
38
+ };
39
+ return {
40
+ debug: (m, meta) => line('debug', m, meta),
41
+ info: (m, meta) => line('info', m, meta),
42
+ warn: (m, meta) => line('warn', m, meta),
43
+ error: (m, meta) => line('error', m, meta),
44
+ };
45
+ }
46
+
47
+ /** A single telemetry data point. A discriminated union keyed on `type`. */
48
+ export type TelemetryEvent =
49
+ | { type: 'command'; cmd: string; reqId: string; durationMs: number; ok: boolean }
50
+ | { type: 'command_timeout'; cmd: string; reqId: string }
51
+ | { type: 'connect'; host: string; port: number; generation: number; durationMs: number }
52
+ | { type: 'disconnect'; host: string; port: number; generation: number }
53
+ | { type: 'reconnect_scheduled'; host: string; port: number; attempt: number; delayMs: number }
54
+ | { type: 'auth'; ok: boolean }
55
+ | { type: 'backpressure'; inFlight: number; maxInFlight: number };
56
+
57
+ export type TelemetryHandler = (event: TelemetryEvent) => void;
58
+
59
+ /** Lifecycle telemetry types that are ALSO emitted as EventEmitter events. */
60
+ export const LIFECYCLE_EVENTS = ['connect', 'disconnect', 'reconnect_scheduled'] as const;
61
+
62
+ export interface Observability {
63
+ /** Structured logger; defaults to {@link noopLogger}. */
64
+ logger?: Logger;
65
+ /**
66
+ * Telemetry sink for metrics and tracing. Receives every command's latency,
67
+ * connect/disconnect/reconnect, auth and backpressure events. Bridge it to
68
+ * OpenTelemetry spans or Prometheus counters without the SDK depending on
69
+ * either.
70
+ */
71
+ onTelemetry?: TelemetryHandler;
72
+ }
73
+
74
+ /** High-resolution monotonic clock (Node/Bun/Deno all expose `performance`). */
75
+ export const nowMs = (): number => performance.now();
76
+
77
+ /**
78
+ * Fans a telemetry event out to (1) the telemetry sink, (2) the logger, and
79
+ * (3) — for lifecycle events — the owning EventEmitter, so users can both
80
+ * scrape metrics and attach imperative `connection.on('connect', …)` handlers.
81
+ */
82
+ export class Telemetry {
83
+ private readonly logger: Logger;
84
+ private readonly sink: TelemetryHandler | undefined;
85
+ private readonly emit: (event: string, payload: unknown) => void;
86
+
87
+ constructor(obs: Observability | undefined, emit: (event: string, payload: unknown) => void) {
88
+ // Wrap the user logger so a throwing logger can never break the transport
89
+ // hot path (dispatch runs inside socket 'data' handlers and connect).
90
+ const inner = obs?.logger ?? noopLogger;
91
+ this.logger =
92
+ inner === noopLogger
93
+ ? inner
94
+ : {
95
+ debug: (m, meta) => Telemetry.safely(() => inner.debug(m, meta)),
96
+ info: (m, meta) => Telemetry.safely(() => inner.info(m, meta)),
97
+ warn: (m, meta) => Telemetry.safely(() => inner.warn(m, meta)),
98
+ error: (m, meta) => Telemetry.safely(() => inner.error(m, meta)),
99
+ };
100
+ this.sink = obs?.onTelemetry;
101
+ this.emit = emit;
102
+ }
103
+
104
+ /** Observability must never break the client: swallow consumer errors. */
105
+ private static safely(fn: () => void): void {
106
+ try {
107
+ fn();
108
+ } catch {
109
+ /* consumer logging/telemetry error — intentionally ignored */
110
+ }
111
+ }
112
+
113
+ get log(): Logger {
114
+ return this.logger;
115
+ }
116
+
117
+ /** Record a completed command's latency and outcome. */
118
+ command(cmd: string, reqId: string, startMs: number, ok: boolean): void {
119
+ this.dispatch({ type: 'command', cmd, reqId, durationMs: nowMs() - startMs, ok });
120
+ }
121
+
122
+ timeout(cmd: string, reqId: string): void {
123
+ this.logger.warn('command timed out', { cmd, reqId });
124
+ this.dispatch({ type: 'command_timeout', cmd, reqId });
125
+ }
126
+
127
+ connected(host: string, port: number, generation: number, startMs: number): void {
128
+ this.logger.info('connected', { host, port, generation });
129
+ this.dispatch({ type: 'connect', host, port, generation, durationMs: nowMs() - startMs });
130
+ }
131
+
132
+ disconnected(host: string, port: number, generation: number): void {
133
+ this.logger.info('disconnected', { host, port, generation });
134
+ this.dispatch({ type: 'disconnect', host, port, generation });
135
+ }
136
+
137
+ reconnectScheduled(host: string, port: number, attempt: number, delayMs: number): void {
138
+ this.logger.warn('reconnect scheduled', { host, port, attempt, delayMs });
139
+ this.dispatch({ type: 'reconnect_scheduled', host, port, attempt, delayMs });
140
+ }
141
+
142
+ auth(ok: boolean): void {
143
+ this.dispatch({ type: 'auth', ok });
144
+ }
145
+
146
+ backpressure(inFlight: number, maxInFlight: number): void {
147
+ this.dispatch({ type: 'backpressure', inFlight, maxInFlight });
148
+ }
149
+
150
+ private dispatch(event: TelemetryEvent): void {
151
+ // A throwing sink or lifecycle listener must never break the transport:
152
+ // dispatch runs inside socket 'data' handlers and the connect path.
153
+ Telemetry.safely(() => this.sink?.(event));
154
+ if ((LIFECYCLE_EVENTS as readonly string[]).includes(event.type)) {
155
+ Telemetry.safely(() => this.emit(event.type, event));
156
+ }
157
+ }
158
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { compact } from './frame.js';
7
7
  import type { Queue } from './queue.js';
8
+ import type { CountResponse, PausedResponse } from './responses.js';
8
9
  import type { JobStateName } from './types.js';
9
10
 
10
11
  type Ctx = Queue<unknown>;
@@ -19,8 +20,7 @@ export const controlMethods = {
19
20
  },
20
21
 
21
22
  async isPaused(this: Ctx): Promise<boolean> {
22
- const response = await this.call({ cmd: 'IsPaused', queue: this.name });
23
- return response.paused === true;
23
+ return (await this.call<PausedResponse>({ cmd: 'IsPaused', queue: this.name })).paused === true;
24
24
  },
25
25
 
26
26
  async drain(this: Ctx): Promise<void> {
@@ -33,10 +33,10 @@ export const controlMethods = {
33
33
 
34
34
  /** Remove old jobs; returns how many were removed. */
35
35
  async clean(this: Ctx, graceMs: number, limit?: number, state?: JobStateName): Promise<number> {
36
- const response = await this.call(
36
+ const response = await this.call<CountResponse>(
37
37
  compact({ cmd: 'Clean', queue: this.name, grace: graceMs, limit, state }) as { cmd: string }
38
38
  );
39
- return Number(response.count ?? 0);
39
+ return response.count ?? 0;
40
40
  },
41
41
 
42
42
  async remove(this: Ctx, id: string): Promise<void> {
@@ -53,10 +53,10 @@ export const controlMethods = {
53
53
 
54
54
  /** Promote delayed jobs to waiting; returns how many were promoted. */
55
55
  async promoteJobs(this: Ctx, opts: { count?: number } = {}): Promise<number> {
56
- const response = await this.call(
56
+ const response = await this.call<CountResponse>(
57
57
  compact({ cmd: 'PromoteJobs', queue: this.name, count: opts.count }) as { cmd: string }
58
58
  );
59
- return Number(response.count ?? 0);
59
+ return response.count ?? 0;
60
60
  },
61
61
 
62
62
  /** BullMQ contract: failed → waiting. */
@@ -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> {