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.
Files changed (52) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +46 -3
  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/bunqueue/bunqueue.js +12 -2
  8. package/dist/connection-pool.d.ts +30 -0
  9. package/dist/connection-pool.js +60 -0
  10. package/dist/connection-types.d.ts +21 -1
  11. package/dist/connection.d.ts +12 -3
  12. package/dist/connection.js +39 -3
  13. package/dist/flow-types.d.ts +2 -1
  14. package/dist/flow.js +12 -3
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +3 -1
  17. package/dist/job.d.ts +2 -2
  18. package/dist/observability.d.ts +95 -0
  19. package/dist/observability.js +110 -0
  20. package/dist/queue-admin.js +16 -2
  21. package/dist/queue-control.d.ts +5 -0
  22. package/dist/queue-control.js +27 -9
  23. package/dist/queue-query.js +13 -21
  24. package/dist/queue.d.ts +13 -4
  25. package/dist/queue.js +14 -7
  26. package/dist/responses.d.ts +79 -0
  27. package/dist/responses.js +10 -0
  28. package/dist/worker-base.d.ts +15 -3
  29. package/dist/worker-base.js +15 -7
  30. package/dist/worker-types.d.ts +43 -1
  31. package/dist/worker.d.ts +5 -1
  32. package/dist/worker.js +59 -14
  33. package/package.json +3 -1
  34. package/src/ack-batcher.ts +76 -0
  35. package/src/backpressure.ts +40 -0
  36. package/src/bunqueue/bunqueue.ts +12 -1
  37. package/src/connection-pool.ts +71 -0
  38. package/src/connection-types.ts +23 -1
  39. package/src/connection.ts +42 -6
  40. package/src/flow-types.ts +2 -1
  41. package/src/flow.ts +24 -16
  42. package/src/index.ts +33 -2
  43. package/src/job.ts +3 -3
  44. package/src/observability.ts +158 -0
  45. package/src/queue-admin.ts +15 -2
  46. package/src/queue-control.ts +34 -11
  47. package/src/queue-query.ts +39 -29
  48. package/src/queue.ts +30 -11
  49. package/src/responses.ts +96 -0
  50. package/src/worker-base.ts +49 -11
  51. package/src/worker-types.ts +42 -1
  52. package/src/worker.ts +63 -16
@@ -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 { MAX_POLL_TIMEOUT_MS, sleep, type WorkerOptions } from './worker-types.js';
11
-
12
- export class WorkerBase extends EventEmitter {
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(event: string | symbol, listener: (...args: unknown[]) => void): this {
91
- if (event === 'ready' && this.readyFired) listener();
92
- return super.on(event, listener);
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(event: string | symbol, listener: (...args: unknown[]) => void): this {
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
- protected async safeCall(command: Record<string, unknown> & { cmd: string }): Promise<void> {
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
  }
@@ -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
- export interface WorkerOptions {
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 = (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,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.processed += 1;
123
- await this.safeCall(
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
- this.emit('completed', job, result);
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.emit('failed', job, error);
144
- } finally {
145
- this.active.delete(job.id);
146
- this.cancelledJobs.delete(job.id);
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 {