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
@@ -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
- const raw = response.job;
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
- if (err instanceof CommandError)
21
- return null; // server: 'Job not found'
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
- const raw = response.job;
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
- const jobs = (response.jobs ?? []);
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
- const response = await this.call({ cmd: 'GetState', id });
80
- return String(response.state);
78
+ return (await this.call({ cmd: 'GetState', id })).state;
81
79
  },
82
80
  async getResult(id) {
83
- const response = await this.call({ cmd: 'GetResult', id });
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
- const response = await this.call({ cmd: 'GetJobCounts', queue: this.name });
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
- const response = await this.call({ cmd: 'Count', queue: this.name });
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: 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<Response>;
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
- new Connection({
27
- host: opts.host,
28
- port: opts.port,
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 {};
@@ -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<void>;
71
+ }): Promise<boolean>;
72
+ /** Hook run during close() before draining in-flight jobs (see Worker). */
73
+ protected beforeClose(): Promise<void>;
62
74
  }
@@ -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
  }
@@ -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
- export interface WorkerOptions {
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 = (response.jobs ?? []);
82
- const tokens = (response.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.processed += 1;
104
- await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
105
- this.emit('completed', job, result);
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.emit('failed', job, error);
122
- }
123
- finally {
124
- this.active.delete(job.id);
125
- this.cancelledJobs.delete(job.id);
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.5",
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",