@basaltkit/queue-kafka 1.0.2 → 1.1.1

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/README.md CHANGED
@@ -70,7 +70,7 @@ await Job.dispatch(payload, { delay: '5m' }) // → throws UnsupportedJobOptionE
70
70
  // with onUnsupported: 'warn' (default) → warns once and runs immediately
71
71
  ```
72
72
 
73
- > If what you need is *streaming*/pub-sub (rather than jobs with retry/delay), the natural fit in Basalt is usually `@basaltkit/events`, not `@basaltkit/queue`.
73
+ **If what you need is *streaming*/pub-sub** (rather than jobs with retry/delay), the natural fit in Basalt is usually `@basaltkit/events`, not `@basaltkit/queue`.
74
74
 
75
75
  ## How it works
76
76
 
@@ -94,8 +94,57 @@ The worker's concurrency is passed as `partitionsConsumedConcurrently` — actua
94
94
  | `retrySuffix` | `string` | `'.retry'` | Suffix for the retry topic. |
95
95
  | `deadSuffix` | `string` | `'.dead'` | Suffix for the dead-letter topic. |
96
96
  | `client` | `KafkaClient` | kafkajs | Injectable client — used in tests without a broker. |
97
+ | `onError` | `(error: unknown, info: { source: 'consumer' \| 'producer'; queue?: string }) => void` | contextual `console.error` | The driver's single fault channel — see below. |
97
98
 
98
- Implements the `QueueDriver` contract from `@basaltkit/queue`.
99
+ Implements the `QueueDriver` contract from `@basaltkit/queue`. It does **not** implement the optional `stats` / `retryFailed`, so `basalt queue:stats` and `basalt queue:retry` report the operation as unsupported — use your Kafka tooling for consumer-group lag instead.
100
+
101
+ ### Failure hooks
102
+
103
+ `onError` is the only callback, and its default (`console.error` with the source and queue) is
104
+ never silent. There is no `onJobFailed`: a job that exhausts `attempts` is produced to
105
+ `<topic>.dead`, which *is* the report — monitor that topic.
106
+
107
+ | `source` | Raised when | What the driver does next |
108
+ |---|---|---|
109
+ | `'consumer'` | A worker's `connect`/`subscribe`/`run` rejected at boot (broker unreachable, missing topic, bad ACLs). | Reports and stops. Without this the rejection would float and be process-fatal, and the app would report healthy with **zero** workers. |
110
+ | `'producer'` | The retry / dead-letter **re-publish itself failed** while handling a failed job. | Reports, then **rethrows** — see below. |
111
+
112
+ ### Redelivery when the DLQ produce fails
113
+
114
+ The subtle case. A job's handler throws, so the driver tries to re-route the message — to
115
+ `<topic>.retry` if attempts remain, otherwise to `<topic>.dead`. If *that* produce also fails
116
+ (the producer lost its broker connection, the dead topic doesn't exist, the request timed out),
117
+ the failed job exists nowhere but in the message currently being consumed.
118
+
119
+ kafkajs auto-commits offsets after `eachMessage` **resolves**. So the driver:
120
+
121
+ 1. reports the publish failure through `onError({ source: 'producer', queue })`, then
122
+ 2. **rethrows** it, so `eachMessage` rejects and the offset is **not** committed.
123
+
124
+ Kafka then redelivers the same message and the driver tries the whole thing again — at-least-once
125
+ rather than a job that quietly evaporated during a producer outage. It is the Kafka equivalent of
126
+ RabbitMQ leaving a message unacked.
127
+
128
+ Two consequences worth planning for:
129
+
130
+ - **Handlers must be idempotent.** A redelivered message re-runs the handler that already failed,
131
+ and a message whose re-publish succeeded is never redelivered — but a partition stalls on the
132
+ failing message while the producer is down, so ordered downstream work backs up behind it.
133
+ - **A normal failure path does commit.** When the re-publish *succeeds*, the failure is
134
+ considered handled: the offset commits and the retry copy carries the incremented
135
+ `x-basalt-attempt` header. Redelivery only happens on the produce failure itself.
136
+
137
+ ### Exported errors
138
+
139
+ This driver throws no error classes of its own. `UnsupportedJobOptionError`
140
+ (`QUEUE_UNSUPPORTED_OPTION`) comes from `@basaltkit/queue` when a `delay`/`priority` dispatch
141
+ meets `onUnsupported: 'throw'`; everything else surfaces through `onError`.
142
+
143
+ ### Hard limits
144
+
145
+ The attempt counters travel in message headers, which any producer on the topic could write, so
146
+ the consumer clamps the `x-basalt-attempts` it reads to at most **50**. A crafted message cannot
147
+ drive an unbounded retry loop.
99
148
 
100
149
  ## How it connects to other modules
101
150
 
package/dist/index.d.ts CHANGED
@@ -47,6 +47,17 @@ export interface KafkaDriverOptions {
47
47
  deadSuffix?: string;
48
48
  /** Injectable client — defaults to kafkajs. Tests pass a fake. */
49
49
  client?: KafkaClient;
50
+ /**
51
+ * Called on infrastructure faults the driver cannot recover in line — a
52
+ * worker's connect/subscribe/run failing at boot (otherwise the app reports
53
+ * healthy with ZERO workers and the rejection is process-fatal), or a
54
+ * retry/dead-letter re-publish failing inside the consume callback. Same
55
+ * pattern as the rabbitmq/sqs drivers. Default: console.error with context.
56
+ */
57
+ onError?: (error: unknown, info: {
58
+ source: 'consumer' | 'producer';
59
+ queue?: string;
60
+ }) => void;
50
61
  }
51
62
  /**
52
63
  * Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
@@ -73,6 +84,7 @@ export declare class KafkaQueueDriver implements QueueDriver {
73
84
  private readonly groupId;
74
85
  private readonly retrySuffix;
75
86
  private readonly deadSuffix;
87
+ private readonly onError;
76
88
  constructor(options: KafkaDriverOptions);
77
89
  setExecutor(executor: JobExecutor): void;
78
90
  add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
package/dist/index.js CHANGED
@@ -41,11 +41,15 @@ export class KafkaQueueDriver {
41
41
  groupId;
42
42
  retrySuffix;
43
43
  deadSuffix;
44
+ onError;
44
45
  constructor(options) {
45
46
  this.options = options;
46
47
  this.groupId = options.groupId ?? 'basalt-queue';
47
48
  this.retrySuffix = options.retrySuffix ?? '.retry';
48
49
  this.deadSuffix = options.deadSuffix ?? '.dead';
50
+ this.onError =
51
+ options.onError ??
52
+ ((error, info) => console.error(`[basalt:queue] kafka ${info.source} error${info.queue ? ` (queue "${info.queue}")` : ''}:`, error));
49
53
  }
50
54
  setExecutor(executor) {
51
55
  this.executor = executor;
@@ -70,7 +74,8 @@ export class KafkaQueueDriver {
70
74
  });
71
75
  }
72
76
  startWorker(queue, options = {}) {
73
- void (async () => {
77
+ ;
78
+ (async () => {
74
79
  const consumer = (await this.client()).consumer({ groupId: this.groupId });
75
80
  this.consumers.push(consumer);
76
81
  await consumer.connect();
@@ -80,7 +85,12 @@ export class KafkaQueueDriver {
80
85
  eachMessage: ({ message }) => this.handle(queue, message),
81
86
  ...(options.concurrency !== undefined ? { partitionsConsumedConcurrently: options.concurrency } : {}),
82
87
  });
83
- })();
88
+ })().catch((error) => {
89
+ // A broker-connect/subscribe failure at boot must be VISIBLE — otherwise
90
+ // the app reports healthy with zero workers, and the floating rejection
91
+ // would be process-fatal by Node's default.
92
+ this.onError(error, { source: 'consumer', queue });
93
+ });
84
94
  }
85
95
  async close() {
86
96
  const producer = await this.producerPromise?.catch(() => undefined);
@@ -102,15 +112,26 @@ export class KafkaQueueDriver {
102
112
  // Clamp the max-attempts read from the (untrusted) message to a hard
103
113
  // ceiling so a crafted `attempts` can't drive a retry-amplification loop.
104
114
  const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
105
- const producer = await this.producer();
106
- if (attempt < attempts) {
107
- await producer.send({
108
- topic: this.retryTopic(queue),
109
- messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }],
110
- });
115
+ try {
116
+ const producer = await this.producer();
117
+ if (attempt < attempts) {
118
+ await producer.send({
119
+ topic: this.retryTopic(queue),
120
+ messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }],
121
+ });
122
+ }
123
+ else {
124
+ await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
125
+ }
111
126
  }
112
- else {
113
- await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
127
+ catch (publishError) {
128
+ // The failed job could not be re-routed. Surface the fault, then
129
+ // RETHROW so eachMessage rejects and the offset is NOT committed —
130
+ // kafkajs redelivers the message (at-least-once) instead of the job
131
+ // silently vanishing on a producer outage. (Rabbitmq keeps the message
132
+ // unacked for the same reason; Kafka's equivalent is not committing.)
133
+ this.onError(publishError, { source: 'producer', queue });
134
+ throw publishError;
114
135
  }
115
136
  }
116
137
  // Offsets auto-commit after eachMessage resolves — a re-routed failure is
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/queue-kafka",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
+ "engines": {
5
+ "node": ">=22.5.0"
6
+ },
4
7
  "description": "Kafka driver for @basaltkit/queue: produce/consume jobs with retry and dead-letter topics (no delayed delivery or priority — Kafka has neither).",
5
8
  "license": "MIT",
6
9
  "type": "module",
10
+ "sideEffects": false,
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -14,7 +18,7 @@
14
18
  "dist"
15
19
  ],
16
20
  "dependencies": {
17
- "@basaltkit/queue": "^1.2.1"
21
+ "@basaltkit/queue": "^1.4.1"
18
22
  },
19
23
  "peerDependencies": {
20
24
  "kafkajs": "^2.0.0"