@platformatic/kafka 1.17.0 → 1.18.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,3 +1,6 @@
1
+ import { type AlterClientQuotasRequestEntry } from '../../apis/admin/alter-client-quotas-v1.ts';
2
+ import { type DescribeClientQuotasRequestComponent } from '../../apis/admin/describe-client-quotas-v0.ts';
3
+ import { type DescribeLogDirsResponse, type DescribeLogDirsResponseResult, type DescribeLogDirsRequestTopic } from '../../apis/admin/describe-log-dirs-v4.ts';
1
4
  import { type CreateTopicsRequestTopicConfig } from '../../apis/admin/create-topics-v7.ts';
2
5
  import { type ConsumerGroupState } from '../../apis/enumerations.ts';
3
6
  import { type NullableString } from '../../protocol/definitions.ts';
@@ -59,3 +62,19 @@ export interface DescribeGroupsOptions {
59
62
  export interface DeleteGroupsOptions {
60
63
  groups: string[];
61
64
  }
65
+ export interface DescribeClientQuotasOptions {
66
+ components: DescribeClientQuotasRequestComponent[];
67
+ strict?: boolean;
68
+ }
69
+ export interface AlterClientQuotasOptions {
70
+ entries: AlterClientQuotasRequestEntry[];
71
+ validateOnly?: boolean;
72
+ }
73
+ export interface DescribeLogDirsOptions {
74
+ topics: DescribeLogDirsRequestTopic[];
75
+ }
76
+ export interface BrokerLogDirDescription {
77
+ broker: number;
78
+ throttleTimeMs: DescribeLogDirsResponse['throttleTimeMs'];
79
+ results: Omit<DescribeLogDirsResponseResult, 'errorCode'>[];
80
+ }
@@ -232,7 +232,9 @@ export class Base extends EventEmitter {
232
232
  return;
233
233
  }
234
234
  const autocreateTopics = options.autocreateTopics ?? this[kOptions].autocreateTopics;
235
- this[kPerformDeduplicated]('metadata', deduplicateCallback => {
235
+ this[kPerformDeduplicated](
236
+ // Unique key to avoid mixing callbacks
237
+ `metadata-${options.topics.sort().join(',')}-${autocreateTopics}-${options.forceUpdate}`, deduplicateCallback => {
236
238
  this[kPerformWithRetry]('metadata', retryCallback => {
237
239
  this[kGetBootstrapConnection]((error, connection) => {
238
240
  if (error) {
@@ -4,7 +4,7 @@ import { type ConnectionPool } from '../../network/connection-pool.ts';
4
4
  import { Base, kFetchConnections } from '../base/base.ts';
5
5
  import { MessagesStream } from './messages-stream.ts';
6
6
  import { TopicsMap } from './topics-map.ts';
7
- import { type CommitOptions, type ConsumeOptions, type ConsumerOptions, type FetchOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
7
+ import { type CommitOptions, type ConsumeOptions, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
8
8
  export declare class Consumer<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends Base<ConsumerOptions<Key, Value, HeaderKey, HeaderValue>> {
9
9
  #private;
10
10
  groupId: string;
@@ -31,6 +31,10 @@ export declare class Consumer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
31
31
  listOffsetsWithTimestamps(options: ListOffsetsOptions): Promise<OffsetsWithTimestamps>;
32
32
  listCommittedOffsets(options: ListCommitsOptions, callback: CallbackWithPromise<Offsets>): void;
33
33
  listCommittedOffsets(options: ListCommitsOptions): Promise<Offsets>;
34
+ getLag(options: GetLagOptions, callback: CallbackWithPromise<Offsets>): void;
35
+ getLag(options: GetLagOptions): Promise<Offsets>;
36
+ startLagMonitoring(options: GetLagOptions, interval: number): void;
37
+ stopLagMonitoring(): void;
34
38
  findGroupCoordinator(callback: CallbackWithPromise<number>): void;
35
39
  findGroupCoordinator(): Promise<number>;
36
40
  joinGroup(options: GroupOptions, callback: CallbackWithPromise<string>): void;
@@ -1,14 +1,16 @@
1
- import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
1
+ import { createPromisifiedCallback, createTimeoutCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
2
2
  import { FetchIsolationLevels, FindCoordinatorKeyTypes } from "../../apis/enumerations.js";
3
- import { consumerCommitsChannel, consumerConsumesChannel, consumerFetchesChannel, consumerGroupChannel, consumerHeartbeatChannel, consumerOffsetsChannel, createDiagnosticContext } from "../../diagnostic.js";
4
- import { UserError } from "../../errors.js";
3
+ import { consumerCommitsChannel, consumerConsumesChannel, consumerFetchesChannel, consumerGroupChannel, consumerHeartbeatChannel, consumerLagChannel, consumerOffsetsChannel, createDiagnosticContext } from "../../diagnostic.js";
4
+ import { protocolErrors, UserError } from "../../errors.js";
5
+ import { INT32_SIZE } from "../../protocol/definitions.js";
5
6
  import { Reader } from "../../protocol/reader.js";
6
7
  import { Writer } from "../../protocol/writer.js";
8
+ import { kAutocommit, kRefreshOffsetsAndFetch } from "../../symbols.js";
7
9
  import { Base, kAfterCreate, kCheckNotClosed, kClearMetadata, kClosed, kCreateConnectionPool, kFetchConnections, kFormatValidationErrors, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kPrometheus, kValidateOptions } from "../base/base.js";
8
10
  import { defaultBaseOptions } from "../base/options.js";
9
11
  import { ensureMetric } from "../metrics.js";
10
12
  import { MessagesStream } from "./messages-stream.js";
11
- import { commitOptionsValidator, consumeOptionsValidator, consumerOptionsValidator, defaultConsumerOptions, fetchOptionsValidator, groupIdAndOptionsValidator, groupOptionsValidator, listCommitsOptionsValidator, listOffsetsOptionsValidator } from "./options.js";
13
+ import { commitOptionsValidator, consumeOptionsValidator, consumerOptionsValidator, defaultConsumerOptions, fetchOptionsValidator, getLagOptionsValidator, groupIdAndOptionsValidator, groupOptionsValidator, listCommitsOptionsValidator, listOffsetsOptionsValidator } from "./options.js";
12
14
  import { roundRobinAssigner } from "./partitions-assigners.js";
13
15
  import { TopicsMap } from "./topics-map.js";
14
16
  export class Consumer extends Base {
@@ -17,15 +19,20 @@ export class Consumer extends Base {
17
19
  memberId;
18
20
  topics;
19
21
  assignments;
22
+ #assignments;
20
23
  #members;
21
24
  #membershipActive;
22
25
  #isLeader;
23
26
  #protocol;
24
27
  #coordinatorId;
25
28
  #heartbeatInterval;
29
+ #lastHeartbeatIntervalMs;
26
30
  #lastHeartbeat;
31
+ #useConsumerGroupProtocol;
32
+ #memberEpoch;
33
+ #groupRemoteAssignor;
27
34
  #streams;
28
- #partitionsAssigner;
35
+ #lagMonitoring;
29
36
  /*
30
37
  The following requests are blocking in Kafka:
31
38
 
@@ -43,6 +50,7 @@ export class Consumer extends Base {
43
50
  [kFetchConnections];
44
51
  // Metrics
45
52
  #metricActiveStreams;
53
+ #metricLags;
46
54
  constructor(options) {
47
55
  super(options);
48
56
  this[kOptions] = Object.assign({}, defaultBaseOptions, defaultConsumerOptions, options);
@@ -52,15 +60,20 @@ export class Consumer extends Base {
52
60
  this.memberId = null;
53
61
  this.topics = new TopicsMap();
54
62
  this.assignments = null;
63
+ this.#assignments = [];
55
64
  this.#members = new Map();
56
65
  this.#membershipActive = false;
57
66
  this.#isLeader = false;
58
67
  this.#protocol = null;
59
68
  this.#coordinatorId = null;
60
69
  this.#heartbeatInterval = null;
70
+ this.#lastHeartbeatIntervalMs = 0;
61
71
  this.#lastHeartbeat = null;
62
72
  this.#streams = new Set();
63
- this.#partitionsAssigner = this[kOptions].partitionAssigner ?? roundRobinAssigner;
73
+ this.#lagMonitoring = null;
74
+ this.#memberEpoch = 0;
75
+ this.#useConsumerGroupProtocol = this[kOptions].groupProtocol === 'consumer';
76
+ this.#groupRemoteAssignor = this[kOptions].groupRemoteAssignor ?? null;
64
77
  this.#validateGroupOptions(this[kOptions], groupIdAndOptionsValidator);
65
78
  // Initialize connection pool
66
79
  this[kFetchConnections] = this[kCreateConnectionPool]();
@@ -68,6 +81,7 @@ export class Consumer extends Base {
68
81
  ensureMetric(this[kPrometheus], 'Gauge', 'kafka_consumers', 'Number of active Kafka consumers').inc();
69
82
  this.#metricActiveStreams = ensureMetric(this[kPrometheus], 'Gauge', 'kafka_consumers_streams', 'Number of active Kafka consumers streams');
70
83
  this.topics.setMetric(ensureMetric(this[kPrometheus], 'Gauge', 'kafka_consumers_topics', 'Number of topics being consumed'));
84
+ this.#metricLags = ensureMetric(this[kPrometheus], 'Histogram', 'kafka_consumers_lags', 'Lag of active Kafka consumers');
71
85
  }
72
86
  this[kAfterCreate]('consumer');
73
87
  }
@@ -90,11 +104,19 @@ export class Consumer extends Base {
90
104
  return callback[kCallbackPromise];
91
105
  }
92
106
  this[kClosed] = true;
93
- const closer = this.#membershipActive
94
- ? this.#leaveGroup.bind(this)
95
- : function noopCloser(_, callback) {
107
+ clearTimeout(this.#lagMonitoring);
108
+ let closer;
109
+ if (this.#useConsumerGroupProtocol) {
110
+ closer = this.#leaveGroupConsumerProtocol.bind(this);
111
+ }
112
+ else if (this.#membershipActive) {
113
+ closer = this.#leaveGroupClassicProtocol.bind(this);
114
+ }
115
+ else {
116
+ closer = function noopCloser(_, callback) {
96
117
  callback(null);
97
118
  };
119
+ }
98
120
  closer(force, error => {
99
121
  if (error) {
100
122
  this[kClosed] = false;
@@ -128,6 +150,9 @@ export class Consumer extends Base {
128
150
  if (!baseReady) {
129
151
  return false;
130
152
  }
153
+ if (this.#useConsumerGroupProtocol) {
154
+ return !!this.memberId && this.#memberEpoch >= 0;
155
+ }
131
156
  // We consider the group ready if we have a groupId, a memberId and heartbeat interval
132
157
  return this.#membershipActive && Boolean(this.groupId) && Boolean(this.memberId) && this.#heartbeatInterval !== null;
133
158
  }
@@ -225,6 +250,79 @@ export class Consumer extends Base {
225
250
  consumerOffsetsChannel.traceCallback(this.#listCommittedOffsets, 1, createDiagnosticContext({ client: this, operation: 'listCommittedOffsets', options }), this, options, callback);
226
251
  return callback[kCallbackPromise];
227
252
  }
253
+ getLag(options, callback) {
254
+ if (!callback) {
255
+ callback = createPromisifiedCallback();
256
+ }
257
+ if (this[kCheckNotClosed](callback)) {
258
+ return callback[kCallbackPromise];
259
+ }
260
+ const validationError = this[kValidateOptions](options, getLagOptionsValidator, '/options', false);
261
+ if (validationError) {
262
+ callback(validationError, undefined);
263
+ return callback[kCallbackPromise];
264
+ }
265
+ this.listOffsets(options, (error, offsets) => {
266
+ if (error) {
267
+ this.emit('consumer:lag:error', error);
268
+ callback(error, undefined);
269
+ return;
270
+ }
271
+ // Now gather the last committed offsets from each stream
272
+ const committeds = new Map();
273
+ for (const stream of this.#streams) {
274
+ for (const [topic, offset] of stream.committedOffsets) {
275
+ committeds.set(topic, offset);
276
+ }
277
+ }
278
+ // Build the lag map back. A -1n denotes that the consumer is not assigned to a certain partition
279
+ const lag = new Map();
280
+ for (const [topic, partitions] of offsets) {
281
+ const toInclude = new Set(options.partitions?.[topic] ?? []);
282
+ const hasPartitionsFilter = toInclude.size > 0;
283
+ const partitionLags = [];
284
+ for (let i = 0; i < partitions.length; i++) {
285
+ if (hasPartitionsFilter && !toInclude.has(i)) {
286
+ partitionLags.push(-2n);
287
+ continue;
288
+ }
289
+ const latest = partitions[i];
290
+ const committed = committeds.get(`${topic}:${i}`);
291
+ // If the consumer is not assigned to this partition, we return -1n.
292
+ // Otherwise we compute the lag as latest - committed - 1. The -1 is because latest is the offset of the next message to be produced.
293
+ partitionLags.push(typeof committed === 'undefined' ? -1n : latest - committed - 1n);
294
+ }
295
+ lag.set(topic, partitionLags);
296
+ }
297
+ // Publish to the diagnostic channel
298
+ consumerLagChannel.publish({ client: this, lag });
299
+ // Publish to the metric if available
300
+ if (this.#metricLags) {
301
+ for (const partitions of lag.values()) {
302
+ for (const l of partitions) {
303
+ if (l >= 0n) {
304
+ this.#metricLags.observe(Number(l));
305
+ }
306
+ }
307
+ }
308
+ }
309
+ this.emit('consumer:lag', lag);
310
+ callback(null, lag);
311
+ });
312
+ return callback[kCallbackPromise];
313
+ }
314
+ startLagMonitoring(options, interval) {
315
+ const validationError = this[kValidateOptions](options, getLagOptionsValidator, '/options', false);
316
+ if (validationError) {
317
+ throw validationError;
318
+ }
319
+ this.#lagMonitoring = setTimeout(() => {
320
+ this.getLag(options, () => this.#lagMonitoring.refresh());
321
+ }, interval);
322
+ }
323
+ stopLagMonitoring() {
324
+ clearTimeout(this.#lagMonitoring);
325
+ }
228
326
  findGroupCoordinator(callback) {
229
327
  if (!callback) {
230
328
  callback = createPromisifiedCallback();
@@ -246,6 +344,10 @@ export class Consumer extends Base {
246
344
  if (this[kCheckNotClosed](callback)) {
247
345
  return callback[kCallbackPromise];
248
346
  }
347
+ if (this.#useConsumerGroupProtocol) {
348
+ callback(null, '');
349
+ return callback[kCallbackPromise];
350
+ }
249
351
  const validationError = this[kValidateOptions](options, groupOptionsValidator, '/options', false);
250
352
  if (validationError) {
251
353
  callback(validationError, undefined);
@@ -271,8 +373,12 @@ export class Consumer extends Base {
271
373
  if (this[kCheckNotClosed](callback)) {
272
374
  return callback[kCallbackPromise];
273
375
  }
376
+ if (this.#useConsumerGroupProtocol) {
377
+ callback(null);
378
+ return callback[kCallbackPromise];
379
+ }
274
380
  this.#membershipActive = false;
275
- this.#leaveGroup(force, error => {
381
+ this.#leaveGroupClassicProtocol(force, error => {
276
382
  if (error) {
277
383
  this.#membershipActive = true;
278
384
  callback(error);
@@ -335,7 +441,7 @@ export class Consumer extends Base {
335
441
  groupCallback(error, undefined);
336
442
  return;
337
443
  }
338
- api(connection, this.groupId, this.generationId, this.memberId, null, Array.from(topics.values()), groupCallback);
444
+ api(connection, this.groupId, this.#useConsumerGroupProtocol ? this.#memberEpoch : this.generationId, this.memberId, null, Array.from(topics.values()), groupCallback);
339
445
  });
340
446
  }, error => {
341
447
  callback(error);
@@ -446,9 +552,14 @@ export class Consumer extends Base {
446
552
  groupCallback(error, undefined);
447
553
  return;
448
554
  }
449
- api(connection,
450
- // Note: once we start implementing KIP-848, the memberEpoch must be obtained
451
- [{ groupId: this.groupId, memberId: this.memberId, memberEpoch: -1, topics }], false, groupCallback);
555
+ api(connection, [
556
+ {
557
+ groupId: this.groupId,
558
+ memberId: this.memberId,
559
+ memberEpoch: this.#useConsumerGroupProtocol ? this.#memberEpoch : -1,
560
+ topics
561
+ }
562
+ ], false, groupCallback);
452
563
  });
453
564
  }, (error, response) => {
454
565
  if (error) {
@@ -479,11 +590,11 @@ export class Consumer extends Base {
479
590
  #joinGroup(options, callback) {
480
591
  consumerGroupChannel.traceCallback(this.#performJoinGroup, 1, createDiagnosticContext({ client: this, operation: 'joinGroup', options }), this, options, callback);
481
592
  }
482
- #leaveGroup(force, callback) {
593
+ #leaveGroupClassicProtocol(force, callback) {
483
594
  consumerGroupChannel.traceCallback(this.#performLeaveGroup, 1, createDiagnosticContext({ client: this, operation: 'leaveGroup', force }), this, force, callback);
484
595
  }
485
- #syncGroup(callback) {
486
- consumerGroupChannel.traceCallback(this.#performSyncGroup, 1, createDiagnosticContext({ client: this, operation: 'syncGroup' }), this, null, callback);
596
+ #syncGroup(partitionsAssigner, callback) {
597
+ consumerGroupChannel.traceCallback(this.#performSyncGroup, 2, createDiagnosticContext({ client: this, operation: 'syncGroup' }), this, partitionsAssigner, null, callback);
487
598
  }
488
599
  #heartbeat(options) {
489
600
  const eventPayload = { groupId: this.groupId, memberId: this.memberId, generationId: this.generationId };
@@ -534,6 +645,186 @@ export class Consumer extends Base {
534
645
  clearTimeout(this.#heartbeatInterval);
535
646
  this.#heartbeatInterval = null;
536
647
  }
648
+ #consumerGroupHeartbeat(options, callback) {
649
+ options.rebalanceTimeout ??= this[kOptions].rebalanceTimeout;
650
+ consumerHeartbeatChannel.traceCallback(this.#performConsumerGroupHeartbeat, 1, createDiagnosticContext({ client: this, operation: 'consumerGroupHeartbeat' }), this, options, callback);
651
+ }
652
+ #performConsumerGroupHeartbeat(options, callback) {
653
+ this.#performGroupOperation('consumerGroupHeartbeat', (connection, groupCallback) => {
654
+ this.emitWithDebug('consumer:heartbeat', 'start');
655
+ this[kGetApi]('ConsumerGroupHeartbeat', (error, api) => {
656
+ if (error) {
657
+ groupCallback(error, undefined);
658
+ return;
659
+ }
660
+ const timeoutCallback = createTimeoutCallback(groupCallback, this[kOptions].timeout, 'Heartbeat timeout.');
661
+ api(connection, this.groupId, this.memberId || '', this.#memberEpoch, null, // instanceId
662
+ null, // rackId
663
+ options.rebalanceTimeout, this.topics.current, this.#groupRemoteAssignor, this.#assignments, timeoutCallback);
664
+ });
665
+ }, (error, response) => {
666
+ if (this[kClosed]) {
667
+ this.emitWithDebug('consumer:heartbeat', 'end');
668
+ callback(null);
669
+ return;
670
+ }
671
+ if (error) {
672
+ this.#cancelHeartbeat();
673
+ this.emitWithDebug('consumer:heartbeat', 'error', { error });
674
+ const fenced = error.response?.errorCode === protocolErrors.FENCED_MEMBER_EPOCH.code;
675
+ if (fenced) {
676
+ this.#assignments = [];
677
+ this.assignments = [];
678
+ this.#memberEpoch = 0;
679
+ this.#consumerGroupHeartbeat(options, () => { });
680
+ callback(error);
681
+ return;
682
+ }
683
+ this.#heartbeatInterval = setTimeout(() => {
684
+ this.#consumerGroupHeartbeat(options, () => { });
685
+ }, this.#lastHeartbeatIntervalMs || 1000);
686
+ callback(error);
687
+ return;
688
+ }
689
+ this.#lastHeartbeat = new Date();
690
+ this.#memberEpoch = response.memberEpoch;
691
+ if (response.memberId) {
692
+ const changed = this.memberId !== response.memberId;
693
+ this.memberId = response.memberId;
694
+ if (changed) {
695
+ this.memberId = response.memberId;
696
+ this.#consumerGroupHeartbeat(options, () => { });
697
+ this.emitWithDebug('consumer:heartbeat', 'end');
698
+ callback(null);
699
+ return;
700
+ }
701
+ }
702
+ if (response.heartbeatIntervalMs > 0) {
703
+ this.#cancelHeartbeat();
704
+ this.#lastHeartbeatIntervalMs = response.heartbeatIntervalMs;
705
+ this.#heartbeatInterval = setTimeout(() => {
706
+ this.#consumerGroupHeartbeat(options, () => { });
707
+ }, response.heartbeatIntervalMs);
708
+ }
709
+ const newAssignments = response.assignment?.topicPartitions;
710
+ if (newAssignments) {
711
+ this.#revokePartitions(newAssignments);
712
+ this.#assignPartitions(newAssignments);
713
+ }
714
+ this.emitWithDebug('consumer:heartbeat', 'end');
715
+ callback(null);
716
+ });
717
+ }
718
+ #diffAssignments(A, B) {
719
+ const result = [];
720
+ for (const a of A) {
721
+ const b = B.find(tp => tp.topicId === a.topicId);
722
+ if (!b) {
723
+ result.push(a);
724
+ }
725
+ else {
726
+ const diff = a.partitions.filter(partition => !b.partitions.includes(partition));
727
+ if (diff.length > 0) {
728
+ result.push({ topicId: a.topicId, partitions: diff });
729
+ }
730
+ }
731
+ }
732
+ return result;
733
+ }
734
+ #revokePartitions(newAssignment) {
735
+ const toRevoke = this.#diffAssignments(this.#assignments, newAssignment);
736
+ if (toRevoke.length === 0) {
737
+ return;
738
+ }
739
+ for (const stream of this.#streams) {
740
+ stream.pause();
741
+ stream[kAutocommit]();
742
+ }
743
+ this.#updateAssignments(newAssignment, error => {
744
+ for (const stream of this.#streams) {
745
+ stream.resume();
746
+ }
747
+ /* c8 ignore next 3 - Hard to test */
748
+ if (error) {
749
+ return;
750
+ }
751
+ this.#cancelHeartbeat();
752
+ this.#consumerGroupHeartbeat(this[kOptions], () => { });
753
+ });
754
+ }
755
+ #assignPartitions(newAssignment) {
756
+ const toAssign = this.#diffAssignments(newAssignment, this.#assignments);
757
+ if (toAssign.length === 0) {
758
+ return;
759
+ }
760
+ this.#updateAssignments(newAssignment, error => {
761
+ if (error) {
762
+ return;
763
+ }
764
+ this.#cancelHeartbeat();
765
+ this.#consumerGroupHeartbeat(this[kOptions], () => { });
766
+ for (const stream of this.#streams) {
767
+ // 3. Refresh partition offsets
768
+ stream[kRefreshOffsetsAndFetch]();
769
+ }
770
+ });
771
+ }
772
+ #updateAssignments(newAssignments, callback) {
773
+ this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
774
+ if (error) {
775
+ callback(error);
776
+ return;
777
+ }
778
+ const topicIdToTopic = new Map();
779
+ for (const [topic, topicMetadata] of metadata.topics) {
780
+ topicIdToTopic.set(topicMetadata.id, topic);
781
+ }
782
+ const assignments = newAssignments.map(tp => ({
783
+ topic: topicIdToTopic.get(tp.topicId),
784
+ partitions: tp.partitions
785
+ }));
786
+ this.#assignments = newAssignments;
787
+ this.assignments = assignments;
788
+ callback(null);
789
+ });
790
+ }
791
+ #joinGroupConsumerProtocol(options, callback) {
792
+ this.#memberEpoch = 0;
793
+ this.#assignments = [];
794
+ this.#membershipActive = true;
795
+ this.#consumerGroupHeartbeat(options, err => {
796
+ if (this.memberId) {
797
+ this.emitWithDebug('consumer', 'group:join', { groupId: this.groupId, memberId: this.memberId });
798
+ }
799
+ callback(err);
800
+ });
801
+ }
802
+ #leaveGroupConsumerProtocol(_, callback) {
803
+ // Leave by sending a heartbeat with memberEpoch = -1
804
+ this.#cancelHeartbeat();
805
+ this.#performDeduplicateGroupOperaton('leaveGroupConsumerProtocol', (connection, groupCallback) => {
806
+ this[kGetApi]('ConsumerGroupHeartbeat', (error, api) => {
807
+ if (error) {
808
+ groupCallback(error, undefined);
809
+ return;
810
+ }
811
+ api(connection, this.groupId, this.memberId, -1, // memberEpoch = -1 signals leave
812
+ null, // instanceId
813
+ null, // rackId
814
+ 0, // rebalanceTimeout
815
+ [], // subscribedTopicNames
816
+ this.#groupRemoteAssignor, [], // topicPartitions
817
+ groupCallback);
818
+ });
819
+ }, _error => {
820
+ this.emitWithDebug('consumer', 'group:leave', { groupId: this.groupId, memberId: this.memberId });
821
+ this.memberId = null;
822
+ this.#memberEpoch = -1;
823
+ this.#assignments = [];
824
+ this.assignments = [];
825
+ callback(null);
826
+ });
827
+ }
537
828
  #performConsume(options, trackTopics, callback) {
538
829
  // Subscribe all topics
539
830
  let joinNeeded = this.memberId === null;
@@ -546,6 +837,17 @@ export class Consumer extends Base {
546
837
  }
547
838
  // If we need to (re)join the group, do that first and then try again
548
839
  if (joinNeeded) {
840
+ if (this.#useConsumerGroupProtocol) {
841
+ this.#joinGroupConsumerProtocol(options, error => {
842
+ if (error) {
843
+ callback(error, undefined);
844
+ return;
845
+ }
846
+ this.#performConsume(options, false, callback);
847
+ });
848
+ return;
849
+ }
850
+ // Classic consumer protocol join
549
851
  this.joinGroup(options, error => {
550
852
  if (error) {
551
853
  callback(error, undefined);
@@ -635,7 +937,7 @@ export class Consumer extends Base {
635
937
  this.#members.set(member.memberId, this.#decodeProtocolSubscriptionMetadata(member.memberId, member.metadata));
636
938
  }
637
939
  // Send a syncGroup request
638
- this.#syncGroup((error, response) => {
940
+ this.#syncGroup(options.partitionAssigner, (error, response) => {
639
941
  if (!this.#membershipActive) {
640
942
  callback(null, undefined);
641
943
  return;
@@ -721,7 +1023,7 @@ export class Consumer extends Base {
721
1023
  callback(null);
722
1024
  });
723
1025
  }
724
- #performSyncGroup(assignments, callback) {
1026
+ #performSyncGroup(partitionsAssigner, assignments, callback) {
725
1027
  if (!this.#membershipActive) {
726
1028
  callback(null, []);
727
1029
  return;
@@ -745,7 +1047,7 @@ export class Consumer extends Base {
745
1047
  callback(this.#handleMetadataError(error), undefined);
746
1048
  return;
747
1049
  }
748
- this.#performSyncGroup(this.#createAssignments(metadata), callback);
1050
+ this.#performSyncGroup(partitionsAssigner, this.#createAssignments(partitionsAssigner, metadata), callback);
749
1051
  });
750
1052
  return;
751
1053
  }
@@ -844,6 +1146,9 @@ export class Consumer extends Base {
844
1146
  #decodeProtocolAssignment(buffer) {
845
1147
  const reader = Reader.from(buffer);
846
1148
  reader.skip(2); // Ignore Version information
1149
+ if (reader.remaining < INT32_SIZE) {
1150
+ return [];
1151
+ }
847
1152
  return reader.readArray(r => {
848
1153
  return {
849
1154
  topic: r.readString(false),
@@ -851,7 +1156,7 @@ export class Consumer extends Base {
851
1156
  };
852
1157
  }, false, false);
853
1158
  }
854
- #createAssignments(metadata) {
1159
+ #createAssignments(partitionsAssigner, metadata) {
855
1160
  const partitionTracker = new Map();
856
1161
  // First of all, layout topics-partitions in a list
857
1162
  for (const [topic, partitions] of metadata.topics) {
@@ -872,7 +1177,8 @@ export class Consumer extends Base {
872
1177
  return [{ memberId: this.memberId, assignment: this.#encodeProtocolAssignment(assignments) }];
873
1178
  }
874
1179
  const encodedAssignments = [];
875
- for (const member of this.#partitionsAssigner(this.memberId, this.#members, new Set(this.topics.current), metadata)) {
1180
+ partitionsAssigner ??= this[kOptions].partitionAssigner ?? roundRobinAssigner;
1181
+ for (const member of partitionsAssigner(this.memberId, this.#members, new Set(this.topics.current), metadata)) {
876
1182
  encodedAssignments.push({
877
1183
  memberId: member.memberId,
878
1184
  assignment: this.#encodeProtocolAssignment(Array.from(member.assignments.values()))
@@ -1,6 +1,7 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import { type CallbackWithPromise } from '../../apis/callbacks.ts';
3
3
  import { type Message } from '../../protocol/records.ts';
4
+ import { kAutocommit, kInstance, kRefreshOffsetsAndFetch } from '../../symbols.ts';
4
5
  import { kInspect } from '../base/base.ts';
5
6
  import { type Consumer } from './consumer.ts';
6
7
  import { type CommitOptionsPartition, type ConsumeOptions } from './types.ts';
@@ -8,12 +9,17 @@ export declare function noopDeserializer(data?: Buffer): Buffer | undefined;
8
9
  export declare function defaultCorruptedMessageHandler(): boolean;
9
10
  export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends Readable {
10
11
  #private;
12
+ [kInstance]: number;
11
13
  constructor(consumer: Consumer<Key, Value, HeaderKey, HeaderValue>, options: ConsumeOptions<Key, Value, HeaderKey, HeaderValue>);
14
+ get committedOffsets(): Map<string, bigint>;
12
15
  close(callback: CallbackWithPromise<void>): void;
13
16
  close(): Promise<void>;
14
17
  isActive(): boolean;
15
18
  isConnected(): boolean;
19
+ resume(): this;
20
+ pause(): this;
16
21
  addListener(event: 'autocommit', listener: (err: Error, offsets: CommitOptionsPartition[]) => void): this;
22
+ addListener(event: 'fetch', listener: () => void): this;
17
23
  addListener(event: 'data', listener: (message: Message<Key, Value, HeaderKey, HeaderValue>) => void): this;
18
24
  addListener(event: 'close', listener: () => void): this;
19
25
  addListener(event: 'end', listener: () => void): this;
@@ -22,6 +28,7 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
22
28
  addListener(event: 'readable', listener: () => void): this;
23
29
  addListener(event: 'resume', listener: () => void): this;
24
30
  on(event: 'autocommit', listener: (err: Error, offsets: CommitOptionsPartition[]) => void): this;
31
+ on(event: 'fetch', listener: () => void): this;
25
32
  on(event: 'data', listener: (message: Message<Key, Value, HeaderKey, HeaderValue>) => void): this;
26
33
  on(event: 'close', listener: () => void): this;
27
34
  on(event: 'end', listener: () => void): this;
@@ -30,6 +37,7 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
30
37
  on(event: 'readable', listener: () => void): this;
31
38
  on(event: 'resume', listener: () => void): this;
32
39
  once(event: 'autocommit', listener: (err: Error, offsets: CommitOptionsPartition[]) => void): this;
40
+ once(event: 'fetch', listener: () => void): this;
33
41
  once(event: 'data', listener: (message: Message<Key, Value, HeaderKey, HeaderValue>) => void): this;
34
42
  once(event: 'close', listener: () => void): this;
35
43
  once(event: 'end', listener: () => void): this;
@@ -37,6 +45,8 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
37
45
  once(event: 'pause', listener: () => void): this;
38
46
  once(event: 'readable', listener: () => void): this;
39
47
  once(event: 'resume', listener: () => void): this;
48
+ prependListener(event: 'autocommit', listener: (err: Error, offsets: CommitOptionsPartition[]) => void): this;
49
+ prependListener(event: 'fetch', listener: () => void): this;
40
50
  prependListener(event: 'data', listener: (message: Message<Key, Value, HeaderKey, HeaderValue>) => void): this;
41
51
  prependListener(event: 'close', listener: () => void): this;
42
52
  prependListener(event: 'end', listener: () => void): this;
@@ -44,6 +54,8 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
44
54
  prependListener(event: 'pause', listener: () => void): this;
45
55
  prependListener(event: 'readable', listener: () => void): this;
46
56
  prependListener(event: 'resume', listener: () => void): this;
57
+ prependOnceListener(event: 'autocommit', listener: (err: Error, offsets: CommitOptionsPartition[]) => void): this;
58
+ prependOnceListener(event: 'fetch', listener: () => void): this;
47
59
  prependOnceListener(event: 'data', listener: (message: Message<Key, Value, HeaderKey, HeaderValue>) => void): this;
48
60
  prependOnceListener(event: 'close', listener: () => void): this;
49
61
  prependOnceListener(event: 'end', listener: () => void): this;
@@ -55,5 +67,7 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
55
67
  _construct(callback: (error?: Error) => void): void;
56
68
  _destroy(error: Error | null, callback: (error?: Error | null) => void): void;
57
69
  _read(): void;
70
+ [kAutocommit](): void;
71
+ [kRefreshOffsetsAndFetch](): void;
58
72
  [kInspect](...args: unknown[]): void;
59
73
  }