kafka-ts 0.0.1-beta.3 → 0.0.1-beta.6

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.
Files changed (164) hide show
  1. package/.github/workflows/release.yml +19 -6
  2. package/README.md +15 -21
  3. package/dist/api/api-versions.d.ts +9 -0
  4. package/dist/api/api-versions.js +24 -0
  5. package/dist/api/create-topics.d.ts +38 -0
  6. package/dist/api/create-topics.js +53 -0
  7. package/dist/api/delete-topics.d.ts +18 -0
  8. package/dist/api/delete-topics.js +33 -0
  9. package/dist/api/fetch.d.ts +84 -0
  10. package/dist/api/fetch.js +142 -0
  11. package/dist/api/find-coordinator.d.ts +21 -0
  12. package/dist/api/find-coordinator.js +39 -0
  13. package/dist/api/heartbeat.d.ts +11 -0
  14. package/dist/api/heartbeat.js +27 -0
  15. package/dist/api/index.d.ts +578 -0
  16. package/dist/api/index.js +165 -0
  17. package/dist/api/init-producer-id.d.ts +13 -0
  18. package/dist/api/init-producer-id.js +29 -0
  19. package/dist/api/join-group.d.ts +34 -0
  20. package/dist/api/join-group.js +51 -0
  21. package/dist/api/leave-group.d.ts +19 -0
  22. package/dist/api/leave-group.js +39 -0
  23. package/dist/api/list-offsets.d.ts +29 -0
  24. package/dist/api/list-offsets.js +48 -0
  25. package/dist/api/metadata.d.ts +40 -0
  26. package/dist/api/metadata.js +58 -0
  27. package/dist/api/offset-commit.d.ts +28 -0
  28. package/dist/api/offset-commit.js +48 -0
  29. package/dist/api/offset-fetch.d.ts +33 -0
  30. package/dist/api/offset-fetch.js +57 -0
  31. package/dist/api/produce.d.ts +54 -0
  32. package/dist/api/produce.js +126 -0
  33. package/dist/api/sasl-authenticate.d.ts +11 -0
  34. package/dist/api/sasl-authenticate.js +23 -0
  35. package/dist/api/sasl-handshake.d.ts +6 -0
  36. package/dist/api/sasl-handshake.js +19 -0
  37. package/dist/api/sync-group.d.ts +24 -0
  38. package/dist/api/sync-group.js +36 -0
  39. package/dist/auth/index.d.ts +2 -0
  40. package/dist/auth/index.js +8 -0
  41. package/dist/auth/plain.d.ts +5 -0
  42. package/dist/auth/plain.js +12 -0
  43. package/dist/auth/scram.d.ts +9 -0
  44. package/dist/auth/scram.js +40 -0
  45. package/dist/broker.d.ts +30 -0
  46. package/dist/broker.js +55 -0
  47. package/dist/client.d.ts +23 -0
  48. package/dist/client.js +36 -0
  49. package/dist/cluster.d.ts +27 -0
  50. package/dist/cluster.js +70 -0
  51. package/dist/cluster.test.d.ts +1 -0
  52. package/dist/cluster.test.js +345 -0
  53. package/dist/codecs/gzip.d.ts +2 -0
  54. package/dist/codecs/gzip.js +8 -0
  55. package/dist/codecs/index.d.ts +2 -0
  56. package/dist/codecs/index.js +17 -0
  57. package/dist/codecs/none.d.ts +2 -0
  58. package/dist/codecs/none.js +7 -0
  59. package/dist/codecs/types.d.ts +5 -0
  60. package/dist/codecs/types.js +2 -0
  61. package/dist/connection.d.ts +26 -0
  62. package/dist/connection.js +175 -0
  63. package/dist/consumer/consumer-group.d.ts +41 -0
  64. package/dist/consumer/consumer-group.js +217 -0
  65. package/dist/consumer/consumer-metadata.d.ts +7 -0
  66. package/dist/consumer/consumer-metadata.js +14 -0
  67. package/dist/consumer/consumer.d.ts +44 -0
  68. package/dist/consumer/consumer.js +225 -0
  69. package/dist/consumer/fetch-manager.d.ts +33 -0
  70. package/dist/consumer/fetch-manager.js +140 -0
  71. package/dist/consumer/fetcher.d.ts +25 -0
  72. package/dist/consumer/fetcher.js +64 -0
  73. package/dist/consumer/offset-manager.d.ts +22 -0
  74. package/dist/consumer/offset-manager.js +66 -0
  75. package/dist/consumer/processor.d.ts +19 -0
  76. package/dist/consumer/processor.js +59 -0
  77. package/dist/distributors/assignments-to-replicas.d.ts +16 -0
  78. package/dist/distributors/assignments-to-replicas.js +59 -0
  79. package/dist/distributors/assignments-to-replicas.test.d.ts +1 -0
  80. package/dist/distributors/assignments-to-replicas.test.js +40 -0
  81. package/dist/distributors/messages-to-topic-partition-leaders.d.ts +17 -0
  82. package/dist/distributors/messages-to-topic-partition-leaders.js +15 -0
  83. package/dist/distributors/messages-to-topic-partition-leaders.test.d.ts +1 -0
  84. package/dist/distributors/messages-to-topic-partition-leaders.test.js +30 -0
  85. package/dist/distributors/partitioner.d.ts +7 -0
  86. package/dist/distributors/partitioner.js +23 -0
  87. package/dist/index.d.ts +9 -0
  88. package/dist/index.js +26 -0
  89. package/dist/metadata.d.ts +24 -0
  90. package/dist/metadata.js +106 -0
  91. package/dist/producer/producer.d.ts +24 -0
  92. package/dist/producer/producer.js +131 -0
  93. package/dist/types.d.ts +11 -0
  94. package/dist/types.js +2 -0
  95. package/dist/utils/api.d.ts +9 -0
  96. package/dist/utils/api.js +5 -0
  97. package/dist/utils/crypto.d.ts +8 -0
  98. package/dist/utils/crypto.js +18 -0
  99. package/dist/utils/decoder.d.ts +30 -0
  100. package/dist/utils/decoder.js +152 -0
  101. package/dist/utils/delay.d.ts +1 -0
  102. package/dist/utils/delay.js +5 -0
  103. package/dist/utils/encoder.d.ts +28 -0
  104. package/dist/utils/encoder.js +125 -0
  105. package/dist/utils/error.d.ts +11 -0
  106. package/dist/utils/error.js +27 -0
  107. package/dist/utils/logger.d.ts +9 -0
  108. package/dist/utils/logger.js +32 -0
  109. package/dist/utils/memo.d.ts +1 -0
  110. package/dist/utils/memo.js +16 -0
  111. package/dist/utils/murmur2.d.ts +3 -0
  112. package/dist/utils/murmur2.js +40 -0
  113. package/dist/utils/retrier.d.ts +10 -0
  114. package/dist/utils/retrier.js +22 -0
  115. package/dist/utils/tracer.d.ts +5 -0
  116. package/dist/utils/tracer.js +39 -0
  117. package/docker-compose.yml +3 -3
  118. package/examples/package-lock.json +3501 -3
  119. package/examples/package.json +8 -1
  120. package/examples/src/benchmark/common.ts +98 -0
  121. package/examples/src/benchmark/kafka-ts.ts +67 -0
  122. package/examples/src/benchmark/kafkajs.ts +51 -0
  123. package/examples/src/client.ts +4 -1
  124. package/examples/src/consumer.ts +7 -1
  125. package/examples/src/create-topic.ts +3 -3
  126. package/examples/src/opentelemetry.ts +46 -0
  127. package/examples/src/producer.ts +11 -11
  128. package/examples/src/replicator.ts +2 -1
  129. package/package.json +4 -2
  130. package/scripts/create-scram-user.sh +4 -2
  131. package/scripts/generate-certs.sh +2 -0
  132. package/src/__snapshots__/cluster.test.ts.snap +160 -53
  133. package/src/api/fetch.ts +83 -28
  134. package/src/api/index.ts +3 -1
  135. package/src/api/metadata.ts +1 -1
  136. package/src/api/produce.ts +7 -10
  137. package/src/cluster.test.ts +10 -7
  138. package/src/cluster.ts +36 -38
  139. package/src/codecs/gzip.ts +9 -0
  140. package/src/codecs/index.ts +16 -0
  141. package/src/codecs/none.ts +6 -0
  142. package/src/codecs/types.ts +4 -0
  143. package/src/connection.ts +31 -17
  144. package/src/consumer/consumer-group.ts +43 -21
  145. package/src/consumer/consumer.ts +58 -37
  146. package/src/consumer/fetch-manager.ts +36 -46
  147. package/src/consumer/fetcher.ts +20 -13
  148. package/src/consumer/offset-manager.ts +18 -7
  149. package/src/consumer/processor.ts +14 -8
  150. package/src/distributors/assignments-to-replicas.ts +1 -3
  151. package/src/index.ts +2 -0
  152. package/src/metadata.ts +4 -0
  153. package/src/producer/producer.ts +14 -9
  154. package/src/utils/api.ts +1 -1
  155. package/src/utils/decoder.ts +9 -3
  156. package/src/utils/encoder.ts +26 -19
  157. package/src/utils/logger.ts +37 -0
  158. package/src/utils/tracer.ts +40 -22
  159. package/certs/ca.key +0 -52
  160. package/certs/ca.srl +0 -1
  161. package/certs/kafka.crt +0 -29
  162. package/certs/kafka.csr +0 -26
  163. package/certs/kafka.key +0 -52
  164. package/src/utils/debug.ts +0 -9
