@basaltkit/queue-kafka 1.0.1 → 1.1.0
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 +7 -0
- package/dist/index.d.ts +19 -10
- package/dist/index.js +165 -120
- package/package.json +9 -10
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://basaltkit-docs.pages.dev">
|
|
3
|
+
<img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# @basaltkit/queue-kafka
|
|
2
8
|
|
|
3
9
|
**Apache Kafka** driver for [`@basaltkit/queue`](https://www.npmjs.com/package/@basaltkit/queue): runs your jobs by producing and consuming messages on Kafka topics, without changing your job code. You need this package when your data platform is already built on Kafka and you want to process background work on the same infrastructure.
|
|
@@ -88,6 +94,7 @@ The worker's concurrency is passed as `partitionsConsumedConcurrently` — actua
|
|
|
88
94
|
| `retrySuffix` | `string` | `'.retry'` | Suffix for the retry topic. |
|
|
89
95
|
| `deadSuffix` | `string` | `'.dead'` | Suffix for the dead-letter topic. |
|
|
90
96
|
| `client` | `KafkaClient` | kafkajs | Injectable client — used in tests without a broker. |
|
|
97
|
+
| `onError` | `(error, { source, queue? }) => void` | contextual `console.error` | Infrastructure-fault hook (same pattern as rabbitmq/sqs): a worker's connect/subscribe/run failing at boot (`source: 'consumer'` — previously an unhandled, process-fatal rejection and an invisible zero-worker app), or a retry/dead-letter re-publish failing (`source: 'producer'` — reported, then rethrown so the offset is not committed and Kafka redelivers; a producer outage cannot silently lose a failing job). |
|
|
91
98
|
|
|
92
99
|
Implements the `QueueDriver` contract from `@basaltkit/queue`.
|
|
93
100
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import type { AddJobOptions, DriverCapabilities, JobExecutor, QueueDriver } from '@basaltkit/queue';
|
|
3
2
|
/** The subset of a kafkajs client this driver uses. */
|
|
4
|
-
interface KafkaClient {
|
|
3
|
+
export interface KafkaClient {
|
|
5
4
|
producer(): KafkaProducer;
|
|
6
5
|
consumer(config: {
|
|
7
6
|
groupId: string;
|
|
8
7
|
}): KafkaConsumer;
|
|
9
8
|
}
|
|
10
|
-
interface KafkaProducer {
|
|
9
|
+
export interface KafkaProducer {
|
|
11
10
|
connect(): Promise<void>;
|
|
12
11
|
send(record: {
|
|
13
12
|
topic: string;
|
|
@@ -18,11 +17,11 @@ interface KafkaProducer {
|
|
|
18
17
|
}): Promise<unknown>;
|
|
19
18
|
disconnect(): Promise<void>;
|
|
20
19
|
}
|
|
21
|
-
interface KafkaMessage {
|
|
20
|
+
export interface KafkaMessage {
|
|
22
21
|
value: Uint8Array | string | null;
|
|
23
22
|
headers?: Record<string, Uint8Array | string | undefined>;
|
|
24
23
|
}
|
|
25
|
-
interface KafkaConsumer {
|
|
24
|
+
export interface KafkaConsumer {
|
|
26
25
|
connect(): Promise<void>;
|
|
27
26
|
subscribe(subscription: {
|
|
28
27
|
topic: string;
|
|
@@ -37,7 +36,7 @@ interface KafkaConsumer {
|
|
|
37
36
|
}): Promise<void>;
|
|
38
37
|
disconnect(): Promise<void>;
|
|
39
38
|
}
|
|
40
|
-
interface KafkaDriverOptions {
|
|
39
|
+
export interface KafkaDriverOptions {
|
|
41
40
|
brokers: string[];
|
|
42
41
|
clientId?: string;
|
|
43
42
|
/** Consumer group used by workers. Default 'basalt-queue'. */
|
|
@@ -48,6 +47,17 @@ interface KafkaDriverOptions {
|
|
|
48
47
|
deadSuffix?: string;
|
|
49
48
|
/** Injectable client — defaults to kafkajs. Tests pass a fake. */
|
|
50
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;
|
|
51
61
|
}
|
|
52
62
|
/**
|
|
53
63
|
* Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
|
|
@@ -63,7 +73,7 @@ interface KafkaDriverOptions {
|
|
|
63
73
|
* Worker concurrency is bounded by the topic's partition count, not the
|
|
64
74
|
* `concurrency` number (passed through as `partitionsConsumedConcurrently`).
|
|
65
75
|
*/
|
|
66
|
-
declare class KafkaQueueDriver implements QueueDriver {
|
|
76
|
+
export declare class KafkaQueueDriver implements QueueDriver {
|
|
67
77
|
private readonly options;
|
|
68
78
|
readonly name = "kafka";
|
|
69
79
|
readonly capabilities: DriverCapabilities;
|
|
@@ -74,6 +84,7 @@ declare class KafkaQueueDriver implements QueueDriver {
|
|
|
74
84
|
private readonly groupId;
|
|
75
85
|
private readonly retrySuffix;
|
|
76
86
|
private readonly deadSuffix;
|
|
87
|
+
private readonly onError;
|
|
77
88
|
constructor(options: KafkaDriverOptions);
|
|
78
89
|
setExecutor(executor: JobExecutor): void;
|
|
79
90
|
add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
|
|
@@ -87,5 +98,3 @@ declare class KafkaQueueDriver implements QueueDriver {
|
|
|
87
98
|
private retryTopic;
|
|
88
99
|
private deadTopic;
|
|
89
100
|
}
|
|
90
|
-
|
|
91
|
-
export { type KafkaClient, type KafkaConsumer, type KafkaDriverOptions, type KafkaMessage, type KafkaProducer, KafkaQueueDriver };
|
package/dist/index.js
CHANGED
|
@@ -1,128 +1,173 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
attempts: "x-basalt-attempts"
|
|
1
|
+
const HEADER = {
|
|
2
|
+
job: 'x-basalt-job',
|
|
3
|
+
attempt: 'x-basalt-attempt',
|
|
4
|
+
attempts: 'x-basalt-attempts',
|
|
6
5
|
};
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
/** Hard ceiling on retries, so a crafted message can't request unbounded ones. */
|
|
7
|
+
const MAX_ATTEMPTS = 50;
|
|
8
|
+
const defaultClient = async (options) => {
|
|
9
|
+
// Opaque specifier keeps kafkajs an optional peer dependency (runtime-resolved).
|
|
10
|
+
const specifier = 'kafkajs';
|
|
11
|
+
const mod = (await import(specifier));
|
|
12
|
+
return new mod.Kafka({ clientId: options.clientId ?? 'basalt', brokers: options.brokers });
|
|
12
13
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
|
|
62
|
-
await consumer.run({
|
|
63
|
-
eachMessage: ({ message }) => this.handle(queue, message),
|
|
64
|
-
...options.concurrency !== void 0 ? { partitionsConsumedConcurrently: options.concurrency } : {}
|
|
65
|
-
});
|
|
66
|
-
})();
|
|
67
|
-
}
|
|
68
|
-
async close() {
|
|
69
|
-
const producer = await this.producerPromise?.catch(() => void 0);
|
|
70
|
-
await producer?.disconnect();
|
|
71
|
-
await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
|
|
72
|
-
}
|
|
73
|
-
// --- internals -----------------------------------------------------------
|
|
74
|
-
async handle(queue, message) {
|
|
75
|
-
if (message.value === null) return;
|
|
76
|
-
const headers = decodeHeaders(message.headers);
|
|
77
|
-
const jobName = headers[HEADER.job] ?? "";
|
|
78
|
-
const value = asString(message.value);
|
|
79
|
-
try {
|
|
80
|
-
await this.executor?.(jobName, JSON.parse(value));
|
|
81
|
-
} catch {
|
|
82
|
-
const attempt = Number(headers[HEADER.attempt] ?? 1);
|
|
83
|
-
const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
|
|
84
|
-
const producer = await this.producer();
|
|
85
|
-
if (attempt < attempts) {
|
|
14
|
+
/**
|
|
15
|
+
* Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
|
|
16
|
+
* driver is deliberately honest about what it can't do:
|
|
17
|
+
*
|
|
18
|
+
* - `delayed` and `priority` are NOT supported (Kafka has neither). With the
|
|
19
|
+
* queue's `onUnsupported: 'throw'` policy a delayed/priority dispatch fails
|
|
20
|
+
* loudly; with the default 'warn' it logs and proceeds without them.
|
|
21
|
+
* - `retries` are supported via a retry topic (`<topic>.retry`) the worker also
|
|
22
|
+
* consumes; exhausted jobs go to `<topic>.dead`. There is no backoff delay
|
|
23
|
+
* (Kafka can't defer a message), so `backoff` is not supported either.
|
|
24
|
+
*
|
|
25
|
+
* Worker concurrency is bounded by the topic's partition count, not the
|
|
26
|
+
* `concurrency` number (passed through as `partitionsConsumedConcurrently`).
|
|
27
|
+
*/
|
|
28
|
+
export class KafkaQueueDriver {
|
|
29
|
+
options;
|
|
30
|
+
name = 'kafka';
|
|
31
|
+
capabilities = {
|
|
32
|
+
delayed: false,
|
|
33
|
+
priority: false,
|
|
34
|
+
retries: true,
|
|
35
|
+
backoff: false,
|
|
36
|
+
};
|
|
37
|
+
executor;
|
|
38
|
+
clientPromise;
|
|
39
|
+
producerPromise;
|
|
40
|
+
consumers = [];
|
|
41
|
+
groupId;
|
|
42
|
+
retrySuffix;
|
|
43
|
+
deadSuffix;
|
|
44
|
+
onError;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
this.groupId = options.groupId ?? 'basalt-queue';
|
|
48
|
+
this.retrySuffix = options.retrySuffix ?? '.retry';
|
|
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));
|
|
53
|
+
}
|
|
54
|
+
setExecutor(executor) {
|
|
55
|
+
this.executor = executor;
|
|
56
|
+
}
|
|
57
|
+
async add(queue, jobName, data, options) {
|
|
58
|
+
// delay/priority are unsupported (see capabilities); the QueueManager has
|
|
59
|
+
// already applied its onUnsupported policy, so they are simply not encoded.
|
|
60
|
+
void options;
|
|
61
|
+
const producer = await this.producer();
|
|
86
62
|
await producer.send({
|
|
87
|
-
|
|
88
|
-
|
|
63
|
+
topic: queue,
|
|
64
|
+
messages: [
|
|
65
|
+
{
|
|
66
|
+
value: JSON.stringify(data),
|
|
67
|
+
headers: {
|
|
68
|
+
[HEADER.job]: jobName,
|
|
69
|
+
[HEADER.attempt]: '1',
|
|
70
|
+
[HEADER.attempts]: String(options.attempts),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
89
74
|
});
|
|
90
|
-
} else {
|
|
91
|
-
await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
|
|
92
|
-
}
|
|
93
75
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
76
|
+
startWorker(queue, options = {}) {
|
|
77
|
+
;
|
|
78
|
+
(async () => {
|
|
79
|
+
const consumer = (await this.client()).consumer({ groupId: this.groupId });
|
|
80
|
+
this.consumers.push(consumer);
|
|
81
|
+
await consumer.connect();
|
|
82
|
+
await consumer.subscribe({ topic: queue, fromBeginning: false });
|
|
83
|
+
await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
|
|
84
|
+
await consumer.run({
|
|
85
|
+
eachMessage: ({ message }) => this.handle(queue, message),
|
|
86
|
+
...(options.concurrency !== undefined ? { partitionsConsumedConcurrently: options.concurrency } : {}),
|
|
87
|
+
});
|
|
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
|
+
});
|
|
98
94
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.producerPromise = (async () => {
|
|
104
|
-
const producer = (await this.client()).producer();
|
|
105
|
-
await producer.connect();
|
|
106
|
-
return producer;
|
|
107
|
-
})();
|
|
95
|
+
async close() {
|
|
96
|
+
const producer = await this.producerPromise?.catch(() => undefined);
|
|
97
|
+
await producer?.disconnect();
|
|
98
|
+
await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
|
|
108
99
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
100
|
+
// --- internals -----------------------------------------------------------
|
|
101
|
+
async handle(queue, message) {
|
|
102
|
+
if (message.value === null)
|
|
103
|
+
return;
|
|
104
|
+
const headers = decodeHeaders(message.headers);
|
|
105
|
+
const jobName = headers[HEADER.job] ?? '';
|
|
106
|
+
const value = asString(message.value);
|
|
107
|
+
try {
|
|
108
|
+
await this.executor?.(jobName, JSON.parse(value));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
const attempt = Number(headers[HEADER.attempt] ?? 1);
|
|
112
|
+
// Clamp the max-attempts read from the (untrusted) message to a hard
|
|
113
|
+
// ceiling so a crafted `attempts` can't drive a retry-amplification loop.
|
|
114
|
+
const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
|
|
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
|
+
}
|
|
126
|
+
}
|
|
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;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Offsets auto-commit after eachMessage resolves — a re-routed failure is
|
|
138
|
+
// considered handled so the same message is not redelivered by Kafka.
|
|
139
|
+
}
|
|
140
|
+
client() {
|
|
141
|
+
if (!this.clientPromise) {
|
|
142
|
+
this.clientPromise = this.options.client
|
|
143
|
+
? Promise.resolve(this.options.client)
|
|
144
|
+
: defaultClient(this.options);
|
|
145
|
+
}
|
|
146
|
+
return this.clientPromise;
|
|
147
|
+
}
|
|
148
|
+
producer() {
|
|
149
|
+
if (!this.producerPromise) {
|
|
150
|
+
this.producerPromise = (async () => {
|
|
151
|
+
const producer = (await this.client()).producer();
|
|
152
|
+
await producer.connect();
|
|
153
|
+
return producer;
|
|
154
|
+
})();
|
|
155
|
+
}
|
|
156
|
+
return this.producerPromise;
|
|
157
|
+
}
|
|
158
|
+
retryTopic(queue) {
|
|
159
|
+
return `${queue}${this.retrySuffix}`;
|
|
160
|
+
}
|
|
161
|
+
deadTopic(queue) {
|
|
162
|
+
return `${queue}${this.deadSuffix}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const asString = (value) => typeof value === 'string' ? value : new TextDecoder().decode(value);
|
|
166
|
+
const decodeHeaders = (headers) => {
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
169
|
+
if (value !== undefined)
|
|
170
|
+
out[key] = asString(value);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
128
173
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/queue-kafka",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Kafka driver for @basaltkit/queue: produce/consume jobs with retry and dead-letter topics (no delayed delivery or priority — Kafka has neither).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,16 +14,15 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@basaltkit/queue": "^1.1
|
|
17
|
+
"@basaltkit/queue": "^1.3.1"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"kafkajs": "^2.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"vitest": "^3.1.0",
|
|
23
|
+
"@types/node": "^26.3.0",
|
|
24
|
+
"typescript": "^7.0.2",
|
|
25
|
+
"vitest": "^4.1.11",
|
|
27
26
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
28
27
|
},
|
|
29
28
|
"publishConfig": {
|
|
@@ -31,11 +30,11 @@
|
|
|
31
30
|
},
|
|
32
31
|
"repository": {
|
|
33
32
|
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/
|
|
33
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
35
34
|
"directory": "packages/queue-kafka"
|
|
36
35
|
},
|
|
37
|
-
"homepage": "https://github.com/
|
|
38
|
-
"bugs": "https://github.com/
|
|
36
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/queue-kafka#readme",
|
|
37
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
39
38
|
"keywords": [
|
|
40
39
|
"basalt",
|
|
41
40
|
"typescript",
|
|
@@ -44,7 +43,7 @@
|
|
|
44
43
|
"kafkajs"
|
|
45
44
|
],
|
|
46
45
|
"scripts": {
|
|
47
|
-
"build": "
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
47
|
"test": "vitest run",
|
|
49
48
|
"typecheck": "tsc --noEmit"
|
|
50
49
|
}
|