@platformatic/kafka 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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') {
@@ -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) {
@@ -1248,9 +1313,7 @@ export class Consumer extends Base {
1248
1313
  .appendArray(assignments, (w, { topic, partitions }) => {
1249
1314
  w.appendString(topic, false).appendArray(partitions, (w, a) => w.appendInt32(a), false, false);
1250
1315
  }, false, false);
1251
- if (userData) {
1252
- writer.append(userData);
1253
- }
1316
+ writer.appendBytes(userData ?? null, false);
1254
1317
  return writer.buffer;
1255
1318
  }
1256
1319
  #decodeProtocolAssignment(buffer) {
@@ -1347,9 +1410,84 @@ export class Consumer extends Base {
1347
1410
  }
1348
1411
  return protocolError;
1349
1412
  }
1350
- #handleMetadataError(error) {
1351
- if (error && error?.findBy('hasStaleMetadata', true)) {
1352
- 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
+ }
1353
1491
  }
1354
1492
  return error;
1355
1493
  }
@@ -2,14 +2,15 @@ import { Readable } from 'node:stream';
2
2
  import { createPromisifiedCallback, kCallbackPromise, noopCallback } from "../../apis/callbacks.js";
3
3
  import { ListOffsetTimestamps } from "../../apis/enumerations.js";
4
4
  import { consumerReceivesChannel, createDiagnosticContext, notifyCreation } from "../../diagnostic.js";
5
- import { UserError } from "../../errors.js";
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,6 +409,13 @@ export class MessagesStream extends Readable {
407
409
  this.push(null);
408
410
  return;
409
411
  }
412
+ if (this.#fallbackMode !== MessagesStreamFallbackModes.FAIL &&
413
+ this.#handleOffsetOutOfRange(error, topicIds)) {
414
+ process.nextTick(() => {
415
+ this.#fetch();
416
+ });
417
+ return;
418
+ }
410
419
  this.destroy(error);
411
420
  return;
412
421
  }
@@ -423,6 +432,43 @@ export class MessagesStream extends Readable {
423
432
  }
424
433
  });
425
434
  }
435
+ #handleOffsetOutOfRange(error, topicIds) {
436
+ if (!error.findBy?.('apiId', 'OFFSET_OUT_OF_RANGE')) {
437
+ return false;
438
+ }
439
+ const response = error.response;
440
+ if (!response || response.errorCode !== 0) {
441
+ return false;
442
+ }
443
+ const recoveredOffsets = [];
444
+ for (const topicResponse of response.responses) {
445
+ const topic = topicIds.get(topicResponse.topicId);
446
+ if (!topic) {
447
+ return false;
448
+ }
449
+ for (const partitionResponse of topicResponse.partitions) {
450
+ if (partitionResponse.errorCode === 0) {
451
+ continue;
452
+ }
453
+ if (partitionResponse.errorCode !== protocolErrors.OFFSET_OUT_OF_RANGE.code) {
454
+ return false;
455
+ }
456
+ const key = `${topic}:${partitionResponse.partitionIndex}`;
457
+ const offset = this.#fallbackMode === MessagesStreamFallbackModes.EARLIEST
458
+ ? partitionResponse.logStartOffset
459
+ : partitionResponse.highWatermark;
460
+ recoveredOffsets.push([key, offset]);
461
+ }
462
+ }
463
+ for (const [key, offset] of recoveredOffsets) {
464
+ this.#offsetsToFetch.set(key, offset);
465
+ this.#offsetsCommitted.set(key, offset);
466
+ }
467
+ if (recoveredOffsets.length > 0) {
468
+ this.emit('offsets');
469
+ }
470
+ return recoveredOffsets.length > 0;
471
+ }
426
472
  #pushRecords(metadata, topicIds, response, requestedOffsets) {
427
473
  const autocommit = this.#autocommitEnabled;
428
474
  const keyDeserializer = this.#keyDeserializer;
@@ -443,6 +489,7 @@ export class MessagesStream extends Readable {
443
489
  for (const topicResponse of response.responses) {
444
490
  const topic = topicIds.get(topicResponse.topicId);
445
491
  for (const { records: recordsBatches, partitionIndex: partition } of topicResponse.partitions) {
492
+ const key = partitionKey(topic, partition);
446
493
  if (!recordsBatches) {
447
494
  continue;
448
495
  }
@@ -450,15 +497,15 @@ export class MessagesStream extends Readable {
450
497
  const firstTimestamp = batch.firstTimestamp;
451
498
  const firstOffset = batch.firstOffset;
452
499
  const leaderEpoch = metadata.topics.get(topic).partitions[partition].leaderEpoch;
453
- this.#partitionsEpochs.set(`${topic}:${partition}`, leaderEpoch);
500
+ this.#partitionsEpochs.set(key, leaderEpoch);
454
501
  // Track offsets
455
502
  if (batch === recordsBatches[recordsBatches.length - 1]) {
456
503
  // Track the last read offset
457
504
  const lastOffset = batch.firstOffset + BigInt(batch.lastOffsetDelta);
458
- this.#offsetsToFetch.set(`${topic}:${partition}`, lastOffset + 1n);
505
+ this.#offsetsToFetch.set(key, lastOffset + 1n);
459
506
  // Autocommit if needed
460
507
  if (autocommit) {
461
- this.#offsetsToCommit.set(`${topic}:${partition}`, {
508
+ this.#offsetsToCommit.set(key, {
462
509
  topic,
463
510
  partition,
464
511
  offset: lastOffset + 1n,
@@ -474,7 +521,7 @@ export class MessagesStream extends Readable {
474
521
  for (const record of batch.records) {
475
522
  const messageToConsume = { ...record, topic, partition };
476
523
  const offset = batch.firstOffset + BigInt(record.offsetDelta);
477
- if (offset < requestedOffsets.get(`${topic}:${partition}`)) {
524
+ if (offset < requestedOffsets.get(key)) {
478
525
  // Thi is a duplicate message, ignore it
479
526
  continue;
480
527
  }
@@ -561,7 +608,7 @@ export class MessagesStream extends Readable {
561
608
  }
562
609
  }
563
610
  #updateCommittedOffset(topic, partition, offset) {
564
- const key = `${topic}:${partition}`;
611
+ const key = partitionKey(topic, partition);
565
612
  const previous = this.#offsetsCommitted.get(key);
566
613
  if (typeof previous === 'undefined' || previous < offset) {
567
614
  this.#offsetsCommitted.set(key, offset);
@@ -723,8 +770,9 @@ export class MessagesStream extends Readable {
723
770
  }
724
771
  const partitions = assignment.partitions;
725
772
  for (const partition of partitions) {
726
- const committed = this.#offsetsToFetch.get(`${topic}:${partition}`);
727
- this.#offsetsCommitted.set(`${topic}:${partition}`, committed);
773
+ const key = partitionKey(topic, partition);
774
+ const committed = this.#offsetsToFetch.get(key);
775
+ this.#offsetsCommitted.set(key, committed);
728
776
  }
729
777
  }
730
778
  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;
@@ -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');
@@ -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.0";
2
+ export const version = "2.1.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
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",