@relaybus/kafka 0.3.0 → 0.4.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 CHANGED
@@ -7,28 +7,28 @@ outbox events to Kafka topics and feeds events consumed from Kafka into the
7
7
  bus inbox, with at-least-once delivery and idempotent storage.
8
8
 
9
9
  - Wire format: [CloudEvents Kafka protocol binding](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/kafka-protocol-binding.md), binary content mode, so any CloudEvents-aware consumer or tool can read the topic.
10
- - Built for and tested against [`kafkajs`](https://kafka.js.org) 2.2.x on a real broker. The KafkaJS-compatible API of `@confluentinc/kafka-javascript` is **experimental and untested**, see [Confluent](#confluent-experimental-untested). Neither client is a dependency; install the one you use.
10
+ - Tested on a real broker with both clients: [`@confluentinc/kafka-javascript`](https://github.com/confluentinc/confluent-kafka-javascript) 1.11.0 (its KafkaJS-compatible API, **recommended**: maintained, librdkafka based) and [`kafkajs`](https://kafka.js.org) 2.2.4 (unmaintained). See [Choosing a client](#choosing-a-client). Neither client is a dependency; install the one you use.
11
11
  - Never loses an event: the offset is committed only after the bus stored the event; a failing store blocks the partition instead of skipping.
12
12
 
13
13
  ## Install
14
14
 
15
15
  ```sh
16
- pnpm add @relaybus/core @relaybus/kafka kafkajs
16
+ pnpm add @relaybus/core @relaybus/kafka @confluentinc/kafka-javascript
17
17
  ```
18
18
 
19
19
  ## Usage
20
20
 
21
21
  ```ts
22
- import { Kafka, Partitioners } from 'kafkajs';
22
+ import { KafkaJS } from '@confluentinc/kafka-javascript';
23
23
  import { RelayBus } from '@relaybus/core';
24
24
  import { KafkaTransport } from '@relaybus/kafka';
25
25
 
26
- const kafka = new Kafka({ clientId: 'orders', brokers: ['kafka:9092'] });
26
+ const kafka = new KafkaJS.Kafka({ kafkaJS: { clientId: 'orders', brokers: ['kafka:9092'] } });
27
27
 
28
28
  const transport = new KafkaTransport({
29
29
  kafka,
30
+ client: 'confluent', // the default is 'kafkajs'
30
31
  groupId: 'billing', // required to subscribe
31
- producerConfig: { createPartitioner: Partitioners.DefaultPartitioner },
32
32
  });
33
33
 
34
34
  const bus = new RelayBus({
@@ -55,13 +55,15 @@ const consuming = transport.isConsuming();
55
55
 
56
56
  | Option | Default | Meaning |
57
57
  | ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance. It is the producer/consumer factory. |
58
+ | `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance: the factory of the transport's consumer and producer. Optional with `producer`, but then the transport cannot subscribe. |
59
+ | `producer` | none | An already-connected producer the application owns (kafkajs `Producer`, Confluent `KafkaJS.Producer`), used to publish and dead-letter instead of one of `kafka`. Never connected or disconnected by the transport. See [Reusing the application's producer](#reusing-the-applications-producer). |
60
+ | `client` | `'kafkajs'` | Which client `kafka` is: `'kafkajs'` or `'confluent'`. Set `'confluent'` for a Confluent `KafkaJS.Kafka`; see [Choosing a client](#choosing-a-client). |
59
61
  | `name` | `'kafka'` | Transport name used by routes and subscriptions. |
60
62
  | `groupId` | none | Consumer group. Required as soon as `subscribe` is used; publish-only services can omit it. |
61
63
  | `fromBeginning` | `true` | Where a group without committed offsets starts. `true` so events published before the first start are not skipped. |
62
64
  | `maxInFlightRequests` | `1` | Producer in-flight requests, 1 to 5. `1` keeps retries from reordering events of one key. |
63
- | `producerConfig` | `{}` | Extra producer settings (partitioner, compression, ...). `idempotent` and `maxInFlightRequests` always win. |
64
- | `consumerConfig` | `{}` | Extra consumer settings (`sessionTimeout`, `retry`, `heartbeatInterval`, ...). `groupId` always wins. |
65
+ | `producerConfig` | `{}` | Extra producer settings (partitioner, compression, ...). `idempotent` and `maxInFlightRequests` always win. Confluent: KafkaJS-style settings in a `kafkaJS` block, librdkafka properties at the top level; `acks: -1` also wins, as do `enable.idempotence`, `max.in.flight` and `acks` there. |
66
+ | `consumerConfig` | `{}` | Extra consumer settings (`sessionTimeout`, `retry`, `heartbeatInterval`, ...). `groupId` always wins. Confluent: KafkaJS-style settings in a `kafkaJS` block, librdkafka properties at the top level; `fromBeginning` also wins, as do `group.id` and `auto.offset.reset` there. Your `rebalance_cb` is still called. |
65
67
  | `processing.maxAttempts` | `5` | In-process attempts per delivery of a record. |
66
68
  | `processing.initialBackoff` | `'200ms'` | Delay before attempt 2, doubling per attempt. |
67
69
  | `processing.maxBackoff` | `'5s'` | Upper bound of the delay between attempts. |
@@ -77,7 +79,8 @@ const consuming = transport.isConsuming();
77
79
 
78
80
  ## Wire format
79
81
 
80
- Value: the JSON payload. Key: the envelope key (`null` means no key).
82
+ Value: the JSON payload. Key: the event key (`null` means no key); `jsonEnvelope` can write
83
+ another key or none (`recordKey`).
81
84
 
82
85
  | Header | Content |
83
86
  | ------------------------------------ | --------------------------------------------------------- |
@@ -171,8 +174,10 @@ indexes an array) or a header (`{ header: 'event-type' }`) for `type`, `id`, `pa
171
174
  `'whole-body'`), `key`, `source`, `occurredAt`, `correlationId`, `causationId` and `headers`, plus a
172
175
  `contentType`. `type`, `id` and `payload` are required by the types; `id: 'record-position'` uses the
173
176
  record position (records relaybus writes carry the event id in a `relaybus-event-id` header, which
174
- wins on read, so outbox retries stay deduplicated). The record key is always the event key, and when `occurredAt` is not mapped the
175
- record timestamp carries it. Event headers that are record headers keep their names, so a name the
177
+ wins on read, so outbox retries stay deduplicated); `{ body: 'id', whenMissing: 'record-position' }`
178
+ uses the body id when a record has one. The record key is the event key unless `recordKey` says
179
+ otherwise (`'none'` or `(event) => string | null`), `contentTypeHeader: false` writes no
180
+ `content-type` header, and when `occurredAt` is not mapped the record timestamp carries it. Event headers that are record headers keep their names, so a name the
176
181
  mapping uses, `content-type` or `relaybus-event-id`, is refused with `UnencodableEventError`. The
177
182
  [interoperability recipe](https://berkcansavur.github.io/relaybus/recipes/interoperating-with-other-services)
178
183
  has a full table and a MassTransit example.
@@ -225,14 +230,23 @@ without a key (`key: null`) are spread over partitions and have no ordering. Use
225
230
  as the key where order matters. Use the same partitioner in every producer of a topic, otherwise
226
231
  one key can map to different partitions depending on who wrote it.
227
232
 
233
+ The record key is the event key by default. A `jsonEnvelope` codec with `recordKey: 'none'`
234
+ writes records without a key, as many existing producers do, and `recordKey: (event) => ...`
235
+ derives another one. The event key, and the outbox's per-key order, stay as they are: relaybus
236
+ still publishes events of one key one after the other. But a record without a key is placed on a
237
+ partition by the producer (round-robin or sticky), so events of one key can land on different
238
+ partitions and consumers may see them out of order. Keep the event key on the wire wherever
239
+ consumers rely on per-key order.
240
+
228
241
  ## Guarantees
229
242
 
230
243
  - **Publish**: idempotent producer, `acks=-1`; `publish` resolves after the broker ack and rejects
231
244
  with `PublishTimeoutError` after `publishTimeout` (kafkajs would otherwise retry an idempotent
232
245
  send for as long as the broker is unreachable). A rejected publish makes the outbox retry; a
233
246
  timed-out send may still land, so a message can be duplicated, and the idempotent producer plus
234
- the consumer's dedup on the event id absorb it. Pass `producerConfig: { retry: { retries } }` to
235
- bound client-side retries further.
247
+ the consumer's dedup on the event id absorb it. Pass `producerConfig: { retry: { retries } }`
248
+ (Confluent: `producerConfig: { kafkaJS: { retry: { retries } } }`) to bound client-side retries
249
+ further.
236
250
  - **Consume**: `eachMessage` awaits the bus (`RelayBus.receive`, one idempotent insert) and the
237
251
  client commits the offset only afterwards. A duplicate message is stored once.
238
252
  - **Handler failure**: retried in-process `processing.maxAttempts` times with backoff and
@@ -243,7 +257,8 @@ one key can map to different partitions depending on who wrote it.
243
257
  same record. The record is never skipped, so a persistent outage blocks the partition until the
244
258
  store recovers. That is deliberate: `receive` is a cheap insert, and skipping would lose the
245
259
  event. `isConsuming()` is `false` while any partition is stuck (tracked per partition).
246
- - **Dead consumer**: the transport does not rely on the client to restart itself. When the client
260
+ - **Dead consumer** (kafkajs; Confluent never gives up on a consumer): the transport does not rely
261
+ on the client to restart itself. When the client
247
262
  reports a crash with `restart=false`, the transport logs it and reconnects, re-subscribes and
248
263
  re-runs the consumer with bounded backoff (`restart.*`) until it joined again or the transport
249
264
  closed. `isReady()` is `false` meanwhile.
@@ -275,31 +290,70 @@ Only these members are called (`KafkaClientLike` and friends in `src/kafka-clien
275
290
  | `kafka` | `producer(config)`, `consumer(config)` |
276
291
  | producer | `connect()`, `send({ topic, acks, messages: [{ key, value, headers }] })`, `disconnect()` |
277
292
  | consumer | `connect()`, `subscribe({ topics, fromBeginning })`, `run({ eachMessage })`, `disconnect()` |
293
+ | Confluent consumer config | `rebalance_cb(error, assignment)` reports joins (`-175`, assigned) and rebalances (`-174`, revoked) |
278
294
  | `eachMessage` payload | `topic`, `partition`, `message.{ key, value, headers, offset }`, `heartbeat()` |
279
295
  | consumer events (optional) | `consumer.on(name, listener)` and `consumer.events.{ GROUP_JOIN, CRASH, REBALANCING, STOP, DISCONNECT }` |
280
296
 
281
- `on`/`events` are optional: without `GROUP_JOIN` the transport cannot wait for the join or observe
282
- crashes, so `subscribe` resolves when `run` returns and `isConsuming()` only reflects processing
283
- stalls and `close()`. kafkajs 2.2.4 is verified by the integration tests.
297
+ With Confluent, `send` gets no `acks` and `subscribe` no `fromBeginning` (it rejects both; they are
298
+ set on the producer and consumer instead), and `on`/`events` are not used.
299
+
300
+ `on`/`events` are optional for other kafkajs-like clients: without `GROUP_JOIN` the transport cannot
301
+ wait for the join or observe crashes, so `subscribe` resolves when `run` returns and
302
+ `isConsuming()` only reflects processing stalls and `close()`. kafkajs 2.2.4 and
303
+ `@confluentinc/kafka-javascript` 1.11.0 are verified by the integration tests.
284
304
 
285
- ## Confluent (experimental, untested)
305
+ ## Reusing the application's producer
286
306
 
287
- `@confluentinc/kafka-javascript` exposes a KafkaJS-compatible API, and the structural client types
288
- here are satisfied by it, but nothing in this repository runs against it. Its documented
289
- differences from kafkajs mean the options below do **not** work as written for Confluent:
307
+ An application that already has a connected producer, with its SSL and SASL settings, can hand it
308
+ to the transport instead of letting it open a second connection:
290
309
 
291
- - `idempotent` and `maxInFlightRequests` (producer) and `groupId` (consumer) must be nested in the
292
- client's `kafkaJS: { ... }` config block. The transport passes them at the top level, so with
293
- Confluent the producer is not guaranteed to be idempotent and the group id may be ignored.
294
- - `fromBeginning` is not supported per `subscribe()`; it is a consumer-level `kafkaJS` setting.
295
- The transport's `fromBeginning` option is therefore not applied.
296
- - `heartbeat()` is a no-op there and the consumer `events` (`GROUP_JOIN`, `CRASH`, ...) are not
297
- documented, so start-up falls back to "resolved when `run` returns" and crash recovery cannot
298
- trigger.
299
- - Failed `eachMessage` handling (re-seek) and the error classification of a rethrown error differ.
310
+ ```ts
311
+ const transport = new KafkaTransport({
312
+ producer: ordersService.producer, // already connected, owned by the application
313
+ client: 'confluent', // when it is a Confluent producer
314
+ });
315
+ ```
300
316
 
301
- Use it only after verifying these against your version; until a Confluent path is tested, treat
302
- kafkajs as the supported client.
317
+ Without `kafka` the transport only publishes; give `kafka` and `groupId` as well to subscribe (the
318
+ consumer stays the transport's own). The transport calls only `send` on the injected producer: it
319
+ never connects or disconnects it, so it is usable before `bus.start()` and stays open after
320
+ `bus.stop()`; closing it is the application's job, after the bus stopped. The guarantees hold
321
+ only if that producer is configured like the transport's own: idempotent, `acks` -1 (all in-sync
322
+ replicas) and at most 5 in-flight requests (1 keeps retries from reordering a key). For kafkajs
323
+ that is `kafka.producer({ idempotent: true, maxInFlightRequests: 1 })`, for Confluent
324
+ `kafka.producer({ kafkaJS: { idempotent: true, acks: -1, maxInFlightRequests: 1 } })`. Because the
325
+ transport cannot apply `producerConfig` or `maxInFlightRequests` to it, giving them together with
326
+ `producer` is a configuration error.
327
+
328
+ ## Choosing a client
329
+
330
+ **Use `@confluentinc/kafka-javascript`** (`client: 'confluent'`): it is maintained and built on
331
+ librdkafka, while `kafkajs` is no longer maintained. Every integration scenario runs against a real
332
+ broker with both clients (publish and subscribe, retries and redelivery, dead letters, codecs,
333
+ per-key order across partitions, start-up failure, close, interoperability and two services
334
+ through the bus); Confluent is tested with 1.11.0, the lowest version the peer range allows.
335
+
336
+ What the transport does differently for Confluent, because its KafkaJS-compatible API differs:
337
+
338
+ - `idempotent`, `maxInFlightRequests` and `acks: -1` go in the producer's `kafkaJS` block, and
339
+ `groupId` and `fromBeginning` in the consumer's: Confluent rejects them at the top level, in
340
+ `send` and in `subscribe`. They are also set as librdkafka properties (`enable.idempotence`,
341
+ `max.in.flight`, `acks`, `group.id`, `auto.offset.reset`), which Confluent applies after the
342
+ block. Your own `kafkaJS` block and properties are merged below them.
343
+ - The group join and rebalances are followed through `rebalance_cb` (Confluent has no
344
+ instrumentation events), so `subscribe` still waits for the join and `isConsuming()` still turns
345
+ `false` during a rebalance.
346
+ - Confluent never gives up on a consumer: a failed `eachMessage` makes it seek back and redeliver
347
+ the record, and it reports no crash. The restart options therefore never apply.
348
+ - `heartbeat()` is a no-op there; librdkafka heartbeats in the background. Keep the time a record
349
+ can spend in retries (`maxAttempts * maxBackoff`) well below `max.poll.interval.ms` (5 minutes by
350
+ default) instead of `sessionTimeout`.
351
+ - Its producer partitions keys with librdkafka's `murmur2_random`, which librdkafka documents as
352
+ Java compatible, like kafkajs' `DefaultPartitioner`. `createPartitioner` does not exist there.
353
+
354
+ Moving from kafkajs: create the client with `new KafkaJS.Kafka({ kafkaJS: { brokers, clientId } })`,
355
+ add `client: 'confluent'`, and move KafkaJS-style settings in `producerConfig` and `consumerConfig`
356
+ into a `kafkaJS` block (drop `createPartitioner`). The wire format is unchanged.
303
357
 
304
358
  ## Operations
305
359
 
@@ -313,31 +367,31 @@ kafkajs as the supported client.
313
367
  consumer group.
314
368
  - **Session timeout**: the retry loop heartbeats between attempts. Keep
315
369
  `maxAttempts * maxBackoff` well below the consumer `sessionTimeout` (30 s default).
316
- - **Concurrency**: one record at a time per partition (kafkajs default); scale with partitions and
370
+ - **Concurrency**: one record at a time per partition (the default of both clients); scale with partitions and
317
371
  service replicas in the same group. With `partitionsConsumedConcurrently > 1` in `consumerConfig`
318
372
  the stall state is tracked per partition.
319
373
  - **Retention vs. inbox**: Kafka may redeliver old records after rebalances or offset resets. The
320
374
  bus deduplicates on the event id for as long as it keeps completed deliveries
321
375
  (`retention.completed`, default 7 days), so keep that longer than your redelivery window.
322
- - **Partitioner warning** (kafkajs 2.x): pass `createPartitioner: Partitioners.DefaultPartitioner`
376
+ - **Partitioner warning** (kafkajs 2.x only): pass `createPartitioner: Partitioners.DefaultPartitioner`
323
377
  in `producerConfig` to silence the legacy partitioner warning.
324
378
 
325
379
  ## Troubleshooting
326
380
 
327
381
  - `did not join group ... within 30000ms` at start: broker unreachable, wrong credentials, or the
328
- group is rebalancing for longer than `startupTimeout`. Check the kafkajs connection settings.
382
+ group is rebalancing for longer than `startupTimeout`. Check the client's connection settings.
329
383
  - `isConsuming()` stays `false` with `Giving up for now` logs: `RelayBus.receive` keeps failing
330
384
  (database down?). Fix the store; consumption resumes by itself.
331
385
  - `Dead-lettered malformed message` logs: a producer on that topic is not writing CloudEvents binary
332
386
  mode with `content-type: application/json`. Inspect `<topic>.dead` and its `relaybus-invalid-reason`.
333
387
  - `Consumer crashed ... restart=false` followed by `Consumer restarted`: the client gave up and the
334
- transport recovered it. Repeated `Restart failed` logs mean the broker or credentials are the problem.
388
+ transport recovered it (kafkajs only). Repeated `Restart failed` logs mean the broker or credentials are the problem.
335
389
  - `PublishTimeoutError`: no broker ack within `publishTimeout`; the outbox retries.
336
390
  - Events appear twice in logs but handlers run once: expected, the inbox deduplicates on the id.
337
391
 
338
392
  ## When to use @relaybus/kafka, and when not to
339
393
 
340
- Use it to send events between services through Kafka without a dual write between your database and the broker. It is a transport, not a store: it needs `@relaybus/core` and a store, and `@confluentinc/kafka-javascript` support is experimental and untested.
394
+ Use it to send events between services through Kafka without a dual write between your database and the broker. It is a transport, not a store: it needs `@relaybus/core`, a store and one of the two clients, `@confluentinc/kafka-javascript` (recommended) or `kafkajs`.
341
395
 
342
396
  Documentation: <https://berkcansavur.github.io/relaybus/packages/kafka>
343
397