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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batches completed-job ACKs into ACKB round-trips for higher throughput.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in (see WorkerOptions.ackBatch). Items flush when the buffer reaches
|
|
5
|
+
* `maxSize` or after `maxDelayMs`, whichever comes first; `flush()` drains the
|
|
6
|
+
* rest on shutdown. Each item's `onSettled` fires after the batch is acked
|
|
7
|
+
* (or errored), so the worker keeps a job "active" — and its lock renewed —
|
|
8
|
+
* until the server has confirmed the ack.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Connection } from './connection.js';
|
|
12
|
+
import { compact } from './frame.js';
|
|
13
|
+
|
|
14
|
+
export interface AckItem {
|
|
15
|
+
id: string;
|
|
16
|
+
token: string;
|
|
17
|
+
result: unknown;
|
|
18
|
+
/** Called once the batch settles: `err` set on failure, undefined on ack. */
|
|
19
|
+
onSettled: (err?: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class AckBatcher {
|
|
23
|
+
private buffer: AckItem[] = [];
|
|
24
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly connection: Connection,
|
|
28
|
+
private readonly maxSize: number,
|
|
29
|
+
private readonly maxDelayMs: number
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
add(item: AckItem): void {
|
|
33
|
+
this.buffer.push(item);
|
|
34
|
+
if (this.buffer.length >= this.maxSize) {
|
|
35
|
+
void this.flush();
|
|
36
|
+
} else if (!this.timer) {
|
|
37
|
+
this.timer = setTimeout(() => void this.flush(), this.maxDelayMs);
|
|
38
|
+
this.timer.unref?.(); // don't keep the process alive for a pending flush
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Send the buffered ACKs as one ACKB (no-op when empty). */
|
|
43
|
+
async flush(): Promise<void> {
|
|
44
|
+
if (this.timer) {
|
|
45
|
+
clearTimeout(this.timer);
|
|
46
|
+
this.timer = null;
|
|
47
|
+
}
|
|
48
|
+
if (this.buffer.length === 0) return;
|
|
49
|
+
const batch = this.buffer;
|
|
50
|
+
this.buffer = [];
|
|
51
|
+
// Capture the wire outcome first; settle callbacks run OUTSIDE the try so
|
|
52
|
+
// a throwing callback can neither be re-invoked with an error (double
|
|
53
|
+
// settle) nor starve the remaining items of their callback.
|
|
54
|
+
let error: unknown;
|
|
55
|
+
try {
|
|
56
|
+
await this.connection.call(
|
|
57
|
+
compact({
|
|
58
|
+
cmd: 'ACKB',
|
|
59
|
+
ids: batch.map((b) => b.id),
|
|
60
|
+
tokens: batch.map((b) => b.token),
|
|
61
|
+
results: batch.map((b) => b.result),
|
|
62
|
+
}) as { cmd: string }
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
error = err ?? new Error('ACKB failed');
|
|
66
|
+
}
|
|
67
|
+
for (const item of batch) {
|
|
68
|
+
try {
|
|
69
|
+
item.onSettled(error);
|
|
70
|
+
} catch {
|
|
71
|
+
// A callback error (e.g. an unhandled 'error' emit in the worker)
|
|
72
|
+
// must never prevent the other items from settling.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backpressure gate: bounds the number of in-flight commands on a connection.
|
|
3
|
+
*
|
|
4
|
+
* When the in-flight count reaches `max`, `acquire()` parks the caller until a
|
|
5
|
+
* slot frees (a pending command settles). This turns unbounded memory growth
|
|
6
|
+
* under a fast producer / slow server into cooperative flow control. `max <= 0`
|
|
7
|
+
* disables the gate (unbounded, the default).
|
|
8
|
+
*/
|
|
9
|
+
export class Backpressure {
|
|
10
|
+
private readonly waiters: Array<() => void> = [];
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly max: number,
|
|
14
|
+
private readonly onWait?: () => void
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
/** Resolve immediately if under the limit, else park until a slot frees. */
|
|
18
|
+
acquire(inFlight: number): Promise<void> | void {
|
|
19
|
+
if (this.max <= 0 || inFlight < this.max) return;
|
|
20
|
+
this.onWait?.();
|
|
21
|
+
return new Promise<void>((resolve) => {
|
|
22
|
+
this.waiters.push(resolve);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Release one parked caller (call when a pending command settles). */
|
|
27
|
+
release(): void {
|
|
28
|
+
this.waiters.shift()?.();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Release every parked caller (call on teardown so none hang forever). */
|
|
32
|
+
clear(): void {
|
|
33
|
+
while (this.waiters.length > 0) this.waiters.shift()?.();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Number of callers currently parked. */
|
|
37
|
+
get waiting(): number {
|
|
38
|
+
return this.waiters.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/bunqueue/bunqueue.ts
CHANGED
|
@@ -90,7 +90,18 @@ export class Bunqueue<T = unknown, R = unknown> {
|
|
|
90
90
|
|
|
91
91
|
// DLQ & rate limit manager
|
|
92
92
|
this.dlqrl = new DlqRateLimitManager<T>(this.queue);
|
|
93
|
-
|
|
93
|
+
// Fire-and-forget config push: without the catch, an unreachable server at
|
|
94
|
+
// construction time becomes an unhandled rejection that kills the process.
|
|
95
|
+
// Route the failure to the worker's 'error' event (the channel every other
|
|
96
|
+
// background command failure uses); with no listener attached, swallow it
|
|
97
|
+
// like pause()/resume() do — an unlistened 'error' emit would itself throw.
|
|
98
|
+
if (opts.dlq) {
|
|
99
|
+
void this.dlqrl.setDlqConfig(opts.dlq).catch((err: unknown) => {
|
|
100
|
+
if (this.worker.listenerCount('error') > 0) {
|
|
101
|
+
this.worker.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
94
105
|
|
|
95
106
|
// Subsystems
|
|
96
107
|
this.cb = opts.circuitBreaker
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A round-robin pool of TCP connections for producer-side throughput.
|
|
3
|
+
*
|
|
4
|
+
* Each `call()` is dispatched to the next connection, so independent commands
|
|
5
|
+
* (PUSH, queries, control) fan out across N sockets instead of head-of-lining
|
|
6
|
+
* on one. Use it for {@link Queue} / {@link FlowProducer}; NOT for a Worker,
|
|
7
|
+
* whose pull→ack flow is bound to a single connection's lock ownership and
|
|
8
|
+
* reconnect/registration lifecycle.
|
|
9
|
+
*
|
|
10
|
+
* Satisfies {@link ConnectionLike}, so it is a drop-in for a single Connection.
|
|
11
|
+
* Lifecycle events from every member connection are re-emitted here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import { Connection } from './connection.js';
|
|
16
|
+
import type { Command, ConnectionLike, ConnectionOptions, Response } from './connection-types.js';
|
|
17
|
+
import { LIFECYCLE_EVENTS } from './observability.js';
|
|
18
|
+
|
|
19
|
+
export class ConnectionPool extends EventEmitter implements ConnectionLike {
|
|
20
|
+
private readonly connections: Connection[];
|
|
21
|
+
private cursor = 0;
|
|
22
|
+
|
|
23
|
+
constructor(size: number, options: ConnectionOptions = {}) {
|
|
24
|
+
super();
|
|
25
|
+
const n = Math.max(1, Math.floor(size));
|
|
26
|
+
this.connections = Array.from({ length: n }, () => {
|
|
27
|
+
const conn = new Connection(options);
|
|
28
|
+
for (const event of LIFECYCLE_EVENTS) {
|
|
29
|
+
conn.on(event, (payload) => this.emit(event, payload));
|
|
30
|
+
}
|
|
31
|
+
return conn;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How many connections back this pool. */
|
|
36
|
+
get size(): number {
|
|
37
|
+
return this.connections.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private next(): Connection {
|
|
41
|
+
const conn = this.connections[this.cursor];
|
|
42
|
+
this.cursor = (this.cursor + 1) % this.connections.length;
|
|
43
|
+
return conn;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
call<R = Response>(command: Command, timeoutMs?: number): Promise<R> {
|
|
47
|
+
return this.next().call<R>(command, timeoutMs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ping(): Promise<boolean> {
|
|
51
|
+
return this.connections[0].ping();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async connect(): Promise<void> {
|
|
55
|
+
await Promise.all(this.connections.map((conn) => conn.connect()));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
close(): void {
|
|
59
|
+
for (const conn of this.connections) conn.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True while at least one member connection is up. */
|
|
63
|
+
get isConnected(): boolean {
|
|
64
|
+
return this.connections.some((conn) => conn.isConnected);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Representative generation (first connection); pools are producer-side. */
|
|
68
|
+
get generation(): number {
|
|
69
|
+
return this.connections[0].generation;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/connection-types.ts
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
/** Connection option and message types. */
|
|
2
2
|
|
|
3
|
+
import type { Observability } from './observability.js';
|
|
4
|
+
|
|
3
5
|
export type TlsOption = boolean | { caFile?: string; rejectUnauthorized?: boolean } | undefined;
|
|
4
6
|
|
|
5
|
-
export interface ConnectionOptions {
|
|
7
|
+
export interface ConnectionOptions extends Observability {
|
|
6
8
|
host?: string;
|
|
7
9
|
port?: number;
|
|
8
10
|
token?: string;
|
|
9
11
|
tls?: TlsOption;
|
|
10
12
|
connectTimeoutMs?: number;
|
|
11
13
|
commandTimeoutMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Max in-flight commands before {@link Connection.call} applies backpressure
|
|
16
|
+
* (awaits a free slot). 0 / undefined = unbounded. Bounds memory under load.
|
|
17
|
+
*/
|
|
18
|
+
maxInFlight?: number;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export type Command = Record<string, unknown> & { cmd: string };
|
|
15
22
|
export type Response = Record<string, unknown> & { ok: boolean };
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The connection surface consumers (Queue, FlowProducer, Job) depend on.
|
|
26
|
+
* Both {@link Connection} and a round-robin ConnectionPool satisfy it, so a
|
|
27
|
+
* pool can be dropped in transparently for producer-side throughput.
|
|
28
|
+
*/
|
|
29
|
+
export interface ConnectionLike {
|
|
30
|
+
call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
|
|
31
|
+
ping(): Promise<boolean>;
|
|
32
|
+
connect(): Promise<void>;
|
|
33
|
+
close(): void;
|
|
34
|
+
readonly isConnected: boolean;
|
|
35
|
+
readonly generation: number;
|
|
36
|
+
on(event: string, listener: (...args: unknown[]) => void): this;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
export interface Pending {
|
|
18
40
|
resolve: (response: Response) => void;
|
|
19
41
|
reject: (error: Error) => void;
|
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;
|
|
@@ -44,14 +53,22 @@ export class Connection {
|
|
|
44
53
|
// socket is presumed dead and torn down so the next call reconnects.
|
|
45
54
|
private readonly maxCommandTimeouts = 3;
|
|
46
55
|
private consecutiveTimeouts = 0;
|
|
56
|
+
private readonly telemetry: Telemetry;
|
|
57
|
+
private readonly backpressure: Backpressure;
|
|
47
58
|
|
|
48
59
|
constructor(options: ConnectionOptions = {}) {
|
|
60
|
+
super();
|
|
49
61
|
this.host = options.host ?? 'localhost';
|
|
50
62
|
this.port = options.port ?? 6789;
|
|
51
63
|
this.token = options.token;
|
|
52
64
|
this.tls = options.tls;
|
|
53
65
|
this.connectTimeoutMs = options.connectTimeoutMs ?? 5000;
|
|
54
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
|
+
);
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
get isConnected(): boolean {
|
|
@@ -89,6 +106,7 @@ export class Connection {
|
|
|
89
106
|
this.failedAttempts += 1;
|
|
90
107
|
const backoff = Math.min(500 * 2 ** (this.failedAttempts - 1), 5000);
|
|
91
108
|
this.nextAttemptAt = Date.now() + backoff;
|
|
109
|
+
this.telemetry.reconnectScheduled(this.host, this.port, this.failedAttempts, backoff);
|
|
92
110
|
throw err;
|
|
93
111
|
})
|
|
94
112
|
.finally(() => {
|
|
@@ -98,6 +116,7 @@ export class Connection {
|
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
private async doConnect(): Promise<void> {
|
|
119
|
+
const startMs = nowMs();
|
|
101
120
|
const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
|
|
102
121
|
socket.setNoDelay(true);
|
|
103
122
|
// TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
|
|
@@ -113,6 +132,7 @@ export class Connection {
|
|
|
113
132
|
this.connected = true;
|
|
114
133
|
this.connectGeneration += 1;
|
|
115
134
|
this.consecutiveTimeouts = 0;
|
|
135
|
+
this.telemetry.connected(this.host, this.port, this.connectGeneration, startMs);
|
|
116
136
|
|
|
117
137
|
// INVARIANT (H3): connected is flipped true before Auth, which is safe
|
|
118
138
|
// ONLY because call() writes the Auth frame synchronously — there is no
|
|
@@ -123,7 +143,9 @@ export class Connection {
|
|
|
123
143
|
if (this.token) {
|
|
124
144
|
try {
|
|
125
145
|
await this.call({ cmd: 'Auth', token: this.token });
|
|
146
|
+
this.telemetry.auth(true);
|
|
126
147
|
} catch (err) {
|
|
148
|
+
this.telemetry.auth(false);
|
|
127
149
|
this.teardown();
|
|
128
150
|
if (err instanceof CommandError) throw new AuthError(err.message);
|
|
129
151
|
throw err;
|
|
@@ -135,19 +157,26 @@ export class Connection {
|
|
|
135
157
|
* Send a command and await its response. Rejects with CommandError when
|
|
136
158
|
* the server answers ok=false. Reconnects lazily if the link was lost.
|
|
137
159
|
*/
|
|
138
|
-
async call(command: Command, timeoutMs?: number): Promise<
|
|
160
|
+
async call<R = Response>(command: Command, timeoutMs?: number): Promise<R> {
|
|
139
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;
|
|
140
166
|
const socket = this.socket;
|
|
141
|
-
if (!socket) throw new ConnectionClosedError('not connected');
|
|
167
|
+
if (!this.connected || !socket) throw new ConnectionClosedError('not connected');
|
|
142
168
|
|
|
143
169
|
this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
|
|
144
170
|
const reqId = String(this.reqCounter);
|
|
145
171
|
const payload = pack({ ...compact(command), reqId });
|
|
146
172
|
const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
|
|
173
|
+
const startMs = nowMs();
|
|
147
174
|
|
|
148
|
-
return new Promise<
|
|
175
|
+
return new Promise<R>((resolve, reject) => {
|
|
149
176
|
const timer = setTimeout(() => {
|
|
150
177
|
this.pending.delete(reqId);
|
|
178
|
+
this.backpressure.release();
|
|
179
|
+
this.telemetry.timeout(command.cmd, reqId);
|
|
151
180
|
this.noteTimeout(gen);
|
|
152
181
|
reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
|
|
153
182
|
}, timeoutMs ?? this.commandTimeoutMs);
|
|
@@ -156,10 +185,11 @@ export class Connection {
|
|
|
156
185
|
resolve: (response) => {
|
|
157
186
|
clearTimeout(timer);
|
|
158
187
|
this.consecutiveTimeouts = 0; // any reply means the link is alive
|
|
188
|
+
this.telemetry.command(command.cmd, reqId, startMs, response.ok);
|
|
159
189
|
if (!response.ok) {
|
|
160
190
|
reject(new CommandError(String(response.error ?? 'unknown server error')));
|
|
161
191
|
} else {
|
|
162
|
-
resolve(response);
|
|
192
|
+
resolve(response as R);
|
|
163
193
|
}
|
|
164
194
|
},
|
|
165
195
|
reject: (err) => {
|
|
@@ -173,6 +203,7 @@ export class Connection {
|
|
|
173
203
|
if (err) {
|
|
174
204
|
const entry = this.pending.get(reqId);
|
|
175
205
|
this.pending.delete(reqId);
|
|
206
|
+
this.backpressure.release();
|
|
176
207
|
entry?.reject(new ConnectionClosedError(`send failed: ${err.message}`));
|
|
177
208
|
this.teardown();
|
|
178
209
|
}
|
|
@@ -228,6 +259,7 @@ export class Connection {
|
|
|
228
259
|
const entry = this.pending.get(String(reqId));
|
|
229
260
|
if (entry) {
|
|
230
261
|
this.pending.delete(String(reqId));
|
|
262
|
+
this.backpressure.release();
|
|
231
263
|
entry.resolve(response);
|
|
232
264
|
}
|
|
233
265
|
}
|
|
@@ -247,6 +279,8 @@ export class Connection {
|
|
|
247
279
|
}
|
|
248
280
|
|
|
249
281
|
private teardown(): void {
|
|
282
|
+
const wasConnected = this.socket !== null;
|
|
283
|
+
const gen = this.connectGeneration;
|
|
250
284
|
this.connected = false;
|
|
251
285
|
const socket = this.socket;
|
|
252
286
|
this.socket = null;
|
|
@@ -260,5 +294,7 @@ export class Connection {
|
|
|
260
294
|
clearTimeout(entry.timer);
|
|
261
295
|
entry.reject(new ConnectionClosedError('connection lost'));
|
|
262
296
|
}
|
|
297
|
+
this.backpressure.clear(); // release parked callers; they re-check + fail/reconnect
|
|
298
|
+
if (wasConnected) this.telemetry.disconnected(this.host, this.port, gen);
|
|
263
299
|
}
|
|
264
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
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from './flow-types.js';
|
|
16
16
|
import { compact } from './frame.js';
|
|
17
17
|
import { Job } from './job.js';
|
|
18
|
+
import type { JobResponse, OkResponse } from './responses.js';
|
|
18
19
|
import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
|
|
19
20
|
|
|
20
21
|
export class FlowProducer {
|
|
@@ -24,7 +25,14 @@ export class FlowProducer {
|
|
|
24
25
|
constructor(opts: FlowProducerOptions = {}) {
|
|
25
26
|
this.connection =
|
|
26
27
|
opts.connection ??
|
|
27
|
-
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
|
+
});
|
|
28
36
|
this.ownsConnection = opts.connection === undefined;
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -70,19 +78,19 @@ export class FlowProducer {
|
|
|
70
78
|
let prevId: string | null = null;
|
|
71
79
|
try {
|
|
72
80
|
for (const step of steps) {
|
|
73
|
-
const data = compact({
|
|
81
|
+
const data: Record<string, unknown> = compact({
|
|
74
82
|
...jobPayload(step.name, step.data),
|
|
75
83
|
__flowParentId: prevId ?? undefined,
|
|
76
84
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
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
|
+
});
|
|
86
94
|
const id = String(response.id);
|
|
87
95
|
jobIds.push(id);
|
|
88
96
|
prevId = id;
|
|
@@ -103,7 +111,7 @@ export class FlowProducer {
|
|
|
103
111
|
try {
|
|
104
112
|
const results = await Promise.allSettled(
|
|
105
113
|
parallel.map(async (step) => {
|
|
106
|
-
const response = await this.connection.call(
|
|
114
|
+
const response = await this.connection.call<OkResponse>(
|
|
107
115
|
compact({
|
|
108
116
|
cmd: 'PUSH',
|
|
109
117
|
queue: step.queueName,
|
|
@@ -188,7 +196,7 @@ export class FlowProducer {
|
|
|
188
196
|
parentRef: { id: string; queue: string } | null,
|
|
189
197
|
childIds: string[]
|
|
190
198
|
): Promise<string> {
|
|
191
|
-
const response = await this.connection.call(
|
|
199
|
+
const response = await this.connection.call<OkResponse>(
|
|
192
200
|
compact({
|
|
193
201
|
cmd: 'PUSH',
|
|
194
202
|
queue,
|
|
@@ -219,16 +227,16 @@ export class FlowProducer {
|
|
|
219
227
|
// A missing job — the root, or a child removed via removeOnComplete/cancel
|
|
220
228
|
// (childrenIds is a static push-time list, never pruned) — yields null and
|
|
221
229
|
// is skipped, returning the surviving partial tree instead of throwing.
|
|
222
|
-
let response:
|
|
230
|
+
let response: JobResponse;
|
|
223
231
|
try {
|
|
224
|
-
response = await this.connection.call({ cmd: 'GetJob', id });
|
|
232
|
+
response = await this.connection.call<JobResponse>({ cmd: 'GetJob', id });
|
|
225
233
|
} catch (err) {
|
|
226
234
|
// Only 'Job not found' means a removed node; a real server error must not
|
|
227
235
|
// masquerade as a missing child and yield a misleading partial tree.
|
|
228
236
|
if (err instanceof CommandError && /not found/i.test(err.message)) return null;
|
|
229
237
|
throw err;
|
|
230
238
|
}
|
|
231
|
-
const raw = response.job
|
|
239
|
+
const raw = response.job;
|
|
232
240
|
if (!raw) return null;
|
|
233
241
|
const job = new Job<T>(raw, this.connection);
|
|
234
242
|
if (depth <= 0 || job.childrenIds.length === 0) return { job };
|
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,11 @@ export type {
|
|
|
57
83
|
RepeatOptions,
|
|
58
84
|
} from './types.js';
|
|
59
85
|
export { Worker } from './worker.js';
|
|
60
|
-
export type {
|
|
86
|
+
export type {
|
|
87
|
+
AckBatchOptions,
|
|
88
|
+
Processor,
|
|
89
|
+
WorkerEventMap,
|
|
90
|
+
WorkerOptions,
|
|
91
|
+
} from './worker-types.js';
|
|
61
92
|
|
|
62
|
-
export const __version__ = '0.1.
|
|
93
|
+
export const __version__ = '0.1.7';
|
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;
|