@znight/messaging 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
@@ -0,0 +1,765 @@
1
+ /**
2
+ * `@znight/messaging/kafka`: Apache Kafka driver built on
3
+ * [@platformatic/kafka](https://github.com/platformatic/kafka) 2.x (optional peer dependency).
4
+ *
5
+ * - Each `@Consumer` uses its own consumer group: `<groupId>.<topic>` (or `@Consumer({ groupId })`),
6
+ * with manual commits: an offset is committed once its record was settled.
7
+ * - Records of a partition are handled one after the other (ordering), different partitions
8
+ * concurrently up to the consumer concurrency.
9
+ * - Retries are non-blocking: the record is produced to the group's retry topic (`<group>.retry`) with
10
+ * `x-znight-attempt` and `x-znight-not-before`, then committed. A dedicated retry consumer waits
11
+ * until `not-before` (bounded by `maxRetryDelayMs`) without blocking the main topic (each stream has
12
+ * its own back-pressure). Retried records lose their ordering relative to the partition.
13
+ * - After a rebalance, records fetched again from the committed offset of a partition this member kept
14
+ * are skipped (already buffered or handled); buffered records of the partitions it lost are dropped.
15
+ * - Dead letters are produced to `<topic>.dlq` (acks from all in-sync replicas), then committed.
16
+ * - On stop, unsettled records are not committed: they are consumed again after a restart or rebalance.
17
+ * - Delayed publishing is not supported.
18
+ */
19
+ import type { ConsumerOptions, Message, MessagesStream, ProducerOptions } from '@platformatic/kafka'
20
+ import type {
21
+ DeadLetterRequest,
22
+ DeliveryHandler,
23
+ DriverCapabilities,
24
+ DriverContext,
25
+ DriverDelivery,
26
+ MessagingDriver,
27
+ RetryRequest,
28
+ SubscribeOptions,
29
+ Subscription,
30
+ } from '../driver.ts'
31
+ import type { OutboundMessage } from '../message.ts'
32
+
33
+ import {
34
+ Admin,
35
+ Consumer,
36
+ MessagesStreamFallbackModes,
37
+ MessagesStreamModes,
38
+ ProduceAcks,
39
+ Producer,
40
+ stringDeserializer,
41
+ stringSerializer,
42
+ } from '@platformatic/kafka'
43
+
44
+ import { brokerUnavailableError, consumerError } from '../errors.ts'
45
+ import { attemptFromHeaders, headerRecord, MessagingHeaders } from '../headers.ts'
46
+ import { errorMessage, MAX_TIMER_DELAY_MS, sleep, truncate } from '../internal/async.ts'
47
+ import { durationOption, MAX_ERROR_HEADER_LENGTH } from './shared.ts'
48
+
49
+ type KafkaRecord = Message<string, Buffer, string, string>
50
+
51
+ /** Topic creation settings. */
52
+ export interface KafkaTopicOptions {
53
+ /** Partitions of created topics. Default: 1. */
54
+ readonly partitions?: number
55
+ /** Replication factor of created topics. Default: 1. */
56
+ readonly replicas?: number
57
+ }
58
+
59
+ /** Options of {@link kafka}. */
60
+ export interface KafkaDriverOptions {
61
+ /** `host:port` of the bootstrap brokers. */
62
+ readonly bootstrapBrokers: readonly string[]
63
+ /** Client id. Default: `znight`. */
64
+ readonly clientId?: string
65
+ /** Prefix of the consumer groups (`<groupId>.<topic>`), typically the service name. Default: `znight`. */
66
+ readonly groupId?: string
67
+ /** Options shared by the producer, the consumers and the admin client (tls, sasl, timeouts...). */
68
+ readonly connection?: Readonly<Partial<ProducerOptions<string, Buffer, string, string>>>
69
+ /** Extra consumer options (sessionTimeout, heartbeatInterval...). */
70
+ readonly consumer?: Readonly<Partial<ConsumerOptions<string, Buffer, string, string>>>
71
+ /** Start new consumer groups at the beginning of the topic (`earliest`). Default: `false` (latest). */
72
+ readonly fromBeginning?: boolean
73
+ /** Create missing topics at subscribe time; otherwise their existence is verified. Default: `false`. */
74
+ readonly createTopics?: boolean | KafkaTopicOptions
75
+ /** Name of the retry topic of a consumer group. Default: `${group}.retry`. */
76
+ readonly retryTopicName?: (topic: string, group: string) => string
77
+ /** Default dead-letter topic. Default: `${topic}.dlq`. */
78
+ readonly deadLetterName?: (topic: string) => string
79
+ /** Longest retry delay waited in-process before producing the retry (blocks the partition). Default: 0. */
80
+ readonly maxInProcessDelayMs?: number
81
+ /** Longest delay a retry record may wait in the retry consumer. Default: 15 minutes. */
82
+ readonly maxRetryDelayMs?: number
83
+ }
84
+
85
+ const DEFAULT_MAX_RETRY_DELAY_MS = 15 * 60_000
86
+ /** Attempts of a topic creation failing with transient broker errors, and the first backoff (doubled each time). */
87
+ const CREATE_TOPICS_ATTEMPTS = 3
88
+ const CREATE_TOPICS_BACKOFF_MS = 250
89
+ /** Headers carrying transport fields (not exposed as message headers). */
90
+ const TRANSPORT_HEADERS = new Set<string>([
91
+ MessagingHeaders.Id,
92
+ MessagingHeaders.Type,
93
+ MessagingHeaders.ContentType,
94
+ MessagingHeaders.NotBefore,
95
+ ])
96
+
97
+ /** Creates a {@link KafkaDriver}. */
98
+ export function kafka(options: KafkaDriverOptions): KafkaDriver {
99
+ return new KafkaDriver(options)
100
+ }
101
+
102
+ /** Kafka {@link MessagingDriver}. See the module documentation. */
103
+ export class KafkaDriver implements MessagingDriver {
104
+ readonly name = 'kafka'
105
+ readonly capabilities: DriverCapabilities
106
+ readonly #options: KafkaDriverOptions
107
+ readonly #clientId: string
108
+ readonly #subscriptions = new Set<KafkaSubscription>()
109
+ /** Consumer group → topic, to refuse groups shared between topics. */
110
+ readonly #groups = new Map<string, string>()
111
+ #logger: DriverContext['logger'] = { debug() {}, info() {}, warn() {}, error() {} }
112
+ #producer: Producer<string, Buffer, string, string> | undefined
113
+ #closed = false
114
+
115
+ constructor(options: KafkaDriverOptions) {
116
+ if (
117
+ options === null ||
118
+ typeof options !== 'object' ||
119
+ !Array.isArray(options.bootstrapBrokers) ||
120
+ options.bootstrapBrokers.length === 0
121
+ ) {
122
+ throw new TypeError('[znight] kafka(): the "bootstrapBrokers" option is required')
123
+ }
124
+ this.#options = options
125
+ this.#clientId = options.clientId ?? 'znight'
126
+ this.capabilities = Object.freeze({
127
+ nativeDelay: true,
128
+ maxNativeDelayMs: durationOption(options.maxRetryDelayMs, DEFAULT_MAX_RETRY_DELAY_MS, 'kafka maxRetryDelayMs'),
129
+ maxInProcessDelayMs: durationOption(options.maxInProcessDelayMs, 0, 'kafka maxInProcessDelayMs'),
130
+ deliveryCount: 'header',
131
+ maxPublishDelayMs: 0,
132
+ })
133
+ }
134
+
135
+ async connect(context: DriverContext): Promise<void> {
136
+ this.#logger = context.logger
137
+ if (this.#closed) throw brokerUnavailableError('Kafka: the driver is closed, create a new one')
138
+ if (this.#producer !== undefined) return
139
+ const producer = new Producer<string, Buffer, string, string>({
140
+ ...this.#options.connection,
141
+ clientId: this.#clientId,
142
+ bootstrapBrokers: [...this.#options.bootstrapBrokers],
143
+ serializers: { key: stringSerializer, headerKey: stringSerializer, headerValue: stringSerializer },
144
+ })
145
+ try {
146
+ await producer.metadata({ topics: [] })
147
+ } catch (error) {
148
+ await producer.close().catch(() => undefined)
149
+ throw brokerUnavailableError(
150
+ `Kafka: cannot reach ${this.#options.bootstrapBrokers.join(', ')}: ${errorMessage(error)}`,
151
+ error,
152
+ )
153
+ }
154
+ this.#producer = producer
155
+ }
156
+
157
+ async close(): Promise<void> {
158
+ if (this.#closed) return
159
+ this.#closed = true
160
+ const subscriptions = [...this.#subscriptions]
161
+ this.#subscriptions.clear()
162
+ await Promise.allSettled(subscriptions.map(subscription => subscription.close()))
163
+ const producer = this.#producer
164
+ this.#producer = undefined
165
+ if (producer !== undefined) await producer.close().catch(() => undefined)
166
+ }
167
+
168
+ deadLetterName(topic: string): string {
169
+ return this.#options.deadLetterName?.(topic) ?? `${topic}.dlq`
170
+ }
171
+
172
+ #producerOrThrow(action: string): Producer<string, Buffer, string, string> {
173
+ const producer = this.#producer
174
+ if (producer === undefined) {
175
+ throw brokerUnavailableError(
176
+ this.#closed ? `Kafka: cannot ${action}, the driver is closed` : `Kafka: cannot ${action} before connect()`,
177
+ )
178
+ }
179
+ return producer
180
+ }
181
+
182
+ /** Produces one record and waits for the acknowledgement of all in-sync replicas. */
183
+ async produce(
184
+ topic: string,
185
+ value: Buffer,
186
+ headers: Readonly<Record<string, string>>,
187
+ key: string | undefined,
188
+ timestamp?: number,
189
+ ): Promise<void> {
190
+ await this.#producerOrThrow(`produce to "${topic}"`).send({
191
+ messages: [
192
+ {
193
+ topic,
194
+ value,
195
+ headers: { ...headers },
196
+ ...(key === undefined ? {} : { key }),
197
+ ...(timestamp === undefined ? {} : { timestamp: BigInt(timestamp) }),
198
+ },
199
+ ],
200
+ acks: ProduceAcks.ALL,
201
+ })
202
+ }
203
+
204
+ async publish(topic: string, message: OutboundMessage): Promise<void> {
205
+ if (message.delayMs > 0) throw new RangeError('[znight] The kafka driver does not support delayed publishing')
206
+ const headers = headerRecord()
207
+ for (const [name, value] of Object.entries(message.headers)) if (!TRANSPORT_HEADERS.has(name)) headers[name] = value
208
+ headers[MessagingHeaders.Id] = message.id
209
+ if (message.type !== undefined) headers[MessagingHeaders.Type] = message.type
210
+ headers[MessagingHeaders.ContentType] = message.contentType
211
+ try {
212
+ await this.produce(topic, Buffer.from(message.body), headers, message.key, message.timestamp)
213
+ } catch (error) {
214
+ throw brokerUnavailableError(`Kafka: producing to "${topic}" failed: ${errorMessage(error)}`, error)
215
+ }
216
+ }
217
+
218
+ // -----------------------------------------------------------------------------------------------
219
+ // Consuming
220
+ // -----------------------------------------------------------------------------------------------
221
+
222
+ async subscribe(topic: string, handler: DeliveryHandler, options: SubscribeOptions): Promise<Subscription> {
223
+ this.#producerOrThrow(`subscribe to "${topic}"`)
224
+ const configuredGroup = options.options['groupId']
225
+ if (configuredGroup !== undefined && (typeof configuredGroup !== 'string' || configuredGroup === '')) {
226
+ throw consumerError(`Kafka: the groupId of ${options.consumer} must be a non-empty string`)
227
+ }
228
+ const group = configuredGroup ?? `${this.#options.groupId ?? 'znight'}.${topic}`
229
+ const owner = this.#groups.get(group)
230
+ if (owner !== undefined && owner !== topic) {
231
+ throw consumerError(`Kafka: consumer group "${group}" is used for "${owner}" and "${topic}"`, [
232
+ 'Each @Consumer needs its own consumer group: remove the groupId option or make it unique',
233
+ ])
234
+ }
235
+ const retryTopic =
236
+ options.maxAttempts > 1 ? (this.#options.retryTopicName?.(topic, group) ?? `${group}.retry`) : undefined
237
+ const topics = [topic, retryTopic, options.deadLetterQueue].filter((name): name is string => name !== undefined)
238
+ this.#groups.set(group, topic) // reserved before any await: subscriptions may start concurrently
239
+ try {
240
+ await this.#ensureTopics(topics, options.consumer)
241
+ } catch (error) {
242
+ this.#groups.delete(group)
243
+ throw error
244
+ }
245
+
246
+ const subscription = new KafkaSubscription({
247
+ driver: this,
248
+ topic,
249
+ group,
250
+ retryTopic,
251
+ handler,
252
+ options,
253
+ logger: this.#logger,
254
+ })
255
+ try {
256
+ await subscription.start(
257
+ groupId =>
258
+ new Consumer<string, Buffer, string, string>({
259
+ ...this.#options.connection,
260
+ ...this.#options.consumer,
261
+ clientId: this.#clientId,
262
+ bootstrapBrokers: [...this.#options.bootstrapBrokers],
263
+ groupId,
264
+ autocommit: false,
265
+ deserializers: { key: stringDeserializer, headerKey: stringDeserializer, headerValue: stringDeserializer },
266
+ } as ConsumerOptions<string, Buffer, string, string>),
267
+ this.#options.fromBeginning === true,
268
+ )
269
+ } catch (error) {
270
+ this.#groups.delete(group)
271
+ await subscription.close()
272
+ throw brokerUnavailableError(`Kafka: cannot consume "${topic}": ${errorMessage(error)}`, error)
273
+ }
274
+ this.#subscriptions.add(subscription)
275
+ return {
276
+ cancel: async (): Promise<void> => {
277
+ await subscription.cancel()
278
+ },
279
+ close: async (): Promise<void> => {
280
+ this.#subscriptions.delete(subscription)
281
+ this.#groups.delete(group)
282
+ await subscription.close()
283
+ },
284
+ }
285
+ }
286
+
287
+ async #ensureTopics(topics: readonly string[], consumer: string): Promise<void> {
288
+ const admin = new Admin({
289
+ ...this.#options.connection,
290
+ clientId: `${this.#clientId}-admin`,
291
+ bootstrapBrokers: [...this.#options.bootstrapBrokers],
292
+ })
293
+ try {
294
+ const existing = new Set(await admin.listTopics())
295
+ const missing = topics.filter(topic => !existing.has(topic))
296
+ if (missing.length === 0) return
297
+ const create = this.#options.createTopics
298
+ if (create === undefined || create === false) {
299
+ throw consumerError(
300
+ `Kafka: topic(s) ${missing.map(name => `"${name}"`).join(', ')} of ${consumer} do not exist`,
301
+ ['Create them, or set kafka({ createTopics: true }) to let Znight create them'],
302
+ )
303
+ }
304
+ await createMissingTopics(admin, missing, create === true ? {} : create, consumer)
305
+ } finally {
306
+ await admin.close().catch(() => undefined)
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Creates `topics`. A topic the broker reports as already existing counts as created: subscriptions
313
+ * sharing a topic (a consumer and the collector of its dead letters) or other instances create it
314
+ * concurrently, and the topic listing lags behind the controller, so it cannot tell on its own. The
315
+ * client retries transient errors by itself, but not in a response that also reports an existing
316
+ * topic (not retriable): then the topics still missing are retried here, with a short backoff.
317
+ */
318
+ async function createMissingTopics(
319
+ admin: Admin,
320
+ topics: readonly string[],
321
+ settings: KafkaTopicOptions,
322
+ consumer: string,
323
+ ): Promise<void> {
324
+ let pending = topics
325
+ for (let attempt = 1; ; attempt++) {
326
+ try {
327
+ await admin.createTopics({
328
+ topics: [...pending],
329
+ partitions: settings.partitions ?? 1,
330
+ replicas: settings.replicas ?? 1,
331
+ })
332
+ return
333
+ } catch (error) {
334
+ const all = topicCreationFailures(error)
335
+ const failures = all.filter(failure => failure.code !== 'TOPIC_ALREADY_EXISTS')
336
+ if (failures.length === 0) return
337
+ // a topic without an error was created, unless the request failed as a whole (timeout...)
338
+ const failed = failures.every(failure => failure.topic !== undefined)
339
+ ? new Set(failures.map(failure => failure.topic))
340
+ : undefined
341
+ // created in the meantime, by this request or concurrently
342
+ const listed = new Set(await admin.listTopics().catch((): string[] => []))
343
+ pending = pending.filter(topic => (failed?.has(topic) ?? true) && !listed.has(topic))
344
+ if (pending.length === 0) return
345
+ // without an existing topic in the response, the client already spent its own retries
346
+ const clientRetried = failures.length === all.length
347
+ if (attempt >= CREATE_TOPICS_ATTEMPTS || clientRetried || failures.some(failure => !failure.retriable)) {
348
+ const causes = new Set(
349
+ failures.map(({ topic, code, message }) => `${topic === undefined ? '' : `"${topic}" `}${code}: ${message}`),
350
+ )
351
+ throw brokerUnavailableError(
352
+ `Kafka: cannot create topic(s) ${pending.map(name => `"${name}"`).join(', ')} of ${consumer}` +
353
+ ` (${attempt} attempt${attempt === 1 ? '' : 's'}): ${[...causes].join('; ')}`,
354
+ error,
355
+ )
356
+ }
357
+ await sleep(CREATE_TOPICS_BACKOFF_MS * 2 ** (attempt - 1))
358
+ }
359
+ }
360
+ }
361
+
362
+ /** One error of a failed topic creation. */
363
+ interface TopicCreationFailure {
364
+ /** The topic it concerns, when the broker answered topic by topic. */
365
+ readonly topic: string | undefined
366
+ /** Kafka error (`TOPIC_ALREADY_EXISTS`, `REQUEST_TIMED_OUT`...) or client error code. */
367
+ readonly code: string
368
+ readonly message: string
369
+ /** Whether the client flags it as worth retrying. */
370
+ readonly retriable: boolean
371
+ }
372
+
373
+ /**
374
+ * The leaf errors of a failed `createTopics`: a `MultipleErrors` of the attempts of the client (only
375
+ * the last one counts), each a `ResponseError` carrying the response and one `ProtocolError` per failed
376
+ * topic (`path: /topics/<index>`), or a network error.
377
+ */
378
+ function topicCreationFailures(error: unknown, response?: unknown): TopicCreationFailure[] {
379
+ const fields = (error !== null && typeof error === 'object' ? error : {}) as Record<string, unknown>
380
+ const nested = fields['errors']
381
+ if (Array.isArray(nested) && nested.length > 0) {
382
+ // the errors of a response are its topics; the errors of a retry wrapper are attempts
383
+ const inners = fields['response'] === undefined ? nested.slice(-1) : nested
384
+ return inners.flatMap(inner => topicCreationFailures(inner, fields['response'] ?? response))
385
+ }
386
+ const index = /^\/topics\/(\d+)$/.exec(String(fields['path']))?.[1]
387
+ const topics = (response as { topics?: unknown } | null | undefined)?.topics
388
+ const name =
389
+ index !== undefined && Array.isArray(topics)
390
+ ? (topics[Number(index)] as { name?: unknown } | undefined)?.name
391
+ : undefined
392
+ return [
393
+ {
394
+ topic: typeof name === 'string' ? name : undefined,
395
+ code: String(fields['apiId'] ?? fields['code'] ?? (error instanceof Error ? error.name : 'UNKNOWN')),
396
+ message: String(fields['serverErrorMessage'] ?? errorMessage(error)),
397
+ retriable: fields['canRetry'] !== false,
398
+ },
399
+ ]
400
+ }
401
+
402
+ /**
403
+ * A consumer of the subscription (main topic, or retry topic) with its stream and its back-pressure:
404
+ * each stream is paused and resumed on its own, so that retries waiting for their `not-before` never
405
+ * hold the main topic.
406
+ */
407
+ interface StreamState {
408
+ readonly topic: string
409
+ readonly consumer: Consumer<string, Buffer, string, string>
410
+ stream: MessagesStream<string, Buffer, string, string> | undefined
411
+ /** Records of this stream in the partition queues (the ones being handled included). */
412
+ buffered: number
413
+ paused: boolean
414
+ /** Partitions of `topic` assigned by the last group join (`undefined` before the first one). */
415
+ assigned: ReadonlySet<number> | undefined
416
+ }
417
+
418
+ interface PartitionQueue {
419
+ readonly key: string
420
+ readonly source: StreamState
421
+ readonly partition: number
422
+ readonly records: KafkaRecord[]
423
+ running: boolean
424
+ queued: boolean
425
+ timer: ReturnType<typeof setTimeout> | undefined
426
+ /**
427
+ * Offset following the last record pushed. After a rebalance the stream fetches the partitions it
428
+ * keeps again from their committed offset: older records were already buffered or handled.
429
+ */
430
+ next: bigint | undefined
431
+ }
432
+
433
+ interface KafkaSubscriptionConfig {
434
+ readonly driver: KafkaDriver
435
+ readonly topic: string
436
+ readonly group: string
437
+ readonly retryTopic: string | undefined
438
+ readonly handler: DeliveryHandler
439
+ readonly options: SubscribeOptions
440
+ readonly logger: DriverContext['logger']
441
+ }
442
+
443
+ type Outcome = 'next' | 'again' | 'stop'
444
+
445
+ /**
446
+ * The consumers of one `@Consumer`: the main topic and, with retries, the retry topic. Records are
447
+ * queued per partition; at most `concurrency` partitions are handled at once, one record at a time.
448
+ */
449
+ class KafkaSubscription {
450
+ readonly #config: KafkaSubscriptionConfig
451
+ readonly #partitions = new Map<string, PartitionQueue>()
452
+ readonly #ready: PartitionQueue[] = []
453
+ readonly #sources: StreamState[] = []
454
+ readonly #highWater: number
455
+ readonly #lowWater: number
456
+ readonly #idle = new Set<() => void>()
457
+ #running = 0
458
+ #active = true
459
+ #closed: Promise<void> | undefined
460
+
461
+ constructor(config: KafkaSubscriptionConfig) {
462
+ this.#config = config
463
+ this.#highWater = Math.max(64, config.options.concurrency * 8)
464
+ this.#lowWater = Math.floor(this.#highWater / 2)
465
+ }
466
+
467
+ async start(
468
+ createConsumer: (groupId: string) => Consumer<string, Buffer, string, string>,
469
+ fromBeginning: boolean,
470
+ ): Promise<void> {
471
+ const { topic, group, retryTopic } = this.#config
472
+ await this.#consume(createConsumer(group), topic, fromBeginning)
473
+ // the retry topic only holds records of this group: always read it from the beginning
474
+ if (retryTopic !== undefined) await this.#consume(createConsumer(`${group}.retry`), retryTopic, true)
475
+ }
476
+
477
+ async #consume(
478
+ consumer: Consumer<string, Buffer, string, string>,
479
+ topic: string,
480
+ fromBeginning: boolean,
481
+ ): Promise<void> {
482
+ const source: StreamState = { topic, consumer, stream: undefined, buffered: 0, paused: false, assigned: undefined }
483
+ this.#sources.push(source)
484
+ consumer.on('consumer:group:join', () => this.#onGroupJoin(source))
485
+ const stream = await consumer.consume({
486
+ topics: [topic],
487
+ autocommit: false,
488
+ mode: MessagesStreamModes.COMMITTED,
489
+ fallbackMode: fromBeginning ? MessagesStreamFallbackModes.EARLIEST : MessagesStreamFallbackModes.LATEST,
490
+ })
491
+ source.stream = stream
492
+ stream.on('data', (record: KafkaRecord) => this.#push(source, record))
493
+ stream.on('error', (error: Error) => {
494
+ this.#config.logger.error(`Kafka: consumer stream of "${topic}" failed`, { err: error })
495
+ })
496
+ }
497
+
498
+ /** Stops dispatching: buffered records are dropped (not committed, consumed again later). */
499
+ async cancel(): Promise<void> {
500
+ await Promise.resolve()
501
+ if (!this.#active) return
502
+ this.#active = false
503
+ for (const source of this.#sources) source.stream?.pause()
504
+ for (const queue of this.#partitions.values()) this.#dropBuffered(queue)
505
+ this.#ready.length = 0
506
+ }
507
+
508
+ /** Waits for the records being handled, then leaves the consumer groups. Idempotent. */
509
+ close(): Promise<void> {
510
+ this.#closed ??= this.#close()
511
+ return this.#closed
512
+ }
513
+
514
+ async #close(): Promise<void> {
515
+ await this.cancel()
516
+ if (this.#running > 0) await new Promise<void>(resolve => this.#idle.add(resolve))
517
+ await Promise.allSettled(this.#sources.map(source => source.stream?.close()))
518
+ await Promise.allSettled(this.#sources.map(source => source.consumer.close()))
519
+ }
520
+
521
+ /**
522
+ * A (re)join of the group, with its new assignment: the partitions this member lost belong to
523
+ * another one now, so their buffered records are dropped (the record being handled finishes).
524
+ */
525
+ #onGroupJoin(source: StreamState): void {
526
+ const assigned = new Set<number>()
527
+ for (const assignment of source.consumer.assignments ?? []) {
528
+ if (assignment.topic === source.topic) for (const partition of assignment.partitions) assigned.add(partition)
529
+ }
530
+ source.assigned = assigned
531
+ for (const queue of this.#partitions.values()) {
532
+ if (queue.source !== source || assigned.has(queue.partition)) continue
533
+ this.#dropBuffered(queue)
534
+ // records older than the one being handled come back if the partition is assigned again
535
+ queue.next = queue.running ? (queue.records[0] as KafkaRecord).offset + 1n : undefined
536
+ }
537
+ }
538
+
539
+ /** Drops the records of `queue` that are not being handled. */
540
+ #dropBuffered(queue: PartitionQueue): void {
541
+ if (queue.timer !== undefined) clearTimeout(queue.timer)
542
+ queue.timer = undefined
543
+ const keep = queue.running ? queue.records.slice(0, 1) : []
544
+ this.#unbuffer(queue.source, queue.records.length - keep.length)
545
+ queue.records.length = 0
546
+ queue.records.push(...keep)
547
+ }
548
+
549
+ #push(source: StreamState, record: KafkaRecord): void {
550
+ if (!this.#active) return
551
+ // a late record of a partition revoked by the last rebalance
552
+ if (source.assigned !== undefined && !source.assigned.has(record.partition)) return
553
+ const key = `${record.topic}:${record.partition}`
554
+ let queue = this.#partitions.get(key)
555
+ if (queue === undefined) {
556
+ queue = {
557
+ key,
558
+ source,
559
+ partition: record.partition,
560
+ records: [],
561
+ running: false,
562
+ queued: false,
563
+ timer: undefined,
564
+ next: undefined,
565
+ }
566
+ this.#partitions.set(key, queue)
567
+ }
568
+ // fetched again after a rebalance (from the committed offset): already buffered or handled
569
+ if (queue.next !== undefined && record.offset < queue.next) return
570
+ queue.next = record.offset + 1n
571
+ queue.records.push(record)
572
+ source.buffered++
573
+ if (!queue.running && !queue.queued && queue.timer === undefined) this.#enqueue(queue)
574
+ if (source.buffered >= this.#highWater) {
575
+ // pause again even when paused: the client resumes its streams after a rebalance
576
+ source.paused = true
577
+ source.stream?.pause()
578
+ }
579
+ this.#pump()
580
+ }
581
+
582
+ /** `count` records of `source` left the partition queues: resume it below the low-water mark. */
583
+ #unbuffer(source: StreamState, count: number): void {
584
+ source.buffered -= count
585
+ if (source.paused && this.#active && source.buffered <= this.#lowWater) {
586
+ source.paused = false
587
+ source.stream?.resume()
588
+ }
589
+ }
590
+
591
+ #enqueue(queue: PartitionQueue): void {
592
+ queue.queued = true
593
+ this.#ready.push(queue)
594
+ }
595
+
596
+ #pump(): void {
597
+ while (this.#active && this.#running < this.#config.options.concurrency && this.#ready.length > 0) {
598
+ const queue = this.#ready.shift() as PartitionQueue
599
+ queue.queued = false
600
+ const head = queue.records[0]
601
+ if (head === undefined) continue
602
+ const wait = notBefore(head) - Date.now()
603
+ if (wait > 0) {
604
+ // a retry that is not due yet: wait without holding a slot (timers cannot wait more than 2^31 - 1 ms)
605
+ queue.timer = setTimeout(
606
+ () => {
607
+ queue.timer = undefined
608
+ if (!this.#active) return
609
+ this.#enqueue(queue)
610
+ this.#pump()
611
+ },
612
+ Math.min(wait, this.#config.driver.capabilities.maxNativeDelayMs, MAX_TIMER_DELAY_MS),
613
+ )
614
+ continue
615
+ }
616
+ queue.running = true
617
+ this.#running++
618
+ this.#work(queue, head).then(
619
+ () => undefined,
620
+ () => undefined,
621
+ )
622
+ }
623
+ }
624
+
625
+ async #work(queue: PartitionQueue, record: KafkaRecord): Promise<void> {
626
+ let outcome: Outcome = 'stop'
627
+ try {
628
+ outcome = await this.#process(record)
629
+ } catch (error) {
630
+ this.#config.logger.error(`Kafka: unexpected failure handling a record of ${queue.key}`, { err: error })
631
+ outcome = this.#active ? 'again' : 'stop'
632
+ } finally {
633
+ queue.running = false
634
+ this.#running--
635
+ const assigned = queue.source.assigned
636
+ if (outcome === 'again' && assigned !== undefined && !assigned.has(queue.partition)) {
637
+ // revoked meanwhile: the new owner of the partition handles it
638
+ outcome = 'stop'
639
+ queue.next = undefined
640
+ }
641
+ if (outcome === 'next') {
642
+ queue.records.shift()
643
+ this.#unbuffer(queue.source, 1)
644
+ } else if (outcome === 'stop') {
645
+ const dropped = queue.records.length
646
+ queue.records.length = 0
647
+ this.#unbuffer(queue.source, dropped)
648
+ }
649
+ if (this.#active && queue.records.length > 0) this.#enqueue(queue)
650
+ if (this.#running === 0) {
651
+ const waiters = [...this.#idle]
652
+ this.#idle.clear()
653
+ for (const waiter of waiters) waiter()
654
+ }
655
+ this.#pump()
656
+ }
657
+ }
658
+
659
+ /** Hands one record to the runtime; resolves once it was settled. */
660
+ async #process(record: KafkaRecord): Promise<Outcome> {
661
+ const { driver, topic, retryTopic, group, logger } = this.#config
662
+ const all = headerRecord()
663
+ for (const [name, value] of record.headers) if (value !== undefined) all[name] = value
664
+ const headers = headerRecord()
665
+ for (const [name, value] of Object.entries(all)) if (!TRANSPORT_HEADERS.has(name)) headers[name] = value
666
+ const id = all[MessagingHeaders.Id] ?? `${record.topic}-${record.partition}-${record.offset}`
667
+ const value = record.value ?? Buffer.alloc(0)
668
+ let settled = false
669
+ let released = false
670
+ const check = (action: string): void => {
671
+ if (settled) throw new Error(`[kafka] record ${id} was already settled (${action})`)
672
+ }
673
+ /** Commits the offset. A failure (rebalance...) is not fatal: the record may be consumed again. */
674
+ const commit = async (): Promise<void> => {
675
+ try {
676
+ await record.commit()
677
+ } catch (error) {
678
+ logger.warn(
679
+ `Kafka: committing record ${id} of ${record.topic}:${record.partition} failed, it may be consumed again`,
680
+ {
681
+ err: error,
682
+ },
683
+ )
684
+ }
685
+ }
686
+ const copyHeaders = (extra: Record<string, string>, drop: readonly string[]): Record<string, string> => {
687
+ const result = headerRecord()
688
+ for (const [name, headerValue] of Object.entries(all)) if (!drop.includes(name)) result[name] = headerValue
689
+ return Object.assign(result, extra)
690
+ }
691
+ const delivery: DriverDelivery = {
692
+ id,
693
+ body: value,
694
+ contentType: all[MessagingHeaders.ContentType],
695
+ headers,
696
+ type: all[MessagingHeaders.Type],
697
+ key: record.key ?? undefined,
698
+ attempt: attemptFromHeaders(all),
699
+ redelivered: attemptFromHeaders(all) > 1,
700
+ timestamp: Number(record.timestamp),
701
+ raw: record,
702
+ ack: async (): Promise<void> => {
703
+ check('ack')
704
+ settled = true
705
+ await commit()
706
+ },
707
+ release: async (): Promise<void> => {
708
+ await Promise.resolve()
709
+ check('release')
710
+ settled = true
711
+ released = true
712
+ },
713
+ retry: async (request: RetryRequest): Promise<void> => {
714
+ check('retry')
715
+ if (retryTopic === undefined) throw new Error(`[kafka] ${topic} has no retry topic (single attempt)`)
716
+ const extra: Record<string, string> = { [MessagingHeaders.Attempt]: String(request.attempt) }
717
+ if (request.delayMs > 0) extra[MessagingHeaders.NotBefore] = String(Date.now() + request.delayMs)
718
+ await driver.produce(
719
+ retryTopic,
720
+ value,
721
+ copyHeaders(extra, [MessagingHeaders.NotBefore]),
722
+ record.key ?? undefined,
723
+ )
724
+ settled = true
725
+ await commit()
726
+ },
727
+ deadLetter: async (request: DeadLetterRequest): Promise<void> => {
728
+ check('dead-letter')
729
+ if (request.queue !== undefined) {
730
+ await driver.produce(
731
+ request.queue,
732
+ value,
733
+ copyHeaders(
734
+ {
735
+ [MessagingHeaders.DeadLetterReason]: request.reason,
736
+ [MessagingHeaders.Error]: truncate(errorMessage(request.error), MAX_ERROR_HEADER_LENGTH),
737
+ [MessagingHeaders.OriginalQueue]: topic,
738
+ [MessagingHeaders.Attempts]: String(request.attempt),
739
+ [MessagingHeaders.ConsumerGroup]: group,
740
+ },
741
+ [MessagingHeaders.Attempt, MessagingHeaders.NotBefore],
742
+ ),
743
+ record.key ?? undefined,
744
+ )
745
+ }
746
+ settled = true
747
+ await commit()
748
+ },
749
+ }
750
+ try {
751
+ await this.#config.handler(delivery)
752
+ } catch (error) {
753
+ logger.error(`Kafka: unexpected failure handling record ${id}`, { err: error })
754
+ }
755
+ if (released || !settled) return this.#active ? 'again' : 'stop'
756
+ return 'next'
757
+ }
758
+ }
759
+
760
+ /** `x-znight-not-before` of a retry record (epoch ms), 0 when absent. */
761
+ function notBefore(record: KafkaRecord): number {
762
+ const value = record.headers.get(MessagingHeaders.NotBefore)
763
+ const time = value === undefined ? 0 : Number(value)
764
+ return Number.isFinite(time) ? time : 0
765
+ }