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.
- package/CHANGELOG.md +77 -0
- package/README.md +44 -1
- 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/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 +20 -3
- package/dist/connection.js +70 -3
- package/dist/flow-types.d.ts +2 -1
- package/dist/flow.js +33 -7
- 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-control.js +6 -5
- package/dist/queue-query.d.ts +9 -2
- package/dist/queue-query.js +44 -26
- package/dist/queue.d.ts +13 -4
- package/dist/queue.js +26 -11
- package/dist/responses.d.ts +79 -0
- package/dist/responses.js +10 -0
- package/dist/worker-base.d.ts +2 -0
- package/dist/worker-base.js +5 -0
- package/dist/worker-types.d.ts +15 -1
- package/dist/worker.d.ts +4 -0
- package/dist/worker.js +46 -7
- package/package.json +2 -1
- package/src/ack-batcher.ts +76 -0
- package/src/backpressure.ts +40 -0
- package/src/connection-pool.ts +71 -0
- package/src/connection-types.ts +23 -1
- package/src/connection.ts +72 -6
- package/src/flow-types.ts +2 -1
- package/src/flow.ts +46 -18
- package/src/index.ts +28 -2
- package/src/job.ts +3 -3
- package/src/observability.ts +158 -0
- package/src/queue-control.ts +9 -11
- package/src/queue-query.ts +73 -35
- package/src/queue.ts +42 -15
- package/src/responses.ts +96 -0
- package/src/worker-base.ts +6 -0
- package/src/worker-types.ts +16 -1
- 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
|
-
/**
|
|
25
|
-
|
|
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<
|
|
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<
|
|
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({
|
|
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>(
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
222
|
+
maxChildren: number | undefined,
|
|
223
|
+
visited: Set<string>
|
|
209
224
|
): Promise<JobNode<T> | null> {
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
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 {
|
|
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:
|
|
15
|
+
private readonly conn: ConnectionLike | undefined;
|
|
16
16
|
private readonly onProgress: ProgressHook | undefined;
|
|
17
17
|
|
|
18
|
-
constructor(raw: JobRaw, connection?:
|
|
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
|
+
}
|
package/src/queue-control.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
);
|
|
75
|
+
// `count` is accepted for API parity but not sent: the server has no
|
|
76
|
+
// partial RetryDlq — it retries the whole DLQ (BullMQ semantics).
|
|
77
|
+
await this.call({ cmd: 'RetryDlq', queue: this.name });
|
|
80
78
|
},
|
|
81
79
|
|
|
82
80
|
async retryCompleted(this: Ctx, id?: string): Promise<void> {
|