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/dist/queue-query.js
CHANGED
|
@@ -13,23 +13,23 @@ export const queryMethods = {
|
|
|
13
13
|
async getJob(id) {
|
|
14
14
|
try {
|
|
15
15
|
const response = await this.call({ cmd: 'GetJob', id });
|
|
16
|
-
|
|
17
|
-
return raw ? new Job(raw, this.connection) : null;
|
|
16
|
+
return response.job ? new Job(response.job, this.connection) : null;
|
|
18
17
|
}
|
|
19
18
|
catch (err) {
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// Only the server's 'Job not found' maps to null; connection loss,
|
|
20
|
+
// timeouts and other server errors must surface.
|
|
21
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
22
|
+
return null;
|
|
22
23
|
throw err;
|
|
23
24
|
}
|
|
24
25
|
},
|
|
25
26
|
async getJobByCustomId(customId) {
|
|
26
27
|
try {
|
|
27
28
|
const response = await this.call({ cmd: 'GetJobByCustomId', customId });
|
|
28
|
-
|
|
29
|
-
return raw ? new Job(raw, this.connection) : null;
|
|
29
|
+
return response.job ? new Job(response.job, this.connection) : null;
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
32
|
-
if (err instanceof CommandError)
|
|
32
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
33
33
|
return null;
|
|
34
34
|
throw err;
|
|
35
35
|
}
|
|
@@ -49,8 +49,7 @@ export const queryMethods = {
|
|
|
49
49
|
offset: start,
|
|
50
50
|
limit: end - start,
|
|
51
51
|
}));
|
|
52
|
-
|
|
53
|
-
return jobs.map((raw) => new Job(raw, this.connection));
|
|
52
|
+
return (response.jobs ?? []).map((raw) => new Job(raw, this.connection));
|
|
54
53
|
},
|
|
55
54
|
getWaiting(start, end) {
|
|
56
55
|
// Only the 'waiting' bucket — prioritized jobs live in a separate bucket
|
|
@@ -76,12 +75,10 @@ export const queryMethods = {
|
|
|
76
75
|
return this.getJobs({ state: 'waiting-children', start, end });
|
|
77
76
|
},
|
|
78
77
|
async getJobState(id) {
|
|
79
|
-
|
|
80
|
-
return String(response.state);
|
|
78
|
+
return (await this.call({ cmd: 'GetState', id })).state;
|
|
81
79
|
},
|
|
82
80
|
async getResult(id) {
|
|
83
|
-
|
|
84
|
-
return response.result;
|
|
81
|
+
return (await this.call({ cmd: 'GetResult', id })).result;
|
|
85
82
|
},
|
|
86
83
|
async getChildrenValues(id) {
|
|
87
84
|
return unwrapValues(await this.call({ cmd: 'GetChildrenValues', id }));
|
|
@@ -130,15 +127,11 @@ export const queryMethods = {
|
|
|
130
127
|
},
|
|
131
128
|
async getProgress(id) {
|
|
132
129
|
const response = await this.call({ cmd: 'GetProgress', id });
|
|
133
|
-
return {
|
|
134
|
-
progress: Number(response.progress ?? 0),
|
|
135
|
-
message: response.message ?? null,
|
|
136
|
-
};
|
|
130
|
+
return { progress: response.progress ?? 0, message: response.message ?? null };
|
|
137
131
|
},
|
|
138
132
|
// ------------------------------------------------------------------- counts
|
|
139
133
|
async getJobCounts() {
|
|
140
|
-
|
|
141
|
-
return response.counts;
|
|
134
|
+
return (await this.call({ cmd: 'GetJobCounts', queue: this.name })).counts;
|
|
142
135
|
},
|
|
143
136
|
async getWaitingCount() {
|
|
144
137
|
// 'waiting' only — prioritized jobs are counted by getPrioritizedCount,
|
|
@@ -164,8 +157,7 @@ export const queryMethods = {
|
|
|
164
157
|
return (await this.getJobCounts())['waiting-children'];
|
|
165
158
|
},
|
|
166
159
|
async count() {
|
|
167
|
-
|
|
168
|
-
return Number(response.count ?? 0);
|
|
160
|
+
return (await this.call({ cmd: 'Count', queue: this.name })).count ?? 0;
|
|
169
161
|
},
|
|
170
162
|
async getCountsPerPriority() {
|
|
171
163
|
const response = await this.call({ cmd: 'GetCountsPerPriority', queue: this.name });
|
package/dist/queue.d.ts
CHANGED
|
@@ -9,18 +9,27 @@
|
|
|
9
9
|
* exposes them on the type.
|
|
10
10
|
*/
|
|
11
11
|
import { Connection, type Response, type TlsOption } from './connection.js';
|
|
12
|
+
import type { ConnectionLike } from './connection-types.js';
|
|
12
13
|
import { Job } from './job.js';
|
|
14
|
+
import type { Observability } from './observability.js';
|
|
13
15
|
import { type QueueAdminApi } from './queue-admin.js';
|
|
14
16
|
import { type QueueControlApi } from './queue-control.js';
|
|
15
17
|
import { type QueueQueryApi } from './queue-query.js';
|
|
16
18
|
import { type JobOptions } from './types.js';
|
|
17
|
-
export interface QueueOptions {
|
|
19
|
+
export interface QueueOptions extends Observability {
|
|
18
20
|
host?: string;
|
|
19
21
|
port?: number;
|
|
20
22
|
token?: string;
|
|
21
23
|
tls?: TlsOption;
|
|
22
24
|
connection?: Connection;
|
|
23
25
|
commandTimeoutMs?: number;
|
|
26
|
+
/** Max in-flight commands before backpressure kicks in (0 = unbounded). */
|
|
27
|
+
maxInFlight?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Fan producer commands across N connections (round-robin) for throughput.
|
|
30
|
+
* >1 builds a ConnectionPool; default 1 = a single connection.
|
|
31
|
+
*/
|
|
32
|
+
poolSize?: number;
|
|
24
33
|
}
|
|
25
34
|
export interface BulkJobEntry<T = unknown> {
|
|
26
35
|
name: string;
|
|
@@ -29,13 +38,13 @@ export interface BulkJobEntry<T = unknown> {
|
|
|
29
38
|
}
|
|
30
39
|
export declare class Queue<T = unknown> {
|
|
31
40
|
readonly name: string;
|
|
32
|
-
readonly connection:
|
|
41
|
+
readonly connection: ConnectionLike;
|
|
33
42
|
private readonly ownsConnection;
|
|
34
43
|
constructor(name: string, opts?: QueueOptions);
|
|
35
44
|
/** Send a raw command on this queue's connection (used by area modules). */
|
|
36
|
-
call(command: Record<string, unknown> & {
|
|
45
|
+
call<R = Response>(command: Record<string, unknown> & {
|
|
37
46
|
cmd: string;
|
|
38
|
-
}, timeoutMs?: number): Promise<
|
|
47
|
+
}, timeoutMs?: number): Promise<R>;
|
|
39
48
|
/** Add a job; returns a Job stub carrying the assigned id. */
|
|
40
49
|
add(name: string, data: T, opts?: JobOptions): Promise<Job<T>>;
|
|
41
50
|
/** Add many jobs in one round-trip; returns Job stubs. */
|
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). */
|
|
@@ -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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import { Connection } from './connection.js';
|
|
7
|
-
import { type WorkerOptions } from './worker-types.js';
|
|
8
|
-
export declare class WorkerBase extends EventEmitter {
|
|
7
|
+
import { type WorkerEventMap, type WorkerOptions } from './worker-types.js';
|
|
8
|
+
export declare class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
|
|
9
9
|
readonly queue: string;
|
|
10
10
|
readonly concurrency: number;
|
|
11
11
|
readonly batchSize: number;
|
|
@@ -41,9 +41,16 @@ export declare class WorkerBase extends EventEmitter {
|
|
|
41
41
|
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
42
42
|
* loop starts inside the constructor, so a plain once-only event could be
|
|
43
43
|
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
44
|
+
*
|
|
45
|
+
* The overloads give the known worker events typed parameters (see
|
|
46
|
+
* WorkerEventMap); unknown event names keep the generic signature.
|
|
44
47
|
*/
|
|
48
|
+
on<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
45
49
|
on(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
50
|
+
once<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
46
51
|
once(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
52
|
+
off<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
53
|
+
off(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
47
54
|
/**
|
|
48
55
|
* Cooperative cancel of a locally active job (mirrors the official client):
|
|
49
56
|
* marks the job and emits 'cancelled'; the processor is expected to check
|
|
@@ -56,7 +63,12 @@ export declare class WorkerBase extends EventEmitter {
|
|
|
56
63
|
* With `force` the wait for in-flight jobs is skipped (parity with the
|
|
57
64
|
* official client's `close(force)`). */
|
|
58
65
|
close(force?: boolean): Promise<void>;
|
|
66
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
67
|
+
* command reached the server — callers gate success-only side effects
|
|
68
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
59
69
|
protected safeCall(command: Record<string, unknown> & {
|
|
60
70
|
cmd: string;
|
|
61
|
-
}): Promise<
|
|
71
|
+
}): Promise<boolean>;
|
|
72
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
73
|
+
protected beforeClose(): Promise<void>;
|
|
62
74
|
}
|
package/dist/worker-base.js
CHANGED
|
@@ -6,7 +6,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
6
6
|
import { EventEmitter } from 'node:events';
|
|
7
7
|
import { hostname } from 'node:os';
|
|
8
8
|
import { Connection } from './connection.js';
|
|
9
|
-
import { MAX_POLL_TIMEOUT_MS, sleep } from './worker-types.js';
|
|
9
|
+
import { MAX_POLL_TIMEOUT_MS, sleep, } from './worker-types.js';
|
|
10
10
|
export class WorkerBase extends EventEmitter {
|
|
11
11
|
queue;
|
|
12
12
|
concurrency;
|
|
@@ -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;
|
|
@@ -72,11 +74,6 @@ export class WorkerBase extends EventEmitter {
|
|
|
72
74
|
async waitUntilReady() {
|
|
73
75
|
await this.readyPromise;
|
|
74
76
|
}
|
|
75
|
-
/**
|
|
76
|
-
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
77
|
-
* loop starts inside the constructor, so a plain once-only event could be
|
|
78
|
-
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
79
|
-
*/
|
|
80
77
|
on(event, listener) {
|
|
81
78
|
if (event === 'ready' && this.readyFired)
|
|
82
79
|
listener();
|
|
@@ -89,6 +86,9 @@ export class WorkerBase extends EventEmitter {
|
|
|
89
86
|
}
|
|
90
87
|
return super.once(event, listener);
|
|
91
88
|
}
|
|
89
|
+
off(event, listener) {
|
|
90
|
+
return super.off(event, listener);
|
|
91
|
+
}
|
|
92
92
|
/**
|
|
93
93
|
* Cooperative cancel of a locally active job (mirrors the official client):
|
|
94
94
|
* marks the job and emits 'cancelled'; the processor is expected to check
|
|
@@ -120,6 +120,7 @@ export class WorkerBase extends EventEmitter {
|
|
|
120
120
|
this.stopped = true;
|
|
121
121
|
if (this.loopPromise)
|
|
122
122
|
await this.loopPromise;
|
|
123
|
+
await this.beforeClose(); // flush any batched ACKs before draining
|
|
123
124
|
while (!force && this.active.size > 0)
|
|
124
125
|
await sleep(20);
|
|
125
126
|
if (this.heartbeatTimer) {
|
|
@@ -136,12 +137,19 @@ export class WorkerBase extends EventEmitter {
|
|
|
136
137
|
this.running = false;
|
|
137
138
|
this.emit('closed');
|
|
138
139
|
}
|
|
140
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
141
|
+
* command reached the server — callers gate success-only side effects
|
|
142
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
139
143
|
async safeCall(command) {
|
|
140
144
|
try {
|
|
141
145
|
await this.connection.call(command);
|
|
146
|
+
return true;
|
|
142
147
|
}
|
|
143
148
|
catch (err) {
|
|
144
|
-
this.emit('error', err);
|
|
149
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
150
|
+
return false;
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
154
|
+
async beforeClose() { }
|
|
147
155
|
}
|
package/dist/worker-types.d.ts
CHANGED
|
@@ -1,12 +1,54 @@
|
|
|
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
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Typed Worker event map: listeners registered via `on`/`once`/`off` for these
|
|
8
|
+
* names get typed job/result/error parameters in strict mode. Unknown event
|
|
9
|
+
* names fall back to a generic `(...args: unknown[])` overload.
|
|
10
|
+
*/
|
|
11
|
+
export interface WorkerEventMap<T = unknown, R = unknown> {
|
|
12
|
+
/** Worker registered and pull loop started (replayed to late listeners). */
|
|
13
|
+
ready: () => void;
|
|
14
|
+
/** A job was pulled and handed to the processor. */
|
|
15
|
+
active: (job: Job<T>) => void;
|
|
16
|
+
/** Processor resolved AND the ACK reached the server. */
|
|
17
|
+
completed: (job: Job<T>, result: R) => void;
|
|
18
|
+
/** Processor threw AND the FAIL reached the server. */
|
|
19
|
+
failed: (job: Job<T>, error: Error) => void;
|
|
20
|
+
/** job.updateProgress() was called from the processor. */
|
|
21
|
+
progress: (job: Job<T>, progress: number) => void;
|
|
22
|
+
/** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
|
|
23
|
+
error: (error: Error) => void;
|
|
24
|
+
/** The queue went from busy to empty (no active jobs, nothing pulled). */
|
|
25
|
+
drained: () => void;
|
|
26
|
+
/** Cooperative cancel was requested for a locally active job. */
|
|
27
|
+
cancelled: (info: {
|
|
28
|
+
jobId: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}) => void;
|
|
31
|
+
/** close() finished. */
|
|
32
|
+
closed: () => void;
|
|
33
|
+
}
|
|
34
|
+
export interface AckBatchOptions {
|
|
35
|
+
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
/** Max ACKs per batch (default 50). */
|
|
38
|
+
maxSize?: number;
|
|
39
|
+
/** Max ms to hold a partial batch before flushing (default 5). */
|
|
40
|
+
maxDelayMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface WorkerOptions extends Observability {
|
|
6
43
|
host?: string;
|
|
7
44
|
port?: number;
|
|
8
45
|
token?: string;
|
|
9
46
|
tls?: TlsOption;
|
|
47
|
+
/**
|
|
48
|
+
* Batch completed-job ACKs into ACKB commands for higher throughput under
|
|
49
|
+
* load. Opt-in: the default (individual ACK per job) is unchanged.
|
|
50
|
+
*/
|
|
51
|
+
ackBatch?: AckBatchOptions;
|
|
10
52
|
/** Max jobs processed in parallel (default 4). */
|
|
11
53
|
concurrency?: number;
|
|
12
54
|
/** Max jobs fetched per PULLB (default 10, capped by free slots). */
|
package/dist/worker.d.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { WorkerBase } from './worker-base.js';
|
|
9
9
|
import { type Processor, type WorkerOptions } from './worker-types.js';
|
|
10
|
-
export declare class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
10
|
+
export declare class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
|
|
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,19 +14,29 @@ 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)
|
|
25
36
|
return;
|
|
26
37
|
this.running = true;
|
|
27
38
|
this.loopPromise = this.loop().catch((err) => {
|
|
28
|
-
this.emit('error', err);
|
|
39
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
// -------------------------------------------------------------------- loop
|
|
@@ -46,7 +57,7 @@ export class Worker extends WorkerBase {
|
|
|
46
57
|
backoffIdx = 0;
|
|
47
58
|
}
|
|
48
59
|
catch (err) {
|
|
49
|
-
this.emit('error', err);
|
|
60
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
50
61
|
if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
|
|
51
62
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
52
63
|
backoffIdx += 1;
|
|
@@ -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,17 +111,45 @@ export class Worker extends WorkerBase {
|
|
|
100
111
|
this.emit('active', job);
|
|
101
112
|
try {
|
|
102
113
|
const result = await this.processor(job);
|
|
103
|
-
this.
|
|
104
|
-
|
|
105
|
-
|
|
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 instanceof Error ? err : new Error(String(err)));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.processed += 1;
|
|
130
|
+
this.emit('completed', job, result);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const acked = await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
|
|
137
|
+
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
138
|
+
// not leak the active slot (same rationale as the batched path).
|
|
139
|
+
this.finishJob(job.id);
|
|
140
|
+
// Mirror the batched path: a failed ACK already emitted 'error' — do not
|
|
141
|
+
// also claim completion (no 'completed', no processed++).
|
|
142
|
+
if (acked) {
|
|
143
|
+
this.processed += 1;
|
|
144
|
+
this.emit('completed', job, result);
|
|
145
|
+
}
|
|
106
146
|
}
|
|
107
147
|
catch (err) {
|
|
108
|
-
this.failedCount += 1;
|
|
109
148
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
110
149
|
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
111
150
|
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
112
151
|
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
113
|
-
await this.safeCall(compact({
|
|
152
|
+
const failed = await this.safeCall(compact({
|
|
114
153
|
cmd: 'FAIL',
|
|
115
154
|
id: job.id,
|
|
116
155
|
token,
|
|
@@ -118,13 +157,19 @@ export class Worker extends WorkerBase {
|
|
|
118
157
|
stack,
|
|
119
158
|
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
120
159
|
}));
|
|
121
|
-
this.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
160
|
+
this.finishJob(job.id);
|
|
161
|
+
// Same asymmetry guard as the ACK path: if the FAIL never reached the
|
|
162
|
+
// server, only 'error' fires (the lock expiry will retry the job).
|
|
163
|
+
if (failed) {
|
|
164
|
+
this.failedCount += 1;
|
|
165
|
+
this.emit('failed', job, error);
|
|
166
|
+
}
|
|
126
167
|
}
|
|
127
168
|
}
|
|
169
|
+
finishJob(id) {
|
|
170
|
+
this.active.delete(id);
|
|
171
|
+
this.cancelledJobs.delete(id);
|
|
172
|
+
}
|
|
128
173
|
// --------------------------------------------------------------- heartbeat
|
|
129
174
|
startHeartbeat() {
|
|
130
175
|
this.heartbeatTimer = setInterval(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
},
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"prepublishOnly": "bun run build",
|
|
27
29
|
"test": "bun tests/e2e.ts",
|
|
28
30
|
"test:integration": "bun tests/integration.ts",
|
|
29
31
|
"lint": "biome lint src tests",
|