@platformatic/kafka 2.9.0 → 2.10.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.
@@ -1,7 +1,7 @@
1
1
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
2
2
  import { ConfigResourceTypes, FetchIsolationLevels, FindCoordinatorKeyTypes } 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[];
@@ -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);
@@ -424,6 +433,7 @@ export class Consumer extends Base {
424
433
  return callback[kCallbackPromise];
425
434
  }
426
435
  this.#membershipActive = false;
436
+ this.#resetHeartbeatStall();
427
437
  this.#leaveGroupClassicProtocol(force, error => {
428
438
  if (error) {
429
439
  this.#membershipActive = true;
@@ -486,12 +496,15 @@ export class Consumer extends Base {
486
496
  }
487
497
  #fetch(options, callback) {
488
498
  const isolationLevel = options.isolationLevel ?? this[kOptions].isolationLevel;
499
+ let refreshMetadata = false;
489
500
  this[kPerformWithRetry]('fetch', retryCallback => {
490
- this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
501
+ this[kMetadata]({ topics: this.topics.current, forceUpdate: refreshMetadata }, (error, metadata) => {
491
502
  if (error) {
503
+ refreshMetadata = NetworkError.isRetryable(error);
492
504
  retryCallback(error);
493
505
  return;
494
506
  }
507
+ refreshMetadata = false;
495
508
  const topicIds = this.#topicIdsById(metadata);
496
509
  const node = this.#fetchNodeForRequest(metadata, options.node, options.topics, topicIds, Date.now());
497
510
  const broker = metadata.brokers.get(node);
@@ -502,6 +515,7 @@ export class Consumer extends Base {
502
515
  const pool = options.connectionPool ?? this[kConnections];
503
516
  pool.get(broker, (error, connection) => {
504
517
  if (error) {
518
+ refreshMetadata = NetworkError.isRetryable(error);
505
519
  this.#clearPreferredReadReplicas(options.topics, topicIds);
506
520
  this.#fetchSessions.delete(node);
507
521
  // When a connection was not available (either interrupted or not available) we
@@ -517,6 +531,7 @@ export class Consumer extends Base {
517
531
  }
518
532
  this[kGetApi]('Fetch', (error, api) => {
519
533
  if (error) {
534
+ refreshMetadata = NetworkError.isRetryable(error);
520
535
  retryCallback(error);
521
536
  return;
522
537
  }
@@ -579,6 +594,7 @@ export class Consumer extends Base {
579
594
  }
580
595
  api(connection, options.maxWaitTime ?? this[kOptions].maxWaitTime, options.minBytes ?? this[kOptions].minBytes, options.maxBytes ?? this[kOptions].maxBytes, isolationLevel, sessionId, sessionEpoch, requestTopics, forgottenTopicsData, this.#clientRack, (error, result) => {
581
596
  if (error) {
597
+ refreshMetadata = NetworkError.isRetryable(error);
582
598
  this.#clearPreferredReadReplicas(options.topics, topicIds);
583
599
  if (findErrorBy(error, 'apiId', 'FETCH_SESSION_ID_NOT_FOUND') ||
584
600
  findErrorBy(error, 'apiId', 'INVALID_FETCH_SESSION_EPOCH') ||
@@ -668,12 +684,12 @@ export class Consumer extends Base {
668
684
  callback(error);
669
685
  });
670
686
  }
671
- #listOffsets(withTimestamps, options, callback) {
687
+ #listOffsets(withTimestamps, options, callback, forceUpdateMetadata = false) {
672
688
  let topics = options.topics;
673
689
  if (!topics || topics.length === 0) {
674
690
  topics = this.topics.current;
675
691
  }
676
- this[kMetadata]({ topics }, (error, metadata) => {
692
+ this[kMetadata]({ topics, forceUpdate: forceUpdateMetadata }, (error, metadata) => {
677
693
  if (error) {
678
694
  callback(error);
679
695
  return;
@@ -728,6 +744,11 @@ export class Consumer extends Base {
728
744
  }, concurrentCallback, 0);
729
745
  }, (error, responses) => {
730
746
  if (error) {
747
+ if (!forceUpdateMetadata && NetworkError.isRetryable(error)) {
748
+ this.clearMetadata();
749
+ this.#listOffsets(withTimestamps, options, callback, true);
750
+ return;
751
+ }
731
752
  callback(this.#handleError(error));
732
753
  return;
733
754
  }
@@ -842,6 +863,7 @@ export class Consumer extends Base {
842
863
  return;
843
864
  }
844
865
  this.emitWithDebug('consumer:heartbeat', 'start', eventPayload);
866
+ this.#trackHeartbeatStall(options);
845
867
  this[kGetApi]('Heartbeat', (error, api) => {
846
868
  if (error) {
847
869
  groupCallback(error);
@@ -858,21 +880,43 @@ export class Consumer extends Base {
858
880
  if (error) {
859
881
  this.#cancelHeartbeat();
860
882
  if (this.#getRejoinError(error)) {
883
+ // A rejoin can legitimately block up to rebalanceTimeout:
884
+ // - extend the stall window by that bound rather than pausing it
885
+ // - a rejoin that never completes still surfaces as a stall
886
+ this.#resetHeartbeatStall();
887
+ this.#trackHeartbeatStall(options, error, options.rebalanceTimeout + this.#heartbeatStallTimeoutFor(options));
861
888
  this[kPerformWithRetry]('rejoinGroup', retryCallback => {
862
889
  this.#joinGroup(options, retryCallback);
863
890
  }, error => {
864
891
  if (error) {
865
892
  this.emitWithDebug(null, 'error', error);
893
+ if (!this.#heartbeatStalled) {
894
+ // No more heartbeats will be scheduled: restart the stall window without
895
+ // the rejoin grace, keeping the triggering heartbeat error as lastError.
896
+ const heartbeatError = this.#heartbeatStallError;
897
+ this.#resetHeartbeatStall();
898
+ this.#trackHeartbeatStall(options, heartbeatError);
899
+ }
900
+ }
901
+ else {
902
+ // Rejoin succeeded: drop the extended window; the next heartbeat opens the normal one.
903
+ this.#resetHeartbeatStall();
866
904
  }
867
905
  this.emitWithDebug('consumer', 'rejoin');
868
906
  }, 0);
869
907
  return;
870
908
  }
909
+ // The error was not group related, so we schedule another heartbeat.
910
+ // Do this before notifying listeners so they can still cancel it.
911
+ this.#heartbeatInterval = setTimeout(() => {
912
+ this.#heartbeat(options);
913
+ }, options.heartbeatInterval);
871
914
  this.emitWithDebug('consumer:heartbeat', 'error', { ...eventPayload, error });
872
- // Note that here we purposely do not return, since it was not a group related problem we schedule another heartbeat
915
+ this.#trackHeartbeatStall(options, error);
873
916
  }
874
917
  else {
875
918
  this.#lastHeartbeat = new Date();
919
+ this.#resetHeartbeatStall();
876
920
  this.emitWithDebug('consumer:heartbeat', 'end', eventPayload);
877
921
  }
878
922
  this.#heartbeatInterval?.refresh();
@@ -882,6 +926,47 @@ export class Consumer extends Base {
882
926
  clearTimeout(this.#heartbeatInterval);
883
927
  this.#heartbeatInterval = null;
884
928
  }
929
+ #heartbeatStallTimeoutFor(options) {
930
+ return (options.heartbeatStallTimeout ??
931
+ this[kOptions].heartbeatStallTimeout ??
932
+ options.rebalanceTimeout ??
933
+ this[kOptions].rebalanceTimeout ??
934
+ defaultConsumerOptions.rebalanceTimeout);
935
+ }
936
+ // Opens the stall window on the first heartbeat attempt after the last success.
937
+ // The window survives retries and only #resetHeartbeatStall closes it.
938
+ #trackHeartbeatStall(options, error, timeout) {
939
+ if (error) {
940
+ this.#heartbeatStallError = error;
941
+ }
942
+ if (this.#heartbeatStalled || this.#heartbeatStallTimer) {
943
+ return;
944
+ }
945
+ timeout ??= this.#heartbeatStallTimeoutFor(options);
946
+ this.#heartbeatStallStart = Date.now();
947
+ this.#heartbeatStallTimer = setTimeout(() => {
948
+ this.#heartbeatStallTimer = null;
949
+ // Emit once per stall episode. The next successful heartbeat re-arms the tracking.
950
+ this.#heartbeatStalled = true;
951
+ if (this[kClosed] || !this.#membershipActive) {
952
+ return;
953
+ }
954
+ this.emitWithDebug('consumer:heartbeat', 'stalled', {
955
+ lastError: this.#heartbeatStallError,
956
+ lastHeartbeat: this.#lastHeartbeat,
957
+ stalledFor: Date.now() - this.#heartbeatStallStart
958
+ });
959
+ }, timeout);
960
+ }
961
+ #resetHeartbeatStall() {
962
+ if (this.#heartbeatStallTimer) {
963
+ clearTimeout(this.#heartbeatStallTimer);
964
+ this.#heartbeatStallTimer = null;
965
+ }
966
+ this.#heartbeatStallStart = null;
967
+ this.#heartbeatStalled = false;
968
+ this.#heartbeatStallError = undefined;
969
+ }
885
970
  #consumerGroupHeartbeat(options, callback) {
886
971
  options.rebalanceTimeout ??= this[kOptions].rebalanceTimeout;
887
972
  consumerHeartbeatChannel.traceCallback(this.#performConsumerGroupHeartbeat, 1, createDiagnosticContext({ client: this, operation: 'consumerGroupHeartbeat' }), this, options, callback);
@@ -889,6 +974,7 @@ export class Consumer extends Base {
889
974
  #performConsumerGroupHeartbeat(options, callback) {
890
975
  this.#performGroupOperation('consumerGroupHeartbeat', (connection, groupCallback) => {
891
976
  this.emitWithDebug('consumer:heartbeat', 'start');
977
+ this.#trackHeartbeatStall(options);
892
978
  this[kGetApi]('ConsumerGroupHeartbeat', (error, api) => {
893
979
  if (error) {
894
980
  groupCallback(error);
@@ -899,6 +985,7 @@ export class Consumer extends Base {
899
985
  });
900
986
  }, (error, response) => {
901
987
  if (this[kClosed]) {
988
+ this.#resetHeartbeatStall();
902
989
  this.emitWithDebug('consumer:heartbeat', 'end');
903
990
  callback(null);
904
991
  return;
@@ -906,6 +993,7 @@ export class Consumer extends Base {
906
993
  if (error) {
907
994
  this.#cancelHeartbeat();
908
995
  this.emitWithDebug('consumer:heartbeat', 'error', { error });
996
+ this.#trackHeartbeatStall(options, error);
909
997
  const fenced = error.response?.errorCode === protocolErrors.FENCED_MEMBER_EPOCH.code;
910
998
  if (fenced) {
911
999
  this.#assignments = [];
@@ -923,6 +1011,7 @@ export class Consumer extends Base {
923
1011
  return;
924
1012
  }
925
1013
  this.#lastHeartbeat = new Date();
1014
+ this.#resetHeartbeatStall();
926
1015
  this.#memberEpoch = response.memberEpoch;
927
1016
  if (response.memberId) {
928
1017
  const changed = this.memberId !== response.memberId;
@@ -972,6 +1061,7 @@ export class Consumer extends Base {
972
1061
  if (toRevoke.length === 0) {
973
1062
  return;
974
1063
  }
1064
+ this.emitWithDebug('consumer', 'group:rebalance', { groupId: this.groupId });
975
1065
  for (const stream of this.#streams) {
976
1066
  stream.pause();
977
1067
  stream[kAutocommit]();
@@ -5,7 +5,7 @@ import { consumerReceivesChannel, createDiagnosticContext, notifyCreation } from
5
5
  import { findErrorBy, 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, kGetFetchNode, kInstance, kRefreshOffsetsAndFetch } from "../../symbols.js";
8
+ import { kAutocommit, kDeserializationError, 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";
@@ -24,11 +24,12 @@ function messageToJSON() {
24
24
  return {
25
25
  key: this.key,
26
26
  value: this.value,
27
- headers: Array.from(this.headers.entries()),
27
+ headers: this.headerEntries.map(([key, value]) => [key, value]),
28
28
  topic: this.topic,
29
29
  partition: this.partition,
30
30
  timestamp: this.timestamp.toString(),
31
31
  offset: this.offset.toString(),
32
+ leaderEpoch: this.leaderEpoch,
32
33
  metadata: this.metadata
33
34
  };
34
35
  }
@@ -309,7 +310,12 @@ export class MessagesStream extends Readable {
309
310
  _construct(callback) {
310
311
  this.#refreshOffsetsInflight = true;
311
312
  this.#refreshOffsets(error => {
313
+ const wasPending = this.#refreshOffsetsPending;
312
314
  this.#refreshOffsetsInflight = false;
315
+ this.#refreshOffsetsPending = false;
316
+ if (!error && wasPending) {
317
+ this.#scheduleRefreshOffsetsAndFetch();
318
+ }
313
319
  callback(error ?? undefined);
314
320
  });
315
321
  }
@@ -610,11 +616,20 @@ export class MessagesStream extends Readable {
610
616
  : this.#commit.bind(this, topic, partition, offset + 1n, leaderEpoch);
611
617
  try {
612
618
  const headers = new Map();
619
+ const headerEntries = [];
613
620
  let payloadType = 'headerKey';
614
621
  let deserializedKey;
615
622
  let deserializedValue;
616
623
  let deserializationError;
624
+ let hookFailed = false;
617
625
  try {
626
+ // A before-deserialization hook already failed for this message, report it as is
627
+ const hookError = messageToConsume[kDeserializationError];
628
+ if (hookError) {
629
+ hookFailed = true;
630
+ payloadType = hookError.payloadType;
631
+ throw hookError.error;
632
+ }
618
633
  for (const [headerKey, headerValue] of record.headers) {
619
634
  payloadType = 'headerKey';
620
635
  /* c8 ignore next - Hard to test */
@@ -623,6 +638,7 @@ export class MessagesStream extends Readable {
623
638
  /* c8 ignore next - Hard to test */
624
639
  const deserializedHeaderValue = headerValueDeserializer(headerValue ?? undefined, messageToConsume);
625
640
  headers.set(deserializedHeaderKey, deserializedHeaderValue);
641
+ headerEntries.push([deserializedHeaderKey, deserializedHeaderValue]);
626
642
  }
627
643
  payloadType = 'key';
628
644
  /* c8 ignore next - Hard to test */
@@ -659,11 +675,14 @@ export class MessagesStream extends Readable {
659
675
  */
660
676
  diagnosticContext.error = error;
661
677
  consumerReceivesChannel.error.publish(diagnosticContext);
662
- const headersFailed = payloadType === 'headerKey' || payloadType === 'headerValue';
678
+ // A hook failure happens before anything is deserialized, so nothing can be kept
679
+ const headersFailed = hookFailed || payloadType === 'headerKey' || payloadType === 'headerValue';
663
680
  if (headersFailed) {
664
681
  headers.clear();
682
+ headerEntries.length = 0;
665
683
  for (const [headerKey, headerValue] of record.headers) {
666
684
  headers.set(headerKey, headerValue);
685
+ headerEntries.push([headerKey, headerValue]);
667
686
  }
668
687
  }
669
688
  if (headersFailed || payloadType === 'key') {
@@ -678,7 +697,7 @@ export class MessagesStream extends Readable {
678
697
  else {
679
698
  diagnosticContext.error = error;
680
699
  consumerReceivesChannel.error.publish(diagnosticContext);
681
- this.destroy(new UserError('Failed to deserialize a message.', { cause: error }));
700
+ this.destroy(hookFailed ? error : new UserError('Failed to deserialize a message.', { cause: error }));
682
701
  return;
683
702
  }
684
703
  }
@@ -687,10 +706,14 @@ export class MessagesStream extends Readable {
687
706
  key: deserializedKey,
688
707
  value: deserializedValue,
689
708
  headers,
709
+ headerEntries,
690
710
  topic,
691
711
  partition,
692
712
  timestamp: firstTimestamp + record.timestampDelta,
693
713
  offset,
714
+ // The batch header epoch is the epoch of the record itself, which is what
715
+ // OffsetCommit expects as committedLeaderEpoch (KIP-320).
716
+ leaderEpoch: batch.partitionLeaderEpoch,
694
717
  commit,
695
718
  // Deserializers and hooks can enrich the metadata of the message they processed
696
719
  metadata: deserializationError
@@ -831,7 +854,10 @@ export class MessagesStream extends Readable {
831
854
  topics.push(assignment);
832
855
  }
833
856
  if (!topics.length) {
834
- this.#assignOffsets(offsets, new Map(), callback);
857
+ // No assignment yet: do not seed fallback offsets for the whole topic.
858
+ // consumer:group:join will refresh once partitions are assigned.
859
+ this.emit('offsets');
860
+ callback(null);
835
861
  return;
836
862
  }
837
863
  this.#consumer.listCommittedOffsets({ topics }, (error, commits) => {
@@ -858,6 +884,7 @@ export class MessagesStream extends Readable {
858
884
  this.#refreshOffsetsInflight = true;
859
885
  this.#refreshOffsets(error => {
860
886
  const shouldDestroyOnError = this.#refreshOffsetsDestroyOnError;
887
+ const wasPending = this.#refreshOffsetsPending;
861
888
  this.#refreshOffsetsInflight = false;
862
889
  this.#refreshOffsetsDestroyOnError = false;
863
890
  this.#refreshOffsetsPending = false;
@@ -869,8 +896,7 @@ export class MessagesStream extends Readable {
869
896
  return;
870
897
  }
871
898
  // A new one was scheduled while the previous one was inflight, we need to run it immediately
872
- /* c8 ignore next 4 - Hard to test */
873
- if (this.#refreshOffsetsPending) {
899
+ if (wasPending) {
874
900
  this.#scheduleRefreshOffsetsAndFetch(shouldDestroyOnError);
875
901
  return;
876
902
  }
@@ -972,9 +998,23 @@ export class MessagesStream extends Readable {
972
998
  }
973
999
  runAsyncSeries((request, cb) => {
974
1000
  const [data, type, message] = request;
975
- const result = hook(data, type, message, cb);
1001
+ /*
1002
+ Without an error handler a failing hook destroys the stream, as it always did. With one,
1003
+ the failure is recorded on the message it belongs to and reported to the handler in
1004
+ #pushRecords, together with everything else which can fail while deserializing, so that
1005
+ the caller can skip or degrade that single message instead of losing the stream.
1006
+ */
1007
+ const onHookSettled = error => {
1008
+ if (error && this.#deserializationErrorHandler) {
1009
+ message[kDeserializationError] ??= { error, payloadType: type };
1010
+ cb(null);
1011
+ return;
1012
+ }
1013
+ cb(error);
1014
+ };
1015
+ const result = hook(data, type, message, onHookSettled);
976
1016
  if (typeof result?.then === 'function') {
977
- result.then(() => cb(null), cb);
1017
+ result.then(() => onHookSettled(null), onHookSettled);
978
1018
  }
979
1019
  }, requests, 0, error => {
980
1020
  if (error) {
@@ -15,6 +15,10 @@ export declare const groupOptionsProperties: {
15
15
  type: string;
16
16
  minimum: number;
17
17
  };
18
+ heartbeatStallTimeout: {
19
+ type: string;
20
+ minimum: number;
21
+ };
18
22
  groupProtocol: {
19
23
  type: string;
20
24
  enum: ("consumer" | "classic")[];
@@ -160,6 +164,10 @@ export declare const groupOptionsSchema: {
160
164
  type: string;
161
165
  minimum: number;
162
166
  };
167
+ heartbeatStallTimeout: {
168
+ type: string;
169
+ minimum: number;
170
+ };
163
171
  groupProtocol: {
164
172
  type: string;
165
173
  enum: ("consumer" | "classic")[];
@@ -283,6 +291,10 @@ export declare const consumeOptionsSchema: {
283
291
  type: string;
284
292
  minimum: number;
285
293
  };
294
+ heartbeatStallTimeout: {
295
+ type: string;
296
+ minimum: number;
297
+ };
286
298
  groupProtocol: {
287
299
  type: string;
288
300
  enum: ("consumer" | "classic")[];
@@ -455,6 +467,10 @@ export declare const consumerOptionsSchema: {
455
467
  type: string;
456
468
  minimum: number;
457
469
  };
470
+ heartbeatStallTimeout: {
471
+ type: string;
472
+ minimum: number;
473
+ };
458
474
  groupProtocol: {
459
475
  type: string;
460
476
  enum: ("consumer" | "classic")[];
@@ -585,6 +601,10 @@ export declare const fetchOptionsSchema: {
585
601
  type: string;
586
602
  minimum: number;
587
603
  };
604
+ heartbeatStallTimeout: {
605
+ type: string;
606
+ minimum: number;
607
+ };
588
608
  groupProtocol: {
589
609
  type: string;
590
610
  enum: ("consumer" | "classic")[];
@@ -8,6 +8,7 @@ export const groupOptionsProperties = {
8
8
  sessionTimeout: { type: 'number', minimum: 0 },
9
9
  rebalanceTimeout: { type: 'number', minimum: 0 },
10
10
  heartbeatInterval: { type: 'number', minimum: 0 },
11
+ heartbeatStallTimeout: { type: 'number', minimum: 0 },
11
12
  groupProtocol: { type: 'string', enum: allowedGroupProtocols },
12
13
  groupRemoteAssignor: { type: 'string' },
13
14
  protocols: {
@@ -82,6 +82,7 @@ export interface GroupOptions {
82
82
  sessionTimeout?: number;
83
83
  rebalanceTimeout?: number;
84
84
  heartbeatInterval?: number;
85
+ heartbeatStallTimeout?: number;
85
86
  protocols?: GroupProtocolSubscription[];
86
87
  protocolsMetadata?: GroupProtocolsMetadataCallback;
87
88
  partitionAssigner?: GroupPartitionsAssigner;
@@ -92,6 +93,7 @@ export interface ConsumerGroupOptions {
92
93
  groupInstanceId?: string;
93
94
  groupRemoteAssignor?: string;
94
95
  rebalanceTimeout?: number;
96
+ heartbeatStallTimeout?: number;
95
97
  assignmentUserData?: Buffer;
96
98
  }
97
99
  export interface ConsumeBaseOptions<Key, Value, HeaderKey, HeaderValue> {
@@ -169,3 +171,8 @@ export interface ConsumerHeartbeatPayload {
169
171
  export interface ConsumerHeartbeatErrorPayload extends ConsumerHeartbeatPayload {
170
172
  error: Error;
171
173
  }
174
+ export interface ConsumerHeartbeatStalledPayload {
175
+ lastError?: Error;
176
+ lastHeartbeat: Date | null;
177
+ stalledFor: number;
178
+ }
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
3
3
  import { FindCoordinatorKeyTypes, ProduceAcks } from "../../apis/enumerations.js";
4
4
  import { createDiagnosticContext, producerInitIdempotentChannel, producerSendsChannel, producerTransactionsChannel } from "../../diagnostic.js";
5
- import { findErrorBy, UserError } from "../../errors.js";
5
+ import { findErrorBy, NetworkError, UserError } from "../../errors.js";
6
6
  import { runAsyncSeries } from "../../registries/abstract.js";
7
7
  import { kInstance, kTransaction, kTransactionAddOffsets, kTransactionAddPartitions, kTransactionCancel, kTransactionCommitOffset, kTransactionEnd, kTransactionFindCoordinator, kTransactionPrepare } from "../../symbols.js";
8
8
  import { emitExperimentalApiWarning, NumericMap } from "../../utils.js";
@@ -744,65 +744,71 @@ export class Producer extends Base {
744
744
  });
745
745
  }
746
746
  #performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, produceOptions, recoverOnProducerStateLoss, callback) {
747
- // Get the metadata with the topic/partitions informations
748
- this[kMetadata]({ topics, autocreateTopics }, (error, metadata) => {
749
- if (error) {
750
- callback(error);
751
- return;
752
- }
753
- const { topic, partition } = messages[0];
754
- const leader = metadata.topics.get(topic).partitions[partition].leader;
755
- this[kPerformWithRetry]('produce', retryCallback => {
747
+ const { topic, partition } = messages[0];
748
+ let refreshMetadata = false;
749
+ this[kPerformWithRetry]('produce', retryCallback => {
750
+ this[kMetadata]({ topics, autocreateTopics, forceUpdate: refreshMetadata }, (error, metadata) => {
751
+ if (error) {
752
+ refreshMetadata = NetworkError.isRetryable(error);
753
+ retryCallback(error);
754
+ return;
755
+ }
756
+ refreshMetadata = false;
757
+ const leader = metadata.topics.get(topic).partitions[partition].leader;
756
758
  this[kGetConnection](metadata.brokers.get(leader), (error, connection) => {
757
759
  if (error) {
760
+ refreshMetadata = NetworkError.isRetryable(error);
758
761
  retryCallback(error);
759
762
  return;
760
763
  }
761
764
  this[kGetApi]('Produce', (error, api) => {
762
765
  if (error) {
766
+ refreshMetadata = NetworkError.isRetryable(error);
763
767
  retryCallback(error);
764
768
  return;
765
769
  }
766
- api(connection, acks, timeout, messages, produceOptions, retryCallback);
770
+ api(connection, acks, timeout, messages, produceOptions, (error, response) => {
771
+ refreshMetadata = NetworkError.isRetryable(error);
772
+ retryCallback(error, response);
773
+ });
767
774
  });
768
775
  });
769
- }, (error, results) => {
770
- if (error) {
771
- // If the last error was due to stale metadata, we retry the operation with this set of messages
772
- // since the partition is already set, it should attempt on the new destination
773
- /* c8 ignore next - Hard to test */
774
- const hasStaleMetadata = findErrorBy(error, 'hasStaleMetadata', true);
775
- if (hasStaleMetadata && repeatOnStaleMetadata) {
776
- this.clearMetadata();
777
- this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, false, produceOptions, recoverOnProducerStateLoss, callback);
778
- return;
779
- }
780
- if (recoverOnProducerStateLoss && !this.#transaction && this.#isProducerStateLost(error)) {
781
- this.#recoverIdempotentProducer(messages, produceOptions, (error, recoveredProduceOptions) => {
782
- if (error) {
783
- callback(error);
784
- return;
785
- }
786
- this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, recoveredProduceOptions, false, callback);
787
- });
788
- return;
789
- }
790
- callback(error, results);
776
+ });
777
+ }, (error, results) => {
778
+ if (error) {
779
+ // If the last error was due to stale metadata, we retry the operation with this set of messages
780
+ // since the partition is already set, it should attempt on the new destination
781
+ /* c8 ignore next - Hard to test */
782
+ const hasStaleMetadata = findErrorBy(error, 'hasStaleMetadata', true);
783
+ if (hasStaleMetadata && repeatOnStaleMetadata) {
784
+ this.clearMetadata();
785
+ this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, false, produceOptions, recoverOnProducerStateLoss, callback);
791
786
  return;
792
787
  }
793
- callback(error, results);
794
- }, 0, [], error => {
795
- /* c8 ignore next 3 - Hard to test */
796
- if (!repeatOnStaleMetadata) {
797
- return false;
788
+ if (recoverOnProducerStateLoss && !this.#transaction && this.#isProducerStateLost(error)) {
789
+ this.#recoverIdempotentProducer(messages, produceOptions, (error, recoveredProduceOptions) => {
790
+ if (error) {
791
+ callback(error);
792
+ return;
793
+ }
794
+ this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, repeatOnStaleMetadata, recoveredProduceOptions, false, callback);
795
+ });
796
+ return;
798
797
  }
799
- return !!findErrorBy(error, 'hasStaleMetadata', true);
800
- });
798
+ callback(error, results);
799
+ return;
800
+ }
801
+ callback(error, results);
802
+ }, 0, [], error => {
803
+ /* c8 ignore next 3 - Hard to test */
804
+ if (!repeatOnStaleMetadata) {
805
+ return false;
806
+ }
807
+ return !!findErrorBy(error, 'hasStaleMetadata', true);
801
808
  });
802
809
  }
803
810
  #isProducerStateLost(error) {
804
- return !!(findErrorBy(error, 'apiId', 'UNKNOWN_PRODUCER_ID') ||
805
- findErrorBy(error, 'apiId', 'OUT_OF_ORDER_SEQUENCE_NUMBER'));
811
+ return !!(findErrorBy(error, 'apiId', 'UNKNOWN_PRODUCER_ID') || findErrorBy(error, 'apiId', 'OUT_OF_ORDER_SEQUENCE_NUMBER'));
806
812
  }
807
813
  #recoverIdempotentProducer(messages, produceOptions, callback) {
808
814
  this.#producerInfo = undefined;
package/dist/errors.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare class AuthenticationError extends GenericError {
36
36
  }
37
37
  export declare class NetworkError extends GenericError {
38
38
  static code: ErrorCode;
39
+ static isRetryable(error: Error | null | undefined): boolean;
39
40
  constructor(message: string, properties?: ErrorProperties);
40
41
  }
41
42
  export declare class ProtocolError extends GenericError {
package/dist/errors.js CHANGED
@@ -119,6 +119,10 @@ export class AuthenticationError extends GenericError {
119
119
  }
120
120
  export class NetworkError extends GenericError {
121
121
  static code = 'PLT_KFK_NETWORK';
122
+ static isRetryable(error) {
123
+ const networkError = findErrorBy(error, 'code', NetworkError.code);
124
+ return networkError?.canRetry === true;
125
+ }
122
126
  constructor(message, properties = {}) {
123
127
  super(NetworkError.code, message, { canRetry: true, ...properties });
124
128
  }
@@ -1,12 +1,30 @@
1
+ const BRACKETED_BROKER_RE = /^\[([^\]]+)](?::(\d*))?$/;
1
2
  export function parseBroker(broker, defaultPort = 9092) {
2
- if (typeof broker === 'string') {
3
- if (broker.includes(':')) {
4
- const [host, port] = broker.split(':');
5
- return { host, port: Number(port) };
6
- }
7
- else {
8
- return { host: broker, port: defaultPort };
3
+ if (typeof broker !== 'string') {
4
+ const host = stripBrackets(broker.host);
5
+ if (host === broker.host) {
6
+ return broker;
9
7
  }
8
+ return { ...broker, host };
9
+ }
10
+ const bracketed = BRACKETED_BROKER_RE.exec(broker);
11
+ if (bracketed) {
12
+ return { host: bracketed[1], port: parsePort(bracketed[2], defaultPort) };
13
+ }
14
+ const separator = broker.lastIndexOf(':');
15
+ // Bare IPv6 addresses contain multiple colons and no port. The host:port form
16
+ // is unambiguous only with a single colon, or with brackets around the host.
17
+ if (separator === -1 || broker.indexOf(':') !== separator) {
18
+ return { host: broker, port: defaultPort };
19
+ }
20
+ return { host: broker.slice(0, separator), port: parsePort(broker.slice(separator + 1), defaultPort) };
21
+ }
22
+ function parsePort(port, defaultPort) {
23
+ return port === undefined || port.length === 0 ? defaultPort : Number(port);
24
+ }
25
+ function stripBrackets(host) {
26
+ if (host.startsWith('[') && host.endsWith(']')) {
27
+ return host.slice(1, -1);
10
28
  }
11
- return broker;
29
+ return host;
12
30
  }
@@ -33,11 +33,14 @@ export interface MessageJSON<Key = unknown, Value = unknown, HeaderKey = unknown
33
33
  partition: number;
34
34
  timestamp: string;
35
35
  offset: string;
36
+ leaderEpoch: number;
36
37
  metadata: Record<string, unknown>;
37
38
  }
38
39
  export interface Message<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends Required<MessageBase<Key, Value>> {
39
40
  headers: Map<HeaderKey, HeaderValue>;
41
+ headerEntries: Array<[HeaderKey, HeaderValue]>;
40
42
  offset: bigint;
43
+ leaderEpoch: number;
41
44
  metadata: Record<string, unknown>;
42
45
  commit(callback?: (error?: Error) => void): void | Promise<void>;
43
46
  toJSON(): MessageJSON<Key, Value, HeaderKey, HeaderValue>;
@@ -1,19 +1,38 @@
1
1
  export function runAsyncSeries(operation, collection, index, callback) {
2
- if (collection.length === 0 || index >= collection.length) {
2
+ const length = collection.length;
3
+ if (length === 0 || index >= length) {
3
4
  callback(null);
4
5
  return;
5
6
  }
6
- operation(collection[index], error => {
7
- if (error) {
8
- callback(error);
7
+ while (index < length) {
8
+ let invocationReturned = false;
9
+ let cbCalled = false;
10
+ let cbError;
11
+ operation(collection[index], error => {
12
+ if (invocationReturned) {
13
+ // The callback was invoked asynchronously, so continuing recursively does not grow the current stack.
14
+ if (error) {
15
+ callback(error);
16
+ return;
17
+ }
18
+ runAsyncSeries(operation, collection, index + 1, callback);
19
+ return;
20
+ }
21
+ cbCalled = true;
22
+ cbError = error;
23
+ });
24
+ invocationReturned = true;
25
+ if (!cbCalled) {
26
+ // Wait for the async callback to continue the series.
9
27
  return;
10
28
  }
11
- else if (index === collection.length - 1) {
12
- callback(null);
29
+ if (cbError) {
30
+ callback(cbError);
13
31
  return;
14
32
  }
15
- runAsyncSeries(operation, collection, index + 1, callback);
16
- });
33
+ index++;
34
+ }
35
+ callback(null);
17
36
  }
18
37
  /* c8 ignore start */
19
38
  export class AbstractSchemaRegistry {
@@ -1,3 +1,5 @@
1
+ import * as AjvDraft04Module from 'ajv-draft-04';
2
+ import draft06MetaSchema from 'ajv/dist/refs/json-schema-draft-06.json' with { type: 'json' };
1
3
  import { Ajv } from 'ajv';
2
4
  import { Ajv2020 } from 'ajv/dist/2020.js';
3
5
  import avro from 'avsc';
@@ -8,8 +10,7 @@ import { EMPTY_BUFFER, MultipleErrors, NetworkError, TimeoutError, UnsupportedFo
8
10
  import { getCredential } from "../protocol/sasl/utils.js";
9
11
  import { AbstractSchemaRegistry } from "./abstract.js";
10
12
  const require = createRequire(import.meta.url);
11
- const AjvDraft04 = require('ajv-draft-04');
12
- const draft06MetaSchema = require('ajv/dist/refs/json-schema-draft-06.json');
13
+ const AjvDraft04 = AjvDraft04Module.default;
13
14
  const undiciGlobalDispatcherSymbol = /^undici\.globalDispatcher\.(\d+)$/;
14
15
  export function createUndiciAgent(options) {
15
16
  /*
package/dist/symbols.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const kInstance: unique symbol;
2
2
  export declare const kRefreshOffsetsAndFetch: unique symbol;
3
3
  export declare const kAutocommit: unique symbol;
4
4
  export declare const kGetFetchNode: unique symbol;
5
+ export declare const kDeserializationError: unique symbol;
5
6
  export declare const kTransaction: unique symbol;
6
7
  export declare const kTransactionPrepare: unique symbol;
7
8
  export declare const kTransactionAddPartitions: unique symbol;
package/dist/symbols.js CHANGED
@@ -3,6 +3,7 @@ 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
5
  export const kGetFetchNode = Symbol('plt.kafka.consumer.getFetchNode');
6
+ export const kDeserializationError = Symbol('plt.kafka.messagesStream.deserializationError');
6
7
  export const kTransaction = Symbol('plt.kafka.producer.transactions');
7
8
  export const kTransactionPrepare = Symbol('plt.kafka.producer.transactions.prepare');
8
9
  export const kTransactionAddPartitions = Symbol('plt.kafka.producer.transactions.addPartitions');
@@ -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;
@@ -42,8 +42,14 @@ export interface Group extends Omit<GroupBase, 'groupType'> {
42
42
  authorizedOperations: number;
43
43
  }
44
44
  export interface AdminOptions extends BaseOptions {}
45
+ export interface CreateTopicsTopicOptions {
46
+ topic: string;
47
+ partitions?: number;
48
+ replicas?: number;
49
+ assignments?: BrokerAssignment[];
50
+ }
45
51
  export interface CreateTopicsOptions {
46
- topics: string[];
52
+ topics: (string | CreateTopicsTopicOptions)[];
47
53
  partitions?: number;
48
54
  replicas?: number;
49
55
  assignments?: BrokerAssignment[];
@@ -6,7 +6,7 @@ import { Base, type BaseEvents, kCreateConnectionPool } from "../base/base";
6
6
  import { type ClusterMetadata } from "../base/types";
7
7
  import { MessagesStream } from "./messages-stream";
8
8
  import { TopicsMap } from "./topics-map";
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";
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";
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
  }
@@ -15,6 +15,10 @@ export declare const groupOptionsProperties: {
15
15
  type: string;
16
16
  minimum: number;
17
17
  };
18
+ heartbeatStallTimeout: {
19
+ type: string;
20
+ minimum: number;
21
+ };
18
22
  groupProtocol: {
19
23
  type: string;
20
24
  enum: ("consumer" | "classic")[];
@@ -160,6 +164,10 @@ export declare const groupOptionsSchema: {
160
164
  type: string;
161
165
  minimum: number;
162
166
  };
167
+ heartbeatStallTimeout: {
168
+ type: string;
169
+ minimum: number;
170
+ };
163
171
  groupProtocol: {
164
172
  type: string;
165
173
  enum: ("consumer" | "classic")[];
@@ -283,6 +291,10 @@ export declare const consumeOptionsSchema: {
283
291
  type: string;
284
292
  minimum: number;
285
293
  };
294
+ heartbeatStallTimeout: {
295
+ type: string;
296
+ minimum: number;
297
+ };
286
298
  groupProtocol: {
287
299
  type: string;
288
300
  enum: ("consumer" | "classic")[];
@@ -455,6 +467,10 @@ export declare const consumerOptionsSchema: {
455
467
  type: string;
456
468
  minimum: number;
457
469
  };
470
+ heartbeatStallTimeout: {
471
+ type: string;
472
+ minimum: number;
473
+ };
458
474
  groupProtocol: {
459
475
  type: string;
460
476
  enum: ("consumer" | "classic")[];
@@ -585,6 +601,10 @@ export declare const fetchOptionsSchema: {
585
601
  type: string;
586
602
  minimum: number;
587
603
  };
604
+ heartbeatStallTimeout: {
605
+ type: string;
606
+ minimum: number;
607
+ };
588
608
  groupProtocol: {
589
609
  type: string;
590
610
  enum: ("consumer" | "classic")[];
@@ -82,6 +82,7 @@ export interface GroupOptions {
82
82
  sessionTimeout?: number;
83
83
  rebalanceTimeout?: number;
84
84
  heartbeatInterval?: number;
85
+ heartbeatStallTimeout?: number;
85
86
  protocols?: GroupProtocolSubscription[];
86
87
  protocolsMetadata?: GroupProtocolsMetadataCallback;
87
88
  partitionAssigner?: GroupPartitionsAssigner;
@@ -92,6 +93,7 @@ export interface ConsumerGroupOptions {
92
93
  groupInstanceId?: string;
93
94
  groupRemoteAssignor?: string;
94
95
  rebalanceTimeout?: number;
96
+ heartbeatStallTimeout?: number;
95
97
  assignmentUserData?: Buffer;
96
98
  }
97
99
  export interface ConsumeBaseOptions<Key, Value, HeaderKey, HeaderValue> {
@@ -168,4 +170,9 @@ export interface ConsumerHeartbeatPayload {
168
170
  }
169
171
  export interface ConsumerHeartbeatErrorPayload extends ConsumerHeartbeatPayload {
170
172
  error: Error;
173
+ }
174
+ export interface ConsumerHeartbeatStalledPayload {
175
+ lastError?: Error;
176
+ lastHeartbeat: Date | null;
177
+ stalledFor: number;
171
178
  }
@@ -42,6 +42,7 @@ export declare class AuthenticationError extends GenericError {
42
42
  }
43
43
  export declare class NetworkError extends GenericError {
44
44
  static code: ErrorCode;
45
+ static isRetryable(error: Error | null | undefined): boolean;
45
46
  constructor(message: string, properties?: ErrorProperties);
46
47
  }
47
48
  export declare class ProtocolError extends GenericError {
@@ -33,11 +33,14 @@ export interface MessageJSON<Key = unknown, Value = unknown, HeaderKey = unknown
33
33
  partition: number;
34
34
  timestamp: string;
35
35
  offset: string;
36
+ leaderEpoch: number;
36
37
  metadata: Record<string, unknown>;
37
38
  }
38
39
  export interface Message<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends Required<MessageBase<Key, Value>> {
39
40
  headers: Map<HeaderKey, HeaderValue>;
41
+ headerEntries: Array<[HeaderKey, HeaderValue]>;
40
42
  offset: bigint;
43
+ leaderEpoch: number;
41
44
  metadata: Record<string, unknown>;
42
45
  commit(callback?: (error?: Error) => void): void | Promise<void>;
43
46
  toJSON(): MessageJSON<Key, Value, HeaderKey, HeaderValue>;
@@ -2,6 +2,7 @@ export declare const kInstance: unique symbol;
2
2
  export declare const kRefreshOffsetsAndFetch: unique symbol;
3
3
  export declare const kAutocommit: unique symbol;
4
4
  export declare const kGetFetchNode: unique symbol;
5
+ export declare const kDeserializationError: unique symbol;
5
6
  export declare const kTransaction: unique symbol;
6
7
  export declare const kTransactionPrepare: unique symbol;
7
8
  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.9.0";
2
+ export const version = "2.10.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.9.0",
3
+ "version": "2.10.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)",