kafka-crab-js 4.0.0-beta.2 → 4.0.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 +883 -659
- package/assets/consumer-benchmark-snapshot.svg +198 -0
- package/dist/diagnostics/channels.cjs.map +1 -1
- package/dist/diagnostics/channels.js.map +1 -1
- package/dist/diagnostics/instrumentation.cjs.map +1 -1
- package/dist/diagnostics/instrumentation.js.map +1 -1
- package/dist/kafka-client.cjs +18 -83
- package/dist/kafka-client.cjs.map +1 -1
- package/dist/kafka-client.js +18 -83
- package/dist/kafka-client.js.map +1 -1
- package/dist/streams/base-kafka-stream-readable.cjs.map +1 -1
- package/dist/streams/base-kafka-stream-readable.js.map +1 -1
- package/dist/streams/kafka-batch-stream-readable.cjs.map +1 -1
- package/dist/streams/kafka-batch-stream-readable.js.map +1 -1
- package/dist/streams/kafka-stream-readable.cjs.map +1 -1
- package/dist/streams/kafka-stream-readable.js.map +1 -1
- package/js-binding.cjs +52 -52
- package/js-binding.d.ts +0 -4
- package/js-binding.js +52 -52
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -1,907 +1,1131 @@
|
|
|
1
|
-
|
|
1
|
+
# Kafka Crab JS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Kafka Crab JS is a native Kafka client for Node.js and TypeScript. It uses Rust, NAPI-RS, and librdkafka to reduce
|
|
4
|
+
JavaScript heap pressure, expose Kafka's mature native client behavior, and push high-throughput consumer workloads
|
|
5
|
+
through a small TypeScript-friendly API.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
[](https://www.npmjs.com/package/kafka-crab-js)
|
|
7
|
+
[](https://www.npmjs.com/package/kafka-crab-js)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
## Version 4
|
|
11
|
+
|
|
12
|
+
`kafka-crab-js` v4 is the current beta line. It focuses on high-throughput consumers, lower JavaScript heap pressure,
|
|
13
|
+
and a cleaner monorepo release model under [`flash-tecnologia/crab-js`](https://github.com/flash-tecnologia/crab-js).
|
|
14
|
+
|
|
15
|
+
The main v4 additions are:
|
|
16
|
+
|
|
17
|
+
- Native Web Stream consumers through `client.createWebStreamConsumer()`, with explicit `serial` and `batch` modes.
|
|
18
|
+
- Direct batch consumption through `consumer.recvBatch(size, timeoutMs)` for workers that can process chunks.
|
|
19
|
+
- Compact native batch streaming internally, reducing native-to-JavaScript marshalling overhead before expanding to the
|
|
20
|
+
public `Message[]` API.
|
|
21
|
+
- Serial Web Stream prefetching, so message-by-message code still benefits from small native batches.
|
|
22
|
+
- Diagnostics-channel events in the core package, with OpenTelemetry tracing and metrics moved to the optional
|
|
23
|
+
`kafka-crab-js-otel` package.
|
|
24
|
+
- Updated TypeScript exports where runtime enum-like values are now string literal types, avoiding missing runtime
|
|
25
|
+
exports in ESM/CommonJS consumers.
|
|
26
|
+
|
|
27
|
+
For production adoption, treat v4 as a beta release: validate it with your broker settings, payload sizes, partition
|
|
28
|
+
count, commit strategy, and shutdown path before replacing an existing Kafka client.
|
|
29
|
+
|
|
30
|
+
## Why Kafka Crab JS?
|
|
31
|
+
|
|
32
|
+
KafkaJS is a strong default for many Node.js services. Kafka Crab JS is for the cases where Kafka throughput, memory
|
|
33
|
+
pressure, native batching, or librdkafka behavior start to matter.
|
|
34
|
+
|
|
35
|
+
| Need | What Kafka Crab JS Provides |
|
|
36
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
37
|
+
| Higher consumer throughput | Native batch receive paths and Web Stream batch consumers. |
|
|
38
|
+
| Lower JavaScript heap pressure | Message fetch and Kafka protocol work happen in Rust/librdkafka before crossing into JS. |
|
|
39
|
+
| Predictable Kafka offset handling | `commitMessage()` commits `message.offset + 1`, the value Kafka expects. |
|
|
40
|
+
| Production Kafka tuning | Advanced librdkafka settings are passed through with their original names. |
|
|
41
|
+
| Node.js ecosystem integration | Direct APIs, Node.js `Readable` streams, native Web Streams, and CommonJS/ESM exports. |
|
|
42
|
+
| Observability without core coupling | Diagnostics-channel events power the optional `kafka-crab-js-otel` package. |
|
|
43
|
+
|
|
44
|
+
In the local isolated consumer benchmark snapshot, `kafka-crab-js v4 (stream, batch)` reached about
|
|
45
|
+
`1.17M messages/sec`, ahead of the measured `@platformatic/kafka` and KafkaJS batch scenarios, while using lower RSS
|
|
46
|
+
delta and peak heap than both. Treat benchmark numbers as workload-specific; the full methodology and memory/GC
|
|
47
|
+
breakdown are documented in [BENCHMARKS.md](../../BENCHMARKS.md) and [Performance Benchmarks](#performance-benchmarks).
|
|
48
|
+
|
|
49
|
+
## Highlights
|
|
50
|
+
|
|
51
|
+
- Native librdkafka client exposed through a TypeScript-friendly API.
|
|
52
|
+
- Producer, direct consumer, Node.js `Readable` stream, and v4 native Web Stream consumer APIs.
|
|
53
|
+
- High-throughput batch receive APIs for workloads that can process more than one message at a time.
|
|
54
|
+
- Compact batch streaming and serial prefetching to reduce native boundary crossings.
|
|
55
|
+
- Manual commit helpers that commit `message.offset + 1` correctly.
|
|
56
|
+
- Optional diagnostics-channel instrumentation for OpenTelemetry through `kafka-crab-js-otel`.
|
|
57
|
+
- Advanced librdkafka settings are forwarded through `configuration` without forcing a custom allowlist.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Node.js `>= 22`.
|
|
62
|
+
- A Kafka broker reachable from the Node.js process.
|
|
63
|
+
- No separate librdkafka install is required for the published binaries.
|
|
64
|
+
|
|
65
|
+
## Table Of Contents
|
|
66
|
+
|
|
67
|
+
1. [Version 4](#version-4)
|
|
68
|
+
2. [Installation](#installation)
|
|
69
|
+
3. [Module Usage](#module-usage)
|
|
70
|
+
4. [Quick Start](#quick-start)
|
|
71
|
+
5. [Message Model](#message-model)
|
|
72
|
+
6. [Choosing A Consumer API](#choosing-a-consumer-api)
|
|
73
|
+
7. [Producer API](#producer-api)
|
|
74
|
+
8. [Consumer API](#consumer-api)
|
|
75
|
+
9. [Stream Consumers](#stream-consumers)
|
|
76
|
+
10. [Batching, Backpressure, And Tuning](#batching-backpressure-and-tuning)
|
|
77
|
+
11. [Configuration](#configuration)
|
|
78
|
+
12. [OpenTelemetry](#opentelemetry)
|
|
79
|
+
13. [Performance Benchmarks](#performance-benchmarks)
|
|
80
|
+
14. [Migration Notes](#migration-notes)
|
|
81
|
+
15. [Troubleshooting](#troubleshooting)
|
|
82
|
+
16. [Development](#development)
|
|
83
|
+
17. [License](#license)
|
|
13
84
|
|
|
14
|
-
##
|
|
85
|
+
## Installation
|
|
15
86
|
|
|
16
|
-
|
|
87
|
+
```bash
|
|
88
|
+
npm install kafka-crab-js
|
|
89
|
+
```
|
|
17
90
|
|
|
18
|
-
|
|
91
|
+
```bash
|
|
92
|
+
pnpm add kafka-crab-js
|
|
93
|
+
```
|
|
19
94
|
|
|
20
|
-
|
|
95
|
+
```bash
|
|
96
|
+
yarn add kafka-crab-js
|
|
97
|
+
```
|
|
21
98
|
|
|
22
|
-
|
|
99
|
+
## Module Usage
|
|
23
100
|
|
|
24
|
-
|
|
101
|
+
The package publishes both ESM and CommonJS entry points:
|
|
25
102
|
|
|
26
103
|
```ts
|
|
27
|
-
import {
|
|
104
|
+
import { KafkaClient } from 'kafka-crab-js'
|
|
105
|
+
import type { Message, ProducerRecord } from 'kafka-crab-js'
|
|
28
106
|
```
|
|
29
107
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import type { CommitMode, SecurityProtocol } from 'kafka-crab-js'
|
|
108
|
+
```js
|
|
109
|
+
const { KafkaClient } = require('kafka-crab-js')
|
|
34
110
|
```
|
|
35
111
|
|
|
36
|
-
|
|
112
|
+
The public TypeScript types are generated from the native NAPI contract and the JavaScript wrapper. Runtime enum-like
|
|
113
|
+
values such as commit modes and security protocols are string literals, not runtime objects:
|
|
37
114
|
|
|
38
115
|
```ts
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
116
|
+
await consumer.commitMessage(message, 'Sync')
|
|
117
|
+
|
|
118
|
+
const client = new KafkaClient({
|
|
119
|
+
brokers: 'localhost:9092',
|
|
120
|
+
securityProtocol: 'Plaintext',
|
|
121
|
+
})
|
|
43
122
|
```
|
|
44
123
|
|
|
45
|
-
|
|
124
|
+
## Quick Start
|
|
46
125
|
|
|
47
|
-
|
|
48
|
-
| ------------------- | ------------------------------------------------------- |
|
|
49
|
-
| `CommitMode` | `'Sync' \| 'Async'` |
|
|
50
|
-
| `KafkaEventName` | `'PreRebalance' \| 'PostRebalance' \| 'CommitCallback'` |
|
|
51
|
-
| `PartitionPosition` | `'Beginning' \| 'End' \| 'Stored' \| 'Invalid'` |
|
|
52
|
-
| `SecurityProtocol` | `'Plaintext' \| 'Ssl' \| 'SaslPlaintext' \| 'SaslSsl'` |
|
|
126
|
+
### Produce Messages
|
|
53
127
|
|
|
54
|
-
|
|
128
|
+
```ts
|
|
129
|
+
import { KafkaClient } from 'kafka-crab-js'
|
|
55
130
|
|
|
56
|
-
|
|
131
|
+
const client = new KafkaClient({
|
|
132
|
+
brokers: 'localhost:9092',
|
|
133
|
+
clientId: 'orders-api',
|
|
134
|
+
securityProtocol: 'Plaintext',
|
|
135
|
+
})
|
|
57
136
|
|
|
58
|
-
|
|
137
|
+
const producer = client.createProducer()
|
|
59
138
|
|
|
60
|
-
|
|
139
|
+
const metadata = await producer.send({
|
|
140
|
+
topic: 'orders',
|
|
141
|
+
messages: [
|
|
142
|
+
{
|
|
143
|
+
key: Buffer.from('order-123'),
|
|
144
|
+
payload: Buffer.from(JSON.stringify({ id: 'order-123', status: 'created' })),
|
|
145
|
+
headers: {
|
|
146
|
+
'content-type': Buffer.from('application/json'),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
})
|
|
61
151
|
|
|
62
|
-
|
|
152
|
+
console.log(metadata)
|
|
153
|
+
```
|
|
63
154
|
|
|
64
|
-
|
|
155
|
+
### Consume One Message At A Time
|
|
65
156
|
|
|
66
|
-
```
|
|
157
|
+
```ts
|
|
67
158
|
import { KafkaClient } from 'kafka-crab-js'
|
|
68
159
|
|
|
69
160
|
const client = new KafkaClient({
|
|
70
161
|
brokers: 'localhost:9092',
|
|
71
|
-
clientId: '
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
162
|
+
clientId: 'orders-worker',
|
|
163
|
+
securityProtocol: 'Plaintext',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const consumer = client.createConsumer({
|
|
167
|
+
groupId: 'orders-worker',
|
|
168
|
+
enableAutoCommit: false,
|
|
169
|
+
configuration: {
|
|
170
|
+
'auto.offset.reset': 'earliest',
|
|
75
171
|
},
|
|
76
172
|
})
|
|
77
|
-
```
|
|
78
173
|
|
|
79
|
-
|
|
174
|
+
await consumer.subscribe('orders')
|
|
80
175
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const message = await consumer.recv()
|
|
179
|
+
if (!message) {
|
|
180
|
+
break
|
|
181
|
+
}
|
|
84
182
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
enableOtelInstrumentation({
|
|
88
|
-
metrics: { enabled: true },
|
|
89
|
-
})
|
|
183
|
+
const value = JSON.parse(message.payload.toString('utf8'))
|
|
184
|
+
console.log({ value, topic: message.topic, partition: message.partition, offset: message.offset })
|
|
90
185
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
186
|
+
await consumer.commitMessage(message, 'Sync')
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
await consumer.disconnect()
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Consume In Batches
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const consumer = client.createConsumer({
|
|
197
|
+
groupId: 'orders-batch-worker',
|
|
198
|
+
enableAutoCommit: false,
|
|
199
|
+
configuration: {
|
|
200
|
+
'auto.offset.reset': 'earliest',
|
|
201
|
+
},
|
|
96
202
|
})
|
|
97
203
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
204
|
+
await consumer.subscribe('orders')
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
while (true) {
|
|
208
|
+
const batch = await consumer.recvBatch(500, 50)
|
|
209
|
+
if (batch.length === 0) {
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const message of batch) {
|
|
214
|
+
await processOrder(message.payload)
|
|
215
|
+
await consumer.commitMessage(message, 'Async')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
await consumer.disconnect()
|
|
220
|
+
}
|
|
102
221
|
```
|
|
103
222
|
|
|
104
|
-
|
|
223
|
+
## Message Model
|
|
105
224
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
| `otel` config in KafkaClient | `enableOtelInstrumentation()` from `kafka-crab-js-otel` |
|
|
109
|
-
| Automatic span ending | Call `endSpan(message)` manually for consumers |
|
|
110
|
-
| N/A | `diagnostics: true` required in KafkaClient config |
|
|
111
|
-
| OTEL bundled | Install `kafka-crab-js-otel` separately |
|
|
225
|
+
Messages use `Buffer` values because the Kafka protocol treats keys, payloads, and headers as bytes. Decode them at the
|
|
226
|
+
edge of your application instead of assuming text:
|
|
112
227
|
|
|
113
|
-
|
|
228
|
+
```ts
|
|
229
|
+
import type { Message, MessageProducer, RecordMetadata } from 'kafka-crab-js'
|
|
230
|
+
|
|
231
|
+
const outgoing: MessageProducer = {
|
|
232
|
+
key: Buffer.from('order-123'),
|
|
233
|
+
payload: Buffer.from(JSON.stringify({ id: 'order-123' })),
|
|
234
|
+
headers: {
|
|
235
|
+
'content-type': Buffer.from('application/json'),
|
|
236
|
+
source: Buffer.from('orders-api'),
|
|
237
|
+
},
|
|
238
|
+
}
|
|
114
239
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
| `kafka-crab-js-otel` | OpenTelemetry instrumentation (separate install) |
|
|
240
|
+
function decodeJsonMessage(message: Message) {
|
|
241
|
+
return JSON.parse(message.payload.toString('utf8')) as unknown
|
|
242
|
+
}
|
|
119
243
|
|
|
120
|
-
|
|
244
|
+
function logDelivery(records: RecordMetadata[]) {
|
|
245
|
+
for (const record of records) {
|
|
246
|
+
console.log(`${record.topic}[${record.partition}]@${record.offset}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
121
250
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- Smaller bundle size for users who don't need OTEL
|
|
251
|
+
`Message` values contain the consumed Kafka offset. When committing manually, commit the next offset. Prefer
|
|
252
|
+
`commitMessage()` unless you intentionally need to calculate offsets yourself:
|
|
125
253
|
|
|
126
|
-
|
|
254
|
+
```ts
|
|
255
|
+
await consumer.commitMessage(message, 'Sync')
|
|
256
|
+
await consumer.commit(message.topic, message.partition, message.offset + 1, 'Async')
|
|
257
|
+
```
|
|
127
258
|
|
|
128
|
-
|
|
259
|
+
Keys are optional. An omitted key keeps Kafka producer partitioning semantics for keyless records. An empty
|
|
260
|
+
`Buffer.alloc(0)` is still a present key.
|
|
129
261
|
|
|
130
|
-
|
|
131
|
-
<summary>What's New in Version 2.1.0</summary>
|
|
262
|
+
## Choosing A Consumer API
|
|
132
263
|
|
|
133
|
-
|
|
264
|
+
| API | Emits | Best For |
|
|
265
|
+
| ---------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
|
266
|
+
| `consumer.recv()` | `Message \| null` | Simple sequential workers and explicit control. |
|
|
267
|
+
| `consumer.recvBatch()` | `Message[]` | High-throughput workers that can process chunks. |
|
|
268
|
+
| `consumer.recvStream()` | Web `ReadableStream<Message>` | Pull-based Web Stream workflows. |
|
|
269
|
+
| `consumer.recvBatchStream()` | Web `ReadableStream<Message[]>` | Native batch streaming with explicit consumer control. |
|
|
270
|
+
| `client.createStreamConsumer()` | Node.js `Readable` emitting `Message` | Existing Node stream pipelines. |
|
|
271
|
+
| `client.createWebStreamConsumer()` | Web `ReadableStream<Message>` or `ReadableStream<Message[]>` | v4 high-throughput stream consumers. |
|
|
134
272
|
|
|
135
|
-
|
|
136
|
-
- New convenience method that accepts a message and commit mode directly
|
|
137
|
-
- Automatically handles `offset + 1` increment internally - no more manual offset arithmetic
|
|
138
|
-
- Available on both `KafkaConsumer` and stream consumers
|
|
139
|
-
- **Before** (v2.0.0):
|
|
140
|
-
```javascript
|
|
141
|
-
const message = await consumer.recv()
|
|
142
|
-
await consumer.commit(message.topic, message.partition, message.offset + 1, 'Sync')
|
|
143
|
-
```
|
|
144
|
-
- **After** (v2.1.0):
|
|
145
|
-
```javascript
|
|
146
|
-
const message = await consumer.recv()
|
|
147
|
-
await consumer.commitMessage(message, 'Sync')
|
|
148
|
-
```
|
|
273
|
+
Two details matter for choosing correctly:
|
|
149
274
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- Seamless integration with standard OTEL SDK setup
|
|
275
|
+
- `createStreamConsumer()` is a Node.js `Readable` compatibility API. Even when `batchSize > 1`, it emits individual
|
|
276
|
+
`Message` objects and uses batching internally to reduce native boundary crossings.
|
|
277
|
+
- `createWebStreamConsumer()` returns a discriminated object. In serial mode it emits `Message`; in batch mode it emits
|
|
278
|
+
`Message[]`. Use this API when your code can process whole batches directly.
|
|
155
279
|
|
|
156
|
-
|
|
157
|
-
- Updated to Node.js 24 support
|
|
158
|
-
- GitHub Actions updated to v6
|
|
159
|
-
- Improved caching with actions/cache v4
|
|
280
|
+
Recommended starting points:
|
|
160
281
|
|
|
161
|
-
|
|
282
|
+
- Use `consumer.recv()` for straightforward workers where one handler processes one message and then commits.
|
|
283
|
+
- Use `consumer.recvBatch()` when the handler can process chunks, flush work in groups, or commit after a batch.
|
|
284
|
+
- Use `createStreamConsumer()` when existing code already expects a Node.js `Readable`.
|
|
285
|
+
- Use `createWebStreamConsumer()` for new v4 stream code, especially when you can keep batch chunks intact.
|
|
162
286
|
|
|
163
|
-
|
|
164
|
-
<summary>What's New in Version 2.0.0</summary>
|
|
287
|
+
## Producer API
|
|
165
288
|
|
|
166
|
-
|
|
289
|
+
Create a producer from a `KafkaClient`:
|
|
167
290
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
291
|
+
```ts
|
|
292
|
+
const producer = client.createProducer({
|
|
293
|
+
autoFlush: true,
|
|
294
|
+
queueTimeout: 5000,
|
|
295
|
+
configuration: {
|
|
296
|
+
'compression.type': 'lz4',
|
|
297
|
+
acks: 'all',
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
```
|
|
171
301
|
|
|
172
|
-
|
|
173
|
-
- Stream consumers now properly implement Node.js stream lifecycle methods (`_destroy()`)
|
|
174
|
-
- **Memory leak prevention**: Streams now automatically disconnect Kafka consumers during destruction
|
|
302
|
+
Send one or more messages:
|
|
175
303
|
|
|
176
|
-
|
|
177
|
-
|
|
304
|
+
```ts
|
|
305
|
+
const records = await producer.send({
|
|
306
|
+
topic: 'orders',
|
|
307
|
+
messages: [
|
|
308
|
+
{ key: Buffer.from('1'), payload: Buffer.from('first') },
|
|
309
|
+
{ key: Buffer.from('2'), payload: Buffer.from('second') },
|
|
310
|
+
],
|
|
311
|
+
})
|
|
312
|
+
```
|
|
178
313
|
|
|
179
|
-
|
|
314
|
+
When `autoFlush` is enabled, `send()` waits for delivery confirmation and returns `RecordMetadata[]`. When `autoFlush`
|
|
315
|
+
is disabled, `send()` buffers messages and `flush()` sends pending messages:
|
|
180
316
|
|
|
181
|
-
|
|
317
|
+
```ts
|
|
318
|
+
const producer = client.createProducer({ autoFlush: false })
|
|
182
319
|
|
|
183
|
-
|
|
320
|
+
await producer.send({
|
|
321
|
+
topic: 'orders',
|
|
322
|
+
messages: [{ payload: Buffer.from('buffered') }],
|
|
323
|
+
})
|
|
184
324
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
- 🎯 Type-safe interfaces (TypeScript support)
|
|
189
|
-
- ⚡ Async/await support
|
|
190
|
-
- 🛠️ Configurable consumer and producer options
|
|
191
|
-
- 📊 Stream processing support with configurable stream options
|
|
192
|
-
- 📦 Message batching capabilities
|
|
193
|
-
- 🔍 Comprehensive error handling
|
|
194
|
-
- 📈 Performance benchmarking suite
|
|
195
|
-
- 🔧 Flexible configuration system supporting complex data types
|
|
325
|
+
const records = await producer.flush()
|
|
326
|
+
console.log(records)
|
|
327
|
+
```
|
|
196
328
|
|
|
197
|
-
|
|
329
|
+
`producer.inFlightCount()` returns the number of messages sent but not yet acknowledged.
|
|
198
330
|
|
|
199
|
-
|
|
200
|
-
2. [Quick Start](#quick-start)
|
|
201
|
-
3. [Consumer Examples](#basic-consumer-setup)
|
|
202
|
-
4. [Producer Examples](#producer-examples)
|
|
203
|
-
5. [Stream Processing](#stream-processing)
|
|
204
|
-
6. [Configuration](#configuration)
|
|
205
|
-
7. [Performance Benchmarks](#performance-benchmarks)
|
|
206
|
-
8. [Best Practices](#best-practices)
|
|
207
|
-
9. [Contributing](#contributing)
|
|
208
|
-
10. [License](#license)
|
|
209
|
-
11. [OpenTelemetry Instrumentation](#opentelemetry-instrumentation)
|
|
331
|
+
### Delivery Semantics
|
|
210
332
|
|
|
211
|
-
|
|
333
|
+
`send()` writes records to librdkafka and, with the default `autoFlush: true`, waits for delivery results before
|
|
334
|
+
resolving. Delivery metadata can still contain a per-record `error`, so production code should inspect it when failed
|
|
335
|
+
records must be retried or reported:
|
|
212
336
|
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
pnpm add kafka-crab-js
|
|
219
|
-
```
|
|
337
|
+
```ts
|
|
338
|
+
const records = await producer.send({
|
|
339
|
+
topic: 'orders',
|
|
340
|
+
messages: [{ payload: Buffer.from('created') }],
|
|
341
|
+
})
|
|
220
342
|
|
|
221
|
-
|
|
343
|
+
for (const record of records) {
|
|
344
|
+
if (record.error) {
|
|
345
|
+
throw new Error(`Kafka delivery failed: ${record.error.message}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
222
349
|
|
|
223
|
-
|
|
350
|
+
With `autoFlush: false`, `send()` returns an empty array and leaves delivery confirmation to the next `flush()` call.
|
|
351
|
+
This can improve throughput for bursty producers, but shutdown code must flush before the process exits:
|
|
224
352
|
|
|
225
|
-
```
|
|
226
|
-
import {
|
|
227
|
-
async function run() {
|
|
228
|
-
const kafkaClient = new KafkaClient({
|
|
229
|
-
brokers: 'localhost:29092',
|
|
230
|
-
clientId: 'foo-client',
|
|
231
|
-
logLevel: 'debug',
|
|
232
|
-
brokerAddressFamily: 'v4',
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
// Create consumer with topic creation control
|
|
236
|
-
const consumer = kafkaClient.createConsumer({
|
|
237
|
-
groupId: 'foo-group',
|
|
238
|
-
configuration: {
|
|
239
|
-
'auto.offset.reset': 'earliest',
|
|
240
|
-
'enable.auto.commit': false, // Use manual commit for better control
|
|
241
|
-
},
|
|
242
|
-
})
|
|
353
|
+
```ts
|
|
354
|
+
import type { RecordMetadata } from 'kafka-crab-js'
|
|
243
355
|
|
|
244
|
-
|
|
245
|
-
await consumer.subscribe([
|
|
246
|
-
{
|
|
247
|
-
topic: 'foo',
|
|
248
|
-
createTopic: true,
|
|
249
|
-
numPartitions: 3,
|
|
250
|
-
},
|
|
251
|
-
])
|
|
252
|
-
|
|
253
|
-
const message = await consumer.recv()
|
|
254
|
-
const { payload, partition, offset, topic } = message
|
|
255
|
-
console.log({
|
|
256
|
-
topic,
|
|
257
|
-
partition,
|
|
258
|
-
offset,
|
|
259
|
-
value: payload.toString(),
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// Manual commit - two options:
|
|
263
|
-
// Option 1 (v2.1.0+): Simplified with commitMessage
|
|
264
|
-
await consumer.commitMessage(message, 'Sync')
|
|
356
|
+
const producer = client.createProducer({ autoFlush: false })
|
|
265
357
|
|
|
266
|
-
|
|
267
|
-
|
|
358
|
+
function assertAllDelivered(records: RecordMetadata[]) {
|
|
359
|
+
for (const record of records) {
|
|
360
|
+
if (record.error) {
|
|
361
|
+
throw new Error(`Kafka delivery failed: ${record.error.message}`)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
268
365
|
|
|
269
|
-
|
|
366
|
+
try {
|
|
367
|
+
await producer.send({ topic: 'orders', messages })
|
|
368
|
+
const records = await producer.flush()
|
|
369
|
+
assertAllDelivered(records)
|
|
370
|
+
} finally {
|
|
371
|
+
const remaining = await producer.flush()
|
|
372
|
+
assertAllDelivered(remaining)
|
|
270
373
|
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Consumer API
|
|
377
|
+
|
|
378
|
+
### Subscribe
|
|
379
|
+
|
|
380
|
+
The simplest subscription accepts a topic string:
|
|
271
381
|
|
|
272
|
-
|
|
382
|
+
```ts
|
|
383
|
+
await consumer.subscribe('orders')
|
|
273
384
|
```
|
|
274
385
|
|
|
275
|
-
|
|
386
|
+
Use `TopicPartitionConfig[]` when you need offsets, partition-specific offsets, or topic creation:
|
|
276
387
|
|
|
277
|
-
```
|
|
278
|
-
|
|
388
|
+
```ts
|
|
389
|
+
await consumer.subscribe([
|
|
390
|
+
{
|
|
391
|
+
topic: 'orders',
|
|
392
|
+
createTopic: true,
|
|
393
|
+
numPartitions: 3,
|
|
394
|
+
replicas: 1,
|
|
395
|
+
allOffsets: { position: 'Beginning' },
|
|
396
|
+
},
|
|
397
|
+
])
|
|
398
|
+
```
|
|
279
399
|
|
|
280
|
-
|
|
281
|
-
brokers: 'localhost:29092',
|
|
282
|
-
clientId: 'my-client-id',
|
|
283
|
-
logLevel: 'info',
|
|
284
|
-
brokerAddressFamily: 'v4',
|
|
285
|
-
})
|
|
400
|
+
For partition-specific offsets:
|
|
286
401
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
'
|
|
291
|
-
|
|
292
|
-
|
|
402
|
+
```ts
|
|
403
|
+
await consumer.subscribe([
|
|
404
|
+
{
|
|
405
|
+
topic: 'orders',
|
|
406
|
+
partitionOffset: [
|
|
407
|
+
{
|
|
408
|
+
partition: 0,
|
|
409
|
+
offset: { offset: 42 },
|
|
410
|
+
},
|
|
411
|
+
],
|
|
293
412
|
},
|
|
294
|
-
|
|
413
|
+
])
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Receive
|
|
417
|
+
|
|
418
|
+
`recv()` waits for one message and returns `null` when the consumer is disconnected:
|
|
295
419
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
420
|
+
```ts
|
|
421
|
+
const message = await consumer.recv()
|
|
422
|
+
if (message) {
|
|
423
|
+
await handleMessage(message)
|
|
300
424
|
}
|
|
425
|
+
```
|
|
301
426
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
427
|
+
`recvBatch(size, timeoutMs)` returns up to `size` messages:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
const messages = await consumer.recvBatch(1000, 100)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
An empty array means the timeout elapsed without a full batch. It does not mean the consumer is closed.
|
|
434
|
+
|
|
435
|
+
### Commit
|
|
436
|
+
|
|
437
|
+
Use `commitMessage()` for normal message processing. It commits `message.offset + 1`, which is the offset Kafka expects:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
await consumer.commitMessage(message, 'Sync')
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Use `commit()` when you already calculated the offset:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
await consumer.commit(message.topic, message.partition, message.offset + 1, 'Async')
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Commit modes are string literal values:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
type CommitMode = 'Sync' | 'Async'
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
For at-least-once processing, disable auto commit, process the message successfully, then commit. If processing fails
|
|
456
|
+
before the commit, Kafka can redeliver that message after restart or rebalance:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
const consumer = client.createConsumer({
|
|
460
|
+
groupId: 'orders-worker',
|
|
461
|
+
enableAutoCommit: false,
|
|
309
462
|
})
|
|
310
463
|
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
console.log('Message sent. Offset:', result)
|
|
464
|
+
const message = await consumer.recv()
|
|
465
|
+
if (message) {
|
|
466
|
+
await processOrder(message)
|
|
467
|
+
await consumer.commitMessage(message, 'Sync')
|
|
316
468
|
}
|
|
317
469
|
```
|
|
318
470
|
|
|
319
|
-
|
|
471
|
+
`'Sync'` waits for the broker commit response. `'Async'` schedules the commit through librdkafka and returns sooner,
|
|
472
|
+
which can be useful in high-throughput workers that tolerate the usual async commit tradeoff.
|
|
320
473
|
|
|
321
|
-
###
|
|
474
|
+
### Pause, Resume, Seek, And Assignment
|
|
322
475
|
|
|
323
|
-
```
|
|
324
|
-
|
|
476
|
+
```ts
|
|
477
|
+
consumer.pause()
|
|
478
|
+
consumer.resume()
|
|
325
479
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
480
|
+
consumer.seek('orders', 0, { position: 'Beginning' })
|
|
481
|
+
consumer.seek('orders', 0, { offset: 42 })
|
|
482
|
+
|
|
483
|
+
const assignment = consumer.assignment()
|
|
484
|
+
const subscription = consumer.getSubscription()
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Consumer events are available through `onEvents()`:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
consumer.onEvents((error, event) => {
|
|
491
|
+
if (error) {
|
|
492
|
+
console.error(error)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
console.log(event.name, event.payload)
|
|
331
497
|
})
|
|
498
|
+
```
|
|
332
499
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
{
|
|
336
|
-
groupId: `my-group-id`,
|
|
337
|
-
enableAutoCommit: true,
|
|
338
|
-
},
|
|
339
|
-
{
|
|
340
|
-
objectMode: true, // Default in v2.0.0+
|
|
341
|
-
highWaterMark: 1024,
|
|
342
|
-
encoding: null,
|
|
343
|
-
},
|
|
344
|
-
)
|
|
500
|
+
Event names are currently `PreRebalance`, `PostRebalance`, and `CommitCallback`. The payload includes the topic-partition
|
|
501
|
+
list associated with the event and may include an `error` string for commit or rebalance failures.
|
|
345
502
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
503
|
+
### Cleanup
|
|
504
|
+
|
|
505
|
+
Always disconnect direct consumers:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
try {
|
|
509
|
+
await consumer.subscribe('orders')
|
|
510
|
+
// consume...
|
|
511
|
+
} finally {
|
|
512
|
+
await consumer.disconnect()
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## Stream Consumers
|
|
517
|
+
|
|
518
|
+
### Node.js Readable Stream
|
|
350
519
|
|
|
351
|
-
|
|
352
|
-
console.log('>>> Message received:', {
|
|
353
|
-
payload: message.payload.toString(),
|
|
354
|
-
offset: message.offset,
|
|
355
|
-
partition: message.partition,
|
|
356
|
-
topic: message.topic,
|
|
357
|
-
})
|
|
520
|
+
Use `createStreamConsumer()` when integrating with Node.js stream tooling. It emits `Message` objects.
|
|
358
521
|
|
|
359
|
-
|
|
360
|
-
|
|
522
|
+
```ts
|
|
523
|
+
const stream = client.createStreamConsumer({
|
|
524
|
+
groupId: 'orders-stream',
|
|
525
|
+
enableAutoCommit: false,
|
|
526
|
+
batchSize: 256,
|
|
527
|
+
batchTimeout: 50,
|
|
528
|
+
configuration: {
|
|
529
|
+
'auto.offset.reset': 'earliest',
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
await stream.subscribe('orders')
|
|
534
|
+
|
|
535
|
+
stream.on('data', async (message) => {
|
|
536
|
+
try {
|
|
537
|
+
await processOrder(message.payload)
|
|
538
|
+
await stream.commitMessage(message, 'Async')
|
|
539
|
+
} catch (error) {
|
|
540
|
+
stream.destroy(error instanceof Error ? error : new Error(String(error)))
|
|
361
541
|
}
|
|
362
542
|
})
|
|
363
543
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
544
|
+
stream.on('error', (error) => {
|
|
545
|
+
console.error('stream error', error)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
stream.on('close', () => {
|
|
549
|
+
console.log('stream closed')
|
|
367
550
|
})
|
|
368
551
|
```
|
|
369
552
|
|
|
370
|
-
|
|
553
|
+
Destroy stream consumers instead of calling `disconnect()` directly. The stream cleanup path cancels the source reader,
|
|
554
|
+
unsubscribes, and disconnects the native consumer:
|
|
371
555
|
|
|
372
|
-
|
|
556
|
+
```ts
|
|
557
|
+
stream.destroy()
|
|
558
|
+
```
|
|
373
559
|
|
|
374
|
-
|
|
375
|
-
const kafkaClient = new KafkaClient({
|
|
376
|
-
brokers: 'localhost:29092',
|
|
377
|
-
clientId: 'my-client-id',
|
|
378
|
-
brokerAddressFamily: 'v4',
|
|
379
|
-
})
|
|
560
|
+
### Native Web Stream
|
|
380
561
|
|
|
381
|
-
|
|
382
|
-
|
|
562
|
+
Use `createWebStreamConsumer()` when you want a Web Stream and clear serial/batch typing.
|
|
563
|
+
|
|
564
|
+
Serial mode is selected when `batchSize` is omitted, `0`, or `1`:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
const webConsumer = client.createWebStreamConsumer({
|
|
568
|
+
groupId: 'orders-web-serial',
|
|
569
|
+
serialPrefetchSize: 64,
|
|
570
|
+
serialPrefetchTimeout: 1,
|
|
383
571
|
configuration: {
|
|
384
|
-
'
|
|
385
|
-
'linger.ms': 10, // Number value supported
|
|
386
|
-
'compression.type': 'lz4',
|
|
387
|
-
'enable.idempotence': true, // Boolean value supported
|
|
572
|
+
'auto.offset.reset': 'earliest',
|
|
388
573
|
},
|
|
389
574
|
})
|
|
390
575
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
_id: i,
|
|
395
|
-
name: `Batch Message ${i}`,
|
|
396
|
-
timestamp: new Date().toISOString(),
|
|
397
|
-
}),
|
|
398
|
-
),
|
|
399
|
-
}))
|
|
576
|
+
await webConsumer.consumer.subscribe('orders')
|
|
577
|
+
|
|
578
|
+
const reader = webConsumer.stream.getReader()
|
|
400
579
|
|
|
401
580
|
try {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
581
|
+
const { value: message, done } = await reader.read()
|
|
582
|
+
if (!done && message) {
|
|
583
|
+
await processOrder(message.payload)
|
|
584
|
+
await webConsumer.consumer.commitMessage(message, 'Async')
|
|
585
|
+
}
|
|
586
|
+
} finally {
|
|
587
|
+
await reader.cancel()
|
|
588
|
+
await webConsumer.consumer.disconnect()
|
|
410
589
|
}
|
|
411
590
|
```
|
|
412
591
|
|
|
413
|
-
|
|
592
|
+
Batch mode is selected with `batchSize > 1` and emits `Message[]` chunks:
|
|
414
593
|
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
594
|
+
```ts
|
|
595
|
+
const webConsumer = client.createWebStreamConsumer({
|
|
596
|
+
groupId: 'orders-web-batch',
|
|
597
|
+
batchSize: 1024,
|
|
598
|
+
batchTimeout: 10,
|
|
599
|
+
configuration: {
|
|
600
|
+
'auto.offset.reset': 'earliest',
|
|
601
|
+
},
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
if (webConsumer.mode === 'batch') {
|
|
605
|
+
await webConsumer.consumer.subscribe('orders')
|
|
606
|
+
const reader = webConsumer.stream.getReader()
|
|
424
607
|
|
|
425
608
|
try {
|
|
426
|
-
await
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}),
|
|
436
|
-
),
|
|
437
|
-
headers: {
|
|
438
|
-
'correlation-id': 'txn-123',
|
|
439
|
-
source: 'user-service',
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
],
|
|
443
|
-
})
|
|
444
|
-
} catch (error) {
|
|
445
|
-
console.error('Error:', error)
|
|
609
|
+
const { value: batch, done } = await reader.read()
|
|
610
|
+
if (!done && batch) {
|
|
611
|
+
for (const message of batch) {
|
|
612
|
+
await processOrder(message.payload)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} finally {
|
|
616
|
+
await reader.cancel()
|
|
617
|
+
await webConsumer.consumer.disconnect()
|
|
446
618
|
}
|
|
447
619
|
}
|
|
448
620
|
```
|
|
449
621
|
|
|
450
|
-
|
|
622
|
+
## Batching, Backpressure, And Tuning
|
|
451
623
|
|
|
452
|
-
|
|
453
|
-
import { KafkaClient } from 'kafka-crab-js'
|
|
624
|
+
The main throughput lever is how many messages cross the native-to-JavaScript boundary per call.
|
|
454
625
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
626
|
+
| Knob | Applies To | Effect |
|
|
627
|
+
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
|
628
|
+
| `recvBatch(size, ms)` | Direct consumer | Pulls up to `size` messages, waiting up to `ms` for data. |
|
|
629
|
+
| `batchSize` | Node stream and Web Stream batch modes | Sets the maximum native batch size used by the stream. |
|
|
630
|
+
| `batchTimeout` | Node stream and Web Stream batch modes | Bounds how long a partially filled batch waits before being emitted. |
|
|
631
|
+
| `serialPrefetchSize` | `createWebStreamConsumer()` serial mode | Pulls small native batches and flattens them into individual messages. |
|
|
632
|
+
| `serialPrefetchTimeout` | `createWebStreamConsumer()` serial mode | Timeout for the serial-mode prefetch batch. |
|
|
633
|
+
| `streamOptions` | `createStreamConsumer()` Node.js `Readable` API | Lets Node stream `highWaterMark` participate in backpressure. |
|
|
634
|
+
|
|
635
|
+
Use larger batches when throughput matters and your handler can process arrays efficiently. Use smaller batches and
|
|
636
|
+
shorter timeouts when tail latency matters more than total throughput. Very large batches can increase RSS because more
|
|
637
|
+
payloads, keys, headers, and metadata must be retained at once.
|
|
638
|
+
|
|
639
|
+
The v4 Web Stream serial path defaults to `serialPrefetchSize: 64` and `serialPrefetchTimeout: 1`. This keeps the public
|
|
640
|
+
serial API message-by-message while reducing native boundary crossings. Batch mode defaults `batchTimeout` to `1000`
|
|
641
|
+
milliseconds when it is omitted.
|
|
642
|
+
|
|
643
|
+
For broker fetch tuning, pass librdkafka settings through `configuration`. Keep these values aligned with your expected
|
|
644
|
+
message size:
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
const consumer = client.createConsumer({
|
|
648
|
+
groupId: 'orders-worker',
|
|
460
649
|
configuration: {
|
|
461
|
-
'
|
|
462
|
-
'
|
|
463
|
-
'
|
|
650
|
+
'fetch.min.bytes': 1,
|
|
651
|
+
'fetch.wait.max.ms': 10,
|
|
652
|
+
'fetch.max.bytes': 1_048_576,
|
|
653
|
+
'max.partition.fetch.bytes': 1_048_576,
|
|
654
|
+
'message.max.bytes': 1_000_000,
|
|
464
655
|
},
|
|
465
656
|
})
|
|
657
|
+
```
|
|
466
658
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
*/
|
|
470
|
-
async function createConsumer() {
|
|
471
|
-
const kafkaStream = kafkaClient.createStreamConsumer(
|
|
472
|
-
{
|
|
473
|
-
groupId: 'reconnect-test',
|
|
474
|
-
enableAutoCommit: true,
|
|
475
|
-
},
|
|
476
|
-
{
|
|
477
|
-
highWaterMark: 100,
|
|
478
|
-
objectMode: true,
|
|
479
|
-
},
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
await kafkaStream.subscribe([
|
|
483
|
-
{ topic: 'foo', createTopic: true },
|
|
484
|
-
{ topic: 'bar', createTopic: true },
|
|
485
|
-
])
|
|
486
|
-
return kafkaStream
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Starts a Kafka consumer with auto-restart capability
|
|
491
|
-
*/
|
|
492
|
-
async function startConsumer() {
|
|
493
|
-
let counter = 0
|
|
494
|
-
let retryCount = 0
|
|
495
|
-
const MAX_RETRIES = 5
|
|
496
|
-
const RETRY_DELAY = 5000 // 5 seconds
|
|
497
|
-
|
|
498
|
-
async function handleRetry() {
|
|
499
|
-
if (retryCount < MAX_RETRIES) {
|
|
500
|
-
retryCount++
|
|
501
|
-
console.log(
|
|
502
|
-
`Attempting to restart consumer (attempt ${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY / 1000} seconds...`,
|
|
503
|
-
)
|
|
504
|
-
setTimeout(setupConsumerWithRetry, RETRY_DELAY)
|
|
505
|
-
} else {
|
|
506
|
-
console.error(`Maximum retry attempts (${MAX_RETRIES}) reached. Stopping consumer.`)
|
|
507
|
-
process.exit(1)
|
|
508
|
-
}
|
|
509
|
-
}
|
|
659
|
+
`fetch.max.bytes` must be greater than or equal to `message.max.bytes`. If this invariant is broken, librdkafka rejects
|
|
660
|
+
the consumer configuration before the benchmark or application starts.
|
|
510
661
|
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
const kafkaStream = await createConsumer()
|
|
514
|
-
retryCount = 0 // Reset retry count on successful connection
|
|
515
|
-
|
|
516
|
-
console.log('Starting consumer')
|
|
517
|
-
|
|
518
|
-
kafkaStream.on('data', (message) => {
|
|
519
|
-
counter++
|
|
520
|
-
console.log('>>> Message received:', {
|
|
521
|
-
counter,
|
|
522
|
-
payload: message.payload.toString(),
|
|
523
|
-
offset: message.offset,
|
|
524
|
-
partition: message.partition,
|
|
525
|
-
topic: message.topic,
|
|
526
|
-
})
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
kafkaStream.on('error', async (error) => {
|
|
530
|
-
console.error('Stream error:', error)
|
|
531
|
-
handleRetry()
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
kafkaStream.on('close', () => {
|
|
535
|
-
console.log('Stream ended')
|
|
536
|
-
try {
|
|
537
|
-
kafkaStream.unsubscribe()
|
|
538
|
-
} catch (unsubError) {
|
|
539
|
-
console.error('Error unsubscribing:', unsubError)
|
|
540
|
-
}
|
|
541
|
-
})
|
|
542
|
-
} catch (error) {
|
|
543
|
-
console.error('Error setting up consumer:', error)
|
|
544
|
-
handleRetry()
|
|
545
|
-
}
|
|
546
|
-
}
|
|
662
|
+
## Configuration
|
|
547
663
|
|
|
548
|
-
|
|
549
|
-
}
|
|
664
|
+
### KafkaClient
|
|
550
665
|
|
|
551
|
-
|
|
666
|
+
```ts
|
|
667
|
+
const client = new KafkaClient({
|
|
668
|
+
brokers: 'localhost:9092',
|
|
669
|
+
clientId: 'orders-service',
|
|
670
|
+
securityProtocol: 'Plaintext',
|
|
671
|
+
logLevel: 'info',
|
|
672
|
+
brokerAddressFamily: 'v4',
|
|
673
|
+
diagnostics: true,
|
|
674
|
+
configuration: {
|
|
675
|
+
'socket.keepalive.enable': true,
|
|
676
|
+
},
|
|
677
|
+
})
|
|
552
678
|
```
|
|
553
679
|
|
|
554
|
-
|
|
680
|
+
| Option | Type | Default | Description |
|
|
681
|
+
| --------------------- | ------------------------------------------------------ | --------- | ------------------------------------------------------------------------- |
|
|
682
|
+
| `brokers` | `string` | required | Comma-separated broker list, for example `localhost:9092,localhost:9093`. |
|
|
683
|
+
| `clientId` | `string` | `rdkafka` | Client identifier sent to Kafka. |
|
|
684
|
+
| `securityProtocol` | `'Plaintext' \| 'Ssl' \| 'SaslPlaintext' \| 'SaslSsl'` | | Security protocol. |
|
|
685
|
+
| `logLevel` | `string` | `error` | librdkafka log level. |
|
|
686
|
+
| `brokerAddressFamily` | `string` | `v4` | Address family hint such as `v4` or `any`. |
|
|
687
|
+
| `diagnostics` | `boolean` | `true` | Enables diagnostic-channel events used by `kafka-crab-js-otel`. |
|
|
688
|
+
| `configuration` | `Record<string, any>` | | Additional librdkafka client settings. |
|
|
555
689
|
|
|
556
|
-
|
|
690
|
+
`configuration` is passed through to librdkafka after values are converted to strings. Use the original librdkafka
|
|
691
|
+
property names, for example `sasl.mechanism`, `queued.min.messages`, or `fetch.wait.max.ms`.
|
|
557
692
|
|
|
558
|
-
|
|
693
|
+
Common connection examples:
|
|
559
694
|
|
|
560
|
-
|
|
695
|
+
```ts
|
|
696
|
+
const localClient = new KafkaClient({
|
|
697
|
+
brokers: 'localhost:9092',
|
|
698
|
+
clientId: 'orders-local',
|
|
699
|
+
securityProtocol: 'Plaintext',
|
|
700
|
+
brokerAddressFamily: 'v4',
|
|
701
|
+
})
|
|
702
|
+
```
|
|
561
703
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
704
|
+
```ts
|
|
705
|
+
const saslClient = new KafkaClient({
|
|
706
|
+
brokers: process.env.KAFKA_BROKERS!,
|
|
707
|
+
clientId: 'orders-worker',
|
|
708
|
+
securityProtocol: 'SaslSsl',
|
|
709
|
+
configuration: {
|
|
710
|
+
'sasl.mechanism': 'PLAIN',
|
|
711
|
+
'sasl.username': process.env.KAFKA_USERNAME!,
|
|
712
|
+
'sasl.password': process.env.KAFKA_PASSWORD!,
|
|
713
|
+
},
|
|
714
|
+
})
|
|
715
|
+
```
|
|
571
716
|
|
|
572
717
|
### ConsumerConfiguration
|
|
573
718
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
719
|
+
```ts
|
|
720
|
+
const consumer = client.createConsumer({
|
|
721
|
+
groupId: 'orders-worker',
|
|
722
|
+
enableAutoCommit: false,
|
|
723
|
+
fetchMetadataTimeout: 5000,
|
|
724
|
+
configuration: {
|
|
725
|
+
'auto.offset.reset': 'earliest',
|
|
726
|
+
'enable.auto.commit': false,
|
|
727
|
+
'fetch.min.bytes': 1,
|
|
728
|
+
},
|
|
729
|
+
})
|
|
730
|
+
```
|
|
581
731
|
|
|
582
|
-
|
|
732
|
+
| Option | Type | Description |
|
|
733
|
+
| ---------------------- | --------------------- | ------------------------------------------ |
|
|
734
|
+
| `groupId` | `string` | Kafka consumer group id. |
|
|
735
|
+
| `enableAutoCommit` | `boolean` | Convenience flag for auto commit behavior. |
|
|
736
|
+
| `fetchMetadataTimeout` | `number` | Metadata fetch timeout in milliseconds. |
|
|
737
|
+
| `configuration` | `Record<string, any>` | Additional librdkafka consumer settings. |
|
|
583
738
|
|
|
584
|
-
|
|
739
|
+
`enableAutoCommit` is a convenience option that sets librdkafka `enable.auto.commit`. You can also pass
|
|
740
|
+
`'enable.auto.commit'` in `configuration`, but using both with different values makes the intent hard to read.
|
|
585
741
|
|
|
586
|
-
|
|
587
|
-
| --------------- | ---------------------------------------- | ----------------------------------------------------------------------- |
|
|
588
|
-
| `commit` | `commit(topic, partition, offset, mode)` | Traditional commit - you must calculate `offset + 1` |
|
|
589
|
-
| `commitMessage` | `commitMessage(message, mode)` | **v2.1.0+**: Simplified commit - automatically handles offset increment |
|
|
742
|
+
For new services that need explicit processing guarantees, start with manual commits:
|
|
590
743
|
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
744
|
+
```ts
|
|
745
|
+
const consumer = client.createConsumer({
|
|
746
|
+
groupId: 'orders-worker',
|
|
747
|
+
enableAutoCommit: false,
|
|
748
|
+
configuration: {
|
|
749
|
+
'auto.offset.reset': 'earliest',
|
|
750
|
+
'enable.partition.eof': false,
|
|
751
|
+
},
|
|
752
|
+
})
|
|
753
|
+
```
|
|
595
754
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
755
|
+
### ProducerConfiguration
|
|
756
|
+
|
|
757
|
+
```ts
|
|
758
|
+
const producer = client.createProducer({
|
|
759
|
+
autoFlush: true,
|
|
760
|
+
queueTimeout: 5000,
|
|
761
|
+
configuration: {
|
|
762
|
+
'compression.type': 'lz4',
|
|
763
|
+
},
|
|
764
|
+
})
|
|
599
765
|
```
|
|
600
766
|
|
|
601
|
-
|
|
767
|
+
| Option | Type | Description |
|
|
768
|
+
| --------------- | --------------------- | ------------------------------------------------------- |
|
|
769
|
+
| `autoFlush` | `boolean` | When enabled, `send()` waits for delivery confirmation. |
|
|
770
|
+
| `queueTimeout` | `number` | Queue timeout in milliseconds. |
|
|
771
|
+
| `configuration` | `Record<string, any>` | Additional librdkafka producer settings. |
|
|
602
772
|
|
|
603
|
-
|
|
773
|
+
Producer settings are workload-specific. A low-latency producer might prefer smaller buffering windows, while a
|
|
774
|
+
throughput-oriented producer can allow librdkafka to coalesce more work:
|
|
604
775
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
776
|
+
```ts
|
|
777
|
+
const producer = client.createProducer({
|
|
778
|
+
autoFlush: false,
|
|
779
|
+
queueTimeout: 5000,
|
|
780
|
+
configuration: {
|
|
781
|
+
acks: 'all',
|
|
782
|
+
'compression.type': 'lz4',
|
|
783
|
+
'queue.buffering.max.ms': 10,
|
|
784
|
+
'queue.buffering.max.messages': 100000,
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
```
|
|
610
788
|
|
|
611
789
|
### TopicPartitionConfig
|
|
612
790
|
|
|
613
|
-
|
|
|
614
|
-
| ----------------- |
|
|
615
|
-
| `topic` | `string`
|
|
616
|
-
| `allOffsets` | `OffsetModel`
|
|
617
|
-
| `partitionOffset` | `
|
|
618
|
-
| `createTopic` | `boolean`
|
|
619
|
-
| `numPartitions` | `number`
|
|
620
|
-
| `replicas` | `number`
|
|
791
|
+
| Option | Type | Description |
|
|
792
|
+
| ----------------- | ------------------- | ------------------------------------------------ |
|
|
793
|
+
| `topic` | `string` | Topic name. |
|
|
794
|
+
| `allOffsets` | `OffsetModel` | Offset model applied to all assigned partitions. |
|
|
795
|
+
| `partitionOffset` | `PartitionOffset[]` | Partition-specific offset model. |
|
|
796
|
+
| `createTopic` | `boolean` | Create the topic before subscribing. |
|
|
797
|
+
| `numPartitions` | `number` | Partition count when creating a topic. |
|
|
798
|
+
| `replicas` | `number` | Replica count when creating a topic. |
|
|
621
799
|
|
|
622
|
-
|
|
800
|
+
Offset models:
|
|
623
801
|
|
|
624
|
-
|
|
802
|
+
```ts
|
|
803
|
+
{
|
|
804
|
+
position: 'Beginning'
|
|
805
|
+
}
|
|
806
|
+
{
|
|
807
|
+
position: 'End'
|
|
808
|
+
}
|
|
809
|
+
{
|
|
810
|
+
position: 'Stored'
|
|
811
|
+
}
|
|
812
|
+
{
|
|
813
|
+
position: 'Invalid'
|
|
814
|
+
}
|
|
815
|
+
{
|
|
816
|
+
offset: 42
|
|
817
|
+
}
|
|
818
|
+
```
|
|
625
819
|
|
|
626
|
-
|
|
820
|
+
### Type-Only Literals
|
|
627
821
|
|
|
628
|
-
|
|
822
|
+
In v4, these names are TypeScript-only exports:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
import type { CommitMode, KafkaEventName, PartitionPosition, SecurityProtocol } from 'kafka-crab-js'
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Use literal values at runtime:
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
const commitMode: CommitMode = 'Sync'
|
|
832
|
+
const securityProtocol: SecurityProtocol = 'Plaintext'
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
## OpenTelemetry
|
|
836
|
+
|
|
837
|
+
OpenTelemetry support lives in the separate `kafka-crab-js-otel` package. The core package emits diagnostic-channel
|
|
838
|
+
events when `diagnostics !== false`; the OTEL package subscribes to those events.
|
|
839
|
+
|
|
840
|
+
Install the instrumentation package and OpenTelemetry dependencies:
|
|
629
841
|
|
|
630
842
|
```bash
|
|
631
|
-
npm install kafka-crab-js-otel @opentelemetry/api
|
|
843
|
+
npm install kafka-crab-js-otel @opentelemetry/api @opentelemetry/sdk-node
|
|
632
844
|
```
|
|
633
845
|
|
|
634
|
-
|
|
846
|
+
Enable instrumentation before creating `KafkaClient`:
|
|
635
847
|
|
|
636
|
-
```
|
|
848
|
+
```ts
|
|
637
849
|
import { KafkaClient } from 'kafka-crab-js'
|
|
638
850
|
import { enableOtelInstrumentation, endSpan } from 'kafka-crab-js-otel'
|
|
639
851
|
|
|
640
|
-
// Enable instrumentation before creating client
|
|
641
|
-
// Note: serviceName is configured via OTEL SDK Resource
|
|
642
852
|
enableOtelInstrumentation({
|
|
643
853
|
metrics: { enabled: true },
|
|
644
854
|
})
|
|
645
855
|
|
|
646
|
-
// Create client with diagnostics enabled
|
|
647
856
|
const client = new KafkaClient({
|
|
648
857
|
brokers: 'localhost:9092',
|
|
649
|
-
clientId: '
|
|
858
|
+
clientId: 'orders-worker',
|
|
650
859
|
diagnostics: true,
|
|
651
860
|
})
|
|
652
861
|
|
|
653
|
-
|
|
862
|
+
const consumer = client.createConsumer({ groupId: 'orders-worker' })
|
|
863
|
+
await consumer.subscribe('orders')
|
|
864
|
+
|
|
654
865
|
const message = await consumer.recv()
|
|
655
|
-
|
|
656
|
-
|
|
866
|
+
if (message) {
|
|
867
|
+
try {
|
|
868
|
+
await processOrder(message.payload)
|
|
869
|
+
} finally {
|
|
870
|
+
endSpan(message)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
657
873
|
```
|
|
658
874
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
#### Examples
|
|
662
|
-
|
|
663
|
-
See comprehensive examples in the `example/` directory:
|
|
664
|
-
|
|
665
|
-
- `example/otel-tracing-example.mjs` - Complete tracing setup with Jaeger
|
|
666
|
-
- `example/otel-metrics-example.mjs` - Metrics collection with Prometheus
|
|
667
|
-
- `example/README.md` - Full documentation for all examples
|
|
875
|
+
See the [OpenTelemetry package README](../kafka-crab-js-otel/README.md) for full tracing and metrics setup.
|
|
668
876
|
|
|
669
877
|
## Performance Benchmarks
|
|
670
878
|
|
|
671
|
-
|
|
879
|
+
The repository includes a benchmark suite that compares `kafka-crab-js` with KafkaJS and `@platformatic/kafka`. The
|
|
880
|
+
latest captured run in [BENCHMARKS.md](../../BENCHMARKS.md) was recorded on May 25, 2026 on a MacBook Pro with Apple M1,
|
|
881
|
+
Node.js `v24.16.0`, and a local three-broker `apache/kafka:4.0.0` cluster.
|
|
672
882
|
|
|
673
|
-
|
|
883
|
+
From the repository root:
|
|
674
884
|
|
|
675
885
|
```bash
|
|
676
|
-
|
|
886
|
+
podman compose up -d
|
|
677
887
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
888
|
+
cd benchmarks/kafka
|
|
889
|
+
|
|
890
|
+
# Set up benchmark data. Requires Kafka running locally.
|
|
891
|
+
KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run setup:consumer
|
|
681
892
|
|
|
682
|
-
# Run
|
|
683
|
-
vp run benchmark
|
|
893
|
+
# Run the default isolated-process memory benchmark.
|
|
894
|
+
KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run benchmark
|
|
684
895
|
```
|
|
685
896
|
|
|
686
|
-
|
|
897
|
+
The default benchmark runs one child Node.js process per selected scenario. This avoids carrying V8 heap pages, native
|
|
898
|
+
allocator arenas, sockets, librdkafka state, and Kafka metadata from one client into the next client's measurement.
|
|
687
899
|
|
|
688
|
-
|
|
900
|
+
### Consumer Benchmark Snapshot
|
|
689
901
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
║ node-rdkafka (evented) │ 84115 │ 24922.67 op/sec │ ± 74.82 % │ + 2887.91 % ║
|
|
696
|
-
║ kafka-crab-js (serial) │ 50000 │ 43213.86 op/sec │ ± 3.46 % │ + 73.39 % ║
|
|
697
|
-
║ node-rdkafka (stream) │ 50000 │ 49805.32 op/sec │ ± 27.10 % │ + 15.25 % ║
|
|
698
|
-
╟────────────────────────┼─────────┼──────────────────┼───────────┼──────────────────────────╢
|
|
699
|
-
║ Fastest test │ Samples │ Result │ Tolerance │ Difference with previous ║
|
|
700
|
-
╟────────────────────────┼─────────┼──────────────────┼───────────┼──────────────────────────╢
|
|
701
|
-
║ kafka-crab-js (batch) │ 50000 │ 205985.31 op/sec │ ± 16.53 % │ + 313.58 % ║
|
|
702
|
-
╚════════════════════════╧═════════╧══════════════════╧═══════════╧══════════════════════════╝
|
|
902
|
+
The captured run below is equivalent to:
|
|
903
|
+
|
|
904
|
+
```bash
|
|
905
|
+
cd benchmarks/kafka
|
|
906
|
+
KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run benchmark
|
|
703
907
|
```
|
|
704
908
|
|
|
705
|
-
The benchmark
|
|
909
|
+
The run uses the default isolated-process lifecycle-memory benchmark mode.
|
|
706
910
|
|
|
707
|
-
|
|
708
|
-
- **kafka-crab-js (batch)**: Batch message processing - **205,985 ops/sec** (fastest)
|
|
709
|
-
- **node-rdkafka (evented)**: Event-based processing - **24,923 ops/sec**
|
|
710
|
-
- **node-rdkafka (stream)**: Stream-based processing - **49,805 ops/sec**
|
|
711
|
-
- **kafkajs**: Official KafkaJS client - **834 ops/sec**
|
|
911
|
+

|
|
712
912
|
|
|
713
|
-
|
|
913
|
+
#### Throughput
|
|
714
914
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
-
|
|
915
|
+
| Rank | Scenario | Result | Relative |
|
|
916
|
+
| ---: | ----------------------------------- | --------------------: | -------: |
|
|
917
|
+
| 1 | `kafka-crab-js v4 (stream, batch)` | `1,167,396.71 op/sec` | `100.0%` |
|
|
918
|
+
| 2 | `@platformatic/kafka` | `653,355.35 op/sec` | `56.0%` |
|
|
919
|
+
| 3 | `kafka-crab-js v4 (stream, serial)` | `558,968.71 op/sec` | `47.9%` |
|
|
920
|
+
| 4 | `KafkaJS (eachBatch)` | `478,411.67 op/sec` | `41.0%` |
|
|
921
|
+
| 5 | `KafkaJS (eachMessage)` | `431,519.93 op/sec` | `37.0%` |
|
|
922
|
+
| 6 | `KafkaJS (eachMessage, concurrent)` | `427,265.29 op/sec` | `36.6%` |
|
|
720
923
|
|
|
721
|
-
|
|
924
|
+
`kafka-crab-js v4 (stream, batch)` is the fastest scenario in this run. It is about `78.7%` faster than
|
|
925
|
+
`@platformatic/kafka` and about `144.0%` faster than `KafkaJS (eachBatch)`. Its tolerance was `+/- 4.40%`, so the exact
|
|
926
|
+
gap should still be treated as a benchmark snapshot rather than a universal constant.
|
|
722
927
|
|
|
723
|
-
|
|
928
|
+
For message-by-message consumption, `kafka-crab-js v4 (stream, serial)` is close to KafkaJS throughput while using much
|
|
929
|
+
less lifecycle memory. In this run it is about `29.5%` faster than `KafkaJS (eachMessage)` and about `30.8%` faster
|
|
930
|
+
than the concurrent KafkaJS `eachMessage` scenario.
|
|
724
931
|
|
|
725
|
-
|
|
726
|
-
export const topic = 'benchmarks'
|
|
727
|
-
export const brokers = ['localhost:9092', 'localhost:9093', 'localhost:9094']
|
|
932
|
+
#### Memory And GC
|
|
728
933
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
934
|
+
| Scenario | RSS delta | Peak heap | GC time | GC share | Notes |
|
|
935
|
+
| ----------------------------------- | ----------: | ---------: | ---------: | -------: | ------------------------------------------ |
|
|
936
|
+
| `kafka-crab-js v4 (stream, serial)` | `64.7 MiB` | `14.6 MiB` | `30.46 ms` | `3.41%` | Lowest lifecycle RSS and heap. |
|
|
937
|
+
| `kafka-crab-js v4 (stream, batch)` | `106.0 MiB` | `29.5 MiB` | `21.69 ms` | `5.06%` | Best throughput, efficiency, and GC time. |
|
|
938
|
+
| `KafkaJS (eachMessage, concurrent)` | `178.3 MiB` | `72.2 MiB` | `83.77 ms` | `7.16%` | Concurrency did not improve this workload. |
|
|
939
|
+
| `KafkaJS (eachMessage)` | `186.3 MiB` | `91.6 MiB` | `90.15 ms` | `7.78%` | Highest peak heap and max GC pause. |
|
|
940
|
+
| `KafkaJS (eachBatch)` | `193.4 MiB` | `83.8 MiB` | `63.05 ms` | `6.03%` | KafkaJS batch baseline. |
|
|
941
|
+
| `@platformatic/kafka` | `216.0 MiB` | `87.4 MiB` | `74.27 ms` | `9.71%` | Fastest non-crab competitor. |
|
|
733
942
|
|
|
734
|
-
|
|
943
|
+
Lifecycle memory includes module loading, client creation, subscription, consumption, cleanup, and retained RSS after
|
|
944
|
+
cleanup. GC metrics use the narrower first-to-last-message window used for throughput.
|
|
735
945
|
|
|
736
|
-
|
|
946
|
+
The most important memory result is efficiency, not just raw RSS. `kafka-crab-js v4 (stream, batch)` delivered
|
|
947
|
+
`11016 op/sec/MiB`, the best score in the run. `kafka-crab-js v4 (stream, serial)` delivered `8635 op/sec/MiB`, ahead
|
|
948
|
+
of `@platformatic/kafka` at `3025 op/sec/MiB` and `KafkaJS (eachBatch)` at `2474 op/sec/MiB`.
|
|
737
949
|
|
|
738
|
-
|
|
739
|
-
- Implement proper error logging and monitoring
|
|
740
|
-
- Handle both operational and programming errors separately
|
|
950
|
+
### Benchmark Interpretation
|
|
741
951
|
|
|
742
|
-
|
|
952
|
+
- Use `kafka-crab-js v4 (stream, batch)` when raw throughput or memory efficiency is the priority.
|
|
953
|
+
- Use `kafka-crab-js v4 (stream, serial)` when message-by-message processing needs the lowest lifecycle RSS and heap
|
|
954
|
+
pressure.
|
|
955
|
+
- Compare batch scenarios with batch scenarios and message scenarios with message scenarios.
|
|
956
|
+
- `@platformatic/kafka` is the strongest non-crab throughput competitor in this run, but it used more RSS, heap, and GC
|
|
957
|
+
time than both v4 scenarios.
|
|
958
|
+
- KafkaJS `eachMessage` concurrency did not help this workload.
|
|
743
959
|
|
|
744
|
-
|
|
745
|
-
- Configure appropriate batch sizes and compression settings
|
|
746
|
-
- Monitor and tune consumer group performance
|
|
747
|
-
- Leverage the benchmark suite to optimize your specific use case
|
|
960
|
+
### Benchmark Configuration
|
|
748
961
|
|
|
749
|
-
|
|
962
|
+
Captured benchmark settings:
|
|
750
963
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
964
|
+
```bash
|
|
965
|
+
BENCHMARK_ITERATIONS=100000
|
|
966
|
+
BENCHMARK_RUNS=5
|
|
967
|
+
BENCHMARK_FETCH_MIN_BYTES=1
|
|
968
|
+
BENCHMARK_FETCH_WAIT_MS=10
|
|
969
|
+
BENCHMARK_MAX_BYTES=2048
|
|
970
|
+
BENCHMARK_BATCH_SIZE=4096
|
|
971
|
+
BENCHMARK_BATCH_TIMEOUT_MS=2
|
|
972
|
+
BENCHMARK_KAFKAJS_EACH_MESSAGE_CONCURRENCY=3
|
|
973
|
+
BENCHMARK_MEMORY=1
|
|
974
|
+
BENCHMARK_MEMORY_SAMPLE_MS=100
|
|
975
|
+
BENCHMARK_MEMORY_SETTLE_MS=100
|
|
976
|
+
BENCHMARK_TOPIC=benchmarks
|
|
977
|
+
BENCHMARK_PARTITIONS=3
|
|
978
|
+
```
|
|
760
979
|
|
|
761
|
-
|
|
980
|
+
See [BENCHMARKS.md](../../BENCHMARKS.md) for the latest captured run and the
|
|
981
|
+
[benchmark README](../../benchmarks/kafka/README.md) for the full benchmark methodology and environment variables.
|
|
762
982
|
|
|
763
|
-
|
|
764
|
-
- Implement proper serialization/deserialization
|
|
765
|
-
- Handle message ordering when required
|
|
766
|
-
- Use topic creation options for better topic management
|
|
983
|
+
## Migration Notes
|
|
767
984
|
|
|
768
|
-
###
|
|
985
|
+
### v4 Type-Only Runtime Exports
|
|
769
986
|
|
|
770
|
-
|
|
771
|
-
-
|
|
772
|
-
- Set appropriate `highWaterMark` based on memory constraints
|
|
987
|
+
`CommitMode`, `KafkaEventName`, `PartitionPosition`, and `SecurityProtocol` are no longer runtime exports from
|
|
988
|
+
`kafka-crab-js`.
|
|
773
989
|
|
|
774
|
-
|
|
990
|
+
Before:
|
|
775
991
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
4. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
780
|
-
5. Push to the branch (`git push origin feature/amazing-feature`)
|
|
781
|
-
6. Open a Pull Request
|
|
992
|
+
```ts
|
|
993
|
+
import { CommitMode, SecurityProtocol } from 'kafka-crab-js'
|
|
994
|
+
```
|
|
782
995
|
|
|
783
|
-
|
|
996
|
+
After:
|
|
784
997
|
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
vp run build
|
|
998
|
+
```ts
|
|
999
|
+
import type { CommitMode, SecurityProtocol } from 'kafka-crab-js'
|
|
788
1000
|
|
|
789
|
-
|
|
790
|
-
|
|
1001
|
+
const commitMode: CommitMode = 'Sync'
|
|
1002
|
+
const securityProtocol: SecurityProtocol = 'Plaintext'
|
|
1003
|
+
```
|
|
791
1004
|
|
|
792
|
-
|
|
793
|
-
vp check
|
|
1005
|
+
### v3 OpenTelemetry Split
|
|
794
1006
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
vp run benchmark
|
|
798
|
-
```
|
|
1007
|
+
OpenTelemetry instrumentation moved from the core package to `kafka-crab-js-otel` in v3. This keeps the core package
|
|
1008
|
+
small and makes tracing/metrics opt-in.
|
|
799
1009
|
|
|
800
|
-
##
|
|
1010
|
+
## Troubleshooting
|
|
801
1011
|
|
|
802
|
-
|
|
1012
|
+
### The Consumer Does Not Receive Existing Messages
|
|
803
1013
|
|
|
804
|
-
|
|
1014
|
+
Use an offset reset policy and a fresh consumer group:
|
|
805
1015
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1016
|
+
```ts
|
|
1017
|
+
const consumer = client.createConsumer({
|
|
1018
|
+
groupId: `orders-worker-${Date.now()}`,
|
|
1019
|
+
configuration: {
|
|
1020
|
+
'auto.offset.reset': 'earliest',
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
```
|
|
810
1024
|
|
|
811
|
-
|
|
1025
|
+
Or subscribe with an explicit starting position:
|
|
812
1026
|
|
|
813
|
-
|
|
1027
|
+
```ts
|
|
1028
|
+
await consumer.subscribe([{ topic: 'orders', allOffsets: { position: 'Beginning' } }])
|
|
1029
|
+
```
|
|
814
1030
|
|
|
815
|
-
|
|
816
|
-
- For batch consumers, call `endSpan(batch)` when you're done processing the batch.
|
|
817
|
-
- The `endSpan()` helper is exported from `kafka-crab-js-otel`.
|
|
1031
|
+
### The Process Hangs On Shutdown
|
|
818
1032
|
|
|
819
|
-
|
|
1033
|
+
Direct consumers should call `disconnect()`:
|
|
820
1034
|
|
|
821
|
-
|
|
1035
|
+
```ts
|
|
1036
|
+
await consumer.disconnect()
|
|
1037
|
+
```
|
|
822
1038
|
|
|
823
|
-
|
|
1039
|
+
Node stream consumers should call `destroy()`:
|
|
824
1040
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1041
|
+
```ts
|
|
1042
|
+
stream.destroy()
|
|
1043
|
+
```
|
|
828
1044
|
|
|
829
|
-
|
|
1045
|
+
Web stream consumers should cancel the reader and disconnect the underlying consumer:
|
|
830
1046
|
|
|
831
|
-
|
|
1047
|
+
```ts
|
|
1048
|
+
await reader.cancel()
|
|
1049
|
+
await webConsumer.consumer.disconnect()
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### Localhost Resolves To IPv6 But Kafka Listens On IPv4
|
|
1053
|
+
|
|
1054
|
+
Set `brokerAddressFamily: 'v4'`:
|
|
832
1055
|
|
|
833
1056
|
```ts
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
|
|
838
|
-
import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'
|
|
839
|
-
import { context } from '@opentelemetry/api'
|
|
840
|
-
|
|
841
|
-
// 1. Set up OpenTelemetry SDK
|
|
842
|
-
const provider = new NodeTracerProvider()
|
|
843
|
-
provider.addSpanProcessor(new SimpleSpanProcessor(exporter))
|
|
844
|
-
provider.register()
|
|
845
|
-
context.setGlobalContextManager(new AsyncHooksContextManager().enable())
|
|
846
|
-
|
|
847
|
-
// 2. Enable kafka-crab-js instrumentation BEFORE creating client
|
|
848
|
-
enableOtelInstrumentation({
|
|
849
|
-
captureMessagePayload: true,
|
|
850
|
-
captureMessageHeaders: true,
|
|
851
|
-
producerHook: (span) => span.setAttribute('messaging.client.kind', 'producer'),
|
|
1057
|
+
const client = new KafkaClient({
|
|
1058
|
+
brokers: 'localhost:9092',
|
|
1059
|
+
brokerAddressFamily: 'v4',
|
|
852
1060
|
})
|
|
1061
|
+
```
|
|
853
1062
|
|
|
854
|
-
|
|
1063
|
+
### Need Advanced Kafka Settings
|
|
1064
|
+
|
|
1065
|
+
Pass librdkafka settings through `configuration`:
|
|
1066
|
+
|
|
1067
|
+
```ts
|
|
855
1068
|
const client = new KafkaClient({
|
|
856
|
-
brokers: 'localhost:
|
|
857
|
-
|
|
858
|
-
|
|
1069
|
+
brokers: 'localhost:9092',
|
|
1070
|
+
configuration: {
|
|
1071
|
+
'socket.keepalive.enable': true,
|
|
1072
|
+
'metadata.max.age.ms': 300000,
|
|
1073
|
+
},
|
|
859
1074
|
})
|
|
1075
|
+
```
|
|
860
1076
|
|
|
861
|
-
|
|
862
|
-
const producer = client.createProducer()
|
|
863
|
-
await producer.send({
|
|
864
|
-
topic: 'orders',
|
|
865
|
-
messages: [
|
|
866
|
-
{
|
|
867
|
-
payload: Buffer.from(JSON.stringify({ orderId: '123' })),
|
|
868
|
-
headers: { 'custom-header': 'foo' },
|
|
869
|
-
},
|
|
870
|
-
],
|
|
871
|
-
})
|
|
1077
|
+
Consumer and producer configuration objects also accept their own `configuration` maps.
|
|
872
1078
|
|
|
873
|
-
|
|
874
|
-
const consumer = client.createStreamConsumer({
|
|
875
|
-
groupId: 'orders-consumer',
|
|
876
|
-
enableAutoCommit: false,
|
|
877
|
-
})
|
|
1079
|
+
### `fetch.max.bytes` Must Be At Least `message.max.bytes`
|
|
878
1080
|
|
|
879
|
-
|
|
1081
|
+
librdkafka validates fetch limits at consumer creation time. If you tune fetch sizes for benchmarks or production and
|
|
1082
|
+
set `message.max.bytes` higher than `fetch.max.bytes`, the consumer will fail to start with an error similar to:
|
|
880
1083
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
endSpan(message)
|
|
887
|
-
}
|
|
888
|
-
})
|
|
1084
|
+
```text
|
|
1085
|
+
`fetch.max.bytes` must be >= `message.max.bytes`
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
Keep the fetch caps aligned:
|
|
889
1089
|
|
|
890
|
-
|
|
891
|
-
consumer
|
|
892
|
-
|
|
1090
|
+
```ts
|
|
1091
|
+
const consumer = client.createConsumer({
|
|
1092
|
+
groupId: 'orders-worker',
|
|
1093
|
+
configuration: {
|
|
1094
|
+
'message.max.bytes': 1_000_000,
|
|
1095
|
+
'fetch.max.bytes': 1_048_576,
|
|
1096
|
+
'max.partition.fetch.bytes': 1_048_576,
|
|
1097
|
+
},
|
|
1098
|
+
})
|
|
893
1099
|
```
|
|
894
1100
|
|
|
895
|
-
|
|
1101
|
+
### Benchmark Numbers Move Between Runs
|
|
896
1102
|
|
|
897
|
-
|
|
1103
|
+
Kafka benchmarks are sensitive to CPU frequency, power mode, broker state, topic data already in page cache, and V8 heap
|
|
1104
|
+
history. The benchmark package defaults to isolated child processes for memory mode to reduce cross-scenario
|
|
1105
|
+
contamination, but you should still compare multiple runs and focus on large, repeatable gaps.
|
|
898
1106
|
|
|
899
|
-
|
|
1107
|
+
## Development
|
|
900
1108
|
|
|
901
|
-
|
|
1109
|
+
From the repository root:
|
|
902
1110
|
|
|
903
|
-
|
|
1111
|
+
```bash
|
|
1112
|
+
vp install
|
|
1113
|
+
vp run build
|
|
1114
|
+
vp run test
|
|
1115
|
+
vp run test:integration
|
|
1116
|
+
vp check
|
|
1117
|
+
```
|
|
904
1118
|
|
|
905
|
-
|
|
1119
|
+
Useful package-local commands:
|
|
1120
|
+
|
|
1121
|
+
```bash
|
|
1122
|
+
vp run --filter kafka-crab-js build
|
|
1123
|
+
vp run --filter kafka-crab-js test
|
|
1124
|
+
vp run --filter kafka-crab-js test:integration
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
Integration tests require Kafka at `localhost:9092` unless `KAFKA_BROKERS` is set.
|
|
1128
|
+
|
|
1129
|
+
## License
|
|
906
1130
|
|
|
907
|
-
|
|
1131
|
+
MIT
|