@platformatic/kafka 2.10.0-alpha.1 → 2.11.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.
@@ -32,8 +32,15 @@ export function createRequest(clientSoftwareName, clientSoftwareVersion) {
32
32
  zk_migration_ready (3) => BOOLEAN
33
33
  */
34
34
  export function parseResponse(_correlationId, apiKey, apiVersion, reader) {
35
+ const errorCode = reader.readInt16();
36
+ // Kafka returns an ApiVersions v0-framed response when v3 is unsupported.
37
+ // Stop before reading the compact array, which would interpret the legacy
38
+ // array length as flexible protocol data and desynchronize the response.
39
+ if (errorCode !== 0) {
40
+ throw new ResponseError(apiKey, apiVersion, { '/': [errorCode, null] }, { errorCode, apiKeys: [], throttleTimeMs: 0 });
41
+ }
35
42
  const response = {
36
- errorCode: reader.readInt16(),
43
+ errorCode,
37
44
  apiKeys: reader.readArray(r => {
38
45
  const apiKey = r.readInt16();
39
46
  const api = {
@@ -1,7 +1,7 @@
1
1
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
2
2
  import { ConfigResourceTypes, FetchIsolationLevels, FindCoordinatorKeyTypes, legacyConsumerGroupStates } from "../../apis/enumerations.js";
3
3
  import { adminAclsChannel, adminClientQuotasChannel, adminConfigsChannel, adminConsumerGroupOffsetsChannel, adminGroupsChannel, adminLogDirsChannel, adminOffsetsChannel, adminTopicsChannel, createDiagnosticContext } from "../../diagnostic.js";
4
- import { findErrorBy, MultipleErrors, UserError } from "../../errors.js";
4
+ import { findErrorBy, MultipleErrors, NetworkError, UserError } from "../../errors.js";
5
5
  import { Reader } from "../../protocol/reader.js";
6
6
  import { Base, kAfterCreate, kCheckNotClosed, kConnections, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kValidateOptions } from "../base/base.js";
7
7
  import { adminListOffsetsOptionsValidator, alterClientQuotasOptionsValidator, alterConfigsOptionsValidator, alterConsumerGroupOffsetsOptionsValidator, createAclsOptionsValidator, createPartitionsOptionsValidator, createTopicsOptionsValidator, deleteAclsOptionsValidator, deleteConsumerGroupOffsetsOptionsValidator, deleteGroupsOptionsValidator, deleteRecordsOptionsValidator, deleteTopicsOptionsValidator, describeAclsOptionsValidator, describeClientQuotasOptionsValidator, describeConfigsOptionsValidator, describeGroupsOptionsValidator, describeLogDirsOptionsValidator, findCoordinatorOptionsValidator, incrementalAlterConfigsOptionsValidator, listConsumerGroupOffsetsOptionsValidator, listGroupsOptionsValidator, listTopicsOptionsValidator, removeMembersFromConsumerGroupOptionsValidator } from "./options.js";
@@ -366,7 +366,12 @@ export class Admin extends Base {
366
366
  }
367
367
  #getControllerConnection(callback) {
368
368
  if (this.#controller) {
369
- this[kConnections].get(this.#controller, callback);
369
+ this[kConnections].get(this.#controller, (error, connection) => {
370
+ if (NetworkError.isRetryable(error)) {
371
+ this.#controller = null;
372
+ }
373
+ callback(error, connection);
374
+ });
370
375
  }
371
376
  else {
372
377
  this[kGetBootstrapConnection](callback);
@@ -422,26 +427,34 @@ export class Admin extends Base {
422
427
  }, 0);
423
428
  }, callback);
424
429
  }
430
+ #mapTopicAssignments(assignments) {
431
+ const mapped = [];
432
+ for (const { partition, brokers } of assignments ?? []) {
433
+ mapped.push({ partitionIndex: partition, brokerIds: brokers });
434
+ }
435
+ return mapped;
436
+ }
425
437
  #createTopics(options, callback) {
426
438
  // -1 is required if manual assignments are used. If no manual assignments are used, -1 will default to broker settings.
427
439
  const numPartitions = options.partitions ?? -1;
428
440
  const replicationFactor = options.replicas ?? -1;
429
- const assignments = [];
441
+ const assignments = this.#mapTopicAssignments(options.assignments);
430
442
  const configs = options.configs ?? [];
431
- for (const { partition, brokers } of options.assignments ?? []) {
432
- assignments.push({ partitionIndex: partition, brokerIds: brokers });
433
- }
434
443
  const requests = [];
435
444
  for (const topic of options.topics) {
445
+ if (typeof topic === 'string') {
446
+ requests.push({ name: topic, numPartitions, replicationFactor, assignments, configs });
447
+ continue;
448
+ }
436
449
  requests.push({
437
- name: topic,
438
- numPartitions,
439
- replicationFactor,
440
- assignments,
450
+ name: topic.topic,
451
+ numPartitions: topic.partitions ?? numPartitions,
452
+ replicationFactor: topic.replicas ?? replicationFactor,
453
+ assignments: topic.assignments ? this.#mapTopicAssignments(topic.assignments) : assignments,
441
454
  configs
442
455
  });
443
456
  }
444
- this[kPerformDeduplicated](`createTopics-${options.topics.join(',')}`, deduplicateCallback => {
457
+ this[kPerformDeduplicated](`createTopics-${options.topics.map(topic => (typeof topic === 'string' ? topic : topic.topic)).join(',')}`, deduplicateCallback => {
445
458
  this[kPerformWithRetry]('createTopics', retryCallback => {
446
459
  this.#getControllerConnection((error, connection) => {
447
460
  if (error) {
@@ -14,8 +14,48 @@ export declare const createTopicOptionsSchema: {
14
14
  topics: {
15
15
  type: string;
16
16
  items: {
17
- type: string;
18
- pattern: string;
17
+ oneOf: ({
18
+ type: string;
19
+ pattern: string;
20
+ } | {
21
+ type: string;
22
+ properties: {
23
+ topic: {
24
+ type: string;
25
+ pattern: string;
26
+ };
27
+ partitions: {
28
+ type: string;
29
+ };
30
+ replicas: {
31
+ type: string;
32
+ };
33
+ assignments: {
34
+ type: string;
35
+ items: {
36
+ type: string;
37
+ properties: {
38
+ partition: {
39
+ type: string;
40
+ minimum: number;
41
+ };
42
+ brokers: {
43
+ type: string;
44
+ items: {
45
+ type: string;
46
+ };
47
+ minItems: number;
48
+ };
49
+ };
50
+ required: string[];
51
+ additionalProperties: boolean;
52
+ };
53
+ minItems: number;
54
+ };
55
+ };
56
+ required: string[];
57
+ additionalProperties: boolean;
58
+ })[];
19
59
  };
20
60
  };
21
61
  partitions: {
@@ -46,6 +86,22 @@ export declare const createTopicOptionsSchema: {
46
86
  };
47
87
  minItems: number;
48
88
  };
89
+ configs: {
90
+ type: string;
91
+ items: {
92
+ type: string;
93
+ properties: {
94
+ name: {
95
+ type: string;
96
+ };
97
+ value: {
98
+ type: string[];
99
+ };
100
+ };
101
+ required: string[];
102
+ additionalProperties: boolean;
103
+ };
104
+ };
49
105
  };
50
106
  required: string[];
51
107
  additionalProperties: boolean;
@@ -8,24 +8,55 @@ export const groupsProperties = {
8
8
  minItems: 1
9
9
  }
10
10
  };
11
+ const createTopicAssignmentsSchema = {
12
+ type: 'array',
13
+ items: {
14
+ type: 'object',
15
+ properties: {
16
+ partition: { type: 'number', minimum: 0 },
17
+ brokers: { type: 'array', items: { type: 'number' }, minItems: 1 }
18
+ },
19
+ required: ['partition', 'brokers'],
20
+ additionalProperties: false
21
+ },
22
+ minItems: 1
23
+ };
11
24
  export const createTopicOptionsSchema = {
12
25
  type: 'object',
13
26
  properties: {
14
- topics: { type: 'array', items: idProperty },
27
+ topics: {
28
+ type: 'array',
29
+ items: {
30
+ oneOf: [
31
+ idProperty,
32
+ {
33
+ type: 'object',
34
+ properties: {
35
+ topic: idProperty,
36
+ partitions: { type: 'number' },
37
+ replicas: { type: 'number' },
38
+ assignments: createTopicAssignmentsSchema
39
+ },
40
+ required: ['topic'],
41
+ additionalProperties: false
42
+ }
43
+ ]
44
+ }
45
+ },
15
46
  partitions: { type: 'number' },
16
47
  replicas: { type: 'number' },
17
- assignments: {
48
+ assignments: createTopicAssignmentsSchema,
49
+ configs: {
18
50
  type: 'array',
19
51
  items: {
20
52
  type: 'object',
21
53
  properties: {
22
- partition: { type: 'number', minimum: 0 },
23
- brokers: { type: 'array', items: { type: 'number' }, minItems: 1 }
54
+ name: { type: 'string' },
55
+ value: { type: ['string', 'null'] }
24
56
  },
25
- required: ['partition', 'brokers'],
57
+ required: ['name'],
26
58
  additionalProperties: false
27
- },
28
- minItems: 1
59
+ }
29
60
  }
30
61
  },
31
62
  required: ['topics'],
@@ -43,8 +43,14 @@ export interface Group extends Omit<GroupBase, 'groupType'> {
43
43
  }
44
44
  export interface AdminOptions extends BaseOptions {
45
45
  }
46
+ export interface CreateTopicsTopicOptions {
47
+ topic: string;
48
+ partitions?: number;
49
+ replicas?: number;
50
+ assignments?: BrokerAssignment[];
51
+ }
46
52
  export interface CreateTopicsOptions {
47
- topics: string[];
53
+ topics: (string | CreateTopicsTopicOptions)[];
48
54
  partitions?: number;
49
55
  replicas?: number;
50
56
  assignments?: BrokerAssignment[];
@@ -1,6 +1,7 @@
1
1
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
2
2
  import * as apis from "../../apis/index.js";
3
3
  import { api as apiVersionsV1 } from "../../apis/metadata/api-versions-v1.js";
4
+ import { api as apiVersionsV3 } from "../../apis/metadata/api-versions-v3.js";
4
5
  import { baseApisChannel, baseMetadataChannel, createDiagnosticContext, notifyCreation } from "../../diagnostic.js";
5
6
  import { findErrorBy, MultipleErrors, NetworkError, UnsupportedApiError, UserError } from "../../errors.js";
6
7
  import { TypedEventEmitter } from "../../events.js";
@@ -47,6 +48,7 @@ export class Base extends TypedEventEmitter {
47
48
  [kPrometheus];
48
49
  #metadata;
49
50
  #inflightDeduplications;
51
+ #apiVersionsVersions;
50
52
  constructor(options) {
51
53
  super();
52
54
  this.setMaxListeners(0);
@@ -54,6 +56,7 @@ export class Base extends TypedEventEmitter {
54
56
  this[kInstance] = currentInstance++;
55
57
  this[kApis] = [];
56
58
  this[kContext] = options.context;
59
+ this.#apiVersionsVersions = new WeakMap();
57
60
  // Validate options
58
61
  this[kOptions] = Object.assign({}, defaultBaseOptions);
59
62
  for (const [key, value] of Object.entries(options)) {
@@ -212,14 +215,29 @@ export class Base extends TypedEventEmitter {
212
215
  retryCallback(error);
213
216
  return;
214
217
  }
215
- // We use V1 because it is supported by Kafka 1.1.0+.
216
- //
217
- // The tradeoff is that clientSoftwareName and clientSoftwareVersion only exist from
218
- // V3, so brokers cannot attribute client metrics to this package (KIP-511) and the
219
- // two arguments below are accepted and discarded by the V1 codec. Removing the
220
- // tradeoff requires negotiating down from V3 on UNSUPPORTED_VERSION rather than
221
- // pinning a version: see https://github.com/platformatic/kafka/issues/343.
222
- apiVersionsV1(connection, clientSoftwareName, clientSoftwareVersion, retryCallback);
218
+ const apiVersionsVersion = this.#apiVersionsVersions.get(connection);
219
+ const listApisV1 = () => {
220
+ apiVersionsV1(connection, clientSoftwareName, clientSoftwareVersion, (error, response) => {
221
+ retryCallback(error, response);
222
+ });
223
+ };
224
+ if (apiVersionsVersion === 1) {
225
+ listApisV1();
226
+ return;
227
+ }
228
+ apiVersionsV3(connection, clientSoftwareName, clientSoftwareVersion, (error, response) => {
229
+ if (!error) {
230
+ this.#apiVersionsVersions.set(connection, 3);
231
+ retryCallback(null, response);
232
+ return;
233
+ }
234
+ if (findErrorBy(error, 'apiId', 'UNSUPPORTED_VERSION')) {
235
+ this.#apiVersionsVersions.set(connection, 1);
236
+ listApisV1();
237
+ return;
238
+ }
239
+ retryCallback(error);
240
+ });
223
241
  }, attempt);
224
242
  }, (error, metadata) => {
225
243
  if (error) {
@@ -6,7 +6,7 @@ import { Base, type BaseEvents, kCreateConnectionPool } from '../base/base.ts';
6
6
  import { type ClusterMetadata } from '../base/types.ts';
7
7
  import { MessagesStream } from './messages-stream.ts';
8
8
  import { TopicsMap } from './topics-map.ts';
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';
9
+ import { type CommitOptions, type ConsumeOptions, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerHeartbeatStalledPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
10
10
  export interface ConsumerEvents extends BaseEvents {
11
11
  'consumer:group:join': (payload: ConsumerGroupJoinPayload) => void;
12
12
  'consumer:group:leave': (payload: ConsumerGroupLeavePayload) => void;
@@ -16,6 +16,7 @@ export interface ConsumerEvents extends BaseEvents {
16
16
  'consumer:heartbeat:cancel': (payload: ConsumerHeartbeatPayload) => void;
17
17
  'consumer:heartbeat:end': (payload?: ConsumerHeartbeatPayload) => void;
18
18
  'consumer:heartbeat:error': (payload: ConsumerHeartbeatErrorPayload) => void;
19
+ 'consumer:heartbeat:stalled': (payload: ConsumerHeartbeatStalledPayload) => void;
19
20
  'consumer:lag': (lag: Offsets) => void;
20
21
  'consumer:lag:error': (error: Error) => void;
21
22
  }
@@ -32,6 +32,10 @@ export class Consumer extends Base {
32
32
  #heartbeatInterval;
33
33
  #lastHeartbeatIntervalMs;
34
34
  #lastHeartbeat;
35
+ #heartbeatStallTimer;
36
+ #heartbeatStallStart;
37
+ #heartbeatStalled;
38
+ #heartbeatStallError;
35
39
  #useConsumerGroupProtocol;
36
40
  #memberEpoch;
37
41
  #groupRemoteAssignor;
@@ -77,6 +81,10 @@ export class Consumer extends Base {
77
81
  this.#heartbeatInterval = null;
78
82
  this.#lastHeartbeatIntervalMs = 0;
79
83
  this.#lastHeartbeat = null;
84
+ this.#heartbeatStallTimer = null;
85
+ this.#heartbeatStallStart = null;
86
+ this.#heartbeatStalled = false;
87
+ this.#heartbeatStallError = undefined;
80
88
  this.#streams = new Set();
81
89
  this.#lagMonitoring = null;
82
90
  this.#memberEpoch = 0;
@@ -121,6 +129,7 @@ export class Consumer extends Base {
121
129
  }
122
130
  this[kClosed] = true;
123
131
  clearTimeout(this.#lagMonitoring);
132
+ this.#resetHeartbeatStall();
124
133
  let closer;
125
134
  if (this.#useConsumerGroupProtocol) {
126
135
  closer = this.#leaveGroupConsumerProtocol.bind(this);
@@ -353,6 +362,13 @@ export class Consumer extends Base {
353
362
  throw validationError;
354
363
  }
355
364
  this.#lagMonitoring = setTimeout(() => {
365
+ // Skip this tick while mid-(re)join: listOffsets adds load to a coordinator/cluster that
366
+ // may already be struggling, and any lag figure computed against stale/absent assignments
367
+ // would be meaningless anyway. Just reschedule and try again next tick.
368
+ if (!this.isActive()) {
369
+ this.#lagMonitoring.refresh();
370
+ return;
371
+ }
356
372
  this.getLag(options, () => this.#lagMonitoring.refresh());
357
373
  }, interval);
358
374
  }
@@ -424,6 +440,7 @@ export class Consumer extends Base {
424
440
  return callback[kCallbackPromise];
425
441
  }
426
442
  this.#membershipActive = false;
443
+ this.#resetHeartbeatStall();
427
444
  this.#leaveGroupClassicProtocol(force, error => {
428
445
  if (error) {
429
446
  this.#membershipActive = true;
@@ -486,12 +503,15 @@ export class Consumer extends Base {
486
503
  }
487
504
  #fetch(options, callback) {
488
505
  const isolationLevel = options.isolationLevel ?? this[kOptions].isolationLevel;
506
+ let refreshMetadata = false;
489
507
  this[kPerformWithRetry]('fetch', retryCallback => {
490
- this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
508
+ this[kMetadata]({ topics: this.topics.current, forceUpdate: refreshMetadata }, (error, metadata) => {
491
509
  if (error) {
510
+ refreshMetadata = NetworkError.isRetryable(error);
492
511
  retryCallback(error);
493
512
  return;
494
513
  }
514
+ refreshMetadata = false;
495
515
  const topicIds = this.#topicIdsById(metadata);
496
516
  const node = this.#fetchNodeForRequest(metadata, options.node, options.topics, topicIds, Date.now());
497
517
  const broker = metadata.brokers.get(node);
@@ -502,6 +522,7 @@ export class Consumer extends Base {
502
522
  const pool = options.connectionPool ?? this[kConnections];
503
523
  pool.get(broker, (error, connection) => {
504
524
  if (error) {
525
+ refreshMetadata = NetworkError.isRetryable(error);
505
526
  this.#clearPreferredReadReplicas(options.topics, topicIds);
506
527
  this.#fetchSessions.delete(node);
507
528
  // When a connection was not available (either interrupted or not available) we
@@ -517,6 +538,7 @@ export class Consumer extends Base {
517
538
  }
518
539
  this[kGetApi]('Fetch', (error, api) => {
519
540
  if (error) {
541
+ refreshMetadata = NetworkError.isRetryable(error);
520
542
  retryCallback(error);
521
543
  return;
522
544
  }
@@ -608,6 +630,7 @@ export class Consumer extends Base {
608
630
  }
609
631
  }
610
632
  if (error) {
633
+ refreshMetadata = NetworkError.isRetryable(error);
611
634
  this.#clearPreferredReadReplicas(options.topics, topicIds);
612
635
  if (findErrorBy(error, 'apiId', 'FETCH_SESSION_ID_NOT_FOUND') ||
613
636
  findErrorBy(error, 'apiId', 'INVALID_FETCH_SESSION_EPOCH') ||
@@ -697,12 +720,12 @@ export class Consumer extends Base {
697
720
  callback(error);
698
721
  });
699
722
  }
700
- #listOffsets(withTimestamps, options, callback) {
723
+ #listOffsets(withTimestamps, options, callback, forceUpdateMetadata = false) {
701
724
  let topics = options.topics;
702
725
  if (!topics || topics.length === 0) {
703
726
  topics = this.topics.current;
704
727
  }
705
- this[kMetadata]({ topics }, (error, metadata) => {
728
+ this[kMetadata]({ topics, forceUpdate: forceUpdateMetadata }, (error, metadata) => {
706
729
  if (error) {
707
730
  callback(error);
708
731
  return;
@@ -757,6 +780,11 @@ export class Consumer extends Base {
757
780
  }, concurrentCallback, 0);
758
781
  }, (error, responses) => {
759
782
  if (error) {
783
+ if (!forceUpdateMetadata && NetworkError.isRetryable(error)) {
784
+ this.clearMetadata();
785
+ this.#listOffsets(withTimestamps, options, callback, true);
786
+ return;
787
+ }
760
788
  callback(this.#handleError(error));
761
789
  return;
762
790
  }
@@ -881,6 +909,7 @@ export class Consumer extends Base {
881
909
  return;
882
910
  }
883
911
  this.emitWithDebug('consumer:heartbeat', 'start', eventPayload);
912
+ this.#trackHeartbeatStall(options);
884
913
  this[kGetApi]('Heartbeat', (error, api) => {
885
914
  if (error) {
886
915
  groupCallback(error);
@@ -897,21 +926,43 @@ export class Consumer extends Base {
897
926
  if (error) {
898
927
  this.#cancelHeartbeat();
899
928
  if (this.#getRejoinError(error)) {
929
+ // A rejoin can legitimately block up to rebalanceTimeout:
930
+ // - extend the stall window by that bound rather than pausing it
931
+ // - a rejoin that never completes still surfaces as a stall
932
+ this.#resetHeartbeatStall();
933
+ this.#trackHeartbeatStall(options, error, options.rebalanceTimeout + this.#heartbeatStallTimeoutFor(options));
900
934
  this[kPerformWithRetry]('rejoinGroup', retryCallback => {
901
935
  this.#joinGroup(options, retryCallback);
902
936
  }, error => {
903
937
  if (error) {
904
938
  this.emitWithDebug(null, 'error', error);
939
+ if (!this.#heartbeatStalled) {
940
+ // No more heartbeats will be scheduled: restart the stall window without
941
+ // the rejoin grace, keeping the triggering heartbeat error as lastError.
942
+ const heartbeatError = this.#heartbeatStallError;
943
+ this.#resetHeartbeatStall();
944
+ this.#trackHeartbeatStall(options, heartbeatError);
945
+ }
946
+ }
947
+ else {
948
+ // Rejoin succeeded: drop the extended window; the next heartbeat opens the normal one.
949
+ this.#resetHeartbeatStall();
905
950
  }
906
951
  this.emitWithDebug('consumer', 'rejoin');
907
952
  }, 0);
908
953
  return;
909
954
  }
955
+ // The error was not group related, so we schedule another heartbeat.
956
+ // Do this before notifying listeners so they can still cancel it.
957
+ this.#heartbeatInterval = setTimeout(() => {
958
+ this.#heartbeat(options);
959
+ }, options.heartbeatInterval);
910
960
  this.emitWithDebug('consumer:heartbeat', 'error', { ...eventPayload, error });
911
- // Note that here we purposely do not return, since it was not a group related problem we schedule another heartbeat
961
+ this.#trackHeartbeatStall(options, error);
912
962
  }
913
963
  else {
914
964
  this.#lastHeartbeat = new Date();
965
+ this.#resetHeartbeatStall();
915
966
  this.emitWithDebug('consumer:heartbeat', 'end', eventPayload);
916
967
  }
917
968
  this.#heartbeatInterval?.refresh();
@@ -921,6 +972,47 @@ export class Consumer extends Base {
921
972
  clearTimeout(this.#heartbeatInterval);
922
973
  this.#heartbeatInterval = null;
923
974
  }
975
+ #heartbeatStallTimeoutFor(options) {
976
+ return (options.heartbeatStallTimeout ??
977
+ this[kOptions].heartbeatStallTimeout ??
978
+ options.rebalanceTimeout ??
979
+ this[kOptions].rebalanceTimeout ??
980
+ defaultConsumerOptions.rebalanceTimeout);
981
+ }
982
+ // Opens the stall window on the first heartbeat attempt after the last success.
983
+ // The window survives retries and only #resetHeartbeatStall closes it.
984
+ #trackHeartbeatStall(options, error, timeout) {
985
+ if (error) {
986
+ this.#heartbeatStallError = error;
987
+ }
988
+ if (this.#heartbeatStalled || this.#heartbeatStallTimer) {
989
+ return;
990
+ }
991
+ timeout ??= this.#heartbeatStallTimeoutFor(options);
992
+ this.#heartbeatStallStart = Date.now();
993
+ this.#heartbeatStallTimer = setTimeout(() => {
994
+ this.#heartbeatStallTimer = null;
995
+ // Emit once per stall episode. The next successful heartbeat re-arms the tracking.
996
+ this.#heartbeatStalled = true;
997
+ if (this[kClosed] || !this.#membershipActive) {
998
+ return;
999
+ }
1000
+ this.emitWithDebug('consumer:heartbeat', 'stalled', {
1001
+ lastError: this.#heartbeatStallError,
1002
+ lastHeartbeat: this.#lastHeartbeat,
1003
+ stalledFor: Date.now() - this.#heartbeatStallStart
1004
+ });
1005
+ }, timeout);
1006
+ }
1007
+ #resetHeartbeatStall() {
1008
+ if (this.#heartbeatStallTimer) {
1009
+ clearTimeout(this.#heartbeatStallTimer);
1010
+ this.#heartbeatStallTimer = null;
1011
+ }
1012
+ this.#heartbeatStallStart = null;
1013
+ this.#heartbeatStalled = false;
1014
+ this.#heartbeatStallError = undefined;
1015
+ }
924
1016
  #consumerGroupHeartbeat(options, callback) {
925
1017
  options.rebalanceTimeout ??= this[kOptions].rebalanceTimeout;
926
1018
  consumerHeartbeatChannel.traceCallback(this.#performConsumerGroupHeartbeat, 1, createDiagnosticContext({ client: this, operation: 'consumerGroupHeartbeat' }), this, options, callback);
@@ -928,6 +1020,7 @@ export class Consumer extends Base {
928
1020
  #performConsumerGroupHeartbeat(options, callback) {
929
1021
  this.#performGroupOperation('consumerGroupHeartbeat', (connection, groupCallback) => {
930
1022
  this.emitWithDebug('consumer:heartbeat', 'start');
1023
+ this.#trackHeartbeatStall(options);
931
1024
  this[kGetApi]('ConsumerGroupHeartbeat', (error, api) => {
932
1025
  if (error) {
933
1026
  groupCallback(error);
@@ -938,6 +1031,7 @@ export class Consumer extends Base {
938
1031
  });
939
1032
  }, (error, response) => {
940
1033
  if (this[kClosed]) {
1034
+ this.#resetHeartbeatStall();
941
1035
  this.emitWithDebug('consumer:heartbeat', 'end');
942
1036
  callback(null);
943
1037
  return;
@@ -945,13 +1039,19 @@ export class Consumer extends Base {
945
1039
  if (error) {
946
1040
  this.#cancelHeartbeat();
947
1041
  this.emitWithDebug('consumer:heartbeat', 'error', { error });
1042
+ this.#trackHeartbeatStall(options, error);
948
1043
  const fenced = error.response?.errorCode === protocolErrors.FENCED_MEMBER_EPOCH.code;
949
1044
  if (fenced) {
950
1045
  this.#assignments = [];
951
1046
  this.assignments = [];
952
1047
  this.#syncPreferredReadReplicas();
953
1048
  this.#memberEpoch = 0;
954
- this.#consumerGroupHeartbeat(options, () => { });
1049
+ // Retry after a delay, not immediately: if many members get fenced around the same
1050
+ // rebalance, resending ConsumerGroupHeartbeat with no delay at all can flood the
1051
+ // coordinator faster than the group can converge.
1052
+ this.#heartbeatInterval = setTimeout(() => {
1053
+ this.#consumerGroupHeartbeat(options, () => { });
1054
+ }, this.#lastHeartbeatIntervalMs || 1000);
955
1055
  callback(error);
956
1056
  return;
957
1057
  }
@@ -962,6 +1062,7 @@ export class Consumer extends Base {
962
1062
  return;
963
1063
  }
964
1064
  this.#lastHeartbeat = new Date();
1065
+ this.#resetHeartbeatStall();
965
1066
  this.#memberEpoch = response.memberEpoch;
966
1067
  if (response.memberId) {
967
1068
  const changed = this.memberId !== response.memberId;
@@ -1011,6 +1112,7 @@ export class Consumer extends Base {
1011
1112
  if (toRevoke.length === 0) {
1012
1113
  return;
1013
1114
  }
1115
+ this.emitWithDebug('consumer', 'group:rebalance', { groupId: this.groupId });
1014
1116
  for (const stream of this.#streams) {
1015
1117
  stream.pause();
1016
1118
  stream[kAutocommit]();