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.
- package/CHANGELOG.md +119 -0
- package/README.md +46 -3
- package/dist/ack-batcher.d.ts +28 -0
- package/dist/ack-batcher.js +67 -0
- package/dist/backpressure.d.ts +22 -0
- package/dist/backpressure.js +39 -0
- package/dist/bunqueue/bunqueue.js +12 -2
- package/dist/connection-pool.d.ts +30 -0
- package/dist/connection-pool.js +60 -0
- package/dist/connection-types.d.ts +21 -1
- package/dist/connection.d.ts +12 -3
- package/dist/connection.js +39 -3
- package/dist/flow-types.d.ts +2 -1
- package/dist/flow.js +12 -3
- package/dist/index.d.ts +7 -2
- package/dist/index.js +3 -1
- package/dist/job.d.ts +2 -2
- package/dist/observability.d.ts +95 -0
- package/dist/observability.js +110 -0
- package/dist/queue-admin.js +16 -2
- package/dist/queue-control.d.ts +5 -0
- package/dist/queue-control.js +27 -9
- package/dist/queue-query.js +13 -21
- package/dist/queue.d.ts +13 -4
- package/dist/queue.js +14 -7
- package/dist/responses.d.ts +79 -0
- package/dist/responses.js +10 -0
- package/dist/worker-base.d.ts +15 -3
- package/dist/worker-base.js +15 -7
- package/dist/worker-types.d.ts +43 -1
- package/dist/worker.d.ts +5 -1
- package/dist/worker.js +59 -14
- package/package.json +3 -1
- package/src/ack-batcher.ts +76 -0
- package/src/backpressure.ts +40 -0
- package/src/bunqueue/bunqueue.ts +12 -1
- package/src/connection-pool.ts +71 -0
- package/src/connection-types.ts +23 -1
- package/src/connection.ts +42 -6
- package/src/flow-types.ts +2 -1
- package/src/flow.ts +24 -16
- package/src/index.ts +33 -2
- package/src/job.ts +3 -3
- package/src/observability.ts +158 -0
- package/src/queue-admin.ts +15 -2
- package/src/queue-control.ts +34 -11
- package/src/queue-query.ts +39 -29
- package/src/queue.ts +30 -11
- package/src/responses.ts +96 -0
- package/src/worker-base.ts +49 -11
- package/src/worker-types.ts +42 -1
- package/src/worker.ts +63 -16
package/dist/connection.js
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
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
|
}
|
package/dist/flow-types.d.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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?:
|
|
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
|
+
}
|
package/dist/queue-admin.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/dist/queue-control.d.ts
CHANGED
|
@@ -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;
|
package/dist/queue-control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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`
|
|
52
|
-
//
|
|
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
|
|
85
|
-
|
|
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
|
};
|