@platformatic/kafka 2.3.0 → 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.
@@ -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";
@@ -512,10 +512,64 @@ export class Consumer extends Base {
512
512
  retryCallback(error);
513
513
  return;
514
514
  }
515
- const session = this.#fetchSessions.get(node);
515
+ let session = this.#fetchSessions.get(node);
516
+ if (session && session.pool !== pool) {
517
+ session = undefined;
518
+ }
516
519
  const sessionId = session?.id ?? 0;
517
520
  const sessionEpoch = session?.epoch ?? 0;
518
- api(connection, options.maxWaitTime ?? this[kOptions].maxWaitTime, options.minBytes ?? this[kOptions].minBytes, options.maxBytes ?? this[kOptions].maxBytes, isolationLevel, sessionId, sessionEpoch, options.topics, [], this.#clientRack, (error, result) => {
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) => {
519
573
  if (error) {
520
574
  this.#clearPreferredReadReplicas(options.topics, topicIds);
521
575
  const genericError = error;
@@ -537,7 +591,7 @@ export class Consumer extends Base {
537
591
  }
538
592
  else {
539
593
  this.#updatePreferredReadReplicas(metadata, topicIds, result);
540
- this.#updateFetchSession(node, sessionEpoch, result.sessionId);
594
+ this.#updateFetchSession(node, pool, sessionEpoch, result.sessionId, desired);
541
595
  }
542
596
  retryCallback(error, result);
543
597
  });
@@ -556,12 +610,19 @@ export class Consumer extends Base {
556
610
  callback(error, response);
557
611
  }, 0);
558
612
  }
559
- #updateFetchSession(node, sentEpoch, responseSessionId) {
613
+ #updateFetchSession(node, pool, sentEpoch, responseSessionId, desired) {
560
614
  if (!responseSessionId) {
561
615
  this.#fetchSessions.delete(node);
562
616
  return;
563
617
  }
564
- this.#fetchSessions.set(node, { id: responseSessionId, epoch: nextFetchEpoch(sentEpoch) });
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
+ });
565
626
  }
566
627
  #commit(options, callback, rejoinAttempts = 0) {
567
628
  this.#performGroupOperation('commit', (connection, groupCallback) => {
@@ -1230,11 +1291,17 @@ export class Consumer extends Base {
1230
1291
  topicSubscriptions.push(subscription);
1231
1292
  }
1232
1293
  }
1233
- this[kMetadata]({ topics: Array.from(topicsSubscriptions.keys()) }, (error, metadata) => {
1294
+ const subscribedTopics = Array.from(topicsSubscriptions.keys());
1295
+ this[kMetadata]({ topics: subscribedTopics }, (error, metadata) => {
1234
1296
  if (error) {
1235
1297
  callback(this.#handleError(error));
1236
1298
  return;
1237
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
+ }
1238
1305
  this.#performSyncGroup(partitionsAssigner, this.#createAssignments(partitionsAssigner, metadata), callback);
1239
1306
  });
1240
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]);
@@ -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);
@@ -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) {
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = "@platformatic/kafka";
2
- export const version = "2.3.0";
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.3.0",
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)",