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.
Files changed (48) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +44 -1
  3. package/dist/ack-batcher.d.ts +28 -0
  4. package/dist/ack-batcher.js +67 -0
  5. package/dist/backpressure.d.ts +22 -0
  6. package/dist/backpressure.js +39 -0
  7. package/dist/connection-pool.d.ts +30 -0
  8. package/dist/connection-pool.js +60 -0
  9. package/dist/connection-types.d.ts +21 -1
  10. package/dist/connection.d.ts +20 -3
  11. package/dist/connection.js +70 -3
  12. package/dist/flow-types.d.ts +2 -1
  13. package/dist/flow.js +33 -7
  14. package/dist/index.d.ts +7 -2
  15. package/dist/index.js +3 -1
  16. package/dist/job.d.ts +2 -2
  17. package/dist/observability.d.ts +95 -0
  18. package/dist/observability.js +110 -0
  19. package/dist/queue-control.js +6 -5
  20. package/dist/queue-query.d.ts +9 -2
  21. package/dist/queue-query.js +44 -26
  22. package/dist/queue.d.ts +13 -4
  23. package/dist/queue.js +26 -11
  24. package/dist/responses.d.ts +79 -0
  25. package/dist/responses.js +10 -0
  26. package/dist/worker-base.d.ts +2 -0
  27. package/dist/worker-base.js +5 -0
  28. package/dist/worker-types.d.ts +15 -1
  29. package/dist/worker.d.ts +4 -0
  30. package/dist/worker.js +46 -7
  31. package/package.json +2 -1
  32. package/src/ack-batcher.ts +76 -0
  33. package/src/backpressure.ts +40 -0
  34. package/src/connection-pool.ts +71 -0
  35. package/src/connection-types.ts +23 -1
  36. package/src/connection.ts +72 -6
  37. package/src/flow-types.ts +2 -1
  38. package/src/flow.ts +46 -18
  39. package/src/index.ts +28 -2
  40. package/src/job.ts +3 -3
  41. package/src/observability.ts +158 -0
  42. package/src/queue-control.ts +9 -11
  43. package/src/queue-query.ts +73 -35
  44. package/src/queue.ts +42 -15
  45. package/src/responses.ts +96 -0
  46. package/src/worker-base.ts +6 -0
  47. package/src/worker-types.ts +16 -1
  48. package/src/worker.ts +48 -7
@@ -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: Raw): Record<string, R> {
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
- const raw = response.job as Raw | null;
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
- const raw = response.job as Raw | null;
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
- const jobs = (response.jobs ?? []) as Raw[];
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
- return this.getJobs({ state: ['waiting', 'prioritized'], start, end });
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
- const response = await this.call({ cmd: 'GetState', id });
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
- const response = await this.call({ cmd: 'GetResult', id });
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>(await this.call({ cmd: 'GetFailedChildrenValues', id }));
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>(await this.call({ cmd: 'GetIgnoredChildrenFailures', id }));
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
- /** Block until the job finishes; returns its result. */
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({ cmd: 'WaitJob', id, timeout: ttlMs }, ttlMs + 5000);
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
- const response = await this.call({ cmd: 'GetJobCounts', queue: this.name });
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
- const counts = await this.getJobCounts();
161
- return counts.waiting + counts.prioritized;
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
- const response = await this.call({ cmd: 'Count', queue: this.name });
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(this: Ctx, id: string, message: string): Promise<void> {
201
- await this.call({ cmd: 'AddLog', id, message });
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
- return logs.map((row) => (typeof row === 'string' ? row : String((row as Raw).message ?? row)));
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: 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
- new Connection({
45
- host: opts.host,
46
- port: opts.port,
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(command: Record<string, unknown> & { cmd: string }, timeoutMs?: number): Promise<Response> {
56
- return this.connection.call(command, timeoutMs);
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
- data: jobPayload(entry.name, entry.data),
77
- ...wireJobOptions(entry.opts),
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(
@@ -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
+ }
@@ -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
  }
@@ -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 WorkerOptions {
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 = (response.jobs ?? []) as Record<string, unknown>[];
98
- const tokens = (response.tokens ?? []) as string[];
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
- const stack = (error.stack ?? error.message).split('\n').slice(-MAX_STACK_LINES);
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 {