@platformatic/kafka 2.2.3 → 2.3.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.
@@ -419,7 +419,13 @@ export class Base extends TypedEventEmitter {
419
419
  retryCallback(error);
420
420
  return;
421
421
  }
422
- api(connection, topicsToFetch, autocreateTopics, true, retryCallback);
422
+ // includeTopicAuthorizedOperations: false. The response's
423
+ // topic_authorized_operations bitfield is parsed but never read
424
+ // by the producer/consumer, and requesting it makes IAM brokers
425
+ // (e.g. MSK) authorize every topic operation and emit a CloudTrail
426
+ // AccessDenied per denied op (CreateTopic/DeleteTopic/...) — pure
427
+ // audit-log noise for permissions the client never exercises.
428
+ api(connection, topicsToFetch, autocreateTopics, false, retryCallback);
423
429
  });
424
430
  }, attempt);
425
431
  }, (error, metadata) => {
@@ -455,6 +461,7 @@ export class Base extends TypedEventEmitter {
455
461
  }
456
462
  else {
457
463
  this.#metadata.lastUpdate = lastUpdate;
464
+ this.#metadata.controllerId = metadata.controllerId;
458
465
  }
459
466
  const brokers = new Map();
460
467
  // This should never change, but we act defensively here
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
3
3
  import { FetchIsolationLevels, FindCoordinatorKeyTypes } from "../../apis/enumerations.js";
4
4
  import { consumerCommitsChannel, consumerConsumesChannel, consumerFetchesChannel, consumerGroupChannel, consumerHeartbeatChannel, consumerLagChannel, consumerOffsetsChannel, createDiagnosticContext } from "../../diagnostic.js";
5
- import { protocolErrors, UserError } from "../../errors.js";
5
+ import { NetworkError, protocolErrors, UserError } from "../../errors.js";
6
6
  import { INT32_SIZE } from "../../protocol/definitions.js";
7
7
  import { Reader } from "../../protocol/reader.js";
8
8
  import { IS_CONTROL } from "../../protocol/records.js";
@@ -15,7 +15,7 @@ import { MessagesStream } from "./messages-stream.js";
15
15
  import { commitOptionsValidator, consumeOptionsValidator, consumerOptionsValidator, defaultConsumerOptions, fetchOptionsValidator, getLagOptionsValidator, groupIdAndOptionsValidator, groupOptionsValidator, listCommitsOptionsValidator, listOffsetsOptionsValidator } from "./options.js";
16
16
  import { roundRobinAssigner } from "./partitions-assigners.js";
17
17
  import { TopicsMap } from "./topics-map.js";
18
- import { partitionKey } from "./utils.js";
18
+ import { nextFetchEpoch, partitionKey } from "./utils.js";
19
19
  export class Consumer extends Base {
20
20
  groupId;
21
21
  groupInstanceId;
@@ -36,6 +36,7 @@ export class Consumer extends Base {
36
36
  #memberEpoch;
37
37
  #groupRemoteAssignor;
38
38
  #clientRack;
39
+ #fetchSessions;
39
40
  #preferredReadReplicas;
40
41
  #streams;
41
42
  #lagMonitoring;
@@ -82,6 +83,7 @@ export class Consumer extends Base {
82
83
  this.#useConsumerGroupProtocol = this[kOptions].groupProtocol === 'consumer';
83
84
  this.#groupRemoteAssignor = this[kOptions].groupRemoteAssignor ?? null;
84
85
  this.#clientRack = this[kOptions].clientRack ?? '';
86
+ this.#fetchSessions = new Map();
85
87
  this.#preferredReadReplicas = new Map();
86
88
  this.#streamContext = options.streamContext ?? options.context;
87
89
  this.#validateGroupOptions(this[kOptions], groupIdAndOptionsValidator);
@@ -144,6 +146,7 @@ export class Consumer extends Base {
144
146
  return;
145
147
  }
146
148
  this.topics.clear();
149
+ this.#fetchSessions.clear();
147
150
  if (this[kPrometheus]) {
148
151
  ensureMetric(this[kPrometheus], 'Gauge', 'kafka_consumers', 'Number of active Kafka consumers').dec();
149
152
  }
@@ -492,6 +495,7 @@ export class Consumer extends Base {
492
495
  pool.get(broker, (error, connection) => {
493
496
  if (error) {
494
497
  this.#clearPreferredReadReplicas(options.topics, topicIds);
498
+ this.#fetchSessions.delete(node);
495
499
  // When a connection was not available (either interrupted or not available) we
496
500
  // reset the leader epoch in the options so that when connection is re-established again we can continue
497
501
  for (const topic of options.topics) {
@@ -508,11 +512,74 @@ export class Consumer extends Base {
508
512
  retryCallback(error);
509
513
  return;
510
514
  }
511
- api(connection, options.maxWaitTime ?? this[kOptions].maxWaitTime, options.minBytes ?? this[kOptions].minBytes, options.maxBytes ?? this[kOptions].maxBytes, isolationLevel, 0, 0, options.topics, [], this.#clientRack, (error, result) => {
515
+ let session = this.#fetchSessions.get(node);
516
+ if (session && session.pool !== pool) {
517
+ session = undefined;
518
+ }
519
+ const sessionId = session?.id ?? 0;
520
+ const sessionEpoch = session?.epoch ?? 0;
521
+ // The desired state of the session: options.topics always lists all the wanted partitions
522
+ const desired = new Map();
523
+ for (const topic of options.topics) {
524
+ let partitions = desired.get(topic.topicId);
525
+ if (!partitions) {
526
+ partitions = new Map();
527
+ desired.set(topic.topicId, partitions);
528
+ }
529
+ for (const partition of topic.partitions) {
530
+ // Copy the partition since the data is mutated in the error paths
531
+ partitions.set(partition.partition, { ...partition });
532
+ }
533
+ }
534
+ let requestTopics = options.topics;
535
+ const forgottenTopicsData = [];
536
+ if (session) {
537
+ // Only send the partitions which are new or whose state changed: the broker
538
+ // keeps serving the unchanged ones from the session (KIP-227)
539
+ requestTopics = [];
540
+ for (const [topicId, partitions] of desired) {
541
+ const cached = session.partitions.get(topicId);
542
+ const changed = [];
543
+ for (const partition of partitions.values()) {
544
+ const previous = cached?.get(partition.partition);
545
+ if (!previous ||
546
+ previous.fetchOffset !== partition.fetchOffset ||
547
+ previous.currentLeaderEpoch !== partition.currentLeaderEpoch ||
548
+ previous.lastFetchedEpoch !== partition.lastFetchedEpoch ||
549
+ previous.partitionMaxBytes !== partition.partitionMaxBytes) {
550
+ changed.push(partition);
551
+ }
552
+ }
553
+ if (changed.length > 0) {
554
+ requestTopics.push({ topicId, partitions: changed });
555
+ }
556
+ }
557
+ // Ask the broker to remove from the session the partitions which are no
558
+ // longer wanted, otherwise it keeps serving them forever
559
+ for (const [topicId, partitions] of session.partitions) {
560
+ const stillDesired = desired.get(topicId);
561
+ const forgotten = [];
562
+ for (const partition of partitions.keys()) {
563
+ if (!stillDesired?.has(partition)) {
564
+ forgotten.push(partition);
565
+ }
566
+ }
567
+ if (forgotten.length > 0) {
568
+ forgottenTopicsData.push({ topic: topicId, partitions: forgotten });
569
+ }
570
+ }
571
+ }
572
+ api(connection, options.maxWaitTime ?? this[kOptions].maxWaitTime, options.minBytes ?? this[kOptions].minBytes, options.maxBytes ?? this[kOptions].maxBytes, isolationLevel, sessionId, sessionEpoch, requestTopics, forgottenTopicsData, this.#clientRack, (error, result) => {
512
573
  if (error) {
513
574
  this.#clearPreferredReadReplicas(options.topics, topicIds);
514
575
  const genericError = error;
576
+ if (genericError.findBy?.('apiId', 'FETCH_SESSION_ID_NOT_FOUND') ||
577
+ genericError.findBy?.('apiId', 'INVALID_FETCH_SESSION_EPOCH') ||
578
+ genericError.findBy?.('apiId', 'FETCH_SESSION_TOPIC_ID_ERROR')) {
579
+ this.#fetchSessions.delete(node);
580
+ }
515
581
  if (genericError.findBy?.('apiId', 'FENCED_LEADER_EPOCH')) {
582
+ this.#fetchSessions.delete(node);
516
583
  this.clearMetadata();
517
584
  for (const topic of options.topics) {
518
585
  for (const partition of topic.partitions) {
@@ -524,6 +591,7 @@ export class Consumer extends Base {
524
591
  }
525
592
  else {
526
593
  this.#updatePreferredReadReplicas(metadata, topicIds, result);
594
+ this.#updateFetchSession(node, pool, sessionEpoch, result.sessionId, desired);
527
595
  }
528
596
  retryCallback(error, result);
529
597
  });
@@ -542,6 +610,20 @@ export class Consumer extends Base {
542
610
  callback(error, response);
543
611
  }, 0);
544
612
  }
613
+ #updateFetchSession(node, pool, sentEpoch, responseSessionId, desired) {
614
+ if (!responseSessionId) {
615
+ this.#fetchSessions.delete(node);
616
+ return;
617
+ }
618
+ // After a successful fetch the broker session contains exactly the desired partitions:
619
+ // unchanged ones already matched it, changed ones were sent and removed ones were forgotten
620
+ this.#fetchSessions.set(node, {
621
+ id: responseSessionId,
622
+ epoch: nextFetchEpoch(sentEpoch),
623
+ pool,
624
+ partitions: desired
625
+ });
626
+ }
545
627
  #commit(options, callback, rejoinAttempts = 0) {
546
628
  this.#performGroupOperation('commit', (connection, groupCallback) => {
547
629
  const topics = new Map();
@@ -1209,11 +1291,17 @@ export class Consumer extends Base {
1209
1291
  topicSubscriptions.push(subscription);
1210
1292
  }
1211
1293
  }
1212
- this[kMetadata]({ topics: Array.from(topicsSubscriptions.keys()) }, (error, metadata) => {
1294
+ const subscribedTopics = Array.from(topicsSubscriptions.keys());
1295
+ this[kMetadata]({ topics: subscribedTopics }, (error, metadata) => {
1213
1296
  if (error) {
1214
1297
  callback(this.#handleError(error));
1215
1298
  return;
1216
1299
  }
1300
+ const missingTopic = subscribedTopics.find(topic => !metadata.topics.has(topic));
1301
+ if (missingTopic) {
1302
+ callback(this.#handleError(new NetworkError(`Cannot compute group assignments: metadata for subscribed topic "${missingTopic}" is not available in the current cluster metadata.`, { canRetry: true, hasStaleMetadata: true })));
1303
+ return;
1304
+ }
1217
1305
  this.#performSyncGroup(partitionsAssigner, this.#createAssignments(partitionsAssigner, metadata), callback);
1218
1306
  });
1219
1307
  return;
@@ -553,6 +553,11 @@ export class MessagesStream extends Readable {
553
553
  const topic = topicIds.get(topicResponse.topicId);
554
554
  for (const { records: recordsBatches, partitionIndex: partition } of topicResponse.partitions) {
555
555
  const key = partitionKey(topic, partition);
556
+ // The broker can return partitions which were not requested when they linger
557
+ // in an incremental fetch session - their data is stale and must not rewind offsets
558
+ if (!requestedOffsets.has(key)) {
559
+ continue;
560
+ }
556
561
  if (!recordsBatches) {
557
562
  continue;
558
563
  }
@@ -860,7 +865,13 @@ export class MessagesStream extends Readable {
860
865
  const requests = [];
861
866
  // Create the pre-deserialization requests
862
867
  for (const topicResponse of response.responses) {
868
+ const topic = topicIds.get(topicResponse.topicId);
863
869
  for (const { records: recordsBatches, partitionIndex: partition } of topicResponse.partitions) {
870
+ // The broker can return partitions which were not requested when they linger
871
+ // in an incremental fetch session - do not run hooks on their stale records
872
+ if (!requestedOffsets.has(partitionKey(topic, partition))) {
873
+ continue;
874
+ }
864
875
  /* c8 ignore next 3 - Hard to test */
865
876
  if (!recordsBatches) {
866
877
  continue;
@@ -872,7 +883,7 @@ export class MessagesStream extends Readable {
872
883
  continue;
873
884
  }
874
885
  for (const message of batch.records) {
875
- message.topic = topicIds.get(topicResponse.topicId);
886
+ message.topic = topic;
876
887
  message.partition = partition;
877
888
  requests.push([message.key, 'key', message]);
878
889
  requests.push([message.value, 'value', message]);
@@ -1 +1,2 @@
1
1
  export declare function partitionKey(topic: string, partition: number): string;
2
+ export declare function nextFetchEpoch(epoch: number): number;
@@ -1,3 +1,9 @@
1
1
  export function partitionKey(topic, partition) {
2
2
  return `${topic}:${partition}`;
3
3
  }
4
+ export function nextFetchEpoch(epoch) {
5
+ if (epoch < 0) {
6
+ return 0;
7
+ }
8
+ return epoch >= 0x7fffffff ? 1 : epoch + 1;
9
+ }
@@ -342,7 +342,7 @@ export class Producer extends Base {
342
342
  }
343
343
  const transactionalId = this.#transaction.id;
344
344
  this[kPerformDeduplicated]('findCoordinator', deduplicateCallback => {
345
- this[kPerformWithRetry]('findCoordinator', retryCallback => {
345
+ this[kPerformWithRetry]('findCoordinator', (retryCallback, attempt) => {
346
346
  this[kGetBootstrapConnection]((error, connection) => {
347
347
  if (error) {
348
348
  retryCallback(error);
@@ -355,7 +355,7 @@ export class Producer extends Base {
355
355
  }
356
356
  api(connection, FindCoordinatorKeyTypes.TRANSACTION, [transactionalId], retryCallback);
357
357
  });
358
- });
358
+ }, attempt);
359
359
  }, (error, response) => {
360
360
  if (error) {
361
361
  deduplicateCallback(error);
@@ -531,11 +531,8 @@ export class Producer extends Base {
531
531
  #initIdempotentProducer(options, callback) {
532
532
  const transactionalId = this.#transaction?.id;
533
533
  this[kPerformDeduplicated]('initProducerId', deduplicateCallback => {
534
- this[kPerformWithRetry]('initProducerId', retryCallback => {
535
- const connector = transactionalId
536
- ? this.#getCoordinatorConnection.bind(this)
537
- : this[kGetBootstrapConnection].bind(this);
538
- connector((error, connection) => {
534
+ this[kPerformWithRetry]('initProducerId', (retryCallback, attempt) => {
535
+ const onConnection = (error, connection) => {
539
536
  if (error) {
540
537
  retryCallback(error);
541
538
  return;
@@ -549,7 +546,16 @@ export class Producer extends Base {
549
546
  retryCallback(this.#handleError(error), response);
550
547
  });
551
548
  });
552
- });
549
+ };
550
+ // The transactional path fails over inside #getCoordinatorConnection (via
551
+ // #findCoordinator -> kGetBootstrapConnection). The idempotent path must thread
552
+ // `attempt` so kGetBootstrapConnection rotates past a reachable-but-hung broker.
553
+ if (transactionalId) {
554
+ this.#getCoordinatorConnection(onConnection);
555
+ }
556
+ else {
557
+ this[kGetBootstrapConnection](onConnection, attempt);
558
+ }
553
559
  }, (error, response) => {
554
560
  if (error) {
555
561
  this.#handleFencingError(error);
@@ -685,9 +691,16 @@ export class Producer extends Base {
685
691
  }
686
692
  // Track nodes so that we can get their ID for delayed write reporting
687
693
  const nodes = [];
694
+ const sequenceSnapshot = produceOptions.sequences ? new NumericMap() : undefined;
695
+ if (sequenceSnapshot) {
696
+ for (const [partitionKey, messagesCount] of messagesPerPartition) {
697
+ sequenceSnapshot.set(partitionKey, this.#sequences.postIncrement(partitionKey, messagesCount, 0));
698
+ }
699
+ produceOptions = { ...produceOptions, sequences: sequenceSnapshot };
700
+ }
688
701
  runConcurrentCallbacks('Producing messages failed.', messagesByDestination, ([destination, destinationMessages], concurrentCallback) => {
689
702
  nodes.push(destination);
690
- this.#performSingleDestinationSend(topics, destinationMessages, this[kOptions].timeout, sendOptions.acks, sendOptions.autocreateTopics, sendOptions.repeatOnStaleMetadata, produceOptions, concurrentCallback);
703
+ this.#performSingleDestinationSend(topics, destinationMessages, this[kOptions].timeout, sendOptions.acks, sendOptions.autocreateTopics, sendOptions.repeatOnStaleMetadata, produceOptions, true, concurrentCallback);
691
704
  }, (error, apiResults) => {
692
705
  this.#metricsProducedMessages?.inc(messages.length);
693
706
  const results = {};
@@ -710,14 +723,16 @@ export class Producer extends Base {
710
723
  partition: partitionResponse.index,
711
724
  offset: partitionResponse.baseOffset
712
725
  });
713
- const partitionKey = `${name}:${partitionResponse.index}`;
714
- this.#sequences.postIncrement(partitionKey, messagesPerPartition.get(partitionKey), 0);
715
726
  }
716
727
  }
717
728
  results.offsets = topics;
718
729
  }
719
730
  }
720
731
  if (error) {
732
+ if (sendOptions.idempotent && !this.#transaction) {
733
+ this.#producerInfo = undefined;
734
+ this.#sequences.clear();
735
+ }
721
736
  ;
722
737
  error.produced = results;
723
738
  callback(error);
@@ -728,7 +743,7 @@ export class Producer extends Base {
728
743
  });
729
744
  });
730
745
  }
731
- #performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, produceOptions, callback) {
746
+ #performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, produceOptions, recoverOnProducerStateLoss, callback) {
732
747
  // Get the metadata with the topic/partitions informations
733
748
  this[kMetadata]({ topics, autocreateTopics }, (error, metadata) => {
734
749
  if (error) {
@@ -762,7 +777,17 @@ export class Producer extends Base {
762
777
  const hasStaleMetadata = kafkaError?.findBy('hasStaleMetadata', true);
763
778
  if (hasStaleMetadata && repeatOnStaleMetadata) {
764
779
  this.clearMetadata();
765
- this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, false, produceOptions, callback);
780
+ this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, false, produceOptions, recoverOnProducerStateLoss, callback);
781
+ return;
782
+ }
783
+ if (recoverOnProducerStateLoss && !this.#transaction && this.#isProducerStateLost(error)) {
784
+ this.#recoverIdempotentProducer(messages, produceOptions, (error, recoveredProduceOptions) => {
785
+ if (error) {
786
+ callback(error);
787
+ return;
788
+ }
789
+ this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, recoveredProduceOptions, false, callback);
790
+ });
766
791
  return;
767
792
  }
768
793
  callback(error, results);
@@ -778,6 +803,35 @@ export class Producer extends Base {
778
803
  });
779
804
  });
780
805
  }
806
+ #isProducerStateLost(error) {
807
+ const kafkaError = error;
808
+ return !!(GenericError.isGenericError(error) &&
809
+ (kafkaError.findBy('apiId', 'UNKNOWN_PRODUCER_ID') || kafkaError.findBy('apiId', 'OUT_OF_ORDER_SEQUENCE_NUMBER')));
810
+ }
811
+ #recoverIdempotentProducer(messages, produceOptions, callback) {
812
+ this.#producerInfo = undefined;
813
+ this.#sequences.clear();
814
+ this.initIdempotentProducer({}, (error, producerInfo) => {
815
+ if (error) {
816
+ callback(error);
817
+ return;
818
+ }
819
+ const sequences = new NumericMap();
820
+ const messagesPerPartition = new NumericMap();
821
+ for (const { topic, partition } of messages) {
822
+ messagesPerPartition.postIncrement(`${topic}:${partition}`, 1, 0);
823
+ }
824
+ for (const [partitionKey, messagesCount] of messagesPerPartition) {
825
+ sequences.set(partitionKey, this.#sequences.postIncrement(partitionKey, messagesCount, 0));
826
+ }
827
+ callback(null, {
828
+ ...produceOptions,
829
+ producerId: producerInfo.producerId,
830
+ producerEpoch: producerInfo.producerEpoch,
831
+ sequences
832
+ });
833
+ });
834
+ }
781
835
  #getCoordinatorConnection(callback) {
782
836
  if (this.#coordinatorId === undefined) {
783
837
  this[kTransactionFindCoordinator](error => {
@@ -17,7 +17,7 @@ export interface Deserializers<Key, Value, HeaderKey, HeaderValue> {
17
17
  headerValue: Deserializer<HeaderValue>;
18
18
  }
19
19
  export type BeforeHookPayloadType = 'key' | 'value' | 'headerKey' | 'headerValue';
20
- export type BeforeDeserializationHook = (payload: Buffer, type: BeforeHookPayloadType, message: MessageToConsume, callback: Callback<void>) => void | Promise<void>;
20
+ export type BeforeDeserializationHook = (payload: Buffer | null, type: BeforeHookPayloadType, message: MessageToConsume, callback: Callback<void>) => void | Promise<void>;
21
21
  export type BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue> = (payload: unknown, type: BeforeHookPayloadType, message: MessageToProduce<Key, Value, HeaderKey, HeaderValue>, callback: Callback<void>) => void | Promise<void>;
22
22
  export declare function stringSerializer(data?: string): Buffer | undefined;
23
23
  export declare function stringDeserializer(data?: string | Buffer): string | undefined;
package/dist/errors.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare class GenericError extends Error {
14
14
  static isGenericError(error: Error): error is GenericError | MultipleErrors;
15
15
  constructor(code: ErrorCode, message: string, { cause, ...rest }?: ErrorProperties);
16
16
  findBy<ErrorType extends GenericError = GenericError>(property: string, value: unknown): ErrorType | null;
17
+ toString(): string;
17
18
  }
18
19
  export declare class MultipleErrors extends AggregateError {
19
20
  code: string;
@@ -25,6 +26,7 @@ export declare class MultipleErrors extends AggregateError {
25
26
  static isMultipleErrors(error: Error): error is MultipleErrors;
26
27
  constructor(message: string, errors: Error[], { cause, ...rest }?: ErrorProperties);
27
28
  findBy<ErrorType extends GenericError | MultipleErrors = MultipleErrors>(property: string, value: unknown): ErrorType | null;
29
+ toString(): string;
28
30
  }
29
31
  export * from './protocol/errors.ts';
30
32
  export declare class AuthenticationError extends GenericError {
package/dist/errors.js CHANGED
@@ -45,6 +45,9 @@ export class GenericError extends Error {
45
45
  }
46
46
  return null;
47
47
  }
48
+ toString() {
49
+ return `Error ${this.code}: ${this.message}`;
50
+ }
48
51
  }
49
52
  export class MultipleErrors extends AggregateError {
50
53
  code;
@@ -91,6 +94,9 @@ export class MultipleErrors extends AggregateError {
91
94
  }
92
95
  return null;
93
96
  }
97
+ toString() {
98
+ return `Error ${this.code}: ${this.message} (caused by: ${this.errors.map(error => error.toString()).join(', ')})`;
99
+ }
94
100
  }
95
101
  export * from "./protocol/errors.js";
96
102
  export class AuthenticationError extends GenericError {
@@ -1,6 +1,6 @@
1
1
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../apis/callbacks.js";
2
2
  import { connectionsPoolGetsChannel, createDiagnosticContext, notifyCreation } from "../diagnostic.js";
3
- import { MultipleErrors } from "../errors.js";
3
+ import { MultipleErrors, NetworkError } from "../errors.js";
4
4
  import { TypedEventEmitter } from "../events.js";
5
5
  import { Connection, ConnectionStatuses } from "./connection.js";
6
6
  let currentInstance = 0;
@@ -95,6 +95,14 @@ export class ConnectionPool extends TypedEventEmitter {
95
95
  callback(new Error('Connection pool is closed.'));
96
96
  return;
97
97
  }
98
+ if (broker == null || broker.host == null || broker.port == null) {
99
+ callback(new NetworkError('Cannot get a connection: the broker is not available in the current cluster metadata.', {
100
+ canRetry: true,
101
+ hasStaleMetadata: true,
102
+ broker
103
+ }));
104
+ return;
105
+ }
98
106
  const key = `${broker.host}:${broker.port}`;
99
107
  const existing = this.#connections.get(key);
100
108
  if (existing) {
@@ -302,9 +302,10 @@ export class Reader {
302
302
  }
303
303
  // TODO: Tagged fields are not supported yet
304
304
  readTaggedFields() {
305
- const length = this.readVarInt();
306
- if (length > 0) {
307
- this.skip(length);
305
+ const length = this.readUnsignedVarInt();
306
+ for (let i = 0; i < length; i++) {
307
+ this.readUnsignedVarInt();
308
+ this.skip(this.readUnsignedVarInt());
308
309
  }
309
310
  }
310
311
  }
@@ -4,7 +4,7 @@ import { type MessageToProduce } from '../protocol/records.ts';
4
4
  export interface SchemaRegistry<Id = unknown, Schema = unknown, Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> {
5
5
  get(id: Id): Schema | undefined;
6
6
  fetch(id: Id, callback?: (error?: Error) => void): void | Promise<void>;
7
- getSchemaId(payload: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): Id;
7
+ getSchemaId(payload: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): Id;
8
8
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
9
9
  getDeserializers(): Deserializers<Key, Value, HeaderKey, HeaderValue>;
10
10
  getBeforeSerializationHook(): BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue>;
@@ -14,7 +14,7 @@ export declare function runAsyncSeries<V>(operation: (item: V, cb: Callback<void
14
14
  export declare class AbstractSchemaRegistry<Id = unknown, Schema = unknown, Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> implements SchemaRegistry<Id, Schema, Key, Value, HeaderKey, HeaderValue> {
15
15
  get(_: Id): Schema | undefined;
16
16
  fetch(_i: unknown, _c?: (error?: Error) => void): void | Promise<void>;
17
- getSchemaId(_p: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, _t?: BeforeHookPayloadType): Id;
17
+ getSchemaId(_p: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, _t?: BeforeHookPayloadType): Id;
18
18
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
19
19
  getDeserializers(): Deserializers<Key, Value, HeaderKey, HeaderValue>;
20
20
  getBeforeSerializationHook(): BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue>;
@@ -30,7 +30,7 @@ export declare function defaultProtobufTypeMapper(_: number, type: BeforeHookPay
30
30
  export declare class ConfluentSchemaRegistry<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends AbstractSchemaRegistry<number | undefined, Schema, Key, Value, HeaderKey, HeaderValue> {
31
31
  #private;
32
32
  constructor(options: ConfluentSchemaRegistryOptions);
33
- getSchemaId(message: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): number | undefined;
33
+ getSchemaId(message: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): number | undefined;
34
34
  get(id: number): Schema | undefined;
35
35
  fetchSchema(id: number, callback: Callback<void>): Promise<void>;
36
36
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
@@ -27,6 +27,9 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
27
27
  this.#auth = options.auth;
28
28
  }
29
29
  getSchemaId(message, type) {
30
+ if (message === null) {
31
+ return undefined;
32
+ }
30
33
  if (Buffer.isBuffer(message)) {
31
34
  if (type !== 'value') {
32
35
  return undefined;
@@ -6,6 +6,4 @@ export type CallbackWithPromise<ReturnType> = Callback<ReturnType> & {
6
6
  [kCallbackPromise]?: Promise<ReturnType>;
7
7
  };
8
8
  export declare function createPromisifiedCallback<ReturnType>(): CallbackWithPromise<ReturnType>;
9
- export declare function runConcurrentCallbacks<ReturnType, K, V>(errorMessage: string, collection: Map<K, V>, operation: (item: [K, V], cb: Callback<ReturnType>) => void, callback: Callback<ReturnType[]>): void;
10
- export declare function runConcurrentCallbacks<ReturnType, V>(errorMessage: string, collection: Set<V>, operation: (item: V, cb: Callback<ReturnType>) => void, callback: Callback<ReturnType[]>): void;
11
- export declare function runConcurrentCallbacks<ReturnType, V>(errorMessage: string, collection: V[], operation: (item: V, cb: Callback<ReturnType>) => void, callback: Callback<ReturnType[]>): void;
9
+ export declare function runConcurrentCallbacks<R, V>(errorMessage: string, collection: Iterable<V>, operation: (item: V, cb: Callback<R>) => void, callback: Callback<R[]>): void;
@@ -1 +1,2 @@
1
- export declare function partitionKey(topic: string, partition: number): string;
1
+ export declare function partitionKey(topic: string, partition: number): string;
2
+ export declare function nextFetchEpoch(epoch: number): number;
@@ -17,7 +17,7 @@ export interface Deserializers<Key, Value, HeaderKey, HeaderValue> {
17
17
  headerValue: Deserializer<HeaderValue>;
18
18
  }
19
19
  export type BeforeHookPayloadType = 'key' | 'value' | 'headerKey' | 'headerValue';
20
- export type BeforeDeserializationHook = (payload: Buffer, type: BeforeHookPayloadType, message: MessageToConsume, callback: Callback<void>) => void | Promise<void>;
20
+ export type BeforeDeserializationHook = (payload: Buffer | null, type: BeforeHookPayloadType, message: MessageToConsume, callback: Callback<void>) => void | Promise<void>;
21
21
  export type BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue> = (payload: unknown, type: BeforeHookPayloadType, message: MessageToProduce<Key, Value, HeaderKey, HeaderValue>, callback: Callback<void>) => void | Promise<void>;
22
22
  export declare function stringSerializer(data?: string): Buffer | undefined;
23
23
  export declare function stringDeserializer(data?: string | Buffer): string | undefined;
@@ -17,6 +17,7 @@ export declare class GenericError extends Error {
17
17
  ...rest
18
18
  }?: ErrorProperties);
19
19
  findBy<ErrorType extends GenericError = GenericError>(property: string, value: unknown): ErrorType | null;
20
+ toString(): string;
20
21
  }
21
22
  export declare class MultipleErrors extends AggregateError {
22
23
  code: string;
@@ -31,6 +32,7 @@ export declare class MultipleErrors extends AggregateError {
31
32
  ...rest
32
33
  }?: ErrorProperties);
33
34
  findBy<ErrorType extends GenericError | MultipleErrors = MultipleErrors>(property: string, value: unknown): ErrorType | null;
35
+ toString(): string;
34
36
  }
35
37
  export * from "./protocol/errors";
36
38
  export declare class AuthenticationError extends GenericError {
@@ -4,7 +4,7 @@ import { type MessageToProduce } from "../protocol/records";
4
4
  export interface SchemaRegistry<Id = unknown, Schema = unknown, Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> {
5
5
  get(id: Id): Schema | undefined;
6
6
  fetch(id: Id, callback?: (error?: Error) => void): void | Promise<void>;
7
- getSchemaId(payload: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): Id;
7
+ getSchemaId(payload: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): Id;
8
8
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
9
9
  getDeserializers(): Deserializers<Key, Value, HeaderKey, HeaderValue>;
10
10
  getBeforeSerializationHook(): BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue>;
@@ -14,7 +14,7 @@ export declare function runAsyncSeries<V>(operation: (item: V, cb: Callback<void
14
14
  export declare class AbstractSchemaRegistry<Id = unknown, Schema = unknown, Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> implements SchemaRegistry<Id, Schema, Key, Value, HeaderKey, HeaderValue> {
15
15
  get(_: Id): Schema | undefined;
16
16
  fetch(_i: unknown, _c?: (error?: Error) => void): void | Promise<void>;
17
- getSchemaId(_p: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, _t?: BeforeHookPayloadType): Id;
17
+ getSchemaId(_p: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, _t?: BeforeHookPayloadType): Id;
18
18
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
19
19
  getDeserializers(): Deserializers<Key, Value, HeaderKey, HeaderValue>;
20
20
  getBeforeSerializationHook(): BeforeSerializationHook<Key, Value, HeaderKey, HeaderValue>;
@@ -30,7 +30,7 @@ export declare function defaultProtobufTypeMapper(_: number, type: BeforeHookPay
30
30
  export declare class ConfluentSchemaRegistry<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends AbstractSchemaRegistry<number | undefined, Schema, Key, Value, HeaderKey, HeaderValue> {
31
31
  #private;
32
32
  constructor(options: ConfluentSchemaRegistryOptions);
33
- getSchemaId(message: Buffer | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): number | undefined;
33
+ getSchemaId(message: Buffer | null | MessageToProduce<Key, Value, HeaderKey, HeaderValue>, type?: BeforeHookPayloadType): number | undefined;
34
34
  get(id: number): Schema | undefined;
35
35
  fetchSchema(id: number, callback: Callback<void>): Promise<void>;
36
36
  getSerializers(): Serializers<Key, Value, HeaderKey, HeaderValue>;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = "@platformatic/kafka";
2
- export const version = "2.2.3";
2
+ export const version = "2.3.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.2.3",
3
+ "version": "2.3.1",
4
4
  "description": "Modern and performant client for Apache Kafka",
5
5
  "homepage": "https://github.com/platformatic/kafka",
6
6
  "author": "Platformatic Inc. <oss@platformatic.dev> (https://platformatic.dev)",
@@ -91,7 +91,7 @@
91
91
  "typecheck": "tsc -p . --noEmit",
92
92
  "format": "prettier -w benchmarks playground regression src test",
93
93
  "test": "c8 -c test/config/c8-local.json ./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/*.test.ts' 'test/*/*.test.ts' 'test/*/*/*.test.ts'",
94
- "test:ci": "c8 -c test/config/c8-ci.json ./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/*.test.ts' 'test/*/*.test.ts' 'test/*/*/*.test.ts'",
94
+ "test:ci": "./scripts/node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=90 --test-reporter=cleaner-spec-reporter 'test/*.test.ts' 'test/*/*.test.ts' 'test/*/*/*.test.ts'",
95
95
  "test:integrity": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/integrity/*.test.ts'",
96
96
  "test:performance": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/performance/*.test.ts'",
97
97
  "test:regression": "npm run test:integrity",