@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 +51 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +31 -10
- package/package.json +6 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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.
|
|
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.
|
|
21
|
+
"@basaltkit/queue": "^1.4.1"
|
|
18
22
|
},
|
|
19
23
|
"peerDependencies": {
|
|
20
24
|
"kafkajs": "^2.0.0"
|