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/src/queue-query.ts
CHANGED
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
* Methods are merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { CommandError } from './errors.js';
|
|
6
|
+
import { CommandError, CommandTimeoutError } from './errors.js';
|
|
7
7
|
import { compact } from './frame.js';
|
|
8
8
|
import { Job } from './job.js';
|
|
9
9
|
import type { Queue } from './queue.js';
|
|
10
|
+
import type {
|
|
11
|
+
CountResponse,
|
|
12
|
+
DataResponse,
|
|
13
|
+
JobCountsResponse,
|
|
14
|
+
JobResponse,
|
|
15
|
+
JobsResponse,
|
|
16
|
+
ProgressResponse,
|
|
17
|
+
ResultResponse,
|
|
18
|
+
StateResponse,
|
|
19
|
+
WaitJobResponse,
|
|
20
|
+
} from './responses.js';
|
|
10
21
|
import type { JobCounts } from './types.js';
|
|
11
22
|
|
|
12
23
|
type Ctx = Queue<unknown>;
|
|
13
24
|
type Raw = Record<string, unknown>;
|
|
14
25
|
|
|
15
|
-
function unwrapValues<R>(response:
|
|
26
|
+
function unwrapValues<R>(response: { data?: unknown; values?: unknown }): Record<string, R> {
|
|
16
27
|
const data = (response.data ?? {}) as Raw;
|
|
17
28
|
return (data.values ?? response.values ?? data ?? {}) as Record<string, R>;
|
|
18
29
|
}
|
|
@@ -20,9 +31,8 @@ function unwrapValues<R>(response: Raw): Record<string, R> {
|
|
|
20
31
|
export const queryMethods = {
|
|
21
32
|
async getJob<T = unknown>(this: Ctx, id: string): Promise<Job<T> | null> {
|
|
22
33
|
try {
|
|
23
|
-
const response = await this.call({ cmd: 'GetJob', id });
|
|
24
|
-
|
|
25
|
-
return raw ? new Job<T>(raw, this.connection) : null;
|
|
34
|
+
const response = await this.call<JobResponse>({ cmd: 'GetJob', id });
|
|
35
|
+
return response.job ? new Job<T>(response.job, this.connection) : null;
|
|
26
36
|
} catch (err) {
|
|
27
37
|
if (err instanceof CommandError) return null; // server: 'Job not found'
|
|
28
38
|
throw err;
|
|
@@ -31,9 +41,8 @@ export const queryMethods = {
|
|
|
31
41
|
|
|
32
42
|
async getJobByCustomId<T = unknown>(this: Ctx, customId: string): Promise<Job<T> | null> {
|
|
33
43
|
try {
|
|
34
|
-
const response = await this.call({ cmd: 'GetJobByCustomId', customId });
|
|
35
|
-
|
|
36
|
-
return raw ? new Job<T>(raw, this.connection) : null;
|
|
44
|
+
const response = await this.call<JobResponse>({ cmd: 'GetJobByCustomId', customId });
|
|
45
|
+
return response.job ? new Job<T>(response.job, this.connection) : null;
|
|
37
46
|
} catch (err) {
|
|
38
47
|
if (err instanceof CommandError) return null;
|
|
39
48
|
throw err;
|
|
@@ -52,7 +61,7 @@ export const queryMethods = {
|
|
|
52
61
|
): Promise<Job<T>[]> {
|
|
53
62
|
const start = opts.start ?? 0;
|
|
54
63
|
const end = opts.end !== undefined && opts.end >= 0 ? opts.end : 1000;
|
|
55
|
-
const response = await this.call(
|
|
64
|
+
const response = await this.call<JobsResponse>(
|
|
56
65
|
compact({
|
|
57
66
|
cmd: 'GetJobs',
|
|
58
67
|
queue: this.name,
|
|
@@ -61,12 +70,13 @@ export const queryMethods = {
|
|
|
61
70
|
limit: end - start,
|
|
62
71
|
}) as { cmd: string }
|
|
63
72
|
);
|
|
64
|
-
|
|
65
|
-
return jobs.map((raw) => new Job<T>(raw, this.connection));
|
|
73
|
+
return (response.jobs ?? []).map((raw) => new Job<T>(raw, this.connection));
|
|
66
74
|
},
|
|
67
75
|
|
|
68
76
|
getWaiting<T = unknown>(this: Ctx, start?: number, end?: number): Promise<Job<T>[]> {
|
|
69
|
-
|
|
77
|
+
// Only the 'waiting' bucket — prioritized jobs live in a separate bucket
|
|
78
|
+
// (getPrioritized), matching BullMQ and the Python SDK / reference client.
|
|
79
|
+
return this.getJobs({ state: 'waiting', start, end });
|
|
70
80
|
},
|
|
71
81
|
|
|
72
82
|
getDelayed<T = unknown>(this: Ctx, start?: number, end?: number): Promise<Job<T>[]> {
|
|
@@ -94,25 +104,27 @@ export const queryMethods = {
|
|
|
94
104
|
},
|
|
95
105
|
|
|
96
106
|
async getJobState(this: Ctx, id: string): Promise<string> {
|
|
97
|
-
|
|
98
|
-
return String(response.state);
|
|
107
|
+
return (await this.call<StateResponse>({ cmd: 'GetState', id })).state;
|
|
99
108
|
},
|
|
100
109
|
|
|
101
110
|
async getResult<R = unknown>(this: Ctx, id: string): Promise<R> {
|
|
102
|
-
|
|
103
|
-
return response.result as R;
|
|
111
|
+
return (await this.call<ResultResponse<R>>({ cmd: 'GetResult', id })).result;
|
|
104
112
|
},
|
|
105
113
|
|
|
106
114
|
async getChildrenValues<R = unknown>(this: Ctx, id: string): Promise<Record<string, R>> {
|
|
107
|
-
return unwrapValues<R>(await this.call({ cmd: 'GetChildrenValues', id }));
|
|
115
|
+
return unwrapValues<R>(await this.call<DataResponse>({ cmd: 'GetChildrenValues', id }));
|
|
108
116
|
},
|
|
109
117
|
|
|
110
118
|
async getFailedChildrenValues(this: Ctx, id: string): Promise<Record<string, string>> {
|
|
111
|
-
return unwrapValues<string>(
|
|
119
|
+
return unwrapValues<string>(
|
|
120
|
+
await this.call<DataResponse>({ cmd: 'GetFailedChildrenValues', id })
|
|
121
|
+
);
|
|
112
122
|
},
|
|
113
123
|
|
|
114
124
|
async getIgnoredChildrenFailures(this: Ctx, id: string): Promise<Record<string, string>> {
|
|
115
|
-
return unwrapValues<string>(
|
|
125
|
+
return unwrapValues<string>(
|
|
126
|
+
await this.call<DataResponse>({ cmd: 'GetIgnoredChildrenFailures', id })
|
|
127
|
+
);
|
|
116
128
|
},
|
|
117
129
|
|
|
118
130
|
/** Detach a child job from its parent's dependency list. */
|
|
@@ -125,9 +137,29 @@ export const queryMethods = {
|
|
|
125
137
|
await this.call({ cmd: 'RemoveUnprocessedChildren', id });
|
|
126
138
|
},
|
|
127
139
|
|
|
128
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Block until the job completes; returns its result.
|
|
142
|
+
* The server's WaitJob waiter resolves only on completion, replying
|
|
143
|
+
* `{ok:true, completed:false}` (no result) otherwise — so returning undefined
|
|
144
|
+
* would be indistinguishable from a genuine undefined result. On
|
|
145
|
+
* non-completion we probe the state: a `failed` job throws CommandError (it
|
|
146
|
+
* will not complete), everything else throws CommandTimeoutError.
|
|
147
|
+
*/
|
|
129
148
|
async waitForJob<R = unknown>(this: Ctx, id: string, ttlMs = 30_000): Promise<R> {
|
|
130
|
-
const response = await this.call(
|
|
149
|
+
const response = await this.call<WaitJobResponse<R>>(
|
|
150
|
+
{ cmd: 'WaitJob', id, timeout: ttlMs },
|
|
151
|
+
ttlMs + 5000
|
|
152
|
+
);
|
|
153
|
+
if (response.completed !== true) {
|
|
154
|
+
let state: string | undefined;
|
|
155
|
+
try {
|
|
156
|
+
state = await this.getJobState(id);
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore probe failure; fall through to timeout */
|
|
159
|
+
}
|
|
160
|
+
if (state === 'failed') throw new CommandError(`job ${id} failed before completion`);
|
|
161
|
+
throw new CommandTimeoutError(`waitUntilFinished timed out after ${ttlMs}ms`);
|
|
162
|
+
}
|
|
131
163
|
return response.result as R;
|
|
132
164
|
},
|
|
133
165
|
|
|
@@ -142,23 +174,20 @@ export const queryMethods = {
|
|
|
142
174
|
},
|
|
143
175
|
|
|
144
176
|
async getProgress(this: Ctx, id: string): Promise<{ progress: number; message: string | null }> {
|
|
145
|
-
const response = await this.call({ cmd: 'GetProgress', id });
|
|
146
|
-
return {
|
|
147
|
-
progress: Number(response.progress ?? 0),
|
|
148
|
-
message: (response.message as string | null) ?? null,
|
|
149
|
-
};
|
|
177
|
+
const response = await this.call<ProgressResponse>({ cmd: 'GetProgress', id });
|
|
178
|
+
return { progress: response.progress ?? 0, message: response.message ?? null };
|
|
150
179
|
},
|
|
151
180
|
|
|
152
181
|
// ------------------------------------------------------------------- counts
|
|
153
182
|
|
|
154
183
|
async getJobCounts(this: Ctx): Promise<JobCounts> {
|
|
155
|
-
|
|
156
|
-
return response.counts as JobCounts;
|
|
184
|
+
return (await this.call<JobCountsResponse>({ cmd: 'GetJobCounts', queue: this.name })).counts;
|
|
157
185
|
},
|
|
158
186
|
|
|
159
187
|
async getWaitingCount(this: Ctx): Promise<number> {
|
|
160
|
-
|
|
161
|
-
|
|
188
|
+
// 'waiting' only — prioritized jobs are counted by getPrioritizedCount,
|
|
189
|
+
// matching BullMQ and the Python SDK / reference client.
|
|
190
|
+
return (await this.getJobCounts()).waiting;
|
|
162
191
|
},
|
|
163
192
|
|
|
164
193
|
async getActiveCount(this: Ctx): Promise<number> {
|
|
@@ -186,8 +215,7 @@ export const queryMethods = {
|
|
|
186
215
|
},
|
|
187
216
|
|
|
188
217
|
async count(this: Ctx): Promise<number> {
|
|
189
|
-
|
|
190
|
-
return Number(response.count ?? 0);
|
|
218
|
+
return (await this.call<CountResponse>({ cmd: 'Count', queue: this.name })).count ?? 0;
|
|
191
219
|
},
|
|
192
220
|
|
|
193
221
|
async getCountsPerPriority(this: Ctx): Promise<Record<string, number>> {
|
|
@@ -197,8 +225,13 @@ export const queryMethods = {
|
|
|
197
225
|
|
|
198
226
|
// --------------------------------------------------------------------- logs
|
|
199
227
|
|
|
200
|
-
async addJobLog(
|
|
201
|
-
|
|
228
|
+
async addJobLog(
|
|
229
|
+
this: Ctx,
|
|
230
|
+
id: string,
|
|
231
|
+
message: string,
|
|
232
|
+
level?: 'info' | 'warn' | 'error'
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
await this.call(compact({ cmd: 'AddLog', id, message, level }) as { cmd: string });
|
|
202
235
|
},
|
|
203
236
|
|
|
204
237
|
async getJobLogs(this: Ctx, id: string, start?: number, end?: number): Promise<string[]> {
|
|
@@ -209,7 +242,12 @@ export const queryMethods = {
|
|
|
209
242
|
);
|
|
210
243
|
const data = (response.data ?? {}) as Raw;
|
|
211
244
|
const logs = (data.logs ?? response.logs ?? []) as unknown[];
|
|
212
|
-
|
|
245
|
+
// Format as `[level] message` (reference client parity); never drop level.
|
|
246
|
+
return logs.map((row) => {
|
|
247
|
+
if (typeof row === 'string') return row;
|
|
248
|
+
const r = row as Raw;
|
|
249
|
+
return r.level ? `[${r.level}] ${r.message}` : String(r.message ?? row);
|
|
250
|
+
});
|
|
213
251
|
},
|
|
214
252
|
|
|
215
253
|
async clearJobLogs(this: Ctx, id: string, keepLogs?: number): Promise<void> {
|
package/src/queue.ts
CHANGED
|
@@ -10,19 +10,29 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Connection, type Response, type TlsOption } from './connection.js';
|
|
13
|
+
import { ConnectionPool } from './connection-pool.js';
|
|
14
|
+
import type { ConnectionLike } from './connection-types.js';
|
|
13
15
|
import { Job } from './job.js';
|
|
16
|
+
import type { Observability } from './observability.js';
|
|
14
17
|
import { adminMethods, type QueueAdminApi } from './queue-admin.js';
|
|
15
18
|
import { controlMethods, type QueueControlApi } from './queue-control.js';
|
|
16
19
|
import { type QueueQueryApi, queryMethods } from './queue-query.js';
|
|
17
20
|
import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
|
|
18
21
|
|
|
19
|
-
export interface QueueOptions {
|
|
22
|
+
export interface QueueOptions extends Observability {
|
|
20
23
|
host?: string;
|
|
21
24
|
port?: number;
|
|
22
25
|
token?: string;
|
|
23
26
|
tls?: TlsOption;
|
|
24
27
|
connection?: Connection;
|
|
25
28
|
commandTimeoutMs?: number;
|
|
29
|
+
/** Max in-flight commands before backpressure kicks in (0 = unbounded). */
|
|
30
|
+
maxInFlight?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Fan producer commands across N connections (round-robin) for throughput.
|
|
33
|
+
* >1 builds a ConnectionPool; default 1 = a single connection.
|
|
34
|
+
*/
|
|
35
|
+
poolSize?: number;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export interface BulkJobEntry<T = unknown> {
|
|
@@ -34,26 +44,35 @@ export interface BulkJobEntry<T = unknown> {
|
|
|
34
44
|
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: prototype-mixin composition — Object.assign at the bottom installs exactly the methods the merged interface declares
|
|
35
45
|
export class Queue<T = unknown> {
|
|
36
46
|
readonly name: string;
|
|
37
|
-
readonly connection:
|
|
47
|
+
readonly connection: ConnectionLike;
|
|
38
48
|
private readonly ownsConnection: boolean;
|
|
39
49
|
|
|
40
50
|
constructor(name: string, opts: QueueOptions = {}) {
|
|
41
51
|
this.name = name;
|
|
52
|
+
const connOptions = {
|
|
53
|
+
host: opts.host,
|
|
54
|
+
port: opts.port,
|
|
55
|
+
token: opts.token,
|
|
56
|
+
tls: opts.tls,
|
|
57
|
+
commandTimeoutMs: opts.commandTimeoutMs,
|
|
58
|
+
maxInFlight: opts.maxInFlight,
|
|
59
|
+
logger: opts.logger,
|
|
60
|
+
onTelemetry: opts.onTelemetry,
|
|
61
|
+
};
|
|
42
62
|
this.connection =
|
|
43
63
|
opts.connection ??
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
token: opts.token,
|
|
48
|
-
tls: opts.tls,
|
|
49
|
-
commandTimeoutMs: opts.commandTimeoutMs,
|
|
50
|
-
});
|
|
64
|
+
(opts.poolSize && opts.poolSize > 1
|
|
65
|
+
? new ConnectionPool(opts.poolSize, connOptions)
|
|
66
|
+
: new Connection(connOptions));
|
|
51
67
|
this.ownsConnection = opts.connection === undefined;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
/** Send a raw command on this queue's connection (used by area modules). */
|
|
55
|
-
call
|
|
56
|
-
|
|
71
|
+
call<R = Response>(
|
|
72
|
+
command: Record<string, unknown> & { cmd: string },
|
|
73
|
+
timeoutMs?: number
|
|
74
|
+
): Promise<R> {
|
|
75
|
+
return this.connection.call<R>(command, timeoutMs);
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
// ------------------------------------------------------------------ produce
|
|
@@ -72,10 +91,18 @@ export class Queue<T = unknown> {
|
|
|
72
91
|
|
|
73
92
|
/** Add many jobs in one round-trip; returns Job stubs. */
|
|
74
93
|
async addBulk(jobs: BulkJobEntry<T>[]): Promise<Job<T>[]> {
|
|
75
|
-
const inputs = jobs.map((entry) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
const inputs = jobs.map((entry) => {
|
|
95
|
+
const opts = wireJobOptions(entry.opts);
|
|
96
|
+
// PUSHB entries are JobInput, whose custom-id field is `customId` —
|
|
97
|
+
// unlike single PUSH which renames `jobId`->`customId` server-side.
|
|
98
|
+
// Without this the batch custom id is silently dropped (idempotency /
|
|
99
|
+
// getJobByCustomId broken).
|
|
100
|
+
if (opts.jobId !== undefined) {
|
|
101
|
+
opts.customId = opts.jobId;
|
|
102
|
+
delete opts.jobId;
|
|
103
|
+
}
|
|
104
|
+
return { data: jobPayload(entry.name, entry.data), ...opts };
|
|
105
|
+
});
|
|
79
106
|
const response = await this.call({ cmd: 'PUSHB', queue: this.name, jobs: inputs });
|
|
80
107
|
const ids = (response.ids ?? []) as string[];
|
|
81
108
|
return ids.map(
|
package/src/responses.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
|
11
|
+
import type { JobRaw } from './job.js';
|
|
12
|
+
import type { JobCounts, JobStateName } from './types.js';
|
|
13
|
+
|
|
14
|
+
interface Ok {
|
|
15
|
+
ok: true;
|
|
16
|
+
reqId?: string;
|
|
17
|
+
// Index signature so these satisfy the loose transport `Response`
|
|
18
|
+
// (Record<string, unknown> & { ok }). Named fields still win for reads.
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** PUSH → id, or a bare ok ack. */
|
|
23
|
+
export interface OkResponse extends Ok {
|
|
24
|
+
id?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PUSHB → ids. */
|
|
28
|
+
export interface BatchResponse extends Ok {
|
|
29
|
+
ids: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** GetJob → job (null when not found is surfaced as a CommandError instead). */
|
|
33
|
+
export interface JobResponse extends Ok {
|
|
34
|
+
job: JobRaw | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PULL → job + lock token. */
|
|
38
|
+
export interface PulledJobResponse extends Ok {
|
|
39
|
+
job: JobRaw | null;
|
|
40
|
+
token: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** PULLB → jobs + lock tokens (same order). */
|
|
44
|
+
export interface PulledJobsResponse extends Ok {
|
|
45
|
+
jobs: JobRaw[];
|
|
46
|
+
tokens: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GetJobs → jobs. */
|
|
50
|
+
export interface JobsResponse extends Ok {
|
|
51
|
+
jobs: JobRaw[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** GetState → state. */
|
|
55
|
+
export interface StateResponse extends Ok {
|
|
56
|
+
id: string;
|
|
57
|
+
state: JobStateName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** GetResult → result. */
|
|
61
|
+
export interface ResultResponse<R = unknown> extends Ok {
|
|
62
|
+
result: R;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** WaitJob → completed flag + (on completion) result. */
|
|
66
|
+
export interface WaitJobResponse<R = unknown> extends Ok {
|
|
67
|
+
completed: boolean;
|
|
68
|
+
result?: R;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** GetJobCounts → counts. */
|
|
72
|
+
export interface JobCountsResponse extends Ok {
|
|
73
|
+
counts: JobCounts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** GetProgress → progress + message. */
|
|
77
|
+
export interface ProgressResponse extends Ok {
|
|
78
|
+
progress: number;
|
|
79
|
+
message: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** IsPaused → paused. */
|
|
83
|
+
export interface PausedResponse extends Ok {
|
|
84
|
+
paused: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Count / Clean / PromoteJobs / RetryDlq → count (+ optional removed ids). */
|
|
88
|
+
export interface CountResponse extends Ok {
|
|
89
|
+
count: number;
|
|
90
|
+
ids?: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Generic data-wrapped payload (logs, workers, values, webhookId, …). */
|
|
94
|
+
export interface DataResponse<T = unknown> extends Ok {
|
|
95
|
+
data: T;
|
|
96
|
+
}
|
package/src/worker-base.ts
CHANGED
|
@@ -52,6 +52,8 @@ export class WorkerBase extends EventEmitter {
|
|
|
52
52
|
port: opts.port,
|
|
53
53
|
token: opts.token,
|
|
54
54
|
tls: opts.tls,
|
|
55
|
+
logger: opts.logger,
|
|
56
|
+
onTelemetry: opts.onTelemetry,
|
|
55
57
|
});
|
|
56
58
|
this.readyPromise = new Promise((resolve) => {
|
|
57
59
|
this.readyResolve = resolve;
|
|
@@ -132,6 +134,7 @@ export class WorkerBase extends EventEmitter {
|
|
|
132
134
|
if (this.closedFlag) return;
|
|
133
135
|
this.stopped = true;
|
|
134
136
|
if (this.loopPromise) await this.loopPromise;
|
|
137
|
+
await this.beforeClose(); // flush any batched ACKs before draining
|
|
135
138
|
while (!force && this.active.size > 0) await sleep(20);
|
|
136
139
|
if (this.heartbeatTimer) {
|
|
137
140
|
clearInterval(this.heartbeatTimer);
|
|
@@ -155,4 +158,7 @@ export class WorkerBase extends EventEmitter {
|
|
|
155
158
|
this.emit('error', err);
|
|
156
159
|
}
|
|
157
160
|
}
|
|
161
|
+
|
|
162
|
+
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
163
|
+
protected async beforeClose(): Promise<void> {}
|
|
158
164
|
}
|
package/src/worker-types.ts
CHANGED
|
@@ -2,14 +2,29 @@
|
|
|
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
|
-
export interface
|
|
9
|
+
export interface AckBatchOptions {
|
|
10
|
+
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Max ACKs per batch (default 50). */
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
/** Max ms to hold a partial batch before flushing (default 5). */
|
|
15
|
+
maxDelayMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkerOptions extends Observability {
|
|
9
19
|
host?: string;
|
|
10
20
|
port?: number;
|
|
11
21
|
token?: string;
|
|
12
22
|
tls?: TlsOption;
|
|
23
|
+
/**
|
|
24
|
+
* Batch completed-job ACKs into ACKB commands for higher throughput under
|
|
25
|
+
* load. Opt-in: the default (individual ACK per job) is unchanged.
|
|
26
|
+
*/
|
|
27
|
+
ackBatch?: AckBatchOptions;
|
|
13
28
|
/** Max jobs processed in parallel (default 4). */
|
|
14
29
|
concurrency?: number;
|
|
15
30
|
/** 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,
|
|
@@ -21,13 +23,23 @@ import {
|
|
|
21
23
|
|
|
22
24
|
export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
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;
|
|
@@ -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,15 +131,41 @@ 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);
|
|
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);
|
|
147
|
+
} else {
|
|
148
|
+
this.processed += 1;
|
|
149
|
+
this.emit('completed', job, result);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
122
155
|
this.processed += 1;
|
|
123
156
|
await this.safeCall(
|
|
124
157
|
compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }) as { cmd: string }
|
|
125
158
|
);
|
|
159
|
+
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
160
|
+
// not leak the active slot (same rationale as the batched path).
|
|
161
|
+
this.finishJob(job.id);
|
|
126
162
|
this.emit('completed', job, result);
|
|
127
163
|
} catch (err) {
|
|
128
164
|
this.failedCount += 1;
|
|
129
165
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
130
|
-
|
|
166
|
+
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
167
|
+
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
168
|
+
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
131
169
|
await this.safeCall(
|
|
132
170
|
compact({
|
|
133
171
|
cmd: 'FAIL',
|
|
@@ -138,13 +176,16 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
138
176
|
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
139
177
|
}) as { cmd: string }
|
|
140
178
|
);
|
|
179
|
+
this.finishJob(job.id);
|
|
141
180
|
this.emit('failed', job, error);
|
|
142
|
-
} finally {
|
|
143
|
-
this.active.delete(job.id);
|
|
144
|
-
this.cancelledJobs.delete(job.id);
|
|
145
181
|
}
|
|
146
182
|
}
|
|
147
183
|
|
|
184
|
+
private finishJob(id: string): void {
|
|
185
|
+
this.active.delete(id);
|
|
186
|
+
this.cancelledJobs.delete(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
148
189
|
// --------------------------------------------------------------- heartbeat
|
|
149
190
|
|
|
150
191
|
private startHeartbeat(): void {
|