@platformatic/kafka 2.0.1 → 2.2.2

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.
@@ -84,11 +84,11 @@ export declare class Base<OptionsType extends BaseOptions = BaseOptions, EventsT
84
84
  [kMetadata](options: MetadataOptions, callback: CallbackWithPromise<ClusterMetadata>): void;
85
85
  [kCheckNotClosed](callback: CallbackWithPromise<any>): boolean;
86
86
  clearMetadata(): void;
87
- [kPerformWithRetry]<ReturnType>(operationId: string, operation: (callback: Callback<ReturnType>) => void, callback: CallbackWithPromise<ReturnType>, attempt?: number, errors?: Error[], shouldSkipRetry?: (e: Error) => boolean): void | Promise<ReturnType>;
87
+ [kPerformWithRetry]<ReturnType>(operationId: string, operation: (callback: Callback<ReturnType>, attempt: number) => void, callback: CallbackWithPromise<ReturnType>, attempt?: number, errors?: Error[], shouldSkipRetry?: (e: Error) => boolean): void | Promise<ReturnType>;
88
88
  [kPerformDeduplicated]<ReturnType>(operationId: string, operation: (callback: CallbackWithPromise<ReturnType>) => void, callback: CallbackWithPromise<ReturnType>): void | Promise<ReturnType>;
89
89
  [kGetApi]<RequestArguments extends Array<unknown>, ResponseType>(name: string, callback: Callback<API<RequestArguments, ResponseType>>): void;
90
90
  [kGetConnection](broker: Broker, callback: Callback<Connection>): void;
91
- [kGetBootstrapConnection](callback: Callback<Connection>): void;
91
+ [kGetBootstrapConnection](callback: Callback<Connection>, attempt?: number): void;
92
92
  [kValidateOptions](target: unknown, validator: ValidateFunction<unknown>, targetName: string, throwOnErrors?: boolean): Error | null;
93
93
  [kInspect](...args: unknown[]): void;
94
94
  [kFormatValidationErrors](validator: ValidateFunction<unknown>, targetName: string): string;
