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/dist/queue.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* exposes them on the type.
|
|
10
10
|
*/
|
|
11
11
|
import { Connection } from './connection.js';
|
|
12
|
+
import { ConnectionPool } from './connection-pool.js';
|
|
12
13
|
import { Job } from './job.js';
|
|
13
14
|
import { adminMethods } from './queue-admin.js';
|
|
14
15
|
import { controlMethods } from './queue-control.js';
|
|
@@ -21,15 +22,21 @@ export class Queue {
|
|
|
21
22
|
ownsConnection;
|
|
22
23
|
constructor(name, opts = {}) {
|
|
23
24
|
this.name = name;
|
|
25
|
+
const connOptions = {
|
|
26
|
+
host: opts.host,
|
|
27
|
+
port: opts.port,
|
|
28
|
+
token: opts.token,
|
|
29
|
+
tls: opts.tls,
|
|
30
|
+
commandTimeoutMs: opts.commandTimeoutMs,
|
|
31
|
+
maxInFlight: opts.maxInFlight,
|
|
32
|
+
logger: opts.logger,
|
|
33
|
+
onTelemetry: opts.onTelemetry,
|
|
34
|
+
};
|
|
24
35
|
this.connection =
|
|
25
36
|
opts.connection ??
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
token: opts.token,
|
|
30
|
-
tls: opts.tls,
|
|
31
|
-
commandTimeoutMs: opts.commandTimeoutMs,
|
|
32
|
-
});
|
|
37
|
+
(opts.poolSize && opts.poolSize > 1
|
|
38
|
+
? new ConnectionPool(opts.poolSize, connOptions)
|
|
39
|
+
: new Connection(connOptions));
|
|
33
40
|
this.ownsConnection = opts.connection === undefined;
|
|
34
41
|
}
|
|
35
42
|
/** Send a raw command on this queue's connection (used by area modules). */
|
|
@@ -50,10 +57,18 @@ export class Queue {
|
|
|
50
57
|
}
|
|
51
58
|
/** Add many jobs in one round-trip; returns Job stubs. */
|
|
52
59
|
async addBulk(jobs) {
|
|
53
|
-
const inputs = jobs.map((entry) =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
const inputs = jobs.map((entry) => {
|
|
61
|
+
const opts = wireJobOptions(entry.opts);
|
|
62
|
+
// PUSHB entries are JobInput, whose custom-id field is `customId` —
|
|
63
|
+
// unlike single PUSH which renames `jobId`->`customId` server-side.
|
|
64
|
+
// Without this the batch custom id is silently dropped (idempotency /
|
|
65
|
+
// getJobByCustomId broken).
|
|
66
|
+
if (opts.jobId !== undefined) {
|
|
67
|
+
opts.customId = opts.jobId;
|
|
68
|
+
delete opts.jobId;
|
|
69
|
+
}
|
|
70
|
+
return { data: jobPayload(entry.name, entry.data), ...opts };
|
|
71
|
+
});
|
|
57
72
|
const response = await this.call({ cmd: 'PUSHB', queue: this.name, jobs: inputs });
|
|
58
73
|
const ids = (response.ids ?? []);
|
|
59
74
|
return ids.map((id, i) => new Job({ id, queue: this.name, data: inputs[i].data }, this.connection));
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed server-response shapes, mirroring the server's response builders
|
|
3
|
+
* (src/domain/types/response.ts). `Connection.call<R>()` and `Queue.call<R>()`
|
|
4
|
+
* are generic over these, so call sites read `response.job` / `response.counts`
|
|
5
|
+
* with real types instead of casting `as Record<string, unknown>`.
|
|
6
|
+
*
|
|
7
|
+
* These narrow the loose transport `Response`; the runtime value is whatever
|
|
8
|
+
* the server sent, so only assert the shape a given command actually returns.
|
|
9
|
+
*/
|
|
10
|
+
import type { JobRaw } from './job.js';
|
|
11
|
+
import type { JobCounts, JobStateName } from './types.js';
|
|
12
|
+
interface Ok {
|
|
13
|
+
ok: true;
|
|
14
|
+
reqId?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** PUSH → id, or a bare ok ack. */
|
|
18
|
+
export interface OkResponse extends Ok {
|
|
19
|
+
id?: string;
|
|
20
|
+
}
|
|
21
|
+
/** PUSHB → ids. */
|
|
22
|
+
export interface BatchResponse extends Ok {
|
|
23
|
+
ids: string[];
|
|
24
|
+
}
|
|
25
|
+
/** GetJob → job (null when not found is surfaced as a CommandError instead). */
|
|
26
|
+
export interface JobResponse extends Ok {
|
|
27
|
+
job: JobRaw | null;
|
|
28
|
+
}
|
|
29
|
+
/** PULL → job + lock token. */
|
|
30
|
+
export interface PulledJobResponse extends Ok {
|
|
31
|
+
job: JobRaw | null;
|
|
32
|
+
token: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** PULLB → jobs + lock tokens (same order). */
|
|
35
|
+
export interface PulledJobsResponse extends Ok {
|
|
36
|
+
jobs: JobRaw[];
|
|
37
|
+
tokens: string[];
|
|
38
|
+
}
|
|
39
|
+
/** GetJobs → jobs. */
|
|
40
|
+
export interface JobsResponse extends Ok {
|
|
41
|
+
jobs: JobRaw[];
|
|
42
|
+
}
|
|
43
|
+
/** GetState → state. */
|
|
44
|
+
export interface StateResponse extends Ok {
|
|
45
|
+
id: string;
|
|
46
|
+
state: JobStateName;
|
|
47
|
+
}
|
|
48
|
+
/** GetResult → result. */
|
|
49
|
+
export interface ResultResponse<R = unknown> extends Ok {
|
|
50
|
+
result: R;
|
|
51
|
+
}
|
|
52
|
+
/** WaitJob → completed flag + (on completion) result. */
|
|
53
|
+
export interface WaitJobResponse<R = unknown> extends Ok {
|
|
54
|
+
completed: boolean;
|
|
55
|
+
result?: R;
|
|
56
|
+
}
|
|
57
|
+
/** GetJobCounts → counts. */
|
|
58
|
+
export interface JobCountsResponse extends Ok {
|
|
59
|
+
counts: JobCounts;
|
|
60
|
+
}
|
|
61
|
+
/** GetProgress → progress + message. */
|
|
62
|
+
export interface ProgressResponse extends Ok {
|
|
63
|
+
progress: number;
|
|
64
|
+
message: string | null;
|
|
65
|
+
}
|
|
66
|
+
/** IsPaused → paused. */
|
|
67
|
+
export interface PausedResponse extends Ok {
|
|
68
|
+
paused: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** Count / Clean / PromoteJobs / RetryDlq → count (+ optional removed ids). */
|
|
71
|
+
export interface CountResponse extends Ok {
|
|
72
|
+
count: number;
|
|
73
|
+
ids?: string[];
|
|
74
|
+
}
|
|
75
|
+
/** Generic data-wrapped payload (logs, workers, values, webhookId, …). */
|
|
76
|
+
export interface DataResponse<T = unknown> extends Ok {
|
|
77
|
+
data: T;
|
|
78
|
+
}
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed server-response shapes, mirroring the server's response builders
|
|
3
|
+
* (src/domain/types/response.ts). `Connection.call<R>()` and `Queue.call<R>()`
|
|
4
|
+
* are generic over these, so call sites read `response.job` / `response.counts`
|
|
5
|
+
* with real types instead of casting `as Record<string, unknown>`.
|
|
6
|
+
*
|
|
7
|
+
* These narrow the loose transport `Response`; the runtime value is whatever
|
|
8
|
+
* the server sent, so only assert the shape a given command actually returns.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
package/dist/worker-base.d.ts
CHANGED
|
@@ -59,4 +59,6 @@ export declare class WorkerBase extends EventEmitter {
|
|
|
59
59
|
protected safeCall(command: Record<string, unknown> & {
|
|
60
60
|
cmd: string;
|
|
61
61
|
}): Promise<void>;
|
|
62
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
63
|
+
protected beforeClose(): Promise<void>;
|
|
62
64
|
}
|
package/dist/worker-base.js
CHANGED
|
@@ -49,6 +49,8 @@ export class WorkerBase extends EventEmitter {
|
|
|
49
49
|
port: opts.port,
|
|
50
50
|
token: opts.token,
|
|
51
51
|
tls: opts.tls,
|
|
52
|
+
logger: opts.logger,
|
|
53
|
+
onTelemetry: opts.onTelemetry,
|
|
52
54
|
});
|
|
53
55
|
this.readyPromise = new Promise((resolve) => {
|
|
54
56
|
this.readyResolve = resolve;
|
|
@@ -120,6 +122,7 @@ export class WorkerBase extends EventEmitter {
|
|
|
120
122
|
this.stopped = true;
|
|
121
123
|
if (this.loopPromise)
|
|
122
124
|
await this.loopPromise;
|
|
125
|
+
await this.beforeClose(); // flush any batched ACKs before draining
|
|
123
126
|
while (!force && this.active.size > 0)
|
|
124
127
|
await sleep(20);
|
|
125
128
|
if (this.heartbeatTimer) {
|
|
@@ -144,4 +147,6 @@ export class WorkerBase extends EventEmitter {
|
|
|
144
147
|
this.emit('error', err);
|
|
145
148
|
}
|
|
146
149
|
}
|
|
150
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
151
|
+
async beforeClose() { }
|
|
147
152
|
}
|
package/dist/worker-types.d.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
/** Worker option types and shared constants. */
|
|
2
2
|
import type { TlsOption } from './connection.js';
|
|
3
3
|
import type { Job } from './job.js';
|
|
4
|
+
import type { Observability } from './observability.js';
|
|
4
5
|
export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
|
|
5
|
-
export interface
|
|
6
|
+
export interface AckBatchOptions {
|
|
7
|
+
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/** Max ACKs per batch (default 50). */
|
|
10
|
+
maxSize?: number;
|
|
11
|
+
/** Max ms to hold a partial batch before flushing (default 5). */
|
|
12
|
+
maxDelayMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface WorkerOptions extends Observability {
|
|
6
15
|
host?: string;
|
|
7
16
|
port?: number;
|
|
8
17
|
token?: string;
|
|
9
18
|
tls?: TlsOption;
|
|
19
|
+
/**
|
|
20
|
+
* Batch completed-job ACKs into ACKB commands for higher throughput under
|
|
21
|
+
* load. Opt-in: the default (individual ACK per job) is unchanged.
|
|
22
|
+
*/
|
|
23
|
+
ackBatch?: AckBatchOptions;
|
|
10
24
|
/** Max jobs processed in parallel (default 4). */
|
|
11
25
|
concurrency?: number;
|
|
12
26
|
/** Max jobs fetched per PULLB (default 10, capped by free slots). */
|
package/dist/worker.d.ts
CHANGED
|
@@ -9,12 +9,16 @@ import { WorkerBase } from './worker-base.js';
|
|
|
9
9
|
import { type Processor, type WorkerOptions } from './worker-types.js';
|
|
10
10
|
export declare class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
11
11
|
private readonly processor;
|
|
12
|
+
private readonly ackBatcher;
|
|
12
13
|
constructor(queue: string, processor: Processor<T, R>, opts?: WorkerOptions);
|
|
14
|
+
/** Flush batched ACKs before the base class drains in-flight jobs. */
|
|
15
|
+
protected beforeClose(): Promise<void>;
|
|
13
16
|
/** Start the pull loop (no-op if already running). */
|
|
14
17
|
run(): void;
|
|
15
18
|
private loop;
|
|
16
19
|
private pollOnce;
|
|
17
20
|
private runJob;
|
|
21
|
+
private finishJob;
|
|
18
22
|
private startHeartbeat;
|
|
19
23
|
private register;
|
|
20
24
|
}
|
package/dist/worker.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Lifecycle state and cooperative cancel live in WorkerBase.
|
|
7
7
|
*/
|
|
8
8
|
import { hostname } from 'node:os';
|
|
9
|
+
import { AckBatcher } from './ack-batcher.js';
|
|
9
10
|
import { CommandTimeoutError, ConnectionClosedError, UnrecoverableError } from './errors.js';
|
|
10
11
|
import { compact } from './frame.js';
|
|
11
12
|
import { Job } from './job.js';
|
|
@@ -13,12 +14,22 @@ import { WorkerBase } from './worker-base.js';
|
|
|
13
14
|
import { MAX_STACK_LINES, RECONNECT_BACKOFF_MS, sleep, } from './worker-types.js';
|
|
14
15
|
export class Worker extends WorkerBase {
|
|
15
16
|
processor;
|
|
17
|
+
ackBatcher;
|
|
16
18
|
constructor(queue, processor, opts = {}) {
|
|
17
19
|
super(queue, opts);
|
|
18
20
|
this.processor = processor;
|
|
21
|
+
const ab = opts.ackBatch;
|
|
22
|
+
this.ackBatcher = ab?.enabled
|
|
23
|
+
? new AckBatcher(this.connection, ab.maxSize ?? 50, ab.maxDelayMs ?? 5)
|
|
24
|
+
: null;
|
|
19
25
|
if (opts.autorun !== false)
|
|
20
26
|
this.run();
|
|
21
27
|
}
|
|
28
|
+
/** Flush batched ACKs before the base class drains in-flight jobs. */
|
|
29
|
+
async beforeClose() {
|
|
30
|
+
if (this.ackBatcher)
|
|
31
|
+
await this.ackBatcher.flush();
|
|
32
|
+
}
|
|
22
33
|
/** Start the pull loop (no-op if already running). */
|
|
23
34
|
run() {
|
|
24
35
|
if (this.running || this.closedFlag)
|
|
@@ -78,8 +89,8 @@ export class Worker extends WorkerBase {
|
|
|
78
89
|
owner: this.workerId,
|
|
79
90
|
lockTtl: this.lockTtlMs,
|
|
80
91
|
}, this.pollTimeoutMs + 10_000);
|
|
81
|
-
const jobs =
|
|
82
|
-
const tokens =
|
|
92
|
+
const jobs = response.jobs ?? [];
|
|
93
|
+
const tokens = response.tokens ?? [];
|
|
83
94
|
if (jobs.length === 0) {
|
|
84
95
|
if (this.wasBusy && this.active.size === 0) {
|
|
85
96
|
this.wasBusy = false;
|
|
@@ -100,14 +111,41 @@ export class Worker extends WorkerBase {
|
|
|
100
111
|
this.emit('active', job);
|
|
101
112
|
try {
|
|
102
113
|
const result = await this.processor(job);
|
|
114
|
+
if (this.ackBatcher) {
|
|
115
|
+
// Defer the ACK into a batch; the job stays active (lock renewed) until
|
|
116
|
+
// the ACKB settles. onSettled frees the slot FIRST — a throwing
|
|
117
|
+
// listener (e.g. an unhandled 'error' emit) must never leak the slot
|
|
118
|
+
// and permanently shrink the worker's effective concurrency.
|
|
119
|
+
this.ackBatcher.add({
|
|
120
|
+
id: job.id,
|
|
121
|
+
token,
|
|
122
|
+
result: result ?? undefined,
|
|
123
|
+
onSettled: (err) => {
|
|
124
|
+
this.finishJob(job.id);
|
|
125
|
+
if (err) {
|
|
126
|
+
this.emit('error', err);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.processed += 1;
|
|
130
|
+
this.emit('completed', job, result);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
103
136
|
this.processed += 1;
|
|
104
137
|
await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
|
|
138
|
+
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
139
|
+
// not leak the active slot (same rationale as the batched path).
|
|
140
|
+
this.finishJob(job.id);
|
|
105
141
|
this.emit('completed', job, result);
|
|
106
142
|
}
|
|
107
143
|
catch (err) {
|
|
108
144
|
this.failedCount += 1;
|
|
109
145
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
110
|
-
|
|
146
|
+
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
147
|
+
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
148
|
+
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
111
149
|
await this.safeCall(compact({
|
|
112
150
|
cmd: 'FAIL',
|
|
113
151
|
id: job.id,
|
|
@@ -116,12 +154,13 @@ export class Worker extends WorkerBase {
|
|
|
116
154
|
stack,
|
|
117
155
|
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
118
156
|
}));
|
|
157
|
+
this.finishJob(job.id);
|
|
119
158
|
this.emit('failed', job, error);
|
|
120
159
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
160
|
+
}
|
|
161
|
+
finishJob(id) {
|
|
162
|
+
this.active.delete(id);
|
|
163
|
+
this.cancelledJobs.delete(id);
|
|
125
164
|
}
|
|
126
165
|
// --------------------------------------------------------------- heartbeat
|
|
127
166
|
startHeartbeat() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Cross-runtime TypeScript client for the bunqueue job queue server — Node.js, Bun, Deno and Cloudflare Workers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dist",
|
|
18
18
|
"src",
|
|
19
19
|
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
22
23
|
"engines": {
|
|
@@ -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
|
+
}
|
|
@@ -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;
|