@relaybus/kafka 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -37
- package/dist/index.cjs +163 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -17
- package/dist/index.d.ts +113 -17
- package/dist/index.js +164 -49
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
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
|
-
-
|
|
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
|
|
16
|
+
pnpm add @relaybus/core @relaybus/kafka @confluentinc/kafka-javascript
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
import {
|
|
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
|
|
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
|
|
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)
|
|
175
|
-
|
|
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 } }`
|
|
235
|
-
bound client-side retries
|
|
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
|
|
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
|
-
`
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
##
|
|
305
|
+
## Reusing the application's producer
|
|
286
306
|
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
package/dist/index.cjs
CHANGED
|
@@ -437,15 +437,30 @@ function jsonEnvelope(mapping) {
|
|
|
437
437
|
transportIds: [RECORD_POSITION],
|
|
438
438
|
failures: { invalid, unencodable: (reason) => new UnencodableEventError(reason) }
|
|
439
439
|
});
|
|
440
|
-
const contentType = mapping.contentType ?? DEFAULT_CONTENT_TYPE;
|
|
440
|
+
const contentType = mapping.contentTypeHeader === false ? {} : { [CONTENT_TYPE]: mapping.contentType ?? DEFAULT_CONTENT_TYPE };
|
|
441
441
|
const stampsTime = mapping.occurredAt === void 0;
|
|
442
|
-
const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? {
|
|
442
|
+
const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? { ...contentType, [EVENT_ID_HEADER]: envelope.id } : contentType;
|
|
443
|
+
const recordKey = resolveRecordKey(mapping.recordKey);
|
|
443
444
|
return {
|
|
444
|
-
encode: (envelope) => encodeJson(envelope, { format, stampsTime, ownHeaders }),
|
|
445
|
+
encode: (envelope) => encodeJson(envelope, { format, stampsTime, ownHeaders, recordKey }),
|
|
445
446
|
decode: (record) => decodeJson(record, format)
|
|
446
447
|
};
|
|
447
448
|
}
|
|
448
|
-
function
|
|
449
|
+
function resolveRecordKey(recordKey) {
|
|
450
|
+
if (recordKey === void 0 || recordKey === "event-key") {
|
|
451
|
+
return (envelope) => envelope.key;
|
|
452
|
+
}
|
|
453
|
+
if (recordKey === "none") {
|
|
454
|
+
return () => null;
|
|
455
|
+
}
|
|
456
|
+
if (typeof recordKey === "function") {
|
|
457
|
+
return recordKey;
|
|
458
|
+
}
|
|
459
|
+
throw new core.ConfigurationError(
|
|
460
|
+
`jsonEnvelope recordKey must be 'event-key', 'none' or a function, received '${String(recordKey)}'`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
function encodeJson(envelope, { format, stampsTime, ownHeaders, recordKey }) {
|
|
449
464
|
assertRepresentable(envelope);
|
|
450
465
|
const { body, headers } = format.write(envelope);
|
|
451
466
|
const taken = [CONTENT_TYPE, EVENT_ID_HEADER].find((name) => Object.hasOwn(headers, name));
|
|
@@ -455,7 +470,7 @@ function encodeJson(envelope, { format, stampsTime, ownHeaders }) {
|
|
|
455
470
|
);
|
|
456
471
|
}
|
|
457
472
|
return {
|
|
458
|
-
key: envelope
|
|
473
|
+
key: recordKey(envelope),
|
|
459
474
|
value: serializeJson(envelope, body),
|
|
460
475
|
headers: { ...headers, ...ownHeaders(envelope) },
|
|
461
476
|
...stampsTime ? { timestamp: recordTimestamp(envelope) } : {}
|
|
@@ -486,9 +501,6 @@ function recordTimestamp(envelope) {
|
|
|
486
501
|
formatTime(envelope);
|
|
487
502
|
return String(envelope.occurredAt.getTime());
|
|
488
503
|
}
|
|
489
|
-
function tracksMembership(consumer) {
|
|
490
|
-
return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
|
|
491
|
-
}
|
|
492
504
|
var GroupJoinWatch = class {
|
|
493
505
|
#settings;
|
|
494
506
|
#waiter;
|
|
@@ -514,7 +526,7 @@ var GroupJoinWatch = class {
|
|
|
514
526
|
eachMessage
|
|
515
527
|
}) {
|
|
516
528
|
await consumer.connect();
|
|
517
|
-
await consumer.subscribe(
|
|
529
|
+
await consumer.subscribe(this.#settings.dialect.subscription(topics, fromBeginning));
|
|
518
530
|
const joined = this.#wait(consumer);
|
|
519
531
|
try {
|
|
520
532
|
await consumer.run({ eachMessage });
|
|
@@ -524,7 +536,7 @@ var GroupJoinWatch = class {
|
|
|
524
536
|
}
|
|
525
537
|
}
|
|
526
538
|
#wait(consumer) {
|
|
527
|
-
if (!tracksMembership(consumer)) {
|
|
539
|
+
if (!this.#settings.dialect.tracksMembership(consumer)) {
|
|
528
540
|
return { promise: Promise.resolve(), dispose: () => void 0 };
|
|
529
541
|
}
|
|
530
542
|
const { name, groupId, timings } = this.#settings;
|
|
@@ -598,7 +610,7 @@ var KafkaProducerSession = class {
|
|
|
598
610
|
delayMs: timings.publishTimeoutMs
|
|
599
611
|
});
|
|
600
612
|
});
|
|
601
|
-
const sending = producer.send(record);
|
|
613
|
+
const sending = producer.send(this.#settings.dialect.record(record));
|
|
602
614
|
sending.catch(() => void 0);
|
|
603
615
|
try {
|
|
604
616
|
await Promise.race([sending, timeout]);
|
|
@@ -607,8 +619,12 @@ var KafkaProducerSession = class {
|
|
|
607
619
|
}
|
|
608
620
|
}
|
|
609
621
|
async #open() {
|
|
610
|
-
const { kafka,
|
|
611
|
-
|
|
622
|
+
const { kafka, dialect, injectedProducer } = this.#settings;
|
|
623
|
+
if (injectedProducer !== void 0) {
|
|
624
|
+
this.#connected = true;
|
|
625
|
+
return withoutLifecycle(injectedProducer);
|
|
626
|
+
}
|
|
627
|
+
const producer = kafka.producer(dialect.producerConfig(this.#settings));
|
|
612
628
|
try {
|
|
613
629
|
await producer.connect();
|
|
614
630
|
} catch (error) {
|
|
@@ -619,6 +635,88 @@ var KafkaProducerSession = class {
|
|
|
619
635
|
return producer;
|
|
620
636
|
}
|
|
621
637
|
};
|
|
638
|
+
function withoutLifecycle(producer) {
|
|
639
|
+
return {
|
|
640
|
+
send: (record) => producer.send(record),
|
|
641
|
+
disconnect: () => Promise.resolve()
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
var KAFKAJS_DIALECT = {
|
|
645
|
+
producerConfig: ({ producerConfig, maxInFlightRequests }) => ({
|
|
646
|
+
...producerConfig,
|
|
647
|
+
idempotent: true,
|
|
648
|
+
maxInFlightRequests
|
|
649
|
+
}),
|
|
650
|
+
consumerConfig: ({ consumerConfig, groupId }) => ({ ...consumerConfig, groupId }),
|
|
651
|
+
subscription: (topics, fromBeginning) => ({ topics, fromBeginning }),
|
|
652
|
+
record: (record) => record,
|
|
653
|
+
tracksMembership: (consumer) => consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0,
|
|
654
|
+
observe: (consumer, membership) => {
|
|
655
|
+
if (!KAFKAJS_DIALECT.tracksMembership(consumer)) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const names = consumer.events;
|
|
659
|
+
const listen = (name, listener) => {
|
|
660
|
+
if (name !== void 0) {
|
|
661
|
+
consumer.on(name, listener);
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
listen(names.GROUP_JOIN, () => membership.joined());
|
|
665
|
+
listen(names.REBALANCING, () => membership.rebalancing());
|
|
666
|
+
listen(names.CRASH, (event) => membership.crashed(event));
|
|
667
|
+
listen(names.STOP, () => membership.left());
|
|
668
|
+
listen(names.DISCONNECT, () => membership.left());
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
var ASSIGN_PARTITIONS = -175;
|
|
672
|
+
var REVOKE_PARTITIONS = -174;
|
|
673
|
+
var CONFLUENT_DIALECT = {
|
|
674
|
+
producerConfig: ({ producerConfig, maxInFlightRequests }) => {
|
|
675
|
+
const user = producerConfig;
|
|
676
|
+
return {
|
|
677
|
+
...user,
|
|
678
|
+
kafkaJS: { ...user.kafkaJS, idempotent: true, maxInFlightRequests, acks: -1 },
|
|
679
|
+
"enable.idempotence": true,
|
|
680
|
+
"max.in.flight": maxInFlightRequests,
|
|
681
|
+
acks: -1
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
consumerConfig: ({ consumerConfig, groupId, fromBeginning }, membership) => {
|
|
685
|
+
const user = consumerConfig;
|
|
686
|
+
const userCallback = typeof user.rebalance_cb === "function" ? user.rebalance_cb : void 0;
|
|
687
|
+
return {
|
|
688
|
+
...user,
|
|
689
|
+
kafkaJS: { ...user.kafkaJS, groupId, fromBeginning },
|
|
690
|
+
"group.id": groupId,
|
|
691
|
+
"auto.offset.reset": fromBeginning ? "earliest" : "latest",
|
|
692
|
+
rebalance_cb: (...args) => {
|
|
693
|
+
if (args[0].code === ASSIGN_PARTITIONS) {
|
|
694
|
+
membership.joined();
|
|
695
|
+
} else if (args[0].code === REVOKE_PARTITIONS) {
|
|
696
|
+
membership.rebalancing();
|
|
697
|
+
}
|
|
698
|
+
return userCallback?.(...args);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
},
|
|
702
|
+
subscription: (topics) => ({ topics }),
|
|
703
|
+
record: ({ acks: _acks, ...record }) => record,
|
|
704
|
+
tracksMembership: () => true,
|
|
705
|
+
observe: () => void 0
|
|
706
|
+
};
|
|
707
|
+
function clientDialect(client) {
|
|
708
|
+
if (client === void 0 || client === "kafkajs") {
|
|
709
|
+
return KAFKAJS_DIALECT;
|
|
710
|
+
}
|
|
711
|
+
if (client === "confluent") {
|
|
712
|
+
return CONFLUENT_DIALECT;
|
|
713
|
+
}
|
|
714
|
+
throw new core.ConfigurationError(
|
|
715
|
+
`Unknown Kafka client '${String(client)}'; use 'kafkajs' or 'confluent'`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/kafka-settings.ts
|
|
622
720
|
var DEFAULTS = {
|
|
623
721
|
name: "kafka",
|
|
624
722
|
maxInFlightRequests: 1,
|
|
@@ -636,6 +734,8 @@ function resolveKafkaSettings(options) {
|
|
|
636
734
|
return {
|
|
637
735
|
name: options.name ?? DEFAULTS.name,
|
|
638
736
|
kafka: options.kafka,
|
|
737
|
+
injectedProducer: resolveInjectedProducer(options),
|
|
738
|
+
dialect: clientDialect(options.client),
|
|
639
739
|
groupId: options.groupId,
|
|
640
740
|
fromBeginning: options.fromBeginning ?? true,
|
|
641
741
|
maxInFlightRequests: internal.BoundedInteger.of(
|
|
@@ -650,6 +750,23 @@ function resolveKafkaSettings(options) {
|
|
|
650
750
|
timings: resolveTimings(options)
|
|
651
751
|
};
|
|
652
752
|
}
|
|
753
|
+
function resolveInjectedProducer(options) {
|
|
754
|
+
const { producer, kafka, producerConfig, maxInFlightRequests } = options;
|
|
755
|
+
if (producer === void 0) {
|
|
756
|
+
if (kafka === void 0) {
|
|
757
|
+
throw new core.ConfigurationError(
|
|
758
|
+
"Give 'kafka', or an already-connected 'producer' to publish with"
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
return void 0;
|
|
762
|
+
}
|
|
763
|
+
if (producerConfig !== void 0 || maxInFlightRequests !== void 0) {
|
|
764
|
+
throw new core.ConfigurationError(
|
|
765
|
+
"Give 'producerConfig' and 'maxInFlightRequests' to a producer you inject yourself"
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
return producer;
|
|
769
|
+
}
|
|
653
770
|
function resolveCodec({ codec, decode }) {
|
|
654
771
|
if (decode === void 0) {
|
|
655
772
|
return codec ?? cloudEventsBinary;
|
|
@@ -719,7 +836,7 @@ function assertCanSubscribe(state, {
|
|
|
719
836
|
settings,
|
|
720
837
|
destinations
|
|
721
838
|
}) {
|
|
722
|
-
const { name, groupId, invalidPolicy } = settings;
|
|
839
|
+
const { name, groupId, invalidPolicy, kafka } = settings;
|
|
723
840
|
if (state === "closed") {
|
|
724
841
|
throw new TransportClosedError(`Transport '${name}' is closed`);
|
|
725
842
|
}
|
|
@@ -728,6 +845,9 @@ function assertCanSubscribe(state, {
|
|
|
728
845
|
`Transport '${name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
|
|
729
846
|
);
|
|
730
847
|
}
|
|
848
|
+
if (kafka === void 0) {
|
|
849
|
+
throw new core.ConfigurationError(`Transport '${name}' needs 'kafka' to create its consumer`);
|
|
850
|
+
}
|
|
731
851
|
if (groupId === void 0 || groupId.length === 0) {
|
|
732
852
|
throw new core.ConfigurationError(`Transport '${name}' needs a groupId to subscribe`);
|
|
733
853
|
}
|
|
@@ -746,10 +866,7 @@ function assertCanSubscribe(state, {
|
|
|
746
866
|
|
|
747
867
|
// src/kafka-records.ts
|
|
748
868
|
function decodeRecord(codec, { topic, partition, message }) {
|
|
749
|
-
|
|
750
|
-
return decodeEnvelope(message);
|
|
751
|
-
}
|
|
752
|
-
const record = {
|
|
869
|
+
return decodeKafkaRecord(codec, {
|
|
753
870
|
topic,
|
|
754
871
|
partition,
|
|
755
872
|
offset: message.offset,
|
|
@@ -757,7 +874,12 @@ function decodeRecord(codec, { topic, partition, message }) {
|
|
|
757
874
|
value: message.value,
|
|
758
875
|
headers: message.headers,
|
|
759
876
|
timestamp: message.timestamp
|
|
760
|
-
};
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
function decodeKafkaRecord(codec, record) {
|
|
880
|
+
if (codec === cloudEventsBinary) {
|
|
881
|
+
return decodeEnvelope(record);
|
|
882
|
+
}
|
|
761
883
|
return completeDecodedEvent(codec.decode(record), record);
|
|
762
884
|
}
|
|
763
885
|
function deadLetterRecord({ topic, partition, message }, {
|
|
@@ -895,10 +1017,12 @@ var KafkaTransport = class extends internal.SubscribingTransport {
|
|
|
895
1017
|
async #subscribe({ destinations, onMessage, hooks }) {
|
|
896
1018
|
assertCanSubscribe(this.#state, { settings: this.#settings, destinations });
|
|
897
1019
|
this.#state = "subscribed";
|
|
898
|
-
const { kafka,
|
|
899
|
-
const
|
|
1020
|
+
const { kafka, dialect, groupId } = this.#settings;
|
|
1021
|
+
const membership = this.#observeConsumer();
|
|
1022
|
+
const consumer = kafka.consumer(dialect.consumerConfig(this.#settings, membership));
|
|
900
1023
|
this.#consumer = consumer;
|
|
901
|
-
this.#
|
|
1024
|
+
this.#groupJoined = !dialect.tracksMembership(consumer);
|
|
1025
|
+
dialect.observe(consumer, membership);
|
|
902
1026
|
const start = () => this.#groupJoin.start(consumer, {
|
|
903
1027
|
topics: [...destinations],
|
|
904
1028
|
fromBeginning: this.#settings.fromBeginning,
|
|
@@ -1039,34 +1163,25 @@ var KafkaTransport = class extends internal.SubscribingTransport {
|
|
|
1039
1163
|
}
|
|
1040
1164
|
}
|
|
1041
1165
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
this.#groupJoined = true;
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1166
|
+
/** What the transport does when the consumer joins, rebalances, crashes or leaves its group. */
|
|
1167
|
+
#observeConsumer() {
|
|
1047
1168
|
const { logger, groupId } = this.#settings;
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1169
|
+
return {
|
|
1170
|
+
joined: () => {
|
|
1171
|
+
this.#groupJoined = true;
|
|
1172
|
+
this.#groupJoin.joined();
|
|
1173
|
+
logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
|
|
1174
|
+
},
|
|
1175
|
+
rebalancing: () => {
|
|
1176
|
+
this.#groupJoined = false;
|
|
1177
|
+
this.#stalledPartitions.clear();
|
|
1178
|
+
logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
|
|
1179
|
+
},
|
|
1180
|
+
crashed: (event) => this.#handleCrash(event),
|
|
1181
|
+
left: () => {
|
|
1182
|
+
this.#groupJoined = false;
|
|
1052
1183
|
}
|
|
1053
1184
|
};
|
|
1054
|
-
listen(names.GROUP_JOIN, () => {
|
|
1055
|
-
this.#groupJoined = true;
|
|
1056
|
-
this.#groupJoin.joined();
|
|
1057
|
-
logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
|
|
1058
|
-
});
|
|
1059
|
-
listen(names.REBALANCING, () => {
|
|
1060
|
-
this.#groupJoined = false;
|
|
1061
|
-
this.#stalledPartitions.clear();
|
|
1062
|
-
logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
|
|
1063
|
-
});
|
|
1064
|
-
listen(names.CRASH, (event) => this.#handleCrash(event));
|
|
1065
|
-
const leftGroup = () => {
|
|
1066
|
-
this.#groupJoined = false;
|
|
1067
|
-
};
|
|
1068
|
-
listen(names.STOP, leftGroup);
|
|
1069
|
-
listen(names.DISCONNECT, leftGroup);
|
|
1070
1185
|
}
|
|
1071
1186
|
#handleCrash(event) {
|
|
1072
1187
|
const { error, restart } = event.payload ?? {};
|
|
@@ -1128,6 +1243,7 @@ exports.cloudEventsBinary = cloudEventsBinary;
|
|
|
1128
1243
|
exports.cloudEventsStructured = cloudEventsStructured;
|
|
1129
1244
|
exports.decodeCloudEvent = decodeCloudEvent;
|
|
1130
1245
|
exports.decodeEnvelope = decodeEnvelope;
|
|
1246
|
+
exports.decodeKafkaRecord = decodeKafkaRecord;
|
|
1131
1247
|
exports.encodeEnvelope = encodeEnvelope;
|
|
1132
1248
|
exports.jsonEnvelope = jsonEnvelope;
|
|
1133
1249
|
exports.plainJsonDecoder = plainJsonDecoder;
|