@@ -55,7 +55,12 @@ export class Base extends TypedEventEmitter {
55
55
  this[kApis] = [];
56
56
  this[kContext] = options.context;
57
57
  // Validate options
58
- this[kOptions] = Object.assign({}, defaultBaseOptions, options);
58
+ this[kOptions] = Object.assign({}, defaultBaseOptions);
59
+ for (const [key, value] of Object.entries(options)) {
60
+ if (value !== undefined) {
61
+ this[kOptions][key] = value;
62
+ }
63
+ }
59
64
  this[kValidateOptions](this[kOptions], baseOptionsValidator, '/options');
60
65
  this[kClientId] = options.clientId;
61
66
  if (typeof this[kOptions].retries === 'boolean') {
@@ -201,7 +206,7 @@ export class Base extends TypedEventEmitter {
201
206
  }
202
207
  [kListApis](callback) {
203
208
  this[kPerformDeduplicated]('listApis', deduplicateCallback => {
204
- this[kPerformWithRetry]('listApis', retryCallback => {
209
+ this[kPerformWithRetry]('listApis', (retryCallback, attempt) => {
205
210
  this[kGetBootstrapConnection]((error, connection) => {
206
211
  if (error) {
207
212
  retryCallback(error);
@@ -209,7 +214,7 @@ export class Base extends TypedEventEmitter {
209
214
  }
210
215
  // We use V3 to be able to get APIS from Kafka 2.4.0+
211
216
  apiVersionsV3(connection, clientSoftwareName, clientSoftwareVersion, retryCallback);
212
- });
217
+ }, attempt);
213
218
  }, (error, metadata) => {
214
219
  if (error) {
215
220
  deduplicateCallback(error);
@@ -255,7 +260,13 @@ export class Base extends TypedEventEmitter {
255
260
  this.emitWithDebug('client', 'performWithRetry:retry', operationId, attempt, retries, delay);
256
261
  const timeout = setTimeout(() => {
257
262
  this.removeListener('client:close', onClose);
258
- this[kPerformWithRetry](operationId, operation, callback, attempt + 1, errors, shouldSkipRetry);
263
+ try {
264
+ this[kPerformWithRetry](operationId, operation, callback, attempt + 1, errors, shouldSkipRetry);
265
+ }
266
+ catch (error) {
267
+ errors.push(error);
268
+ callback(new MultipleErrors(`${operationId} failed ${attempt + 1} times.`, errors));
269
+ }
259
270
  }, delay);
260
271
  this.once('client:close', onClose);
261
272
  }
@@ -274,7 +285,7 @@ export class Base extends TypedEventEmitter {
274
285
  errors.splice(0, errors.length);
275
286
  }
276
287
  callback(null, result);
277
- });
288
+ }, attempt);
278
289
  return callback[kCallbackPromise];
279
290
  }
280
291
  [kPerformDeduplicated](operationId, operation, callback) {
@@ -329,13 +340,20 @@ export class Base extends TypedEventEmitter {
329
340
  [kGetConnection](broker, callback) {
330
341
  this[kConnections].get(broker, callback);
331
342
  }
332
- [kGetBootstrapConnection](callback) {
343
+ [kGetBootstrapConnection](callback, attempt = 0) {
344
+ let brokers;
333
345
  if (!this.#metadata) {
334
- this[kConnections].getFirstAvailable(this[kBootstrapBrokers], callback);
335
- return;
346
+ brokers = this[kBootstrapBrokers];
347
+ }
348
+ else {
349
+ const discovered = Array.from(this.#metadata.brokers.values());
350
+ brokers = [...this[kBootstrapBrokers], ...discovered];
336
351
  }
337
- const discovered = Array.from(this.#metadata.brokers.values());
338
- this[kConnections].getFirstAvailable([...this[kBootstrapBrokers], ...discovered], callback);
352
+ if (attempt > 0 && brokers.length > 1) {
353
+ const offset = attempt % brokers.length;
354
+ brokers = [...brokers.slice(offset), ...brokers.slice(0, offset)];
355
+ }
356
+ this[kConnections].getFirstAvailable(brokers, callback);
339
357
  }
340
358
  [kValidateOptions](target, validator, targetName, throwOnErrors = true) {
341
359
  if (!this[kOptions].strict) {
@@ -390,7 +408,7 @@ export class Base extends TypedEventEmitter {
390
408
  this[kPerformDeduplicated](
391
409
  // Unique key to avoid mixing callbacks
392
410
  `metadata-${topics.sort().join(',')}-${autocreateTopics}-${options.forceUpdate}`, deduplicateCallback => {
393
- this[kPerformWithRetry]('metadata', retryCallback => {
411
+ this[kPerformWithRetry]('metadata', (retryCallback, attempt) => {
394
412
  this[kGetBootstrapConnection]((error, connection) => {
395
413
  if (error) {
396
414
  retryCallback(error);
@@ -403,7 +421,7 @@ export class Base extends TypedEventEmitter {
403
421
  }
404
422
  api(connection, topicsToFetch, autocreateTopics, true, retryCallback);
405
423
  });
406
- });
424
+ }, attempt);
407
425
  }, (error, metadata) => {
408
426
  if (error) {
409
427
  const unknownTopicError = error.findBy('apiCode', 3);
@@ -25,6 +25,9 @@ export declare const baseOptionsSchema: {
25
25
  type: string;
26
26
  pattern: string;
27
27
  };
28
+ clientRack: {
29
+ type: string;
30
+ };
28
31
  bootstrapBrokers: {
29
32
  oneOf: ({
30
33
  type: string;
@@ -14,6 +14,7 @@ export const baseOptionsSchema = {
14
14
  type: 'object',
15
15
  properties: {
16
16
  clientId: idProperty,
17
+ clientRack: { type: 'string' },
17
18
  bootstrapBrokers: {
18
19
  oneOf: [
19
20
  { type: 'array', items: { type: 'string' } },
@@ -32,6 +32,7 @@ export interface ClusterMetadata {
32
32
  export type RetryDelayGetter<Owner = object> = (client: Owner, operationId: string, attempt: number, retries: number, error: Error) => number;
33
33
  export interface BaseOptions extends ConnectionOptions {
34
34
  clientId: string;
35
+ clientRack?: string;
35
36
  bootstrapBrokers: Broker[] | string[];
36
37
  context?: unknown;
37
38
  timeout?: number;
@@ -1,7 +1,9 @@
1
1
  import { type CallbackWithPromise } from '../../apis/callbacks.ts';
2
2
  import { type FetchResponse } from '../../apis/consumer/fetch-v17.ts';
3
3
  import { type ConnectionPool } from '../../network/connection-pool.ts';
4
+ import { kGetFetchNode } from '../../symbols.ts';
4
5
  import { Base, type BaseEvents, kCreateConnectionPool } from '../base/base.ts';
6
+ import { type ClusterMetadata } from '../base/types.ts';
5
7
  import { MessagesStream } from './messages-stream.ts';
6
8
  import { TopicsMap } from './topics-map.ts';
7
9
  import { type CommitOptions, type ConsumeOptions, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
@@ -56,4 +58,5 @@ export declare class Consumer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
56
58
  joinGroup(options?: GroupOptions): Promise<string>;
57
59
  leaveGroup(force?: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
58
60
  leaveGroup(force?: boolean): Promise<void>;
61
+ [kGetFetchNode](metadata: ClusterMetadata, topic: string, partition: number, now: number): number;
59
62
  }
@@ -7,7 +7,7 @@ import { INT32_SIZE } from "../../protocol/definitions.js";
7
7
  import { Reader } from "../../protocol/reader.js";
8
8
  import { IS_CONTROL } from "../../protocol/records.js";
9
9
  import { Writer } from "../../protocol/writer.js";
10
- import { kAutocommit, kRefreshOffsetsAndFetch } from "../../symbols.js";
10
+ import { kAutocommit, kGetFetchNode, kRefreshOffsetsAndFetch } from "../../symbols.js";
11
11
  import { emitExperimentalApiWarning } from "../../utils.js";
12
12
  import { Base, kAfterCreate, kCheckNotClosed, kClosed, kConnections, kCreateConnectionPool, kFormatValidationErrors, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kPrometheus, kValidateOptions } from "../base/base.js";
13
13
  import { ensureMetric } from "../metrics.js";
@@ -15,6 +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
19
  export class Consumer extends Base {
19
20
  groupId;
20
21
  groupInstanceId;
@@ -34,6 +35,8 @@ export class Consumer extends Base {
34
35
  #useConsumerGroupProtocol;
35
36
  #memberEpoch;
36
37
  #groupRemoteAssignor;
38
+ #clientRack;
39
+ #preferredReadReplicas;
37
40
  #streams;
38
41
  #lagMonitoring;
39
42
  #streamContext;
@@ -78,6 +81,8 @@ export class Consumer extends Base {
78
81
  this.#memberEpoch = 0;
79
82
  this.#useConsumerGroupProtocol = this[kOptions].groupProtocol === 'consumer';
80
83
  this.#groupRemoteAssignor = this[kOptions].groupRemoteAssignor ?? null;
84
+ this.#clientRack = this[kOptions].clientRack ?? '';
85
+ this.#preferredReadReplicas = new Map();
81
86
  this.#streamContext = options.streamContext ?? options.context;
82
87
  this.#validateGroupOptions(this[kOptions], groupIdAndOptionsValidator);
83
88
  if (this[kPrometheus]) {
@@ -354,7 +359,7 @@ export class Consumer extends Base {
354
359
  if (this[kCheckNotClosed](callback)) {
355
360
  return callback[kCallbackPromise];
356
361
  }
357
- if (this.#coordinatorId) {
362
+ if (this.#coordinatorId !== null) {
358
363
  callback(null, this.#coordinatorId);
359
364
  return callback[kCallbackPromise];
360
365
  }
@@ -422,6 +427,52 @@ export class Consumer extends Base {
422
427
  #consume(options, callback) {
423
428
  consumerConsumesChannel.traceCallback(this.#performConsume, 2, createDiagnosticContext({ client: this, operation: 'consume', options }), this, options, true, callback);
424
429
  }
430
+ [kGetFetchNode](metadata, topic, partition, now) {
431
+ const partitionMetadata = metadata.topics.get(topic).partitions[partition];
432
+ const key = partitionKey(topic, partition);
433
+ const preferredReadReplica = this.#preferredReadReplicas.get(key);
434
+ if (preferredReadReplica === undefined) {
435
+ return partitionMetadata.leader;
436
+ }
437
+ if (now > preferredReadReplica.expiresAt) {
438
+ this.#preferredReadReplicas.delete(key);
439
+ return partitionMetadata.leader;
440
+ }
441
+ if (metadata.brokers.has(preferredReadReplica.node) &&
442
+ partitionMetadata.replicas.includes(preferredReadReplica.node) &&
443
+ !partitionMetadata.offlineReplicas.includes(preferredReadReplica.node)) {
444
+ return preferredReadReplica.node;
445
+ }
446
+ this.#preferredReadReplicas.delete(key);
447
+ this.clearMetadata();
448
+ return partitionMetadata.leader;
449
+ }
450
+ #topicIdsById(metadata) {
451
+ const topicIds = new Map();
452
+ for (const [topic, { id }] of metadata.topics) {
453
+ topicIds.set(id, topic);
454
+ }
455
+ return topicIds;
456
+ }
457
+ #fetchNodeForRequest(metadata, fallbackNode, topics, topicIds, now) {
458
+ let requestNode;
459
+ for (const topicRequest of topics) {
460
+ const topic = topicIds.get(topicRequest.topicId);
461
+ if (!topic) {
462
+ return fallbackNode;
463
+ }
464
+ for (const { partition } of topicRequest.partitions) {
465
+ const node = this[kGetFetchNode](metadata, topic, partition, now);
466
+ if (requestNode === undefined) {
467
+ requestNode = node;
468
+ }
469
+ else if (requestNode !== node) {
470
+ return fallbackNode;
471
+ }
472
+ }
473
+ }
474
+ return requestNode ?? fallbackNode;
475
+ }
425
476
  #fetch(options, callback) {
426
477
  const isolationLevel = options.isolationLevel ?? this[kOptions].isolationLevel;
427
478
  this[kPerformWithRetry]('fetch', retryCallback => {
@@ -430,14 +481,17 @@ export class Consumer extends Base {
430
481
  retryCallback(error);
431
482
  return;
432
483
  }
433
- const broker = metadata.brokers.get(options.node);
484
+ const topicIds = this.#topicIdsById(metadata);
485
+ const node = this.#fetchNodeForRequest(metadata, options.node, options.topics, topicIds, Date.now());
486
+ const broker = metadata.brokers.get(node);
434
487
  if (!broker) {
435
- retryCallback(new UserError(`Cannot find broker with node id ${options.node}`));
488
+ retryCallback(new UserError(`Cannot find broker with node id ${node}`));
436
489
  return;
437
490
  }
438
491
  const pool = options.connectionPool ?? this[kConnections];
439
492
  pool.get(broker, (error, connection) => {
440
493
  if (error) {
494
+ this.#clearPreferredReadReplicas(options.topics, topicIds);
441
495
  // When a connection was not available (either interrupted or not available) we
442
496
  // reset the leader epoch in the options so that when connection is re-established again we can continue
443
497
  for (const topic of options.topics) {
@@ -454,8 +508,9 @@ export class Consumer extends Base {
454
508
  retryCallback(error);
455
509
  return;
456
510
  }
457
- api(connection, options.maxWaitTime ?? this[kOptions].maxWaitTime, options.minBytes ?? this[kOptions].minBytes, options.maxBytes ?? this[kOptions].maxBytes, isolationLevel, 0, 0, options.topics, [], '', (error, result) => {
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) => {
458
512
  if (error) {
513
+ this.#clearPreferredReadReplicas(options.topics, topicIds);
459
514
  const genericError = error;
460
515
  if (genericError.findBy?.('apiId', 'FENCED_LEADER_EPOCH')) {
461
516
  this.clearMetadata();
@@ -467,6 +522,9 @@ export class Consumer extends Base {
467
522
  }
468
523
  }
469
524
  }
525
+ else {
526
+ this.#updatePreferredReadReplicas(metadata, topicIds, result);
527
+ }
470
528
  retryCallback(error, result);
471
529
  });
472
530
  });
@@ -581,7 +639,7 @@ export class Consumer extends Base {
581
639
  }, concurrentCallback, 0);
582
640
  }, (error, responses) => {
583
641
  if (error) {
584
- callback(this.#handleMetadataError(error));
642
+ callback(this.#handleError(error));
585
643
  return;
586
644
  }
587
645
  let offsets = new Map();
@@ -646,14 +704,14 @@ export class Consumer extends Base {
646
704
  error.findBy?.('apiId', 'STALE_MEMBER_EPOCH')) {
647
705
  this.#consumerGroupHeartbeat(this[kOptions], heartbeatError => {
648
706
  if (heartbeatError) {
649
- callback(this.#handleMetadataError(heartbeatError));
707
+ callback(this.#handleError(heartbeatError));
650
708
  return;
651
709
  }
652
710
  this.#listCommittedOffsets(options, callback, staleMemberEpochRetries + 1);
653
711
  });
654
712
  return;
655
713
  }
656
- callback(this.#handleMetadataError(error));
714
+ callback(this.#handleError(error));
657
715
  return;
658
716
  }
659
717
  const committed = new Map();
@@ -671,7 +729,7 @@ export class Consumer extends Base {
671
729
  });
672
730
  }
673
731
  #findGroupCoordinator(callback) {
674
- if (this.#coordinatorId) {
732
+ if (this.#coordinatorId !== null) {
675
733
  callback(null, this.#coordinatorId);
676
734
  return;
677
735
  }
@@ -748,8 +806,7 @@ export class Consumer extends Base {
748
806
  return;
749
807
  }
750
808
  const memberId = this.#getConsumerGroupHeartbeatMemberId(api.version);
751
- api(connection, this.groupId, memberId, this.#memberEpoch, this.groupInstanceId, null, // rackId
752
- options.rebalanceTimeout, this.topics.current, null, this.#groupRemoteAssignor, this.#assignments, groupCallback);
809
+ api(connection, this.groupId, memberId, this.#memberEpoch, this.groupInstanceId, this.#clientRack || null, options.rebalanceTimeout, this.topics.current, null, this.#groupRemoteAssignor, this.#assignments, groupCallback);
753
810
  });
754
811
  }, (error, response) => {
755
812
  if (this[kClosed]) {
@@ -764,6 +821,7 @@ export class Consumer extends Base {
764
821
  if (fenced) {
765
822
  this.#assignments = [];
766
823
  this.assignments = [];
824
+ this.#syncPreferredReadReplicas();
767
825
  this.#memberEpoch = 0;
768
826
  this.#consumerGroupHeartbeat(options, () => { });
769
827
  callback(error);
@@ -878,6 +936,7 @@ export class Consumer extends Base {
878
936
  }));
879
937
  this.#assignments = newAssignments;
880
938
  this.assignments = assignments;
939
+ this.#syncPreferredReadReplicas();
881
940
  callback(null);
882
941
  });
883
942
  }
@@ -903,8 +962,7 @@ export class Consumer extends Base {
903
962
  }
904
963
  const memberId = this.#getConsumerGroupHeartbeatMemberId(api.version);
905
964
  api(connection, this.groupId, memberId, -1, // memberEpoch = -1 signals leave
906
- this.groupInstanceId, null, // rackId
907
- 0, // rebalanceTimeout
965
+ this.groupInstanceId, this.#clientRack || null, 0, // rebalanceTimeout
908
966
  [], // subscribedTopicNames
909
967
  null, // subscribedTopicRegex
910
968
  this.#groupRemoteAssignor, [], // topicPartitions
@@ -916,6 +974,7 @@ export class Consumer extends Base {
916
974
  this.#memberEpoch = -1;
917
975
  this.#assignments = [];
918
976
  this.assignments = [];
977
+ this.#syncPreferredReadReplicas();
919
978
  callback(null);
920
979
  });
921
980
  }
@@ -1057,6 +1116,7 @@ export class Consumer extends Base {
1057
1116
  return;
1058
1117
  }
1059
1118
  this.assignments = response;
1119
+ this.#syncPreferredReadReplicas();
1060
1120
  this.#cancelHeartbeat();
1061
1121
  this.#heartbeatInterval = setTimeout(() => {
1062
1122
  this.#heartbeat(options);
@@ -1126,6 +1186,7 @@ export class Consumer extends Base {
1126
1186
  this.memberId = null;
1127
1187
  this.generationId = 0;
1128
1188
  this.assignments = null;
1189
+ this.#syncPreferredReadReplicas();
1129
1190
  callback(null);
1130
1191
  });
1131
1192
  }
@@ -1150,7 +1211,7 @@ export class Consumer extends Base {
1150
1211
  }
1151
1212
  this[kMetadata]({ topics: Array.from(topicsSubscriptions.keys()) }, (error, metadata) => {
1152
1213
  if (error) {
1153
- callback(this.#handleMetadataError(error));
1214
+ callback(this.#handleError(error));
1154
1215
  return;
1155
1216
  }
1156
1217
  this.#performSyncGroup(partitionsAssigner, this.#createAssignments(partitionsAssigner, metadata), callback);
@@ -1188,26 +1249,30 @@ export class Consumer extends Base {
1188
1249
  }, callback);
1189
1250
  }
1190
1251
  #performGroupOperation(operationId, operation, callback) {
1191
- this.#findGroupCoordinator((error, coordinatorId) => {
1192
- if (error) {
1193
- callback(error);
1194
- return;
1195
- }
1196
- this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
1252
+ this[kPerformWithRetry](operationId, retryCallback => {
1253
+ this.#findGroupCoordinator((error, coordinatorId) => {
1197
1254
  if (error) {
1198
- callback(this.#handleMetadataError(error));
1255
+ retryCallback(error);
1199
1256
  return;
1200
1257
  }
1201
- this[kPerformWithRetry](operationId, retryCallback => {
1258
+ this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
1259
+ if (error) {
1260
+ retryCallback(this.#handleError(error));
1261
+ return;
1262
+ }
1202
1263
  this[kGetConnection](metadata.brokers.get(coordinatorId), (error, connection) => {
1203
1264
  if (error) {
1204
- retryCallback(error);
1265
+ retryCallback(this.#handleError(error));
1205
1266
  return;
1206
1267
  }
1207
- operation(connection, retryCallback);
1268
+ operation(connection, (error, result) => {
1269
+ retryCallback(this.#handleError(error), result);
1270
+ });
1208
1271
  });
1209
- }, callback);
1272
+ });
1210
1273
  });
1274
+ }, (error, result) => {
1275
+ callback(this.#handleError(error), result);
1211
1276
  });
1212
1277
  }
1213
1278
  #validateGroupOptions(options, validator) {
@@ -1345,9 +1410,84 @@ export class Consumer extends Base {
1345
1410
  }
1346
1411
  return protocolError;
1347
1412
  }
1348
- #handleMetadataError(error) {
1349
- if (error && error?.findBy('hasStaleMetadata', true)) {
1350
- this.clearMetadata();
1413
+ #updatePreferredReadReplicas(metadata, topicIds, response) {
1414
+ for (const topicResponse of response.responses) {
1415
+ const topic = topicIds.get(topicResponse.topicId);
1416
+ if (!topic) {
1417
+ continue;
1418
+ }
1419
+ for (const { partitionIndex: partition, preferredReadReplica } of topicResponse.partitions) {
1420
+ if (preferredReadReplica < 0) {
1421
+ continue;
1422
+ }
1423
+ const partitionMetadata = metadata.topics.get(topic).partitions[partition];
1424
+ const key = partitionKey(topic, partition);
1425
+ if (metadata.brokers.has(preferredReadReplica) &&
1426
+ partitionMetadata.replicas.includes(preferredReadReplica) &&
1427
+ !partitionMetadata.offlineReplicas.includes(preferredReadReplica)) {
1428
+ const cachedPreferredReadReplica = this.#preferredReadReplicas.get(key);
1429
+ if (cachedPreferredReadReplica?.node !== preferredReadReplica) {
1430
+ this.#preferredReadReplicas.set(key, {
1431
+ node: preferredReadReplica,
1432
+ expiresAt: Date.now() + this[kOptions].metadataMaxAge
1433
+ });
1434
+ }
1435
+ }
1436
+ else {
1437
+ this.#preferredReadReplicas.delete(key);
1438
+ this.clearMetadata();
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+ #clearPreferredReadReplicas(topics, topicIds) {
1444
+ let cleared = false;
1445
+ for (const topicRequest of topics) {
1446
+ const topic = topicIds.get(topicRequest.topicId);
1447
+ if (!topic) {
1448
+ continue;
1449
+ }
1450
+ for (const { partition } of topicRequest.partitions) {
1451
+ cleared = this.#preferredReadReplicas.delete(partitionKey(topic, partition)) || cleared;
1452
+ }
1453
+ }
1454
+ return cleared;
1455
+ }
1456
+ // Drops cached preferred replicas for partitions that are no longer assigned.
1457
+ // Called whenever `this.assignments` changes so the cache does not retain
1458
+ // entries for partitions the consumer has lost during a rebalance.
1459
+ #syncPreferredReadReplicas() {
1460
+ if (this.#preferredReadReplicas.size === 0) {
1461
+ return;
1462
+ }
1463
+ if (!this.assignments?.length) {
1464
+ this.#preferredReadReplicas.clear();
1465
+ return;
1466
+ }
1467
+ const assignedKeys = new Set();
1468
+ for (const { topic, partitions } of this.assignments) {
1469
+ for (const partition of partitions) {
1470
+ assignedKeys.add(partitionKey(topic, partition));
1471
+ }
1472
+ }
1473
+ for (const key of this.#preferredReadReplicas.keys()) {
1474
+ if (!assignedKeys.has(key)) {
1475
+ this.#preferredReadReplicas.delete(key);
1476
+ }
1477
+ }
1478
+ }
1479
+ #handleError(error) {
1480
+ const kafkaError = error;
1481
+ if (kafkaError) {
1482
+ if (kafkaError.findBy('hasStaleMetadata', true)) {
1483
+ this.clearMetadata();
1484
+ }
1485
+ else if (kafkaError.findBy('code', 'PLT_KFK_NETWORK') ||
1486
+ kafkaError.findBy('apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
1487
+ kafkaError.findBy('apiId', 'COORDINATOR_NOT_AVAILABLE') ||
1488
+ kafkaError.findBy('apiId', 'NOT_COORDINATOR')) {
1489
+ this.#coordinatorId = null;
1490
+ }
1351
1491
  }
1352
1492
  return error;
1353
1493
  }
@@ -5,11 +5,12 @@ import { consumerReceivesChannel, createDiagnosticContext, notifyCreation } from
5
5
  import { protocolErrors, UserError } from "../../errors.js";
6
6
  import { IS_CONTROL } from "../../protocol/records.js";
7
7
  import { runAsyncSeries } from "../../registries/abstract.js";
8
- import { kAutocommit, kInstance, kRefreshOffsetsAndFetch } from "../../symbols.js";
8
+ import { kAutocommit, kGetFetchNode, kInstance, kRefreshOffsetsAndFetch } from "../../symbols.js";
9
9
  import { kConnections, kCreateConnectionPool, kInspect, kPrometheus } from "../base/base.js";
10
10
  import { ensureMetric } from "../metrics.js";
11
11
  import { defaultConsumerOptions } from "./options.js";
12
12
  import { MessagesStreamFallbackModes, MessagesStreamModes } from "./types.js";
13
+ import { partitionKey } from "./utils.js";
13
14
  // Don't move this function as being in the same file will enable V8 to remove.
14
15
  // For futher info, ask Matteo.
15
16
  /* c8 ignore next 3 - Fallback deserializer, nothing to really test */
@@ -145,7 +146,7 @@ export class MessagesStream extends Readable {
145
146
  this.#offsetsToFetch = new Map();
146
147
  if (offsets) {
147
148
  for (const { topic, partition, offset } of offsets) {
148
- this.#offsetsToFetch.set(`${topic}:${partition}`, offset);
149
+ this.#offsetsToFetch.set(partitionKey(topic, partition), offset);
149
150
  }
150
151
  }
151
152
  // Clone the rest of the options so the user can never mutate them
@@ -364,21 +365,22 @@ export class MessagesStream extends Readable {
364
365
  }
365
366
  const partitions = assignment.partitions;
366
367
  for (const partition of partitions) {
367
- const leader = metadata.topics.get(topic).partitions[partition].leader;
368
- if (this.#inflightNodes.has(leader)) {
368
+ const targetNode = this.#consumer[kGetFetchNode](metadata, topic, partition, now);
369
+ const key = partitionKey(topic, partition);
370
+ if (this.#inflightNodes.has(targetNode)) {
369
371
  continue;
370
372
  }
371
- let leaderRequests = requests.get(leader);
372
- if (!leaderRequests) {
373
- leaderRequests = [];
374
- requests.set(leader, leaderRequests);
373
+ let nodeRequests = requests.get(targetNode);
374
+ if (!nodeRequests) {
375
+ nodeRequests = [];
376
+ requests.set(targetNode, nodeRequests);
375
377
  }
376
378
  const topicId = metadata.topics.get(topic).id;
377
379
  topicIds.set(topicId, topic);
378
- const fetchOffset = this.#offsetsToFetch.get(`${topic}:${partition}`);
379
- requestedOffsets.set(`${topic}:${partition}`, fetchOffset);
380
- const leaderEpoch = this.#partitionsEpochs.get(`${topic}:${partition}`) ?? -1;
381
- leaderRequests.push({
380
+ const fetchOffset = this.#offsetsToFetch.get(key);
381
+ requestedOffsets.set(key, fetchOffset);
382
+ const leaderEpoch = this.#partitionsEpochs.get(key) ?? -1;
383
+ nodeRequests.push({
382
384
  topicId,
383
385
  partitions: [
384
386
  {
@@ -395,10 +397,10 @@ export class MessagesStream extends Readable {
395
397
  if (requests.size === 0) {
396
398
  return;
397
399
  }
398
- for (const [leader, leaderRequests] of requests) {
399
- this.#inflightNodes.set(leader, Date.now());
400
- this.#consumer.fetch({ ...this.#options, node: leader, topics: leaderRequests, connectionPool: this[kConnections] }, (error, response) => {
401
- this.#inflightNodes.delete(leader);
400
+ for (const [node, nodeRequests] of requests) {
401
+ this.#inflightNodes.set(node, Date.now());
402
+ this.#consumer.fetch({ ...this.#options, node, topics: nodeRequests, connectionPool: this[kConnections] }, (error, response) => {
403
+ this.#inflightNodes.delete(node);
402
404
  this.emit('fetch');
403
405
  if (error) {
404
406
  // The stream has been closed, ignore the error
@@ -407,10 +409,22 @@ export class MessagesStream extends Readable {
407
409
  this.push(null);
408
410
  return;
409
411
  }
410
- if (this.#fallbackMode !== MessagesStreamFallbackModes.FAIL &&
411
- this.#handleOffsetOutOfRange(error, topicIds)) {
412
- process.nextTick(() => {
413
- this.#fetch();
412
+ if (this.#fallbackMode !== MessagesStreamFallbackModes.FAIL) {
413
+ this.#handleOffsetOutOfRange(error, topicIds, (recoveryError, recovered) => {
414
+ if (this.#closed || this.closed || this.destroyed) {
415
+ return;
416
+ }
417
+ if (recoveryError) {
418
+ this.destroy(recoveryError);
419
+ return;
420
+ }
421
+ if (recovered) {
422
+ process.nextTick(() => {
423
+ this.#fetch();
424
+ });
425
+ return;
426
+ }
427
+ this.destroy(error);
414
428
  });
415
429
  return;
416
430
  }
@@ -430,34 +444,81 @@ export class MessagesStream extends Readable {
430
444
  }
431
445
  });
432
446
  }
433
- #handleOffsetOutOfRange(error, topicIds) {
447
+ #handleOffsetOutOfRange(error, topicIds, callback) {
434
448
  if (!error.findBy?.('apiId', 'OFFSET_OUT_OF_RANGE')) {
435
- return false;
449
+ callback(null, false);
450
+ return;
436
451
  }
437
452
  const response = error.response;
438
453
  if (!response || response.errorCode !== 0) {
439
- return false;
454
+ callback(null, false);
455
+ return;
440
456
  }
441
457
  const recoveredOffsets = [];
458
+ const partitionsToRefresh = new Map();
442
459
  for (const topicResponse of response.responses) {
443
460
  const topic = topicIds.get(topicResponse.topicId);
444
461
  if (!topic) {
445
- return false;
462
+ callback(null, false);
463
+ return;
446
464
  }
447
465
  for (const partitionResponse of topicResponse.partitions) {
448
466
  if (partitionResponse.errorCode === 0) {
449
467
  continue;
450
468
  }
451
469
  if (partitionResponse.errorCode !== protocolErrors.OFFSET_OUT_OF_RANGE.code) {
452
- return false;
470
+ callback(null, false);
471
+ return;
453
472
  }
454
- const key = `${topic}:${partitionResponse.partitionIndex}`;
455
473
  const offset = this.#fallbackMode === MessagesStreamFallbackModes.EARLIEST
456
474
  ? partitionResponse.logStartOffset
457
475
  : partitionResponse.highWatermark;
458
- recoveredOffsets.push([key, offset]);
476
+ if (offset >= 0n) {
477
+ recoveredOffsets.push([partitionKey(topic, partitionResponse.partitionIndex), offset]);
478
+ continue;
479
+ }
480
+ let partitions = partitionsToRefresh.get(topic);
481
+ if (!partitions) {
482
+ partitions = [];
483
+ partitionsToRefresh.set(topic, partitions);
484
+ }
485
+ partitions.push(partitionResponse.partitionIndex);
459
486
  }
460
487
  }
488
+ if (partitionsToRefresh.size > 0) {
489
+ this.#refreshOutOfRangeOffsets(partitionsToRefresh, recoveredOffsets, callback);
490
+ return;
491
+ }
492
+ callback(null, this.#applyRecoveredOffsets(recoveredOffsets));
493
+ }
494
+ #refreshOutOfRangeOffsets(partitionsToRefresh, recoveredOffsets, callback) {
495
+ const partitions = Object.fromEntries(partitionsToRefresh);
496
+ this.#consumer.listOffsets({
497
+ topics: Array.from(partitionsToRefresh.keys()),
498
+ partitions,
499
+ timestamp: this.#fallbackMode === MessagesStreamFallbackModes.EARLIEST
500
+ ? ListOffsetTimestamps.EARLIEST
501
+ : ListOffsetTimestamps.LATEST
502
+ }, (error, offsets) => {
503
+ if (error) {
504
+ callback(error);
505
+ return;
506
+ }
507
+ for (const [topic, refreshedPartitions] of partitionsToRefresh) {
508
+ const topicOffsets = offsets.get(topic);
509
+ for (const partition of refreshedPartitions) {
510
+ const offset = topicOffsets?.[partition];
511
+ if (typeof offset !== 'bigint' || offset < 0n) {
512
+ callback(new UserError(`Cannot recover offset out of range for topic ${topic} partition ${partition}.`));
513
+ return;
514
+ }
515
+ recoveredOffsets.push([partitionKey(topic, partition), offset]);
516
+ }
517
+ }
518
+ callback(null, this.#applyRecoveredOffsets(recoveredOffsets));
519
+ });
520
+ }
521
+ #applyRecoveredOffsets(recoveredOffsets) {
461
522
  for (const [key, offset] of recoveredOffsets) {
462
523
  this.#offsetsToFetch.set(key, offset);
463
524
  this.#offsetsCommitted.set(key, offset);
@@ -487,6 +548,7 @@ export class MessagesStream extends Readable {
487
548
  for (const topicResponse of response.responses) {
488
549
  const topic = topicIds.get(topicResponse.topicId);
489
550
  for (const { records: recordsBatches, partitionIndex: partition } of topicResponse.partitions) {
551
+ const key = partitionKey(topic, partition);
490
552
  if (!recordsBatches) {
491
553
  continue;
492
554
  }
@@ -494,15 +556,15 @@ export class MessagesStream extends Readable {
494
556
  const firstTimestamp = batch.firstTimestamp;
495
557
  const firstOffset = batch.firstOffset;
496
558
  const leaderEpoch = metadata.topics.get(topic).partitions[partition].leaderEpoch;
497
- this.#partitionsEpochs.set(`${topic}:${partition}`, leaderEpoch);
559
+ this.#partitionsEpochs.set(key, leaderEpoch);
498
560
  // Track offsets
499
561
  if (batch === recordsBatches[recordsBatches.length - 1]) {
500
562
  // Track the last read offset
501
563
  const lastOffset = batch.firstOffset + BigInt(batch.lastOffsetDelta);
502
- this.#offsetsToFetch.set(`${topic}:${partition}`, lastOffset + 1n);
564
+ this.#offsetsToFetch.set(key, lastOffset + 1n);
503
565
  // Autocommit if needed
504
566
  if (autocommit) {
505
- this.#offsetsToCommit.set(`${topic}:${partition}`, {
567
+ this.#offsetsToCommit.set(key, {
506
568
  topic,
507
569
  partition,
508
570
  offset: lastOffset + 1n,
@@ -518,7 +580,7 @@ export class MessagesStream extends Readable {
518
580
  for (const record of batch.records) {
519
581
  const messageToConsume = { ...record, topic, partition };
520
582
  const offset = batch.firstOffset + BigInt(record.offsetDelta);
521
- if (offset < requestedOffsets.get(`${topic}:${partition}`)) {
583
+ if (offset < requestedOffsets.get(key)) {
522
584
  // Thi is a duplicate message, ignore it
523
585
  continue;
524
586
  }
@@ -605,7 +667,7 @@ export class MessagesStream extends Readable {
605
667
  }
606
668
  }
607
669
  #updateCommittedOffset(topic, partition, offset) {
608
- const key = `${topic}:${partition}`;
670
+ const key = partitionKey(topic, partition);
609
671
  const previous = this.#offsetsCommitted.get(key);
610
672
  if (typeof previous === 'undefined' || previous < offset) {
611
673
  this.#offsetsCommitted.set(key, offset);
@@ -767,8 +829,9 @@ export class MessagesStream extends Readable {
767
829
  }
768
830
  const partitions = assignment.partitions;
769
831
  for (const partition of partitions) {
770
- const committed = this.#offsetsToFetch.get(`${topic}:${partition}`);
771
- this.#offsetsCommitted.set(`${topic}:${partition}`, committed);
832
+ const key = partitionKey(topic, partition);
833
+ const committed = this.#offsetsToFetch.get(key);
834
+ this.#offsetsCommitted.set(key, committed);
772
835
  }
773
836
  }
774
837
  this.emit('offsets');
@@ -27,6 +27,10 @@ export type OffsetsWithTimestamps = Map<string, Map<number, {
27
27
  offset: bigint;
28
28
  timestamp: bigint;
29
29
  }>>;
30
+ export interface PreferredReadReplica {
31
+ node: number;
32
+ expiresAt: number;
33
+ }
30
34
  export type CorruptedMessageHandler = (record: KafkaRecord, topic: string, partition: number, firstTimestamp: bigint, firstOffset: bigint, commit: Message['commit']) => boolean;
31
35
  export type GroupPartitionsAssigner = (current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata) => GroupPartitionsAssignments[];
32
36
  export declare const MessagesStreamModes: {
@@ -0,0 +1 @@
1
+ export declare function partitionKey(topic: string, partition: number): string;
@@ -0,0 +1,3 @@
1
+ export function partitionKey(topic, partition) {
2
+ return `${topic}:${partition}`;
3
+ }
@@ -13,7 +13,7 @@ export declare class Producer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
13
13
  get producerId(): bigint | undefined;
14
14
  get producerEpoch(): number | undefined;
15
15
  get transaction(): Transaction<Key, Value, HeaderKey, HeaderValue> | undefined;
16
- get coordinatorId(): number;
16
+ get coordinatorId(): number | undefined;
17
17
  get streamsCount(): number;
18
18
  close(force: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
19
19
  close(force?: boolean): Promise<void>;
@@ -383,12 +383,12 @@ export class Producer extends Base {
383
383
  this[kPerformWithRetry]('addPartitionsToTransaction', retryCallback => {
384
384
  this.#getCoordinatorConnection((error, connection) => {
385
385
  if (error) {
386
- retryCallback(error);
386
+ retryCallback(this.#handleError(error));
387
387
  return;
388
388
  }
389
389
  this[kGetApi]('AddPartitionsToTxn', (error, api) => {
390
390
  if (error) {
391
- retryCallback(error);
391
+ retryCallback(this.#handleError(error));
392
392
  return;
393
393
  }
394
394
  api(connection, [
@@ -401,7 +401,7 @@ export class Producer extends Base {
401
401
  }
402
402
  ], error => {
403
403
  if (error) {
404
- retryCallback(error);
404
+ retryCallback(this.#handleError(error));
405
405
  return;
406
406
  }
407
407
  retryCallback(null);
@@ -420,17 +420,17 @@ export class Producer extends Base {
420
420
  this[kPerformWithRetry]('addOffsetsToTransaction', retryCallback => {
421
421
  this.#getCoordinatorConnection((error, connection) => {
422
422
  if (error) {
423
- retryCallback(error);
423
+ retryCallback(this.#handleError(error));
424
424
  return;
425
425
  }
426
426
  this[kGetApi]('AddOffsetsToTxn', (error, api) => {
427
427
  if (error) {
428
- retryCallback(error);
428
+ retryCallback(this.#handleError(error));
429
429
  return;
430
430
  }
431
431
  api(connection, this.#transaction.id, this.#producerInfo.producerId, this.#producerInfo.producerEpoch, groupId, error => {
432
432
  if (error) {
433
- retryCallback(error);
433
+ retryCallback(this.#handleError(error));
434
434
  return;
435
435
  }
436
436
  retryCallback(null);
@@ -498,18 +498,18 @@ export class Producer extends Base {
498
498
  this[kPerformWithRetry]('endTransaction', retryCallback => {
499
499
  this.#getCoordinatorConnection((error, connection) => {
500
500
  if (error) {
501
- retryCallback(error);
501
+ retryCallback(this.#handleError(error));
502
502
  return;
503
503
  }
504
504
  this[kGetApi]('EndTxn', (error, api) => {
505
505
  if (error) {
506
- retryCallback(error);
506
+ retryCallback(this.#handleError(error));
507
507
  return;
508
508
  }
509
509
  api(connection, this.#transaction.id, this.#producerInfo.producerId, this.#producerInfo.producerEpoch, commit, error => {
510
510
  if (error) {
511
511
  this.#handleFencingError(error);
512
- retryCallback(error);
512
+ retryCallback(this.#handleError(error));
513
513
  return;
514
514
  }
515
515
  this.#transaction = undefined;
@@ -545,13 +545,15 @@ export class Producer extends Base {
545
545
  retryCallback(error);
546
546
  return;
547
547
  }
548
- api(connection, transactionalId, this[kOptions].timeout, options.producerId ?? this[kOptions].producerId ?? 0n, options.producerEpoch ?? this[kOptions].producerEpoch ?? 0, retryCallback);
548
+ api(connection, transactionalId, this[kOptions].timeout, options.producerId ?? this[kOptions].producerId ?? 0n, options.producerEpoch ?? this[kOptions].producerEpoch ?? 0, (error, response) => {
549
+ retryCallback(this.#handleError(error), response);
550
+ });
549
551
  });
550
552
  });
551
553
  }, (error, response) => {
552
554
  if (error) {
553
555
  this.#handleFencingError(error);
554
- deduplicateCallback(error);
556
+ deduplicateCallback(this.#handleError(error));
555
557
  return;
556
558
  }
557
559
  this.#producerInfo = {
@@ -777,21 +779,29 @@ export class Producer extends Base {
777
779
  });
778
780
  }
779
781
  #getCoordinatorConnection(callback) {
782
+ if (this.#coordinatorId === undefined) {
783
+ this[kTransactionFindCoordinator](error => {
784
+ if (error) {
785
+ callback(error);
786
+ return;
787
+ }
788
+ this.#getCoordinatorConnection(callback);
789
+ });
790
+ return;
791
+ }
780
792
  // Get a connection to the coordinator
781
793
  this[kMetadata]({}, (error, metadata) => {
782
794
  if (error) {
783
795
  callback(error);
784
796
  return;
785
797
  }
786
- this[kPerformWithRetry]('getCoordinatorConnection', retryCallback => {
787
- this[kGetConnection](metadata.brokers.get(this.#coordinatorId), (error, connection) => {
788
- if (error) {
789
- retryCallback(error);
790
- return;
791
- }
792
- retryCallback(null, connection);
793
- });
794
- }, callback);
798
+ this[kGetConnection](metadata.brokers.get(this.#coordinatorId), (error, connection) => {
799
+ if (error) {
800
+ callback(this.#handleError(error));
801
+ return;
802
+ }
803
+ callback(null, connection);
804
+ });
795
805
  });
796
806
  }
797
807
  #handleFencingError(error) {
@@ -800,6 +810,18 @@ export class Producer extends Base {
800
810
  this.#transaction = undefined;
801
811
  }
802
812
  }
813
+ #handleError(error) {
814
+ const kafkaError = error;
815
+ if (kafkaError) {
816
+ if (kafkaError.findBy('code', 'PLT_KFK_NETWORK') ||
817
+ kafkaError.findBy('apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
818
+ kafkaError.findBy('apiId', 'COORDINATOR_NOT_AVAILABLE') ||
819
+ kafkaError.findBy('apiId', 'NOT_COORDINATOR')) {
820
+ this.#coordinatorId = undefined;
821
+ }
822
+ }
823
+ return error;
824
+ }
803
825
  #beforeSerialization(hook, options, callback) {
804
826
  // Create the pre-serialization requests
805
827
  const requests = [];
@@ -55,7 +55,12 @@ export class Connection extends TypedEventEmitter {
55
55
  super();
56
56
  this.setMaxListeners(0);
57
57
  this.#instanceId = currentInstance++;
58
- this.#options = Object.assign({}, defaultOptions, options);
58
+ this.#options = Object.assign({}, defaultOptions);
59
+ for (const [key, value] of Object.entries(options)) {
60
+ if (value !== undefined) {
61
+ this.#options[key] = value;
62
+ }
63
+ }
59
64
  this.#options.tls ??= this.#options.ssl;
60
65
  this.#status = ConnectionStatuses.NONE;
61
66
  this.#clientId = clientId;
@@ -124,7 +129,7 @@ export class Connection extends TypedEventEmitter {
124
129
  }
125
130
  /* c8 ignore next 13 - Hard to test */
126
131
  const connectingSocketTimeoutHandler = () => {
127
- const error = new TimeoutError(`Connection to ${host}:${port} timed out.`);
132
+ const error = new TimeoutError(`Connection to ${host}:${port} timed out.`, { canRetry: true });
128
133
  diagnosticContext.error = error;
129
134
  this.#socket.destroy();
130
135
  this.#status = ConnectionStatuses.ERROR;
@@ -214,7 +219,7 @@ export class Connection extends TypedEventEmitter {
214
219
  this.#socket?.destroy();
215
220
  callback(new TimeoutError(this.#host
216
221
  ? `Connection to ${this.#host}:${this.#port} timed out.`
217
- : `Connection ready timed out after ${this.#options.connectTimeout}ms.`));
222
+ : `Connection ready timed out after ${this.#options.connectTimeout}ms.`, { canRetry: true }));
218
223
  }, this.#options.connectTimeout);
219
224
  this.once('connect', onConnect);
220
225
  this.once('error', onError);
@@ -253,7 +258,9 @@ export class Connection extends TypedEventEmitter {
253
258
  return callback[kCallbackPromise];
254
259
  }
255
260
  send(apiKey, apiVersion, createPayload, responseParser, hasRequestHeaderTaggedFields, hasResponseHeaderTaggedFields, callback) {
256
- const correlationId = ++this.#correlationId;
261
+ // Correlation ID is a 32-bit integer in the protocol, so we need to wrap around after 2^31 - 1
262
+ const correlationId = (this.#correlationId + 1) & 0x7FFFFFFF;
263
+ this.#correlationId = correlationId;
257
264
  const diagnostic = createDiagnosticContext({
258
265
  connection: this,
259
266
  operation: 'send',
@@ -60,8 +60,9 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
60
60
  if (!response.ok) {
61
61
  throw new UserError(`Failed to fetch a schema: [HTTP ${response.status}]`, { response: await response.text() });
62
62
  }
63
- const responseBody = await response.json();
64
- const { schema, schemaType } = responseBody;
63
+ const responseBody = (await response.json());
64
+ const { schema } = responseBody;
65
+ const schemaType = responseBody.schemaType ?? 'AVRO';
65
66
  switch (schemaType) {
66
67
  case 'AVRO':
67
68
  this.#schemas.set(id, { id, type: 'avro', schema: avro.Type.forSchema(JSON.parse(schema)) });
package/dist/symbols.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export declare const kInstance: unique symbol;
2
2
  export declare const kRefreshOffsetsAndFetch: unique symbol;
3
3
  export declare const kAutocommit: unique symbol;
4
+ export declare const kGetFetchNode: unique symbol;
4
5
  export declare const kTransaction: unique symbol;
5
6
  export declare const kTransactionPrepare: unique symbol;
6
7
  export declare const kTransactionAddPartitions: unique symbol;
package/dist/symbols.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export const kInstance = Symbol('plt.kafka.base.instance');
3
3
  export const kRefreshOffsetsAndFetch = Symbol('plt.kafka.messagesStream.refreshOffsetsAndFetch');
4
4
  export const kAutocommit = Symbol('plt.kafka.messagesStream.autocommit');
5
+ export const kGetFetchNode = Symbol('plt.kafka.consumer.getFetchNode');
5
6
  export const kTransaction = Symbol('plt.kafka.producer.transactions');
6
7
  export const kTransactionPrepare = Symbol('plt.kafka.producer.transactions.prepare');
7
8
  export const kTransactionAddPartitions = Symbol('plt.kafka.producer.transactions.addPartitions');
@@ -84,11 +84,11 @@ export declare class Base<OptionsType extends BaseOptions = BaseOptions, EventsT
84
84
  [kMetadata](options: MetadataOptions, callback: CallbackWithPromise<ClusterMetadata>): void;
85
85
  [kCheckNotClosed](callback: CallbackWithPromise<any>): boolean;
86
86
  clearMetadata(): void;
87
- [kPerformWithRetry]<ReturnType>(operationId: string, operation: (callback: Callback<ReturnType>) => void, callback: CallbackWithPromise<ReturnType>, attempt?: number, errors?: Error[], shouldSkipRetry?: (e: Error) => boolean): void | Promise<ReturnType>;
87
+ [kPerformWithRetry]<ReturnType>(operationId: string, operation: (callback: Callback<ReturnType>, attempt: number) => void, callback: CallbackWithPromise<ReturnType>, attempt?: number, errors?: Error[], shouldSkipRetry?: (e: Error) => boolean): void | Promise<ReturnType>;
88
88
  [kPerformDeduplicated]<ReturnType>(operationId: string, operation: (callback: CallbackWithPromise<ReturnType>) => void, callback: CallbackWithPromise<ReturnType>): void | Promise<ReturnType>;
89
89
  [kGetApi]<RequestArguments extends Array<unknown>, ResponseType>(name: string, callback: Callback<API<RequestArguments, ResponseType>>): void;
90
90
  [kGetConnection](broker: Broker, callback: Callback<Connection>): void;
91
- [kGetBootstrapConnection](callback: Callback<Connection>): void;
91
+ [kGetBootstrapConnection](callback: Callback<Connection>, attempt?: number): void;
92
92
  [kValidateOptions](target: unknown, validator: ValidateFunction<unknown>, targetName: string, throwOnErrors?: boolean): Error | null;
93
93
  [kInspect](...args: unknown[]): void;
94
94
  [kFormatValidationErrors](validator: ValidateFunction<unknown>, targetName: string): string;
@@ -25,6 +25,9 @@ export declare const baseOptionsSchema: {
25
25
  type: string;
26
26
  pattern: string;
27
27
  };
28
+ clientRack: {
29
+ type: string;
30
+ };
28
31
  bootstrapBrokers: {
29
32
  oneOf: ({
30
33
  type: string;
@@ -32,6 +32,7 @@ export interface ClusterMetadata {
32
32
  export type RetryDelayGetter<Owner = object> = (client: Owner, operationId: string, attempt: number, retries: number, error: Error) => number;
33
33
  export interface BaseOptions extends ConnectionOptions {
34
34
  clientId: string;
35
+ clientRack?: string;
35
36
  bootstrapBrokers: Broker[] | string[];
36
37
  context?: unknown;
37
38
  timeout?: number;
@@ -1,7 +1,9 @@
1
1
  import { type CallbackWithPromise } from "../../apis/callbacks";
2
2
  import { type FetchResponse } from "../../apis/consumer/fetch-v17";
3
3
  import { type ConnectionPool } from "../../network/connection-pool";
4
+ import { kGetFetchNode } from "../../symbols";
4
5
  import { Base, type BaseEvents, kCreateConnectionPool } from "../base/base";
6
+ import { type ClusterMetadata } from "../base/types";
5
7
  import { MessagesStream } from "./messages-stream";
6
8
  import { TopicsMap } from "./topics-map";
7
9
  import { type CommitOptions, type ConsumeOptions, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from "./types";
@@ -56,4 +58,5 @@ export declare class Consumer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
56
58
  joinGroup(options?: GroupOptions): Promise<string>;
57
59
  leaveGroup(force?: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
58
60
  leaveGroup(force?: boolean): Promise<void>;
61
+ [kGetFetchNode](metadata: ClusterMetadata, topic: string, partition: number, now: number): number;
59
62
  }
@@ -27,6 +27,10 @@ export type OffsetsWithTimestamps = Map<string, Map<number, {
27
27
  offset: bigint;
28
28
  timestamp: bigint;
29
29
  }>>;
30
+ export interface PreferredReadReplica {
31
+ node: number;
32
+ expiresAt: number;
33
+ }
30
34
  export type CorruptedMessageHandler = (record: KafkaRecord, topic: string, partition: number, firstTimestamp: bigint, firstOffset: bigint, commit: Message['commit']) => boolean;
31
35
  export type GroupPartitionsAssigner = (current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata) => GroupPartitionsAssignments[];
32
36
  export declare const MessagesStreamModes: {
@@ -0,0 +1 @@
1
+ export declare function partitionKey(topic: string, partition: number): string;
@@ -13,7 +13,7 @@ export declare class Producer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
13
13
  get producerId(): bigint | undefined;
14
14
  get producerEpoch(): number | undefined;
15
15
  get transaction(): Transaction<Key, Value, HeaderKey, HeaderValue> | undefined;
16
- get coordinatorId(): number;
16
+ get coordinatorId(): number | undefined;
17
17
  get streamsCount(): number;
18
18
  close(force: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
19
19
  close(force?: boolean): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  export declare const kInstance: unique symbol;
2
2
  export declare const kRefreshOffsetsAndFetch: unique symbol;
3
3
  export declare const kAutocommit: unique symbol;
4
+ export declare const kGetFetchNode: unique symbol;
4
5
  export declare const kTransaction: unique symbol;
5
6
  export declare const kTransactionPrepare: unique symbol;
6
7
  export declare const kTransactionAddPartitions: unique symbol;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = "@platformatic/kafka";
2
- export const version = "2.0.1";
2
+ export const version = "2.2.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.0.1",
3
+ "version": "2.2.2",
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)",
@@ -89,12 +89,19 @@
89
89
  "postbuild": "./scripts/node scripts/postbuild.ts",
90
90
  "lint": "eslint --cache",
91
91
  "typecheck": "tsc -p . --noEmit",
92
- "format": "prettier -w benchmarks playground src test",
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
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'",
95
+ "test:integrity": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/integrity/*.test.ts'",
96
+ "test:performance": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/performance/*.test.ts'",
97
+ "test:regression": "npm run test:integrity",
98
+ "test:regression:all": "npm run test:integrity && npm run test:performance",
99
+ "test:all": "npm test && npm run test:regression:all",
95
100
  "test:memory": "NODE_OPTIONS='--expose-gc' ./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/**/*.memory-test.ts'",
96
101
  "test:docker:up": "docker-compose up -d --wait",
97
102
  "test:docker:down": "docker-compose down",
103
+ "regression:baseline:compare": "npm run test:performance",
104
+ "regression:baseline:update": "./scripts/node regression/helpers/run-benchmarks.ts",
98
105
  "ci": "npm run build && npm run lint && npm run test:ci",
99
106
  "generate:apis": "node scripts/generate-apis.ts",
100
107
  "generate:errors": "node scripts/generate-errors.ts",