bunqueue-client 0.1.5 → 0.1.7

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 (52) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +46 -3
  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/bunqueue/bunqueue.js +12 -2
  8. package/dist/connection-pool.d.ts +30 -0
  9. package/dist/connection-pool.js +60 -0
  10. package/dist/connection-types.d.ts +21 -1
  11. package/dist/connection.d.ts +12 -3
  12. package/dist/connection.js +39 -3
  13. package/dist/flow-types.d.ts +2 -1
  14. package/dist/flow.js +12 -3
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +3 -1
  17. package/dist/job.d.ts +2 -2
  18. package/dist/observability.d.ts +95 -0
  19. package/dist/observability.js +110 -0
  20. package/dist/queue-admin.js +16 -2
  21. package/dist/queue-control.d.ts +5 -0
  22. package/dist/queue-control.js +27 -9
  23. package/dist/queue-query.js +13 -21
  24. package/dist/queue.d.ts +13 -4
  25. package/dist/queue.js +14 -7
  26. package/dist/responses.d.ts +79 -0
  27. package/dist/responses.js +10 -0
  28. package/dist/worker-base.d.ts +15 -3
  29. package/dist/worker-base.js +15 -7
  30. package/dist/worker-types.d.ts +43 -1
  31. package/dist/worker.d.ts +5 -1
  32. package/dist/worker.js +59 -14
  33. package/package.json +3 -1
  34. package/src/ack-batcher.ts +76 -0
  35. package/src/backpressure.ts +40 -0
  36. package/src/bunqueue/bunqueue.ts +12 -1
  37. package/src/connection-pool.ts +71 -0
  38. package/src/connection-types.ts +23 -1
  39. package/src/connection.ts +42 -6
  40. package/src/flow-types.ts +2 -1
  41. package/src/flow.ts +24 -16
  42. package/src/index.ts +33 -2
  43. package/src/job.ts +3 -3
  44. package/src/observability.ts +158 -0
  45. package/src/queue-admin.ts +15 -2
  46. package/src/queue-control.ts +34 -11
  47. package/src/queue-query.ts +39 -29
  48. package/src/queue.ts +30 -11
  49. package/src/responses.ts +96 -0
  50. package/src/worker-base.ts +49 -11
  51. package/src/worker-types.ts +42 -1
  52. package/src/worker.ts +63 -16
@@ -5,12 +5,21 @@
5
5
  * pipelining (many in-flight commands per socket). Uses only `node:`
6
6
  * builtins (net/tls), which Node, Bun and Deno all support.
7
7
  */
8
+ import { EventEmitter } from 'node:events';
8
9
  import { pack, unpack } from 'msgpackr';
10
+ import { Backpressure } from './backpressure.js';
9
11
  import { AuthError, CommandError, CommandTimeoutError, ConnectionClosedError } from './errors.js';
10
12
  import { compact, FrameParser, frame, PROTOCOL_VERSION } from './frame.js';
13
+ import { nowMs, Telemetry } from './observability.js';
11
14
  import { openSocket } from './socket-factory.js';