@@ -8,20 +8,21 @@ import { createKafkaClient } from './client';
8
8
  import { Cluster } from './cluster';
9
9
  import { KafkaTSApiError } from './utils/error';
10
10
 
11
- export const kafka = createKafkaClient({
11
+ const kafka = createKafkaClient({
12
12
  clientId: 'kafka-ts',
13
13
  bootstrapServers: [{ host: 'localhost', port: 9092 }],
14
14
  sasl: saslPlain({ username: 'admin', password: 'admin' }),
15
15
  ssl: { ca: readFileSync('./certs/ca.crt').toString() },
16
16
  });
17
17
 
18
- describe.sequential('Request handler', () => {
18
+ describe.sequential('Low-level API', () => {
19
19
  const groupId = randomBytes(16).toString('hex');
20
20
 
21
21
  let cluster: Cluster;
22
22
 
23
23
  beforeAll(async () => {
24
- cluster = await kafka.createCluster().connect();
24
+ cluster = await kafka.createCluster();
25
+ await cluster.connect();
25
26
 
26
27
  const metadataResult = await cluster.sendRequest(API.METADATA, {
27
28
  topics: null,
@@ -52,8 +53,8 @@ describe.sequential('Request handler', () => {
52
53
  topics: [
53
54
  {
54
55
  name: 'kafka-ts-test-topic',
55
- numPartitions: 1,
56
- replicationFactor: 1,
56
+ numPartitions: 10,
57
+ replicationFactor: 3,
57
58
  assignments: [],
58
59
  configs: [],
59
60
  },
@@ -89,6 +90,7 @@ describe.sequential('Request handler', () => {
89
90
  expect(result).toMatchSnapshot();
90
91
  });
91
92
 
93
+ let partitionIndex = 0;
92
94
  let leaderId = 0;
93
95
 
94
96
  it('should request metadata for a topic', async () => {
@@ -97,6 +99,7 @@ describe.sequential('Request handler', () => {
97
99
  allowTopicAutoCreation: false,
98
100
  includeTopicAuthorizedOperations: false,
99
101
  });
102
+ partitionIndex = result.topics[0].partitions[0].partitionIndex;
100
103
  leaderId = result.topics[0].partitions[0].leaderId;
101
104
  result.controllerId = 0;
102
105
  result.topics.forEach((topic) => {
@@ -134,7 +137,7 @@ describe.sequential('Request handler', () => {
134
137
  name: 'kafka-ts-test-topic',
135
138
  partitionData: [
136
139
  {
137
- index: 0,
140
+ index: partitionIndex,
138
141
  baseOffset: 0n,
139
142
  partitionLeaderEpoch: 0,
140
143
  attributes: 0,
@@ -180,7 +183,7 @@ describe.sequential('Request handler', () => {
180
183
  topicId,
181
184
  partitions: [
182
185
  {
183
- partition: 0,
186
+ partition: partitionIndex,
184
187
  currentLeaderEpoch: -1,
185
188
  fetchOffset: 0n,
186
189
  lastFetchedEpoch: 0,
package/src/cluster.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { TcpSocketConnectOpts } from 'net';
2
2
  import { TLSSocketOptions } from 'tls';
3
3
  import { API } from './api';
4
+ import { Metadata } from './api/metadata';
4
5
  import { Broker, SASLProvider } from './broker';
5
6
  import { SendRequest } from './connection';
6
- import { ConnectionError, KafkaTSError } from './utils/error';
7
+ import { KafkaTSError } from './utils/error';
8
+ import { log } from './utils/logger';
7
9
 
8
10
  type ClusterOptions = {
9
11
  clientId: string | null;
@@ -13,73 +15,69 @@ type ClusterOptions = {
13
15
  };
14
16
 
15
17
  export class Cluster {
16
- private seedBroker: Broker;
18
+ private seedBroker = new Broker({ clientId: null, sasl: null, ssl: null, options: { port: 9092 } });
17
19
  private brokerById: Record<number, Broker> = {};
20
+ private brokerMetadata: Record<number, Metadata['brokers'][number]> = {};
18
21
 
19
- constructor(private options: ClusterOptions) {
20
- this.seedBroker = new Broker({
21
- clientId: this.options.clientId,
22
- sasl: this.options.sasl,
23
- ssl: this.options.ssl,
24
- options: this.options.bootstrapServers[0],
25
- });
26
- }
22
+ constructor(private options: ClusterOptions) {}
27
23
 
28
24
  public async connect() {
29
- await this.connectSeedBroker();
25
+ this.seedBroker = await this.findSeedBroker();
26
+ this.brokerById = {};
27
+
30
28
  const metadata = await this.sendRequest(API.METADATA, {
31
29
  allowTopicAutoCreation: false,
32
30
  includeTopicAuthorizedOperations: false,
33
31
  topics: [],
34
32
  });
35
-
36
- this.brokerById = Object.fromEntries(
37
- metadata.brokers.map(({ nodeId, ...options }) => [
38
- nodeId,
39
- new Broker({
40
- clientId: this.options.clientId,
41
- sasl: this.options.sasl,
42
- ssl: this.options.ssl,
43
- options,
44
- }),
45
- ]),
46
- );
47
- return this;
33
+ this.brokerMetadata = Object.fromEntries(metadata.brokers.map((options) => [options.nodeId, options]));
48
34
  }
49
35
 
50
36
  public async disconnect() {
51
- await Promise.all([
52
- this.seedBroker.disconnect(),
53
- ...Object.values(this.brokerById).map((broker) => broker.disconnect()),
54
- ]);
37
+ await Promise.all([this.seedBroker.disconnect(), ...Object.values(this.brokerById).map((x) => x.disconnect())]);
55
38
  }
56
39
 
40
+ public setSeedBroker = async (nodeId: number) => {
41
+ await this.seedBroker.disconnect();
42
+ this.seedBroker = await this.acquireBroker(nodeId);
43
+ };
44
+
57
45
  public sendRequest: SendRequest = (...args) => this.seedBroker.sendRequest(...args);
58
46
 
59
47
  public sendRequestToNode =
60
48
  (nodeId: number): SendRequest =>
61
49
  async (...args) => {
62
- const broker = this.brokerById[nodeId];
63
- if (!broker) {
64
- throw new ConnectionError(`Broker ${nodeId} is not available`);
50
+ if (!this.brokerById[nodeId]) {
51
+ this.brokerById[nodeId] = await this.acquireBroker(nodeId);
65
52
  }
66
- await broker.ensureConnected();
67
- return broker.sendRequest(...args);
53
+ return this.brokerById[nodeId].sendRequest(...args);
68
54
  };
69
55
 
70
- private async connectSeedBroker() {
56
+ public async acquireBroker(nodeId: number) {
57
+ const broker = new Broker({
58
+ clientId: this.options.clientId,
59
+ sasl: this.options.sasl,
60
+ ssl: this.options.ssl,
61
+ options: this.brokerMetadata[nodeId],
62
+ });
63
+ await broker.connect();
64
+ return broker;
65
+ }
66
+
67
+ private async findSeedBroker() {
71
68
  const randomizedBrokers = this.options.bootstrapServers.toSorted(() => Math.random() - 0.5);
72
69
  for (const options of randomizedBrokers) {
73
70
  try {
74
- this.seedBroker = await new Broker({
71
+ const broker = await new Broker({
75
72
  clientId: this.options.clientId,
76
73
  sasl: this.options.sasl,
77
74
  ssl: this.options.ssl,
78
75
  options,
79
- }).connect();
80
- return;
76
+ });
77
+ await broker.connect();
78
+ return broker;
81
79
  } catch (error) {
82
- console.warn(`Failed to connect to seed broker ${options.host}:${options.port}`, error);
80
+ log.warn(`Failed to connect to seed broker ${options.host}:${options.port}`, error);
83
81
  }
84
82
  }
85
83
  throw new KafkaTSError('No seed brokers found');
@@ -0,0 +1,9 @@
1
+ import { gzip, unzip } from 'zlib';
2
+ import { Codec } from './types';
3
+
4
+ export const GZIP: Codec = {
5
+ compress: async (data) =>
6
+ new Promise<Buffer>((resolve, reject) => gzip(data, (err, result) => (err ? reject(err) : resolve(result)))),
7
+ decompress: async (data) =>
8
+ new Promise<Buffer>((resolve, reject) => unzip(data, (err, result) => (err ? reject(err) : resolve(result)))),
9
+ };
@@ -0,0 +1,16 @@
1
+ import { GZIP } from './gzip';
2
+ import { NONE } from './none';
3
+ import { Codec } from './types';
4
+
5
+ const codecs: Record<number, Codec> = {
6
+ 0: NONE,
7
+ 1: GZIP,
8
+ };
9
+
10
+ export const findCodec = (type: number) => {
11
+ const codec = codecs[type];
12
+ if (!codec) {
13
+ throw new Error(`Unsupported codec: ${type}`);
14
+ }
15
+ return codec;
16
+ };
@@ -0,0 +1,6 @@
1
+ import { Codec } from './types';
2
+
3
+ export const NONE: Codec = {
4
+ compress: (data: Buffer) => Promise.resolve(data),
5
+ decompress: (data: Buffer) => Promise.resolve(data),
6
+ };
@@ -0,0 +1,4 @@
1
+ export type Codec = {
2
+ compress: (data: Buffer) => Promise<Buffer>;
3
+ decompress: (data: Buffer) => Promise<Buffer>;
4
+ };
package/src/connection.ts CHANGED
@@ -6,11 +6,12 @@ import { Api } from './utils/api';
6
6
  import { Decoder } from './utils/decoder';
7
7
  import { Encoder } from './utils/encoder';
8
8
  import { ConnectionError } from './utils/error';
9
+ import { log } from './utils/logger';
9
10
  import { createTracer } from './utils/tracer';
10
11
 
11
12
  const trace = createTracer('Connection');
12
13
 
13
- export type ConnectionOptions = {
14
+ type ConnectionOptions = {
14
15
  clientId: string | null;
15
16
  connection: TcpSocketConnectOpts;
16
17
  ssl: TLSSocketOptions | null;
@@ -24,14 +25,14 @@ export class Connection {
24
25
  [correlationId: number]: { resolve: (response: RawResonse) => void; reject: (error: Error) => void };
25
26
  } = {};
26
27
  private lastCorrelationId = 0;
27
- private buffer: Buffer | null = null;
28
+ private chunks: Buffer[] = [];
28
29
 
29
30
  constructor(private options: ConnectionOptions) {}
30
31
 
31
32
  @trace()
32
33
  public async connect() {
33
34
  this.queue = {};
34
- this.buffer = null;
35
+ this.chunks = [];
35
36
 
36
37
  await new Promise<void>((resolve, reject) => {
37
38
  const { ssl, connection } = this.options;
@@ -59,6 +60,7 @@ export class Connection {
59
60
  });
60
61
  }
61
62
 
63
+ @trace()
62
64
  public disconnect() {
63
65
  this.socket.removeAllListeners();
64
66
  return new Promise<void>((resolve) => {
@@ -69,9 +71,10 @@ export class Connection {
69
71
  });
70
72
  }
71
73
 
72
- @trace((api, body) => ({ apiName: getApiName(api), body }))
74
+ @trace((api, body) => ({ message: getApiName(api), body }))
73
75
  public async sendRequest<Request, Response>(api: Api<Request, Response>, body: Request): Promise<Response> {
74
76
  const correlationId = this.nextCorrelationId();
77
+ const apiName = getApiName(api);
75
78
 
76
79
  const encoder = new Encoder()
77
80
  .writeInt16(api.apiKey)
@@ -79,18 +82,25 @@ export class Connection {
79
82
  .writeInt32(correlationId)
80
83
  .writeString(this.options.clientId);
81
84
 
82
- const request = api.request(encoder, body).value();
83
- const requestEncoder = new Encoder().writeInt32(request.length).write(request);
85
+ const request = api.request(encoder, body);
86
+ const requestEncoder = new Encoder().writeInt32(request.getByteLength()).writeEncoder(request);
84
87
 
88
+ let timeout: NodeJS.Timeout | undefined;
85
89
  const { responseDecoder, responseSize } = await new Promise<RawResonse>(async (resolve, reject) => {
90
+ timeout = setTimeout(() => {
91
+ delete this.queue[correlationId];
92
+ reject(new ConnectionError(`${apiName} timed out`));
93
+ }, 30_000);
94
+
86
95
  try {
87
- await this.write(requestEncoder.value());
88
96
  this.queue[correlationId] = { resolve, reject };
97
+ await this.write(requestEncoder.value());
89
98
  } catch (error) {
90
99
  reject(error);
91
100
  }
92
101
  });
93
- const response = api.response(responseDecoder);
102
+ clearTimeout(timeout);
103
+ const response = await api.response(responseDecoder);
94
104
 
95
105
  assert(
96
106
  responseDecoder.getOffset() - 4 === responseSize,
@@ -115,12 +125,13 @@ export class Connection {
115
125
  }
116
126
 
117
127
  private handleData(buffer: Buffer) {
118
- this.buffer = this.buffer ? Buffer.concat([this.buffer, buffer]) : buffer;
119
- if (this.buffer.length < 4) {
128
+ this.chunks.push(buffer);
129
+
130
+ const decoder = new Decoder(Buffer.concat(this.chunks));
131
+ if (decoder.getBufferLength() < 4) {
120
132
  return;
121
133
  }
122
134
 
123
- const decoder = new Decoder(this.buffer);
124
135
  const size = decoder.readInt32();
125
136
  if (size !== decoder.getBufferLength() - 4) {
126
137
  return;
@@ -128,15 +139,18 @@ export class Connection {
128
139
 
129
140
  const correlationId = decoder.readInt32();
130
141
 
131
- const { resolve } = this.queue[correlationId];
132
- delete this.queue[correlationId];
133
-
134
- resolve({ responseDecoder: decoder, responseSize: size });
135
- this.buffer = null;
142
+ const context = this.queue[correlationId];
143
+ if (context) {
144
+ delete this.queue[correlationId];
145
+ context.resolve({ responseDecoder: decoder, responseSize: size });
146
+ } else {
147
+ log.debug('Could not find pending request for correlationId', { correlationId });
148
+ }
149
+ this.chunks = [];
136
150
  }
137
151
 
138
152
  private nextCorrelationId() {
139
- return (this.lastCorrelationId = (this.lastCorrelationId + 1) % 2 ** 31);
153
+ return this.lastCorrelationId++;
140
154
  }
141
155
  }
142
156
 
@@ -1,11 +1,15 @@
1
+ import EventEmitter from 'events';
1
2
  import { API, API_ERROR } from '../api';
2
3
  import { KEY_TYPE } from '../api/find-coordinator';
3
4
  import { Assignment, MemberAssignment } from '../api/sync-group';
4
5
  import { Cluster } from '../cluster';
5
6
  import { KafkaTSApiError, KafkaTSError } from '../utils/error';
7
+ import { createTracer } from '../utils/tracer';
6
8
  import { ConsumerMetadata } from './consumer-metadata';
7
9
  import { OffsetManager } from './offset-manager';
8
10
 
11
+ const trace = createTracer('ConsumerGroup');
12
+
9
13
  type ConsumerGroupOptions = {
10
14
  cluster: Cluster;
11
15
  topics: string[];
@@ -17,7 +21,7 @@ type ConsumerGroupOptions = {
17
21
  offsetManager: OffsetManager;
18
22
  };
19
23
 
20
- export class ConsumerGroup {
24
+ export class ConsumerGroup extends EventEmitter<{ offsetCommit: [] }> {
21
25
  private coordinatorId = -1;
22
26
  private memberId = '';
23
27
  private generationId = -1;
@@ -26,10 +30,16 @@ export class ConsumerGroup {
26
30
  private heartbeatInterval: NodeJS.Timeout | null = null;
27
31
  private heartbeatError: KafkaTSError | null = null;
28
32
 
29
- constructor(private options: ConsumerGroupOptions) {}
33
+ constructor(private options: ConsumerGroupOptions) {
34
+ super();
35
+ }
30
36
 
37
+ @trace()
31
38
  public async join() {
32
39
  await this.findCoordinator();
40
+ await this.options.cluster.setSeedBroker(this.coordinatorId);
41
+
42
+ this.memberId = '';
33
43
  await this.joinGroup();
34
44
  await this.syncGroup();
35
45
  await this.offsetFetch();
@@ -53,12 +63,16 @@ export class ConsumerGroup {
53
63
  }
54
64
  }
55
65
 
56
- public async handleLastHeartbeat() {
66
+ public handleLastHeartbeat() {
57
67
  if (this.heartbeatError) {
58
68
  throw this.heartbeatError;
59
69
  }
60
70
  }
61
71
 
72
+ public resetHeartbeat() {
73
+ this.heartbeatError = null;
74
+ }
75
+
62
76
  private async findCoordinator() {
63
77
  const { coordinators } = await this.options.cluster.sendRequest(API.FIND_COORDINATOR, {
64
78
  keyType: KEY_TYPE.GROUP,
@@ -70,7 +84,7 @@ export class ConsumerGroup {
70
84
  private async joinGroup(): Promise<void> {
71
85
  const { cluster, groupId, groupInstanceId, sessionTimeoutMs, rebalanceTimeoutMs, topics } = this.options;
72
86
  try {
73
- const response = await cluster.sendRequestToNode(this.coordinatorId)(API.JOIN_GROUP, {
87
+ const response = await cluster.sendRequest(API.JOIN_GROUP, {
74
88
  groupId,
75
89
  groupInstanceId,
76
90
  memberId: this.memberId,
@@ -113,7 +127,7 @@ export class ConsumerGroup {
113
127
  assignments = Object.entries(memberAssignments).map(([memberId, assignment]) => ({ memberId, assignment }));
114
128
  }
115
129
 
116
- const response = await cluster.sendRequestToNode(this.coordinatorId)(API.SYNC_GROUP, {
130
+ const response = await cluster.sendRequest(API.SYNC_GROUP, {
117
131
  groupId,
118
132
  groupInstanceId,
119
133
  memberId: this.memberId,
@@ -144,31 +158,35 @@ export class ConsumerGroup {
144
158
  };
145
159
  if (!request.groups.length) return;
146
160
 
147
- const response = await cluster.sendRequestToNode(this.coordinatorId)(API.OFFSET_FETCH, request);
161
+ const response = await cluster.sendRequest(API.OFFSET_FETCH, request);
162
+
163
+ const topicPartitions: Record<string, Set<number>> = {};
148
164
  response.groups.forEach((group) => {
149
165
  group.topics.forEach((topic) => {
150
- topic.partitions
151
- .filter(({ committedOffset }) => committedOffset >= 0)
152
- .forEach(({ partitionIndex, committedOffset }) =>
153
- offsetManager.resolve(topic.name, partitionIndex, committedOffset),
154
- );
166
+ topicPartitions[topic.name] ??= new Set();
167
+ topic.partitions.forEach(({ partitionIndex, committedOffset }) => {
168
+ if (committedOffset >= 0) {
169
+ topicPartitions[topic.name].add(partitionIndex);
170
+ offsetManager.resolve(topic.name, partitionIndex, committedOffset);
171
+ }
172
+ });
155
173
  });
156
174
  });
157
- offsetManager.flush();
175
+ offsetManager.flush(topicPartitions);
158
176
  }
159
177
 
160
- public async offsetCommit() {
178
+ public async offsetCommit(topicPartitions: Record<string, Set<number>>) {
161
179
  const { cluster, groupId, groupInstanceId, offsetManager } = this.options;
162
180
  const request = {
163
181
  groupId,
164
182
  groupInstanceId,
165
183
  memberId: this.memberId,
166
184
  generationIdOrMemberEpoch: this.generationId,
167
- topics: Object.entries(offsetManager.pendingOffsets).map(([topic, partitions]) => ({
185
+ topics: Object.entries(topicPartitions).map(([topic, partitions]) => ({
168
186
  name: topic,
169
- partitions: Object.entries(partitions).map(([partition, offset]) => ({
170
- partitionIndex: parseInt(partition),
171
- committedOffset: offset,
187
+ partitions: [...partitions].map((partitionIndex) => ({
188
+ partitionIndex,
189
+ committedOffset: offsetManager.pendingOffsets[topic][partitionIndex],
172
190
  committedLeaderEpoch: -1,
173
191
  committedMetadata: null,
174
192
  })),
@@ -177,13 +195,13 @@ export class ConsumerGroup {
177
195
  if (!request.topics.length) {
178
196
  return;
179
197
  }
180
- await cluster.sendRequestToNode(this.coordinatorId)(API.OFFSET_COMMIT, request);
181
- offsetManager.flush();
198
+ await cluster.sendRequest(API.OFFSET_COMMIT, request);
199
+ this.emit('offsetCommit');
182
200
  }
183
201
 
184
202
  public async heartbeat() {
185
203
  const { cluster, groupId, groupInstanceId } = this.options;
186
- await cluster.sendRequestToNode(this.coordinatorId)(API.HEARTBEAT, {
204
+ await cluster.sendRequest(API.HEARTBEAT, {
187
205
  groupId,
188
206
  groupInstanceId,
189
207
  memberId: this.memberId,
@@ -192,10 +210,14 @@ export class ConsumerGroup {
192
210
  }
193
211
 
194
212
  public async leaveGroup() {
213
+ if (this.coordinatorId === -1) {
214
+ return;
215
+ }
216
+
195
217
  const { cluster, groupId, groupInstanceId } = this.options;
196
218
  this.stopHeartbeater();
197
219
  try {
198
- await cluster.sendRequestToNode(this.coordinatorId)(API.LEAVE_GROUP, {
220
+ await cluster.sendRequest(API.LEAVE_GROUP, {
199
221
  groupId,
200
222
  members: [{ memberId: this.memberId, groupInstanceId, reason: null }],
201
223
  });