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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `bunqueue-client` (TypeScript SDK) are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.6] - 2026-07-09
|
|
9
|
+
|
|
10
|
+
Enterprise-grade hardening. All additive and backward-compatible; defaults are
|
|
11
|
+
unchanged (observability is silent, backpressure unbounded, ACK batching off).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Observability.** Every `Connection`/`Queue`/`Worker`/`FlowProducer` accepts
|
|
16
|
+
an injectable `logger` and an `onTelemetry` sink (zero hard deps — bridge it to
|
|
17
|
+
OpenTelemetry/Prometheus yourself). `TelemetryEvent` is a typed union covering
|
|
18
|
+
per-command latency, connect/disconnect/reconnect, auth and backpressure.
|
|
19
|
+
`Connection` is now an `EventEmitter` emitting `connect` / `disconnect` /
|
|
20
|
+
`reconnect_scheduled`. Ships `noopLogger` (default) and `consoleLogger`.
|
|
21
|
+
- **Backpressure.** `maxInFlight` bounds concurrent in-flight commands; callers
|
|
22
|
+
park until a slot frees instead of growing memory unbounded under load.
|
|
23
|
+
- **ACK batching.** Opt-in `Worker({ ackBatch: { enabled: true } })` coalesces
|
|
24
|
+
completed-job ACKs into `ACKB` round-trips for higher throughput; a job stays
|
|
25
|
+
active (lock renewed) until its batch is confirmed.
|
|
26
|
+
- **Connection pool.** `Queue({ poolSize: N })` fans producer commands across N
|
|
27
|
+
round-robin connections (`ConnectionPool`, producer-side; workers stay single-
|
|
28
|
+
connection by design).
|
|
29
|
+
- **Typed responses.** `call<R>()` is generic over the exported response shapes
|
|
30
|
+
(`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, …); internal
|
|
31
|
+
`as Record<string, unknown>` casts removed across the query/control/flow paths.
|
|
32
|
+
|
|
33
|
+
### CI
|
|
34
|
+
|
|
35
|
+
- GitHub Actions runs both SDK suites on every `sdk/`/`src/` change (TypeScript
|
|
36
|
+
on Bun + Node, Python 3.10/3.12); an npm release workflow publishes with
|
|
37
|
+
build provenance.
|
|
38
|
+
|
|
39
|
+
## [0.1.5] - 2026-07-08
|
|
40
|
+
|
|
41
|
+
Protocol-coherence audit against the bunqueue server. Every fix ships with a
|
|
42
|
+
RED→GREEN repro in `tests/e2e-audit-fixes.ts`.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **addBulk dropped the custom job id.** PUSHB entries are `JobInput`
|
|
47
|
+
(`customId`), not the single-PUSH `jobId` the server renames — the batch
|
|
48
|
+
path now renames `jobId`→`customId`, so `getJobByCustomId` and idempotent
|
|
49
|
+
bulk ingest work. (H1)
|
|
50
|
+
- **Half-open link wedge.** Enable TCP keepalive (~15s idle) and tear down the
|
|
51
|
+
socket after 3 consecutive command timeouts so the next call reconnects,
|
|
52
|
+
instead of wedging until the OS abandons the writes. The teardown is
|
|
53
|
+
generation-guarded so a stale-connection timeout can't abort a fresh
|
|
54
|
+
reconnect. (H2)
|
|
55
|
+
- **getFlow crashed on a missing job.** A missing root/child now yields `null`
|
|
56
|
+
and is skipped (partial tree) instead of throwing; the catch is narrowed to
|
|
57
|
+
`'not found'` so real server errors still surface, and a `visited` set guards
|
|
58
|
+
against cycles now that depth defaults to unlimited. (H4)
|
|
59
|
+
- **waitForJob returned `undefined` on timeout.** It now rejects on
|
|
60
|
+
non-completion: a `failed` job throws `CommandError`, otherwise
|
|
61
|
+
`CommandTimeoutError` — the `completed` flag is no longer ignored. (M1)
|
|
62
|
+
- **getWaitingCount / getWaiting counted prioritized jobs.** Now waiting-only,
|
|
63
|
+
matching BullMQ and the Python SDK. (M2)
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- `addJobLog(id, message, level?)` accepts an optional level;
|
|
68
|
+
`getJobLogs` formats entries as `[level] message` (no longer drops the level).
|
|
69
|
+
- `retryJobs`: the dead `count` field is no longer sent on the wire (the server
|
|
70
|
+
has no partial RetryDlq; `count` is accepted only for API parity).
|
|
71
|
+
- Worker `FAIL` keeps the leading stack lines (`slice(0, N)`) so the error
|
|
72
|
+
message is preserved on long stacks.
|
|
73
|
+
|
|
74
|
+
## [0.1.4] - initial published release
|
|
75
|
+
|
|
76
|
+
- Cross-runtime (Node/Bun/Deno) TCP client: `Queue`, `Worker`, `FlowProducer`,
|
|
77
|
+
`Bunqueue` Simple Mode, msgpack wire protocol, TLS, auth, pipelining.
|
package/README.md
CHANGED
|
@@ -272,12 +272,55 @@ const queue = new Queue('emails', {
|
|
|
272
272
|
|
|
273
273
|
Authentication uses server side tokens (`AUTH_TOKENS`). Transport security uses native TLS, with support for system certificate authorities, a custom CA bundle, or disabled verification for development environments.
|
|
274
274
|
|
|
275
|
+
## Observability
|
|
276
|
+
|
|
277
|
+
Inject a logger and a telemetry sink to bridge the client into your stack. There are no hard dependencies — you wire OpenTelemetry, Prometheus or your own logger. Defaults are silent.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
import { Queue, consoleLogger, type TelemetryEvent } from 'bunqueue-client';
|
|
281
|
+
|
|
282
|
+
const queue = new Queue('emails', {
|
|
283
|
+
logger: consoleLogger('info'), // or your own { debug, info, warn, error }
|
|
284
|
+
onTelemetry: (e: TelemetryEvent) => {
|
|
285
|
+
// per-command latency, connect/disconnect/reconnect, auth, backpressure
|
|
286
|
+
if (e.type === 'command') metrics.observe(e.cmd, e.durationMs, e.ok);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Connection is an EventEmitter for lifecycle hooks:
|
|
291
|
+
queue.connection.on('reconnect_scheduled', (i) => log.warn('reconnecting', i));
|
|
292
|
+
queue.connection.on('disconnect', () => log.warn('link down'));
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Throughput and resilience
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// Bound in-flight commands (backpressure) — parks callers instead of growing memory:
|
|
299
|
+
const queue = new Queue('emails', { maxInFlight: 10_000 });
|
|
300
|
+
|
|
301
|
+
// Fan producer commands across N connections (round-robin, producer-side):
|
|
302
|
+
const pooled = new Queue('emails', { poolSize: 4 });
|
|
303
|
+
|
|
304
|
+
// Batch worker ACKs into ACKB round-trips (opt-in) for high-volume consumers:
|
|
305
|
+
const worker = new Worker('emails', process, {
|
|
306
|
+
ackBatch: { enabled: true, maxSize: 50, maxDelayMs: 5 },
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Always attach a `worker.on('error', …)` listener: per Node `EventEmitter` semantics an unhandled `error` event throws. The worker frees each job's concurrency slot before emitting, so even a throwing listener cannot degrade throughput — but the error itself is yours to observe.
|
|
311
|
+
|
|
312
|
+
Half-open links are detected via TCP keepalive and a consecutive-timeout teardown, so a silently dropped connection (cloud LB/NAT idle drop) recovers in seconds rather than minutes.
|
|
313
|
+
|
|
314
|
+
## Typed responses
|
|
315
|
+
|
|
316
|
+
`connection.call<R>()` and `queue.call<R>()` are generic over the exported response shapes (`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, `WaitJobResponse`, …), so raw command access is fully typed without casting.
|
|
317
|
+
|
|
275
318
|
## API surface
|
|
276
319
|
|
|
277
320
|
| Area | Capabilities |
|
|
278
321
|
|---|---|
|
|
279
322
|
| Queue | `add`, `addBulk`, full `JobOptions`: priority, delay, attempts, backoff, ttl, timeout, jobId, deduplication, dependsOn, tags, groupId, lifo, removeOnComplete, removeOnFail, durable, repeat, debounce |
|
|
280
|
-
| Query | `getJob`, `getJobByCustomId`, `getJobs` with per state helpers, state, result, progress, `waitForJob
|
|
323
|
+
| Query | `getJob`, `getJobByCustomId`, `getJobs` with per state helpers, state, result, progress, `waitForJob` (throws on timeout, BullMQ contract), counts, counts per priority, children values, job logs |
|
|
281
324
|
| Control | pause, resume, drain, obliterate, clean, remove, discard, promote, `retryJob`, `retryJobs`, move to wait or delayed, change priority or delay, update data, extend lock |
|
|
282
325
|
| Dead letter queue | `getDlq`, `retryDlq`, `purgeDlq`, DLQ configuration |
|
|
283
326
|
| Administration | rate limiting, global concurrency, stall configuration, webhooks, stats, metrics, `listQueues`, `getWorkers` |
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
import type { Connection } from './connection.js';
|
|
11
|
+
export interface AckItem {
|
|
12
|
+
id: string;
|
|
13
|
+
token: string;
|
|
14
|
+
result: unknown;
|
|
15
|
+
/** Called once the batch settles: `err` set on failure, undefined on ack. */
|
|
16
|
+
onSettled: (err?: unknown) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare class AckBatcher {
|
|
19
|
+
private readonly connection;
|
|
20
|
+
private readonly maxSize;
|
|
21
|
+
private readonly maxDelayMs;
|
|
22
|
+
private buffer;
|
|
23
|
+
private timer;
|
|
24
|
+
constructor(connection: Connection, maxSize: number, maxDelayMs: number);
|
|
25
|
+
add(item: AckItem): void;
|
|
26
|
+
/** Send the buffered ACKs as one ACKB (no-op when empty). */
|
|
27
|
+
flush(): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
import { compact } from './frame.js';
|
|
11
|
+
export class AckBatcher {
|
|
12
|
+
connection;
|
|
13
|
+
maxSize;
|
|
14
|
+
maxDelayMs;
|
|
15
|
+
buffer = [];
|
|
16
|
+
timer = null;
|
|
17
|
+
constructor(connection, maxSize, maxDelayMs) {
|
|
18
|
+
this.connection = connection;
|
|
19
|
+
this.maxSize = maxSize;
|
|
20
|
+
this.maxDelayMs = maxDelayMs;
|
|
21
|
+
}
|
|
22
|
+
add(item) {
|
|
23
|
+
this.buffer.push(item);
|
|
24
|
+
if (this.buffer.length >= this.maxSize) {
|
|
25
|
+
void this.flush();
|
|
26
|
+
}
|
|
27
|
+
else if (!this.timer) {
|
|
28
|
+
this.timer = setTimeout(() => void this.flush(), this.maxDelayMs);
|
|
29
|
+
this.timer.unref?.(); // don't keep the process alive for a pending flush
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Send the buffered ACKs as one ACKB (no-op when empty). */
|
|
33
|
+
async flush() {
|
|
34
|
+
if (this.timer) {
|
|
35
|
+
clearTimeout(this.timer);
|
|
36
|
+
this.timer = null;
|
|
37
|
+
}
|
|
38
|
+
if (this.buffer.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const batch = this.buffer;
|
|
41
|
+
this.buffer = [];
|
|
42
|
+
// Capture the wire outcome first; settle callbacks run OUTSIDE the try so
|
|
43
|
+
// a throwing callback can neither be re-invoked with an error (double
|
|
44
|
+
// settle) nor starve the remaining items of their callback.
|
|
45
|
+
let error;
|
|
46
|
+
try {
|
|
47
|
+
await this.connection.call(compact({
|
|
48
|
+
cmd: 'ACKB',
|
|
49
|
+
ids: batch.map((b) => b.id),
|
|
50
|
+
tokens: batch.map((b) => b.token),
|
|
51
|
+
results: batch.map((b) => b.result),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
error = err ?? new Error('ACKB failed');
|
|
56
|
+
}
|
|
57
|
+
for (const item of batch) {
|
|
58
|
+
try {
|
|
59
|
+
item.onSettled(error);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A callback error (e.g. an unhandled 'error' emit in the worker)
|
|
63
|
+
// must never prevent the other items from settling.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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 declare class Backpressure {
|
|
10
|
+
private readonly max;
|
|
11
|
+
private readonly onWait?;
|
|
12
|
+
private readonly waiters;
|
|
13
|
+
constructor(max: number, onWait?: (() => void) | undefined);
|
|
14
|
+
/** Resolve immediately if under the limit, else park until a slot frees. */
|
|
15
|
+
acquire(inFlight: number): Promise<void> | void;
|
|
16
|
+
/** Release one parked caller (call when a pending command settles). */
|
|
17
|
+
release(): void;
|
|
18
|
+
/** Release every parked caller (call on teardown so none hang forever). */
|
|
19
|
+
clear(): void;
|
|
20
|
+
/** Number of callers currently parked. */
|
|
21
|
+
get waiting(): number;
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
max;
|
|
11
|
+
onWait;
|
|
12
|
+
waiters = [];
|
|
13
|
+
constructor(max, onWait) {
|
|
14
|
+
this.max = max;
|
|
15
|
+
this.onWait = onWait;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve immediately if under the limit, else park until a slot frees. */
|
|
18
|
+
acquire(inFlight) {
|
|
19
|
+
if (this.max <= 0 || inFlight < this.max)
|
|
20
|
+
return;
|
|
21
|
+
this.onWait?.();
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
this.waiters.push(resolve);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Release one parked caller (call when a pending command settles). */
|
|
27
|
+
release() {
|
|
28
|
+
this.waiters.shift()?.();
|
|
29
|
+
}
|
|
30
|
+
/** Release every parked caller (call on teardown so none hang forever). */
|
|
31
|
+
clear() {
|
|
32
|
+
while (this.waiters.length > 0)
|
|
33
|
+
this.waiters.shift()?.();
|
|
34
|
+
}
|
|
35
|
+
/** Number of callers currently parked. */
|
|
36
|
+
get waiting() {
|
|
37
|
+
return this.waiters.length;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import type { Command, ConnectionLike, ConnectionOptions, Response } from './connection-types.js';
|
|
15
|
+
export declare class ConnectionPool extends EventEmitter implements ConnectionLike {
|
|
16
|
+
private readonly connections;
|
|
17
|
+
private cursor;
|
|
18
|
+
constructor(size: number, options?: ConnectionOptions);
|
|
19
|
+
/** How many connections back this pool. */
|
|
20
|
+
get size(): number;
|
|
21
|
+
private next;
|
|
22
|
+
call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
|
|
23
|
+
ping(): Promise<boolean>;
|
|
24
|
+
connect(): Promise<void>;
|
|
25
|
+
close(): void;
|
|
26
|
+
/** True while at least one member connection is up. */
|
|
27
|
+
get isConnected(): boolean;
|
|
28
|
+
/** Representative generation (first connection); pools are producer-side. */
|
|
29
|
+
get generation(): number;
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import { Connection } from './connection.js';
|
|
15
|
+
import { LIFECYCLE_EVENTS } from './observability.js';
|
|
16
|
+
export class ConnectionPool extends EventEmitter {
|
|
17
|
+
connections;
|
|
18
|
+
cursor = 0;
|
|
19
|
+
constructor(size, options = {}) {
|
|
20
|
+
super();
|
|
21
|
+
const n = Math.max(1, Math.floor(size));
|
|
22
|
+
this.connections = Array.from({ length: n }, () => {
|
|
23
|
+
const conn = new Connection(options);
|
|
24
|
+
for (const event of LIFECYCLE_EVENTS) {
|
|
25
|
+
conn.on(event, (payload) => this.emit(event, payload));
|
|
26
|
+
}
|
|
27
|
+
return conn;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** How many connections back this pool. */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.connections.length;
|
|
33
|
+
}
|
|
34
|
+
next() {
|
|
35
|
+
const conn = this.connections[this.cursor];
|
|
36
|
+
this.cursor = (this.cursor + 1) % this.connections.length;
|
|
37
|
+
return conn;
|
|
38
|
+
}
|
|
39
|
+
call(command, timeoutMs) {
|
|
40
|
+
return this.next().call(command, timeoutMs);
|
|
41
|
+
}
|
|
42
|
+
ping() {
|
|
43
|
+
return this.connections[0].ping();
|
|
44
|
+
}
|
|
45
|
+
async connect() {
|
|
46
|
+
await Promise.all(this.connections.map((conn) => conn.connect()));
|
|
47
|
+
}
|
|
48
|
+
close() {
|
|
49
|
+
for (const conn of this.connections)
|
|
50
|
+
conn.close();
|
|
51
|
+
}
|
|
52
|
+
/** True while at least one member connection is up. */
|
|
53
|
+
get isConnected() {
|
|
54
|
+
return this.connections.some((conn) => conn.isConnected);
|
|
55
|
+
}
|
|
56
|
+
/** Representative generation (first connection); pools are producer-side. */
|
|
57
|
+
get generation() {
|
|
58
|
+
return this.connections[0].generation;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/** Connection option and message types. */
|
|
2
|
+
import type { Observability } from './observability.js';
|
|
2
3
|
export type TlsOption = boolean | {
|
|
3
4
|
caFile?: string;
|
|
4
5
|
rejectUnauthorized?: boolean;
|
|
5
6
|
} | undefined;
|
|
6
|
-
export interface ConnectionOptions {
|
|
7
|
+
export interface ConnectionOptions extends Observability {
|
|
7
8
|
host?: string;
|
|
8
9
|
port?: number;
|
|
9
10
|
token?: string;
|
|
10
11
|
tls?: TlsOption;
|
|
11
12
|
connectTimeoutMs?: number;
|
|
12
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;
|
|
13
19
|
}
|
|
14
20
|
export type Command = Record<string, unknown> & {
|
|
15
21
|
cmd: string;
|
|
@@ -17,6 +23,20 @@ export type Command = Record<string, unknown> & {
|
|
|
17
23
|
export type Response = Record<string, unknown> & {
|
|
18
24
|
ok: boolean;
|
|
19
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The connection surface consumers (Queue, FlowProducer, Job) depend on.
|
|
28
|
+
* Both {@link Connection} and a round-robin ConnectionPool satisfy it, so a
|
|
29
|
+
* pool can be dropped in transparently for producer-side throughput.
|
|
30
|
+
*/
|
|
31
|
+
export interface ConnectionLike {
|
|
32
|
+
call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
|
|
33
|
+
ping(): Promise<boolean>;
|
|
34
|
+
connect(): Promise<void>;
|
|
35
|
+
close(): void;
|
|
36
|
+
readonly isConnected: boolean;
|
|
37
|
+
readonly generation: number;
|
|
38
|
+
on(event: string, listener: (...args: unknown[]) => void): this;
|
|
39
|
+
}
|
|
20
40
|
export interface Pending {
|
|
21
41
|
resolve: (response: Response) => void;
|
|
22
42
|
reject: (error: Error) => void;
|
package/dist/connection.d.ts
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
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 type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
|
|
9
10
|
export type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A single pipelined TCP connection to a bunqueue server.
|
|
13
|
+
*
|
|
14
|
+
* Emits typed lifecycle events for observability: `connect`, `disconnect` and
|
|
15
|
+
* `reconnect_scheduled` (payloads mirror the telemetry events). Attach a
|
|
16
|
+
* telemetry sink via the `onTelemetry` option for per-command latency metrics.
|
|
17
|
+
*/
|
|
18
|
+
export declare class Connection extends EventEmitter {
|
|
12
19
|
readonly host: string;
|
|
13
20
|
readonly port: number;
|
|
14
21
|
readonly token: string | undefined;
|
|
@@ -25,6 +32,10 @@ export declare class Connection {
|
|
|
25
32
|
private connectGeneration;
|
|
26
33
|
private failedAttempts;
|
|
27
34
|
private nextAttemptAt;
|
|
35
|
+
private readonly maxCommandTimeouts;
|
|
36
|
+
private consecutiveTimeouts;
|
|
37
|
+
private readonly telemetry;
|
|
38
|
+
private readonly backpressure;
|
|
28
39
|
constructor(options?: ConnectionOptions);
|
|
29
40
|
get isConnected(): boolean;
|
|
30
41
|
/**
|
|
@@ -40,7 +51,7 @@ export declare class Connection {
|
|
|
40
51
|
* Send a command and await its response. Rejects with CommandError when
|
|
41
52
|
* the server answers ok=false. Reconnects lazily if the link was lost.
|
|
42
53
|
*/
|
|
43
|
-
call(command: Command, timeoutMs?: number): Promise<
|
|
54
|
+
call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
|
|
44
55
|
/** Ping the server; returns true when it answers pong. */
|
|
45
56
|
ping(): Promise<boolean>;
|
|
46
57
|
/** Protocol negotiation; returns server name/version/protocolVersion. */
|
|
@@ -48,5 +59,11 @@ export declare class Connection {
|
|
|
48
59
|
/** Close permanently; in-flight commands reject. */
|
|
49
60
|
close(): void;
|
|
50
61
|
private handleData;
|
|
62
|
+
/**
|
|
63
|
+
* A dead/half-open link makes every command time out while the socket still
|
|
64
|
+
* looks connected. After maxCommandTimeouts consecutive timeouts, tear down
|
|
65
|
+
* so the next call() reconnects instead of wedging (mirrors #94).
|
|
66
|
+
*/
|
|
67
|
+
private noteTimeout;
|
|
51
68
|
private teardown;
|
|
52
69
|
}
|
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;
|
|
@@ -27,13 +36,23 @@ export class Connection {
|
|
|
27
36
|
connectGeneration = 0;
|
|
28
37
|
failedAttempts = 0;
|
|
29
38
|
nextAttemptAt = 0;
|
|
39
|
+
// Half-open recovery (#94): after this many consecutive command timeouts the
|
|
40
|
+
// socket is presumed dead and torn down so the next call reconnects.
|
|
41
|
+
maxCommandTimeouts = 3;
|
|
42
|
+
consecutiveTimeouts = 0;
|
|
43
|
+
telemetry;
|
|
44
|
+
backpressure;
|
|
30
45
|
constructor(options = {}) {
|
|
46
|
+
super();
|
|
31
47
|
this.host = options.host ?? 'localhost';
|
|
32
48
|
this.port = options.port ?? 6789;
|
|
33
49
|
this.token = options.token;
|
|
34
50
|
this.tls = options.tls;
|
|
35
51
|
this.connectTimeoutMs = options.connectTimeoutMs ?? 5000;
|
|
36
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));
|
|
37
56
|
}
|
|
38
57
|
get isConnected() {
|
|
39
58
|
return this.connected;
|
|
@@ -69,6 +88,7 @@ export class Connection {
|
|
|
69
88
|
this.failedAttempts += 1;
|
|
70
89
|
const backoff = Math.min(500 * 2 ** (this.failedAttempts - 1), 5000);
|
|
71
90
|
this.nextAttemptAt = Date.now() + backoff;
|
|
91
|
+
this.telemetry.reconnectScheduled(this.host, this.port, this.failedAttempts, backoff);
|
|
72
92
|
throw err;
|
|
73
93
|
})
|
|
74
94
|
.finally(() => {
|
|
@@ -77,8 +97,12 @@ export class Connection {
|
|
|
77
97
|
return this.connecting;
|
|
78
98
|
}
|
|
79
99
|
async doConnect() {
|
|
100
|
+
const startMs = nowMs();
|
|
80
101
|
const socket = await openSocket(this.host, this.port, this.tls, this.connectTimeoutMs);
|
|
81
102
|
socket.setNoDelay(true);
|
|
103
|
+
// TCP keepalive (~15s idle) surfaces a half-open link (cloud LB/NAT idle
|
|
104
|
+
// drop with no FIN/RST) in seconds instead of ~tcp_retries2 minutes.
|
|
105
|
+
socket.setKeepAlive(true, 15_000);
|
|
82
106
|
this.parser.clear();
|
|
83
107
|
this.socket = socket;
|
|
84
108
|
socket.on('data', (chunk) => this.handleData(chunk));
|
|
@@ -86,11 +110,21 @@ export class Connection {
|
|
|
86
110
|
socket.on('close', () => this.teardown());
|
|
87
111
|
this.connected = true;
|
|
88
112
|
this.connectGeneration += 1;
|
|
113
|
+
this.consecutiveTimeouts = 0;
|
|
114
|
+
this.telemetry.connected(this.host, this.port, this.connectGeneration, startMs);
|
|
115
|
+
// INVARIANT (H3): connected is flipped true before Auth, which is safe
|
|
116
|
+
// ONLY because call() writes the Auth frame synchronously — there is no
|
|
117
|
+
// `await` between this line and the Auth socket.write, so no concurrent
|
|
118
|
+
// call() can interleave a frame ahead of Auth on the wire. Do NOT insert
|
|
119
|
+
// an await here or before the Auth call, or a command could race ahead of
|
|
120
|
+
// Auth (the Python SDK guards this with a lock; JS relies on this ordering).
|
|
89
121
|
if (this.token) {
|
|
90
122
|
try {
|
|
91
123
|
await this.call({ cmd: 'Auth', token: this.token });
|
|
124
|
+
this.telemetry.auth(true);
|
|
92
125
|
}
|
|
93
126
|
catch (err) {
|
|
127
|
+
this.telemetry.auth(false);
|
|
94
128
|
this.teardown();
|
|
95
129
|
if (err instanceof CommandError)
|
|
96
130
|
throw new AuthError(err.message);
|
|
@@ -105,20 +139,32 @@ export class Connection {
|
|
|
105
139
|
async call(command, timeoutMs) {
|
|
106
140
|
if (!this.connected)
|
|
107
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;
|
|
108
147
|
const socket = this.socket;
|
|
109
|
-
if (!socket)
|
|
148
|
+
if (!this.connected || !socket)
|
|
110
149
|
throw new ConnectionClosedError('not connected');
|
|
111
150
|
this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
|
|
112
151
|
const reqId = String(this.reqCounter);
|
|
113
152
|
const payload = pack({ ...compact(command), reqId });
|
|
153
|
+
const gen = this.connectGeneration; // snapshot: a timeout must not tear down a newer conn
|
|
154
|
+
const startMs = nowMs();
|
|
114
155
|
return new Promise((resolve, reject) => {
|
|
115
156
|
const timer = setTimeout(() => {
|
|
116
157
|
this.pending.delete(reqId);
|
|
158
|
+
this.backpressure.release();
|
|
159
|
+
this.telemetry.timeout(command.cmd, reqId);
|
|
160
|
+
this.noteTimeout(gen);
|
|
117
161
|
reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
|
|
118
162
|
}, timeoutMs ?? this.commandTimeoutMs);
|
|
119
163
|
this.pending.set(reqId, {
|
|
120
164
|
resolve: (response) => {
|
|
121
165
|
clearTimeout(timer);
|
|
166
|
+
this.consecutiveTimeouts = 0; // any reply means the link is alive
|
|
167
|
+
this.telemetry.command(command.cmd, reqId, startMs, response.ok);
|
|
122
168
|
if (!response.ok) {
|
|
123
169
|
reject(new CommandError(String(response.error ?? 'unknown server error')));
|
|
124
170
|
}
|
|
@@ -136,6 +182,7 @@ export class Connection {
|
|
|
136
182
|
if (err) {
|
|
137
183
|
const entry = this.pending.get(reqId);
|
|
138
184
|
this.pending.delete(reqId);
|
|
185
|
+
this.backpressure.release();
|
|
139
186
|
entry?.reject(new ConnectionClosedError(`send failed: ${err.message}`));
|
|
140
187
|
this.teardown();
|
|
141
188
|
}
|
|
@@ -192,11 +239,28 @@ export class Connection {
|
|
|
192
239
|
const entry = this.pending.get(String(reqId));
|
|
193
240
|
if (entry) {
|
|
194
241
|
this.pending.delete(String(reqId));
|
|
242
|
+
this.backpressure.release();
|
|
195
243
|
entry.resolve(response);
|
|
196
244
|
}
|
|
197
245
|
}
|
|
198
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* A dead/half-open link makes every command time out while the socket still
|
|
249
|
+
* looks connected. After maxCommandTimeouts consecutive timeouts, tear down
|
|
250
|
+
* so the next call() reconnects instead of wedging (mirrors #94).
|
|
251
|
+
*/
|
|
252
|
+
noteTimeout(gen) {
|
|
253
|
+
// A timeout from an already-replaced connection must not tear down (or
|
|
254
|
+
// miscount against) the current one.
|
|
255
|
+
if (gen !== this.connectGeneration)
|
|
256
|
+
return;
|
|
257
|
+
this.consecutiveTimeouts += 1;
|
|
258
|
+
if (this.consecutiveTimeouts >= this.maxCommandTimeouts)
|
|
259
|
+
this.teardown();
|
|
260
|
+
}
|
|
199
261
|
teardown() {
|
|
262
|
+
const wasConnected = this.socket !== null;
|
|
263
|
+
const gen = this.connectGeneration;
|
|
200
264
|
this.connected = false;
|
|
201
265
|
const socket = this.socket;
|
|
202
266
|
this.socket = null;
|
|
@@ -210,5 +274,8 @@ export class Connection {
|
|
|
210
274
|
clearTimeout(entry.timer);
|
|
211
275
|
entry.reject(new ConnectionClosedError('connection lost'));
|
|
212
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);
|
|
213
280
|
}
|
|
214
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;
|