12
- /** A single pipelined TCP connection to a bunqueue server. */
13
- export class Connection {
15
+ /**
16
+ * A single pipelined TCP connection to a bunqueue server.
17
+ *
18
+ * Emits typed lifecycle events for observability: `connect`, `disconnect` and
19
+ * `reconnect_scheduled` (payloads mirror the telemetry events). Attach a
20
+ * telemetry sink via the `onTelemetry` option for per-command latency metrics.
21
+ */
22
+ export class Connection extends EventEmitter {
14
23
  host;
15
24
  port;
16
25
  token;
@@ -31,13 +40,19 @@ export class Connection {
31
40
  // socket is presumed dead and torn down so the next call reconnects.
32
41
  maxCommandTimeouts = 3;
33
42
  consecutiveTimeouts = 0;
43
+ telemetry;
44
+ backpressure;
34
45
  constructor(options = {}) {
46
+ super();
35
47
  this.host = options.host ?? 'localhost';
36
48
  this.port = options.port ?? 6789;
37
49
  this.token = options.token;
38
50
  this.tls = options.tls;
39
51
  this.connectTimeoutMs = options.connectTimeoutMs ?? 5000;
40
52
  this.commandTimeoutMs = options.commandTimeoutMs ?? 10_000;
53
+ this.telemetry = new Telemetry(options, (event, payload) => this.emit(event, payload));
54
+ const maxInFlight = options.maxInFlight ?? 0;
55
+ this.backpressure = new Backpressure(maxInFlight, () => this.telemetry.backpressure(this.pending.size, maxInFlight));
41
56
  }
42
57
  get isConnected() {
43
58
  return this.connected;
@@ -73,6 +88,7 @@ export class Connection {
73
88
  this.failedAttempts += 1;
74
89
  const backoff = Math.min(500 * 2 ** (this.failedAttempts - 1), 5000);
75
90
  this.nextAttemptAt = Date.now() + backoff;
91
+ this.telemetry.reconnectScheduled(this.host, this.port, this.failedAttempts, backoff);
76
92
  throw err;
77
93
  })
78
94
  .finally(() => {
@@ -81,6 +97,7 @@ export class Connection {
81
97
  return this.connecting;
82
98
  }
83
99
  async doConnect() {
100
+ const startMs = nowMs();
84
101
  const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
85
102
  socket.setNoDelay(true);
86
103
  // TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
@@ -94,6 +111,7 @@ export class Connection {
94
111
  this.connected = true;
95
112
  this.connectGeneration += 1;
96
113
  this.consecutiveTimeouts = 0;
114
+ this.telemetry.connected(this.host, this.port, this.connectGeneration, startMs);
97
115
  // INVARIANT (H3): connected is flipped true before Auth, which is safe
98
116
  // ONLY because call() writes the Auth frame synchronously — there is no
99
117
  // `await` between this line and the Auth socket.write, so no concurrent
@@ -103,8 +121,10 @@ export class Connection {
103
121
  if (this.token) {
104
122
  try {
105
123
  await this.call({ cmd: 'Auth', token: this.token });
124
+ this.telemetry.auth(true);
106
125
  }
107
126
  catch (err) {
127
+ this.telemetry.auth(false);
108
128
  this.teardown();
109
129
  if (err instanceof CommandError)
110
130
  throw new AuthError(err.message);
@@ -119,16 +139,24 @@ export class Connection {
119
139
  async call(command, timeoutMs) {
120
140
  if (!this.connected)
121
141
  await this.connect();
142
+ // Backpressure: park here if too many commands are already in flight. The
143
+ // socket may be torn down while parked, so re-check after the gate.
144
+ const gate = this.backpressure.acquire(this.pending.size);
145
+ if (gate)
146
+ await gate;
122
147
  const socket = this.socket;
123
- if (!socket)
148
+ if (!this.connected || !socket)
124
149
  throw new ConnectionClosedError('not connected');
125
150
  this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
126
151
  const reqId = String(this.reqCounter);
127
152
  const payload = pack({ ...compact(command), reqId });
128
153
  const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
154
+ const startMs = nowMs();
129
155
  return new Promise((resolve, reject) => {
130
156
  const timer = setTimeout(() => {
131
157
  this.pending.delete(reqId);
158
+ this.backpressure.release();
159
+ this.telemetry.timeout(command.cmd, reqId);
132
160
  this.noteTimeout(gen);
133
161
  reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
134
162
  }, timeoutMs ?? this.commandTimeoutMs);
@@ -136,6 +164,7 @@ export class Connection {
136
164
  resolve: (response) => {
137
165
  clearTimeout(timer);
138
166
  this.consecutiveTimeouts = 0; // any reply means the link is alive
167
+ this.telemetry.command(command.cmd, reqId, startMs, response.ok);
139
168
  if (!response.ok) {
140
169
  reject(new CommandError(String(response.error ?? 'unknown server error')));
141
170
  }
@@ -153,6 +182,7 @@ export class Connection {
153
182
  if (err) {
154
183
  const entry = this.pending.get(reqId);
155
184
  this.pending.delete(reqId);
185
+ this.backpressure.release();
156
186
  entry?.reject(new ConnectionClosedError(`send failed: ${err.message}`));
157
187
  this.teardown();
158
188
  }
@@ -209,6 +239,7 @@ export class Connection {
209
239
  const entry = this.pending.get(String(reqId));
210
240
  if (entry) {
211
241
  this.pending.delete(String(reqId));
242
+ this.backpressure.release();
212
243
  entry.resolve(response);
213
244
  }
214
245
  }
@@ -228,6 +259,8 @@ export class Connection {
228
259
  this.teardown();
229
260
  }
230
261
  teardown() {
262
+ const wasConnected = this.socket !== null;
263
+ const gen = this.connectGeneration;
231
264
  this.connected = false;
232
265
  const socket = this.socket;
233
266
  this.socket = null;
@@ -241,5 +274,8 @@ export class Connection {
241
274
  clearTimeout(entry.timer);
242
275
  entry.reject(new ConnectionClosedError('connection lost'));
243
276
  }
277
+ this.backpressure.clear(); // release parked callers; they re-check + fail/reconnect
278
+ if (wasConnected)
279
+ this.telemetry.disconnected(this.host, this.port, gen);
244
280
  }
245
281
  }
@@ -1,6 +1,7 @@
1
1
  /** FlowProducer types. */
2
2
  import type { Connection, TlsOption } from './connection.js';
3
3
  import type { Job } from './job.js';
4
+ import type { Observability } from './observability.js';
4
5
  import type { JobOptions } from './types.js';
5
6
  export interface FlowJob<T = unknown> {
6
7
  name: string;
@@ -19,7 +20,7 @@ export interface FlowStep<T = unknown> {
19
20
  data?: T;
20
21
  opts?: JobOptions;
21
22
  }
22
- export interface FlowProducerOptions {
23
+ export interface FlowProducerOptions extends Observability {
23
24
  host?: string;
24
25
  port?: number;
25
26
  token?: string;
package/dist/flow.js CHANGED
@@ -14,7 +14,14 @@ export class FlowProducer {
14
14
  constructor(opts = {}) {
15
15
  this.connection =
16
16
  opts.connection ??
17
- new Connection({ host: opts.host, port: opts.port, token: opts.token, tls: opts.tls });
17
+ new Connection({
18
+ host: opts.host,
19
+ port: opts.port,
20
+ token: opts.token,
21
+ tls: opts.tls,
22
+ logger: opts.logger,
23
+ onTelemetry: opts.onTelemetry,
24
+ });
18
25
  this.ownsConnection = opts.connection === undefined;
19
26
  }
20
27
  /** Add a flow tree. Children are created (and processed) BEFORE their parent. */
@@ -58,13 +65,15 @@ export class FlowProducer {
58
65
  ...jobPayload(step.name, step.data),
59
66
  __flowParentId: prevId ?? undefined,
60
67
  });
61
- const response = await this.connection.call(compact({
68
+ // Connection.call compacts internally, so no outer compact() is needed
69
+ // (nesting it confused generic inference of `data`/`response`).
70
+ const response = await this.connection.call({
62
71
  cmd: 'PUSH',
63
72
  queue: step.queueName,
64
73
  data,
65
74
  ...wireJobOptions(step.opts),
66
75
  dependsOn: prevId ? [prevId] : undefined,
67
- }));
76
+ });
68
77
  const id = String(response.id);
69
78
  jobIds.push(id);
70
79
  prevId = id;
package/dist/index.d.ts CHANGED
@@ -7,16 +7,21 @@ export type { DlqFilter, DlqStats } from './bunqueue/dlq-rate-limit.js';
7
7
  export type { BatchConfig, BatchProcessor, BunqueueConnection, BunqueueDebounceConfig, BunqueueDeduplicationConfig, BunqueueDlqConfig, BunqueueMiddleware, BunqueueOptions, CircuitBreakerConfig, CircuitState, JobTtlConfig, PriorityAgingConfig, RateLimiterOptions, RetryConfig, RetryStrategy, TriggerRule, } from './bunqueue/types.js';
8
8
  export type { Command, ConnectionOptions, Response, TlsOption } from './connection.js';
9
9
  export { Connection } from './connection.js';
10
+ export { ConnectionPool } from './connection-pool.js';
11
+ export type { ConnectionLike } from './connection-types.js';
10
12
  export { AuthError, BunqueueError, CommandError, CommandTimeoutError, ConnectionClosedError, UnrecoverableError, } from './errors.js';
11
13
  export { FlowProducer } from './flow.js';
12
14
  export type { FlowJob, FlowProducerOptions, FlowStep, GetFlowOptions, JobNode, } from './flow-types.js';
13
15
  export { MAX_FRAME_SIZE, PROTOCOL_VERSION } from './frame.js';
14
16
  export type { JobRaw } from './job.js';
15
17
  export { Job } from './job.js';
18
+ export type { Logger, LogLevel, Observability, TelemetryEvent, TelemetryHandler, } from './observability.js';
19
+ export { consoleLogger, noopLogger } from './observability.js';
16
20
  export type { BulkJobEntry, QueueOptions } from './queue.js';
17
21
  export { Queue } from './queue.js';
18
22
  export type { SchedulerOptions } from './queue-admin.js';
23
+ export type { BatchResponse, CountResponse, DataResponse, JobCountsResponse, JobResponse, JobsResponse, OkResponse, PausedResponse, ProgressResponse, PulledJobResponse, PulledJobsResponse, ResultResponse, StateResponse, WaitJobResponse, } from './responses.js';
19
24
  export type { BackoffOptions, DeduplicationOptions, JobCounts, JobOptions, JobStateName, RepeatOptions, } from './types.js';
20
25
  export { Worker } from './worker.js';
21
- export type { Processor, WorkerOptions } from './worker-types.js';
22
- export declare const __version__ = "0.1.2";
26
+ export type { AckBatchOptions, Processor, WorkerEventMap, WorkerOptions, } from './worker-types.js';
27
+ export declare const __version__ = "0.1.7";
package/dist/index.js CHANGED
@@ -5,10 +5,12 @@
5
5
  // Simple Mode (Bunqueue): all-in-one Queue + Worker, 1:1 with the official client
6
6
  export { Bunqueue } from './bunqueue/bunqueue.js';
7
7
  export { Connection } from './connection.js';
8
+ export { ConnectionPool } from './connection-pool.js';
8
9
  export { AuthError, BunqueueError, CommandError, CommandTimeoutError, ConnectionClosedError, UnrecoverableError, } from './errors.js';
9
10
  export { FlowProducer } from './flow.js';
10
11
  export { MAX_FRAME_SIZE, PROTOCOL_VERSION } from './frame.js';
11
12
  export { Job } from './job.js';
13
+ export { consoleLogger, noopLogger } from './observability.js';
12
14
  export { Queue } from './queue.js';
13
15
  export { Worker } from './worker.js';
14
- export const __version__ = '0.1.2';
16
+ export const __version__ = '0.1.7';
package/dist/job.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Job wrapper: read view over the server job record + per-job operations.
3
3
  * Mirrors the official TS client's Job surface (TCP mode).
4
4
  */
5
- import type { Connection } from './connection.js';
5
+ import type { ConnectionLike } from './connection-types.js';
6
6
  export type JobRaw = Record<string, unknown>;
7
7
  export type ProgressHook = (job: Job, progress: number) => void;
8
8
  export declare class Job<T = unknown> {
@@ -10,7 +10,7 @@ export declare class Job<T = unknown> {
10
10
  readonly token: string | undefined;
11
11
  private readonly conn;
12
12
  private readonly onProgress;
13
- constructor(raw: JobRaw, connection?: Connection, token?: string, onProgress?: ProgressHook);
13
+ constructor(raw: JobRaw, connection?: ConnectionLike, token?: string, onProgress?: ProgressHook);
14
14
  get id(): string;
15
15
  get queue(): string;
16
16
  get data(): T;
@@ -0,0 +1,95 @@
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
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
8
+ export interface Logger {
9
+ debug(message: string, meta?: Record<string, unknown>): void;
10
+ info(message: string, meta?: Record<string, unknown>): void;
11
+ warn(message: string, meta?: Record<string, unknown>): void;
12
+ error(message: string, meta?: Record<string, unknown>): void;
13
+ }
14
+ /** Silent logger (the default). */
15
+ export declare const noopLogger: Logger;
16
+ /**
17
+ * A console-backed logger. Levels below `min` are dropped. Opt-in — pass it as
18
+ * `logger` when you want the SDK to log to the console.
19
+ */
20
+ export declare function consoleLogger(min?: LogLevel): Logger;
21
+ /** A single telemetry data point. A discriminated union keyed on `type`. */
22
+ export type TelemetryEvent = {
23
+ type: 'command';
24
+ cmd: string;
25
+ reqId: string;
26
+ durationMs: number;
27
+ ok: boolean;
28
+ } | {
29
+ type: 'command_timeout';
30
+ cmd: string;
31
+ reqId: string;
32
+ } | {
33
+ type: 'connect';
34
+ host: string;
35
+ port: number;
36
+ generation: number;
37
+ durationMs: number;
38
+ } | {
39
+ type: 'disconnect';
40
+ host: string;
41
+ port: number;
42
+ generation: number;
43
+ } | {
44
+ type: 'reconnect_scheduled';
45
+ host: string;
46
+ port: number;
47
+ attempt: number;
48
+ delayMs: number;
49
+ } | {
50
+ type: 'auth';
51
+ ok: boolean;
52
+ } | {
53
+ type: 'backpressure';
54
+ inFlight: number;
55
+ maxInFlight: number;
56
+ };
57
+ export type TelemetryHandler = (event: TelemetryEvent) => void;
58
+ /** Lifecycle telemetry types that are ALSO emitted as EventEmitter events. */
59
+ export declare const LIFECYCLE_EVENTS: readonly ["connect", "disconnect", "reconnect_scheduled"];
60
+ export interface Observability {
61
+ /** Structured logger; defaults to {@link noopLogger}. */
62
+ logger?: Logger;
63
+ /**
64
+ * Telemetry sink for metrics and tracing. Receives every command's latency,
65
+ * connect/disconnect/reconnect, auth and backpressure events. Bridge it to
66
+ * OpenTelemetry spans or Prometheus counters without the SDK depending on
67
+ * either.
68
+ */
69
+ onTelemetry?: TelemetryHandler;
70
+ }
71
+ /** High-resolution monotonic clock (Node/Bun/Deno all expose `performance`). */
72
+ export declare const nowMs: () => number;
73
+ /**
74
+ * Fans a telemetry event out to (1) the telemetry sink, (2) the logger, and
75
+ * (3) — for lifecycle events — the owning EventEmitter, so users can both
76
+ * scrape metrics and attach imperative `connection.on('connect', …)` handlers.
77
+ */
78
+ export declare class Telemetry {
79
+ private readonly logger;
80
+ private readonly sink;
81
+ private readonly emit;
82
+ constructor(obs: Observability | undefined, emit: (event: string, payload: unknown) => void);
83
+ /** Observability must never break the client: swallow consumer errors. */
84
+ private static safely;
85
+ get log(): Logger;
86
+ /** Record a completed command's latency and outcome. */
87
+ command(cmd: string, reqId: string, startMs: number, ok: boolean): void;
88
+ timeout(cmd: string, reqId: string): void;
89
+ connected(host: string, port: number, generation: number, startMs: number): void;
90
+ disconnected(host: string, port: number, generation: number): void;
91
+ reconnectScheduled(host: string, port: number, attempt: number, delayMs: number): void;
92
+ auth(ok: boolean): void;
93
+ backpressure(inFlight: number, maxInFlight: number): void;
94
+ private dispatch;
95
+ }
@@ -0,0 +1,110 @@
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
+ /** Silent logger (the default). */
8
+ export const noopLogger = {
9
+ debug() { },
10
+ info() { },
11
+ warn() { },
12
+ error() { },
13
+ };
14
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
15
+ /**
16
+ * A console-backed logger. Levels below `min` are dropped. Opt-in — pass it as
17
+ * `logger` when you want the SDK to log to the console.
18
+ */
19
+ export function consoleLogger(min = 'info') {
20
+ const floor = LEVELS[min];
21
+ const line = (lvl, msg, meta) => {
22
+ if (LEVELS[lvl] < floor)
23
+ return;
24
+ const suffix = meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
25
+ const sink = lvl === 'debug' ? console.log : console[lvl];
26
+ sink(`[bunqueue] ${lvl} ${msg}${suffix}`);
27
+ };
28
+ return {
29
+ debug: (m, meta) => line('debug', m, meta),
30
+ info: (m, meta) => line('info', m, meta),
31
+ warn: (m, meta) => line('warn', m, meta),
32
+ error: (m, meta) => line('error', m, meta),
33
+ };
34
+ }
35
+ /** Lifecycle telemetry types that are ALSO emitted as EventEmitter events. */
36
+ export const LIFECYCLE_EVENTS = ['connect', 'disconnect', 'reconnect_scheduled'];
37
+ /** High-resolution monotonic clock (Node/Bun/Deno all expose `performance`). */
38
+ export const nowMs = () => performance.now();
39
+ /**
40
+ * Fans a telemetry event out to (1) the telemetry sink, (2) the logger, and
41
+ * (3) — for lifecycle events — the owning EventEmitter, so users can both
42
+ * scrape metrics and attach imperative `connection.on('connect', …)` handlers.
43
+ */
44
+ export class Telemetry {
45
+ logger;
46
+ sink;
47
+ emit;
48
+ constructor(obs, emit) {
49
+ // Wrap the user logger so a throwing logger can never break the transport
50
+ // hot path (dispatch runs inside socket 'data' handlers and connect).
51
+ const inner = obs?.logger ?? noopLogger;
52
+ this.logger =
53
+ inner === noopLogger
54
+ ? inner
55
+ : {
56
+ debug: (m, meta) => Telemetry.safely(() => inner.debug(m, meta)),
57
+ info: (m, meta) => Telemetry.safely(() => inner.info(m, meta)),
58
+ warn: (m, meta) => Telemetry.safely(() => inner.warn(m, meta)),
59
+ error: (m, meta) => Telemetry.safely(() => inner.error(m, meta)),
60
+ };
61
+ this.sink = obs?.onTelemetry;
62
+ this.emit = emit;
63
+ }
64
+ /** Observability must never break the client: swallow consumer errors. */
65
+ static safely(fn) {
66
+ try {
67
+ fn();
68
+ }
69
+ catch {
70
+ /* consumer logging/telemetry error — intentionally ignored */
71
+ }
72
+ }
73
+ get log() {
74
+ return this.logger;
75
+ }
76
+ /** Record a completed command's latency and outcome. */
77
+ command(cmd, reqId, startMs, ok) {
78
+ this.dispatch({ type: 'command', cmd, reqId, durationMs: nowMs() - startMs, ok });
79
+ }
80
+ timeout(cmd, reqId) {
81
+ this.logger.warn('command timed out', { cmd, reqId });
82
+ this.dispatch({ type: 'command_timeout', cmd, reqId });
83
+ }
84
+ connected(host, port, generation, startMs) {
85
+ this.logger.info('connected', { host, port, generation });
86
+ this.dispatch({ type: 'connect', host, port, generation, durationMs: nowMs() - startMs });
87
+ }
88
+ disconnected(host, port, generation) {
89
+ this.logger.info('disconnected', { host, port, generation });
90
+ this.dispatch({ type: 'disconnect', host, port, generation });
91
+ }
92
+ reconnectScheduled(host, port, attempt, delayMs) {
93
+ this.logger.warn('reconnect scheduled', { host, port, attempt, delayMs });
94
+ this.dispatch({ type: 'reconnect_scheduled', host, port, attempt, delayMs });
95
+ }
96
+ auth(ok) {
97
+ this.dispatch({ type: 'auth', ok });
98
+ }
99
+ backpressure(inFlight, maxInFlight) {
100
+ this.dispatch({ type: 'backpressure', inFlight, maxInFlight });
101
+ }
102
+ dispatch(event) {
103
+ // A throwing sink or lifecycle listener must never break the transport:
104
+ // dispatch runs inside socket 'data' handlers and the connect path.
105
+ Telemetry.safely(() => this.sink?.(event));
106
+ if (LIFECYCLE_EVENTS.includes(event.type)) {
107
+ Telemetry.safely(() => this.emit(event.type, event));
108
+ }
109
+ }
110
+ }
@@ -2,6 +2,7 @@
2
2
  * Queue admin surface: DLQ, stall/DLQ configs, rate limits, schedulers,
3
3
  * monitoring and webhooks. Merged onto Queue.prototype by queue.ts.
4
4
  */
5
+ import { CommandError } from './errors.js';
5
6
  import { compact } from './frame.js';
6
7
  import { jobPayload, wireJobOptions } from './types.js';
7
8
  export const adminMethods = {
@@ -50,6 +51,10 @@ export const adminMethods = {
50
51
  // ---------------------------------------------------------------- scheduler
51
52
  /** Create/update a recurring job scheduler (cron pattern or fixed interval). */
52
53
  async upsertJobScheduler(schedulerId, repeat, template = {}) {
54
+ // Priority and deduplication of spawned jobs travel as TOP-LEVEL Cron
55
+ // fields (the handler reads cmd.priority/uniqueKey/dedup); inside
56
+ // jobOptions the server's CronJobOptions silently ignores them.
57
+ const dedup = template.opts?.deduplication;
53
58
  await this.call(compact({
54
59
  cmd: 'Cron',
55
60
  name: schedulerId,
@@ -57,9 +62,14 @@ export const adminMethods = {
57
62
  data: jobPayload(template.name ?? schedulerId, template.data ?? {}),
58
63
  schedule: repeat.pattern,
59
64
  repeatEvery: repeat.every,
65
+ priority: template.opts?.priority,
60
66
  timezone: repeat.tz,
61
67
  immediately: repeat.immediately,
62
68
  maxLimit: repeat.limit,
69
+ uniqueKey: dedup?.id,
70
+ dedup: dedup
71
+ ? compact({ ttl: dedup.ttl, extend: dedup.extend, replace: dedup.replace })
72
+ : undefined,
63
73
  skipMissedOnRestart: repeat.skipMissedOnRestart,
64
74
  skipIfNoWorker: repeat.skipIfNoWorker,
65
75
  preventOverlap: repeat.preventOverlap,
@@ -74,8 +84,12 @@ export const adminMethods = {
74
84
  const response = await this.call({ cmd: 'CronGet', name: schedulerId });
75
85
  return (response.cron ?? response.data ?? null);
76
86
  }
77
- catch {
78
- return null; // not-found surfaces as a CommandError
87
+ catch (err) {
88
+ // Only 'Cron job not found' maps to null; connection loss, timeouts and
89
+ // real server errors must surface, not masquerade as a missing scheduler.
90
+ if (err instanceof CommandError && /not found/i.test(err.message))
91
+ return null;
92
+ throw err;
79
93
  }
80
94
  },
81
95
  async getJobSchedulers() {
@@ -38,6 +38,11 @@ export declare const controlMethods: {
38
38
  moveJobToDelayed(this: Ctx, id: string, delayMs: number): Promise<void>;
39
39
  extendJobLock(this: Ctx, id: string, token: string, durationMs: number): Promise<void>;
40
40
  moveJobToCompleted(this: Ctx, id: string, returnValue: unknown, token?: string): Promise<void>;
41
+ /**
42
+ * Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
43
+ * and the UnrecoverableError "do not retry" intent are persisted (#111
44
+ * silent-loss class), not just the message.
45
+ */
41
46
  moveJobToFailed(this: Ctx, id: string, error: Error | string, token?: string): Promise<void>;
42
47
  };
43
48
  export type QueueControlApi = typeof controlMethods;
@@ -2,7 +2,9 @@
2
2
  * Queue control surface: pause/drain/clean, promotion, retry and per-job
3
3
  * mutations. Methods are merged onto Queue.prototype by queue.ts.
4
4
  */
5
+ import { UnrecoverableError } from './errors.js';
5
6
  import { compact } from './frame.js';
7
+ import { MAX_STACK_LINES } from './worker-types.js';
6
8
  export const controlMethods = {
7
9
  async pause() {
8
10
  await this.call({ cmd: 'Pause', queue: this.name });
@@ -11,8 +13,7 @@ export const controlMethods = {
11
13
  await this.call({ cmd: 'Resume', queue: this.name });
12
14
  },
13
15
  async isPaused() {
14
- const response = await this.call({ cmd: 'IsPaused', queue: this.name });
15
- return response.paused === true;
16
+ return (await this.call({ cmd: 'IsPaused', queue: this.name })).paused === true;
16
17
  },
17
18
  async drain() {
18
19
  await this.call({ cmd: 'Drain', queue: this.name });
@@ -23,7 +24,7 @@ export const controlMethods = {
23
24
  /** Remove old jobs; returns how many were removed. */
24
25
  async clean(graceMs, limit, state) {
25
26
  const response = await this.call(compact({ cmd: 'Clean', queue: this.name, grace: graceMs, limit, state }));
26
- return Number(response.count ?? 0);
27
+ return response.count ?? 0;
27
28
  },
28
29
  async remove(id) {
29
30
  await this.call({ cmd: 'Cancel', id });
@@ -37,7 +38,7 @@ export const controlMethods = {
37
38
  /** Promote delayed jobs to waiting; returns how many were promoted. */
38
39
  async promoteJobs(opts = {}) {
39
40
  const response = await this.call(compact({ cmd: 'PromoteJobs', queue: this.name, count: opts.count }));
40
- return Number(response.count ?? 0);
41
+ return response.count ?? 0;
41
42
  },
42
43
  /** BullMQ contract: failed → waiting. */
43
44
  async retryJob(id) {
@@ -48,9 +49,9 @@ export const controlMethods = {
48
49
  await this.call({ cmd: 'RetryCompleted', queue: this.name });
49
50
  return;
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
+ // `count` caps how many DLQ entries are retried (server >= 2.8.29). Older
53
+ // servers ignore the field and retry the whole DLQ — forward-compatible.
54
+ await this.call(compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }));
54
55
  },
55
56
  async retryCompleted(id) {
56
57
  await this.call(compact({ cmd: 'RetryCompleted', queue: this.name, id }));
@@ -80,8 +81,25 @@ export const controlMethods = {
80
81
  async moveJobToCompleted(id, returnValue, token) {
81
82
  await this.call(compact({ cmd: 'ACK', id, result: returnValue, token }));
82
83
  },
84
+ /**
85
+ * Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
86
+ * and the UnrecoverableError "do not retry" intent are persisted (#111
87
+ * silent-loss class), not just the message.
88
+ */
83
89
  async moveJobToFailed(id, error, token) {
84
- const message = typeof error === 'string' ? error : error.message;
85
- await this.call(compact({ cmd: 'FAIL', id, error: message, token }));
90
+ const err = typeof error === 'string' ? undefined : error;
91
+ const message = err ? err.message || err.name : error;
92
+ // Keep the FIRST lines (message + throw site), like the worker path.
93
+ const stack = err
94
+ ? (err.stack ?? err.message).split('\n').slice(0, MAX_STACK_LINES)
95
+ : undefined;
96
+ await this.call(compact({
97
+ cmd: 'FAIL',
98
+ id,
99
+ error: message,
100
+ stack,
101
+ unrecoverable: err instanceof UnrecoverableError ? true : undefined,
102
+ token,
103
+ }));
86
104
  },
87
105
  };