bunqueue-client 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -0
- package/README.md +46 -3
- package/dist/ack-batcher.d.ts +28 -0
- package/dist/ack-batcher.js +67 -0
- package/dist/backpressure.d.ts +22 -0
- package/dist/backpressure.js +39 -0
- package/dist/bunqueue/bunqueue.js +12 -2
- package/dist/connection-pool.d.ts +30 -0
- package/dist/connection-pool.js +60 -0
- package/dist/connection-types.d.ts +21 -1
- package/dist/connection.d.ts +12 -3
- package/dist/connection.js +39 -3
- package/dist/flow-types.d.ts +2 -1
- package/dist/flow.js +12 -3
- package/dist/index.d.ts +7 -2
- package/dist/index.js +3 -1
- package/dist/job.d.ts +2 -2
- package/dist/observability.d.ts +95 -0
- package/dist/observability.js +110 -0
- package/dist/queue-admin.js +16 -2
- package/dist/queue-control.d.ts +5 -0
- package/dist/queue-control.js +27 -9
- package/dist/queue-query.js +13 -21
- package/dist/queue.d.ts +13 -4
- package/dist/queue.js +14 -7
- package/dist/responses.d.ts +79 -0
- package/dist/responses.js +10 -0
- package/dist/worker-base.d.ts +15 -3
- package/dist/worker-base.js +15 -7
- package/dist/worker-types.d.ts +43 -1
- package/dist/worker.d.ts +5 -1
- package/dist/worker.js +59 -14
- package/package.json +3 -1
- package/src/ack-batcher.ts +76 -0
- package/src/backpressure.ts +40 -0
- package/src/bunqueue/bunqueue.ts +12 -1
- package/src/connection-pool.ts +71 -0
- package/src/connection-types.ts +23 -1
- package/src/connection.ts +42 -6
- package/src/flow-types.ts +2 -1
- package/src/flow.ts +24 -16
- package/src/index.ts +33 -2
- package/src/job.ts +3 -3
- package/src/observability.ts +158 -0
- package/src/queue-admin.ts +15 -2
- package/src/queue-control.ts +34 -11
- package/src/queue-query.ts +39 -29
- package/src/queue.ts +30 -11
- package/src/responses.ts +96 -0
- package/src/worker-base.ts +49 -11
- package/src/worker-types.ts +42 -1
- package/src/worker.ts +63 -16
package/src/worker-base.ts
CHANGED
|
@@ -7,9 +7,14 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { EventEmitter } from 'node:events';
|
|
8
8
|
import { hostname } from 'node:os';
|
|
9
9
|
import { Connection } from './connection.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import {
|
|
11
|
+
MAX_POLL_TIMEOUT_MS,
|
|
12
|
+
sleep,
|
|
13
|
+
type WorkerEventMap,
|
|
14
|
+
type WorkerOptions,
|
|
15
|
+
} from './worker-types.js';
|
|
16
|
+
|
|
17
|
+
export class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
|
|
13
18
|
readonly queue: string;
|
|
14
19
|
readonly concurrency: number;
|
|
15
20
|
readonly batchSize: number;
|
|
@@ -52,6 +57,8 @@ export class WorkerBase extends EventEmitter {
|
|
|
52
57
|
port: opts.port,
|
|
53
58
|
token: opts.token,
|
|
54
59
|
tls: opts.tls,
|
|
60
|
+
logger: opts.logger,
|
|
61
|
+
onTelemetry: opts.onTelemetry,
|
|
55
62
|
});
|
|
56
63
|
this.readyPromise = new Promise((resolve) => {
|
|
57
64
|
this.readyResolve = resolve;
|
|
@@ -86,18 +93,40 @@ export class WorkerBase extends EventEmitter {
|
|
|
86
93
|
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
87
94
|
* loop starts inside the constructor, so a plain once-only event could be
|
|
88
95
|
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
96
|
+
*
|
|
97
|
+
* The overloads give the known worker events typed parameters (see
|
|
98
|
+
* WorkerEventMap); unknown event names keep the generic signature.
|
|
89
99
|
*/
|
|
90
|
-
override on
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
override on<E extends keyof WorkerEventMap<T, R>>(
|
|
101
|
+
event: E,
|
|
102
|
+
listener: WorkerEventMap<T, R>[E]
|
|
103
|
+
): this;
|
|
104
|
+
override on(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
105
|
+
override on(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
106
|
+
if (event === 'ready' && this.readyFired) (listener as () => void)();
|
|
107
|
+
return super.on(event, listener as (...args: unknown[]) => void);
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
override once
|
|
110
|
+
override once<E extends keyof WorkerEventMap<T, R>>(
|
|
111
|
+
event: E,
|
|
112
|
+
listener: WorkerEventMap<T, R>[E]
|
|
113
|
+
): this;
|
|
114
|
+
override once(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
115
|
+
override once(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
96
116
|
if (event === 'ready' && this.readyFired) {
|
|
97
|
-
listener();
|
|
117
|
+
(listener as () => void)();
|
|
98
118
|
return this;
|
|
99
119
|
}
|
|
100
|
-
return super.once(event, listener);
|
|
120
|
+
return super.once(event, listener as (...args: unknown[]) => void);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
override off<E extends keyof WorkerEventMap<T, R>>(
|
|
124
|
+
event: E,
|
|
125
|
+
listener: WorkerEventMap<T, R>[E]
|
|
126
|
+
): this;
|
|
127
|
+
override off(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
128
|
+
override off(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
129
|
+
return super.off(event, listener as (...args: unknown[]) => void);
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
/**
|
|
@@ -132,6 +161,7 @@ export class WorkerBase extends EventEmitter {
|
|
|
132
161
|
if (this.closedFlag) return;
|
|
133
162
|
this.stopped = true;
|
|
134
163
|
if (this.loopPromise) await this.loopPromise;
|
|
164
|
+
await this.beforeClose(); // flush any batched ACKs before draining
|
|
135
165
|
while (!force && this.active.size > 0) await sleep(20);
|
|
136
166
|
if (this.heartbeatTimer) {
|
|
137
167
|
clearInterval(this.heartbeatTimer);
|
|
@@ -148,11 +178,19 @@ export class WorkerBase extends EventEmitter {
|
|
|
148
178
|
this.emit('closed');
|
|
149
179
|
}
|
|
150
180
|
|
|
151
|
-
|
|
181
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
182
|
+
* command reached the server — callers gate success-only side effects
|
|
183
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
184
|
+
protected async safeCall(command: Record<string, unknown> & { cmd: string }): Promise<boolean> {
|
|
152
185
|
try {
|
|
153
186
|
await this.connection.call(command);
|
|
187
|
+
return true;
|
|
154
188
|
} catch (err) {
|
|
155
|
-
this.emit('error', err);
|
|
189
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
190
|
+
return false;
|
|
156
191
|
}
|
|
157
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
195
|
+
protected async beforeClose(): Promise<void> {}
|
|
158
196
|
}
|
package/src/worker-types.ts
CHANGED
|
@@ -2,14 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
import type { TlsOption } from './connection.js';
|
|
4
4
|
import type { Job } from './job.js';
|
|
5
|
+
import type { Observability } from './observability.js';
|
|
5
6
|
|
|
6
7
|
export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Typed Worker event map: listeners registered via `on`/`once`/`off` for these
|
|
11
|
+
* names get typed job/result/error parameters in strict mode. Unknown event
|
|
12
|
+
* names fall back to a generic `(...args: unknown[])` overload.
|
|
13
|
+
*/
|
|
14
|
+
export interface WorkerEventMap<T = unknown, R = unknown> {
|
|
15
|
+
/** Worker registered and pull loop started (replayed to late listeners). */
|
|
16
|
+
ready: () => void;
|
|
17
|
+
/** A job was pulled and handed to the processor. */
|
|
18
|
+
active: (job: Job<T>) => void;
|
|
19
|
+
/** Processor resolved AND the ACK reached the server. */
|
|
20
|
+
completed: (job: Job<T>, result: R) => void;
|
|
21
|
+
/** Processor threw AND the FAIL reached the server. */
|
|
22
|
+
failed: (job: Job<T>, error: Error) => void;
|
|
23
|
+
/** job.updateProgress() was called from the processor. */
|
|
24
|
+
progress: (job: Job<T>, progress: number) => void;
|
|
25
|
+
/** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
|
|
26
|
+
error: (error: Error) => void;
|
|
27
|
+
/** The queue went from busy to empty (no active jobs, nothing pulled). */
|
|
28
|
+
drained: () => void;
|
|
29
|
+
/** Cooperative cancel was requested for a locally active job. */
|
|
30
|
+
cancelled: (info: { jobId: string; reason: string }) => void;
|
|
31
|
+
/** close() finished. */
|
|
32
|
+
closed: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AckBatchOptions {
|
|
36
|
+
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
37
|
+
enabled?: boolean;
|
|
38
|
+
/** Max ACKs per batch (default 50). */
|
|
39
|
+
maxSize?: number;
|
|
40
|
+
/** Max ms to hold a partial batch before flushing (default 5). */
|
|
41
|
+
maxDelayMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkerOptions extends Observability {
|
|
9
45
|
host?: string;
|
|
10
46
|
port?: number;
|
|
11
47
|
token?: string;
|
|
12
48
|
tls?: TlsOption;
|
|
49
|
+
/**
|
|
50
|
+
* Batch completed-job ACKs into ACKB commands for higher throughput under
|
|
51
|
+
* load. Opt-in: the default (individual ACK per job) is unchanged.
|
|
52
|
+
*/
|
|
53
|
+
ackBatch?: AckBatchOptions;
|
|
13
54
|
/** Max jobs processed in parallel (default 4). */
|
|
14
55
|
concurrency?: number;
|
|
15
56
|
/** Max jobs fetched per PULLB (default 10, capped by free slots). */
|
package/src/worker.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { hostname } from 'node:os';
|
|
10
|
+
import { AckBatcher } from './ack-batcher.js';
|
|
10
11
|
import { CommandTimeoutError, ConnectionClosedError, UnrecoverableError } from './errors.js';
|
|
11
12
|
import { compact } from './frame.js';
|
|
12
13
|
import { Job } from './job.js';
|
|
14
|
+
import type { PulledJobsResponse } from './responses.js';
|
|
13
15
|
import { WorkerBase } from './worker-base.js';
|
|
14
16
|
import {
|
|
15
17
|
MAX_STACK_LINES,
|
|
@@ -19,21 +21,31 @@ import {
|
|
|
19
21
|
type WorkerOptions,
|
|
20
22
|
} from './worker-types.js';
|
|
21
23
|
|
|
22
|
-
export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
24
|
+
export class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
|
|
23
25
|
private readonly processor: Processor<T, R>;
|
|
26
|
+
private readonly ackBatcher: AckBatcher | null;
|
|
24
27
|
|
|
25
28
|
constructor(queue: string, processor: Processor<T, R>, opts: WorkerOptions = {}) {
|
|
26
29
|
super(queue, opts);
|
|
27
30
|
this.processor = processor;
|
|
31
|
+
const ab = opts.ackBatch;
|
|
32
|
+
this.ackBatcher = ab?.enabled
|
|
33
|
+
? new AckBatcher(this.connection, ab.maxSize ?? 50, ab.maxDelayMs ?? 5)
|
|
34
|
+
: null;
|
|
28
35
|
if (opts.autorun !== false) this.run();
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/** Flush batched ACKs before the base class drains in-flight jobs. */
|
|
39
|
+
protected override async beforeClose(): Promise<void> {
|
|
40
|
+
if (this.ackBatcher) await this.ackBatcher.flush();
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
/** Start the pull loop (no-op if already running). */
|
|
32
44
|
run(): void {
|
|
33
45
|
if (this.running || this.closedFlag) return;
|
|
34
46
|
this.running = true;
|
|
35
|
-
this.loopPromise = this.loop().catch((err) => {
|
|
36
|
-
this.emit('error', err);
|
|
47
|
+
this.loopPromise = this.loop().catch((err: unknown) => {
|
|
48
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
37
49
|
});
|
|
38
50
|
}
|
|
39
51
|
|
|
@@ -56,7 +68,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
56
68
|
await this.pollOnce();
|
|
57
69
|
backoffIdx = 0;
|
|
58
70
|
} catch (err) {
|
|
59
|
-
this.emit('error', err);
|
|
71
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
60
72
|
if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
|
|
61
73
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
62
74
|
backoffIdx += 1;
|
|
@@ -82,7 +94,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
82
94
|
await this.register();
|
|
83
95
|
}
|
|
84
96
|
|
|
85
|
-
const response = await this.connection.call(
|
|
97
|
+
const response = await this.connection.call<PulledJobsResponse>(
|
|
86
98
|
{
|
|
87
99
|
cmd: 'PULLB',
|
|
88
100
|
queue: this.queue,
|
|
@@ -94,8 +106,8 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
94
106
|
this.pollTimeoutMs + 10_000
|
|
95
107
|
);
|
|
96
108
|
|
|
97
|
-
const jobs =
|
|
98
|
-
const tokens =
|
|
109
|
+
const jobs = response.jobs ?? [];
|
|
110
|
+
const tokens = response.tokens ?? [];
|
|
99
111
|
|
|
100
112
|
if (jobs.length === 0) {
|
|
101
113
|
if (this.wasBusy && this.active.size === 0) {
|
|
@@ -119,18 +131,45 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
119
131
|
this.emit('active', job);
|
|
120
132
|
try {
|
|
121
133
|
const result = await this.processor(job);
|
|
122
|
-
this.
|
|
123
|
-
|
|
134
|
+
if (this.ackBatcher) {
|
|
135
|
+
// Defer the ACK into a batch; the job stays active (lock renewed) until
|
|
136
|
+
// the ACKB settles. onSettled frees the slot FIRST — a throwing
|
|
137
|
+
// listener (e.g. an unhandled 'error' emit) must never leak the slot
|
|
138
|
+
// and permanently shrink the worker's effective concurrency.
|
|
139
|
+
this.ackBatcher.add({
|
|
140
|
+
id: job.id,
|
|
141
|
+
token,
|
|
142
|
+
result: result ?? undefined,
|
|
143
|
+
onSettled: (err) => {
|
|
144
|
+
this.finishJob(job.id);
|
|
145
|
+
if (err) {
|
|
146
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
147
|
+
} else {
|
|
148
|
+
this.processed += 1;
|
|
149
|
+
this.emit('completed', job, result);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const acked = await this.safeCall(
|
|
124
156
|
compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }) as { cmd: string }
|
|
125
157
|
);
|
|
126
|
-
|
|
158
|
+
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
159
|
+
// not leak the active slot (same rationale as the batched path).
|
|
160
|
+
this.finishJob(job.id);
|
|
161
|
+
// Mirror the batched path: a failed ACK already emitted 'error' — do not
|
|
162
|
+
// also claim completion (no 'completed', no processed++).
|
|
163
|
+
if (acked) {
|
|
164
|
+
this.processed += 1;
|
|
165
|
+
this.emit('completed', job, result);
|
|
166
|
+
}
|
|
127
167
|
} catch (err) {
|
|
128
|
-
this.failedCount += 1;
|
|
129
168
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
130
169
|
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
131
170
|
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
132
171
|
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
133
|
-
await this.safeCall(
|
|
172
|
+
const failed = await this.safeCall(
|
|
134
173
|
compact({
|
|
135
174
|
cmd: 'FAIL',
|
|
136
175
|
id: job.id,
|
|
@@ -140,13 +179,21 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
140
179
|
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
141
180
|
}) as { cmd: string }
|
|
142
181
|
);
|
|
143
|
-
this.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
182
|
+
this.finishJob(job.id);
|
|
183
|
+
// Same asymmetry guard as the ACK path: if the FAIL never reached the
|
|
184
|
+
// server, only 'error' fires (the lock expiry will retry the job).
|
|
185
|
+
if (failed) {
|
|
186
|
+
this.failedCount += 1;
|
|
187
|
+
this.emit('failed', job, error);
|
|
188
|
+
}
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
|
|
192
|
+
private finishJob(id: string): void {
|
|
193
|
+
this.active.delete(id);
|
|
194
|
+
this.cancelledJobs.delete(id);
|
|
195
|
+
}
|
|
196
|
+
|
|
150
197
|
// --------------------------------------------------------------- heartbeat
|
|
151
198
|
|
|
152
199
|
private startHeartbeat(): void {
|