kafka-ts 0.0.2-beta → 0.0.2

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 (195) hide show
  1. package/.prettierrc +3 -2
  2. package/README.md +109 -39
  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/package.json +30 -19
  118. package/src/__snapshots__/{request-handler.test.ts.snap → cluster.test.ts.snap} +329 -26
  119. package/src/api/api-versions.ts +2 -2
  120. package/src/api/create-topics.ts +2 -2
  121. package/src/api/delete-topics.ts +2 -2
  122. package/src/api/fetch.ts +86 -31
  123. package/src/api/find-coordinator.ts +2 -2
  124. package/src/api/heartbeat.ts +2 -2
  125. package/src/api/index.ts +21 -19
  126. package/src/api/init-producer-id.ts +2 -2
  127. package/src/api/join-group.ts +3 -3
  128. package/src/api/leave-group.ts +2 -2
  129. package/src/api/list-offsets.ts +3 -3
  130. package/src/api/metadata.ts +3 -3
  131. package/src/api/offset-commit.ts +2 -2
  132. package/src/api/offset-fetch.ts +2 -2
  133. package/src/api/produce.ts +17 -20
  134. package/src/api/sasl-authenticate.ts +2 -2
  135. package/src/api/sasl-handshake.ts +2 -2
  136. package/src/api/sync-group.ts +2 -2
  137. package/src/auth/index.ts +2 -0
  138. package/src/auth/plain.ts +10 -0
  139. package/src/auth/scram.ts +52 -0
  140. package/src/broker.ts +12 -14
  141. package/src/client.ts +7 -7
  142. package/src/cluster.test.ts +78 -74
  143. package/src/cluster.ts +43 -45
  144. package/src/codecs/gzip.ts +9 -0
  145. package/src/codecs/index.ts +16 -0
  146. package/src/codecs/none.ts +6 -0
  147. package/src/codecs/types.ts +4 -0
  148. package/src/connection.ts +49 -33
  149. package/src/consumer/consumer-group.ts +57 -35
  150. package/src/consumer/consumer-metadata.ts +2 -2
  151. package/src/consumer/consumer.ts +115 -92
  152. package/src/consumer/fetch-manager.ts +169 -0
  153. package/src/consumer/fetcher.ts +64 -0
  154. package/src/consumer/offset-manager.ts +24 -13
  155. package/src/consumer/processor.ts +53 -0
  156. package/src/distributors/assignments-to-replicas.test.ts +7 -7
  157. package/src/distributors/assignments-to-replicas.ts +2 -4
  158. package/src/distributors/messages-to-topic-partition-leaders.test.ts +6 -6
  159. package/src/distributors/partitioner.ts +27 -0
  160. package/src/index.ts +9 -3
  161. package/src/metadata.ts +8 -4
  162. package/src/producer/producer.ts +30 -20
  163. package/src/types.ts +5 -3
  164. package/src/utils/api.ts +5 -5
  165. package/src/utils/crypto.ts +15 -0
  166. package/src/utils/decoder.ts +14 -8
  167. package/src/utils/encoder.ts +34 -27
  168. package/src/utils/error.ts +3 -3
  169. package/src/utils/logger.ts +37 -0
  170. package/src/utils/murmur2.ts +44 -0
  171. package/src/utils/retrier.ts +1 -1
  172. package/src/utils/tracer.ts +41 -20
  173. package/tsconfig.json +16 -16
  174. package/.github/workflows/release.yml +0 -17
  175. package/certs/ca.crt +0 -29
  176. package/certs/ca.key +0 -52
  177. package/certs/ca.srl +0 -1
  178. package/certs/kafka.crt +0 -29
  179. package/certs/kafka.csr +0 -26
  180. package/certs/kafka.key +0 -52
  181. package/certs/kafka.keystore.jks +0 -0
  182. package/certs/kafka.truststore.jks +0 -0
  183. package/docker-compose.yml +0 -104
  184. package/examples/package-lock.json +0 -31
  185. package/examples/package.json +0 -14
  186. package/examples/src/client.ts +0 -9
  187. package/examples/src/consumer.ts +0 -17
  188. package/examples/src/create-topic.ts +0 -37
  189. package/examples/src/producer.ts +0 -24
  190. package/examples/src/replicator.ts +0 -25
  191. package/examples/src/utils/json.ts +0 -1
  192. package/examples/tsconfig.json +0 -7
  193. package/log4j.properties +0 -95
  194. package/scripts/generate-certs.sh +0 -24
  195. package/src/utils/debug.ts +0 -9
package/src/connection.ts CHANGED
@@ -1,14 +1,17 @@
1
- import assert from "assert";
2
- import net, { isIP, Socket, TcpSocketConnectOpts } from "net";
3
- import tls, { TLSSocketOptions } from "tls";
4
- import { getApiName } from "./api";
5
- import { Api } from "./utils/api";
6
- import { Decoder } from "./utils/decoder";
7
- import { Encoder } from "./utils/encoder";
8
- import { ConnectionError } from "./utils/error";
9
- import { trace } from "./utils/tracer";
10
-
11
- export type ConnectionOptions = {
1
+ import assert from 'assert';
2
+ import net, { isIP, Socket, TcpSocketConnectOpts } from 'net';
3
+ import tls, { TLSSocketOptions } from 'tls';
4
+ import { getApiName } from './api';
5
+ import { Api } from './utils/api';
6
+ import { Decoder } from './utils/decoder';
7
+ import { Encoder } from './utils/encoder';
8
+ import { ConnectionError } from './utils/error';
9
+ import { log } from './utils/logger';
10
+ import { createTracer } from './utils/tracer';
11
+
12
+ const trace = createTracer('Connection');
13
+
14
+ type ConnectionOptions = {
12
15
  clientId: string | null;
13
16
  connection: TcpSocketConnectOpts;
14
17
  ssl: TLSSocketOptions | null;
@@ -22,14 +25,14 @@ export class Connection {
22
25
  [correlationId: number]: { resolve: (response: RawResonse) => void; reject: (error: Error) => void };
23
26
  } = {};
24
27
  private lastCorrelationId = 0;
25
- private buffer: Buffer | null = null;
28
+ private chunks: Buffer[] = [];
26
29
 
27
30
  constructor(private options: ConnectionOptions) {}
28
31
 
29
32
  @trace()
30
33
  public async connect() {
31
34
  this.queue = {};
32
- this.buffer = null;
35
+ this.chunks = [];
33
36
 
34
37
  await new Promise<void>((resolve, reject) => {
35
38
  const { ssl, connection } = this.options;
@@ -44,19 +47,20 @@ export class Connection {
44
47
  resolve,
45
48
  )
46
49
  : net.connect(connection, resolve);
47
- this.socket.once("error", reject);
50
+ this.socket.once('error', reject);
48
51
  });
49
- this.socket.removeAllListeners("error");
52
+ this.socket.removeAllListeners('error');
50
53
 
51
- this.socket.on("data", (data) => this.handleData(data));
52
- this.socket.once("close", async () => {
54
+ this.socket.on('data', (data) => this.handleData(data));
55
+ this.socket.once('close', async () => {
53
56
  Object.values(this.queue).forEach(({ reject }) => {
54
- reject(new ConnectionError("Socket closed unexpectedly"));
57
+ reject(new ConnectionError('Socket closed unexpectedly'));
55
58
  });
56
59
  this.queue = {};
57
60
  });
58
61
  }
59
62
 
63
+ @trace()
60
64
  public disconnect() {
61
65
  this.socket.removeAllListeners();
62
66
  return new Promise<void>((resolve) => {
@@ -67,9 +71,10 @@ export class Connection {
67
71
  });
68
72
  }
69
73
 
70
- @trace((api, body) => ({ apiName: getApiName(api), body }))
74
+ @trace((api, body) => ({ message: getApiName(api), body }))
71
75
  public async sendRequest<Request, Response>(api: Api<Request, Response>, body: Request): Promise<Response> {
72
76
  const correlationId = this.nextCorrelationId();
77
+ const apiName = getApiName(api);
73
78
 
74
79
  const encoder = new Encoder()
75
80
  .writeInt16(api.apiKey)
@@ -77,18 +82,25 @@ export class Connection {
77
82
  .writeInt32(correlationId)
78
83
  .writeString(this.options.clientId);
79
84
 
80
- const request = api.request(encoder, body).value();
81
- 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);
82
87
 
88
+ let timeout: NodeJS.Timeout | undefined;
83
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
+
84
95
  try {
85
- await this.write(requestEncoder.value());
86
96
  this.queue[correlationId] = { resolve, reject };
97
+ await this.write(requestEncoder.value());
87
98
  } catch (error) {
88
99
  reject(error);
89
100
  }
90
101
  });
91
- const response = api.response(responseDecoder);
102
+ clearTimeout(timeout);
103
+ const response = await api.response(responseDecoder);
92
104
 
93
105
  assert(
94
106
  responseDecoder.getOffset() - 4 === responseSize,
@@ -100,7 +112,7 @@ export class Connection {
100
112
 
101
113
  private write(buffer: Buffer) {
102
114
  return new Promise<void>((resolve, reject) => {
103
- const { stack } = new Error("Write error");
115
+ const { stack } = new Error('Write error');
104
116
  this.socket.write(buffer, (error) => {
105
117
  if (error) {
106
118
  const err = new ConnectionError(error.message);
@@ -113,12 +125,13 @@ export class Connection {
113
125
  }
114
126
 
115
127
  private handleData(buffer: Buffer) {
116
- this.buffer = this.buffer ? Buffer.concat([this.buffer, buffer]) : buffer;
117
- 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) {
118
132
  return;
119
133
  }
120
134
 
121
- const decoder = new Decoder(this.buffer);
122
135
  const size = decoder.readInt32();
123
136
  if (size !== decoder.getBufferLength() - 4) {
124
137
  return;
@@ -126,15 +139,18 @@ export class Connection {
126
139
 
127
140
  const correlationId = decoder.readInt32();
128
141
 
129
- const { resolve } = this.queue[correlationId];
130
- delete this.queue[correlationId];
131
-
132
- resolve({ responseDecoder: decoder, responseSize: size });
133
- 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 = [];
134
150
  }
135
151
 
136
152
  private nextCorrelationId() {
137
- return (this.lastCorrelationId = (this.lastCorrelationId + 1) % 2 ** 31);
153
+ return this.lastCorrelationId++;
138
154
  }
139
155
  }
140
156
 
@@ -1,10 +1,14 @@
1
- import { API, API_ERROR } from "../api";
2
- import { KEY_TYPE } from "../api/find-coordinator";
3
- import { Assignment, MemberAssignment } from "../api/sync-group";
4
- import { Cluster } from "../cluster";
5
- import { KafkaTSApiError, KafkaTSError } from "../utils/error";
6
- import { ConsumerMetadata } from "./consumer-metadata";
7
- import { OffsetManager } from "./offset-manager";
1
+ import EventEmitter from 'events';
2
+ import { API, API_ERROR } from '../api';
3
+ import { KEY_TYPE } from '../api/find-coordinator';
4
+ import { Assignment, MemberAssignment } from '../api/sync-group';
5
+ import { Cluster } from '../cluster';
6
+ import { KafkaTSApiError, KafkaTSError } from '../utils/error';
7
+ import { createTracer } from '../utils/tracer';
8
+ import { ConsumerMetadata } from './consumer-metadata';
9
+ import { OffsetManager } from './offset-manager';
10
+
11
+ const trace = createTracer('ConsumerGroup');
8
12
 
9
13
  type ConsumerGroupOptions = {
10
14
  cluster: Cluster;
@@ -17,19 +21,25 @@ 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
- private memberId = "";
26
+ private memberId = '';
23
27
  private generationId = -1;
24
- private leaderId = "";
28
+ private leaderId = '';
25
29
  private memberIds: string[] = [];
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,14 +84,14 @@ 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,
77
91
  sessionTimeoutMs,
78
92
  rebalanceTimeoutMs,
79
- protocolType: "consumer",
80
- protocols: [{ name: "RoundRobinAssigner", metadata: { version: 0, topics } }],
93
+ protocolType: 'consumer',
94
+ protocols: [{ name: 'RoundRobinAssigner', metadata: { version: 0, topics } }],
81
95
  reason: null,
82
96
  });
83
97
  this.memberId = response.memberId;
@@ -113,16 +127,16 @@ 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,
120
134
  generationId: this.generationId,
121
- protocolType: "consumer",
122
- protocolName: "RoundRobinAssigner",
135
+ protocolType: 'consumer',
136
+ protocolName: 'RoundRobinAssigner',
123
137
  assignments,
124
138
  });
125
- metadata.setAssignment(JSON.parse(response.assignments || "{}") as Assignment);
139
+ metadata.setAssignment(JSON.parse(response.assignments || '{}') as Assignment);
126
140
  }
127
141
 
128
142
  private async offsetFetch() {
@@ -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
  });
@@ -1,5 +1,5 @@
1
- import { Assignment } from "../api/sync-group";
2
- import { Metadata } from "../metadata";
1
+ import { Assignment } from '../api/sync-group';
2
+ import { Metadata } from '../metadata';
3
3
 
4
4
  export class ConsumerMetadata extends Metadata {
5
5
  private assignment: Assignment = {};
@@ -1,15 +1,20 @@
1
- import { API, API_ERROR } from "../api";
2
- import { IsolationLevel } from "../api/fetch";
3
- import { Assignment } from "../api/sync-group";
4
- import { Cluster } from "../cluster";
5
- import { distributeAssignmentsToNodes } from "../distributors/assignments-to-replicas";
6
- import { Message } from "../types";
7
- import { delay } from "../utils/delay";
8
- import { ConnectionError, KafkaTSApiError } from "../utils/error";
9
- import { defaultRetrier, Retrier } from "../utils/retrier";
10
- import { ConsumerGroup } from "./consumer-group";
11
- import { ConsumerMetadata } from "./consumer-metadata";
12
- import { OffsetManager } from "./offset-manager";
1
+ import EventEmitter from 'events';
2
+ import { API, API_ERROR } from '../api';
3
+ import { IsolationLevel } from '../api/fetch';
4
+ import { Assignment } from '../api/sync-group';
5
+ import { Cluster } from '../cluster';
6
+ import { distributeMessagesToTopicPartitionLeaders } from '../distributors/messages-to-topic-partition-leaders';
7
+ import { Message } from '../types';
8
+ import { delay } from '../utils/delay';
9
+ import { ConnectionError, KafkaTSApiError } from '../utils/error';
10
+ import { log } from '../utils/logger';
11
+ import { createTracer } from '../utils/tracer';
12
+ import { ConsumerGroup } from './consumer-group';
13
+ import { ConsumerMetadata } from './consumer-metadata';
14
+ import { BatchGranularity, FetchManager } from './fetch-manager';
15
+ import { OffsetManager } from './offset-manager';
16
+
17
+ const trace = createTracer('Consumer');
13
18
 
14
19
  export type ConsumerOptions = {
15
20
  topics: string[];
@@ -25,35 +30,40 @@ export type ConsumerOptions = {
25
30
  partitionMaxBytes?: number;
26
31
  allowTopicAutoCreation?: boolean;
27
32
  fromBeginning?: boolean;
28
- retrier?: Retrier;
29
- } & ({ onMessage: (message: Message) => unknown } | { onBatch: (messages: Message[]) => unknown });
33
+ batchGranularity?: BatchGranularity;
34
+ concurrency?: number;
35
+ } & ({ onBatch: (messages: Required<Message>[]) => unknown } | { onMessage: (message: Required<Message>) => unknown });
30
36
 
31
- export class Consumer {
37
+ export class Consumer extends EventEmitter<{ offsetCommit: [] }> {
32
38
  private options: Required<ConsumerOptions>;
33
39
  private metadata: ConsumerMetadata;
34
40
  private consumerGroup: ConsumerGroup | undefined;
35
41
  private offsetManager: OffsetManager;
42
+ private fetchManager?: FetchManager;
36
43
  private stopHook: (() => void) | undefined;
37
44
 
38
45
  constructor(
39
46
  private cluster: Cluster,
40
47
  options: ConsumerOptions,
41
48
  ) {
49
+ super();
50
+
42
51
  this.options = {
43
52
  ...options,
44
53
  groupId: options.groupId ?? null,
45
54
  groupInstanceId: options.groupInstanceId ?? null,
46
- rackId: options.rackId ?? "",
55
+ rackId: options.rackId ?? '',
47
56
  sessionTimeoutMs: options.sessionTimeoutMs ?? 30_000,
48
57
  rebalanceTimeoutMs: options.rebalanceTimeoutMs ?? 60_000,
49
58
  maxWaitMs: options.maxWaitMs ?? 5000,
50
59
  minBytes: options.minBytes ?? 1,
51
- maxBytes: options.maxBytes ?? 1_000_000,
52
- partitionMaxBytes: options.partitionMaxBytes ?? 1_000_000,
60
+ maxBytes: options.maxBytes ?? 1_048_576,
61
+ partitionMaxBytes: options.partitionMaxBytes ?? 1_048_576,
53
62
  isolationLevel: options.isolationLevel ?? IsolationLevel.READ_UNCOMMITTED,
54
63
  allowTopicAutoCreation: options.allowTopicAutoCreation ?? false,
55
64
  fromBeginning: options.fromBeginning ?? false,
56
- retrier: options.retrier ?? defaultRetrier,
65
+ batchGranularity: options.batchGranularity ?? 'partition',
66
+ concurrency: options.concurrency ?? 1,
57
67
  };
58
68
 
59
69
  this.metadata = new ConsumerMetadata({ cluster: this.cluster });
@@ -74,8 +84,10 @@ export class Consumer {
74
84
  offsetManager: this.offsetManager,
75
85
  })
76
86
  : undefined;
87
+ this.consumerGroup?.on('offsetCommit', () => this.emit('offsetCommit'));
77
88
  }
78
89
 
90
+ @trace()
79
91
  public async start(): Promise<void> {
80
92
  const { topics, allowTopicAutoCreation, fromBeginning } = this.options;
81
93
 
@@ -88,88 +100,82 @@ export class Consumer {
88
100
  await this.offsetManager.fetchOffsets({ fromBeginning });
89
101
  await this.consumerGroup?.join();
90
102
  } catch (error) {
91
- console.error(error);
92
- console.debug(`Restarting consumer in 1 second...`);
103
+ log.warn('Failed to start consumer', error);
104
+ log.debug(`Restarting consumer in 1 second...`);
93
105
  await delay(1000);
94
106
 
95
107
  if (this.stopHook) return (this.stopHook as () => void)();
96
108
  return this.close(true).then(() => this.start());
97
109
  }
98
- this.fetchLoop();
110
+ this.startFetchManager();
99
111
  }
100
112
 
101
- private fetchLoop = async () => {
102
- const { options } = this;
103
- const { retrier } = options;
113
+ @trace()
114
+ public async close(force = false): Promise<void> {
115
+ if (!force) {
116
+ await new Promise<void>(async (resolve) => {
117
+ this.stopHook = resolve;
118
+ await this.fetchManager?.stop();
119
+ });
120
+ }
121
+ await this.consumerGroup?.leaveGroup().catch((error) => log.debug(`Failed to leave group: ${error.message}`));
122
+ await this.cluster.disconnect().catch((error) => log.debug(`Failed to disconnect: ${error.message}`));
123
+ }
104
124
 
105
- let nodeAssignments: { nodeId: number; assignment: Assignment }[] = [];
106
- let shouldReassign = true;
125
+ private async startFetchManager() {
126
+ const { batchGranularity, concurrency } = this.options;
107
127
 
108
128
  while (!this.stopHook) {
109
- if (shouldReassign || !nodeAssignments) {
110
- nodeAssignments = Object.entries(
111
- distributeAssignmentsToNodes(
112
- this.metadata.getAssignment(),
113
- this.metadata.getTopicPartitionReplicaIds(),
129
+ this.consumerGroup?.resetHeartbeat();
130
+
131
+ // TODO: If leader is not available, find another read replica
132
+ const nodeAssignments = Object.entries(
133
+ distributeMessagesToTopicPartitionLeaders(
134
+ Object.entries(this.metadata.getAssignment()).flatMap(([topic, partitions]) =>
135
+ partitions.map((partition) => ({ topic, partition })),
114
136
  ),
115
- ).map(([nodeId, assignment]) => ({ nodeId: parseInt(nodeId), assignment }));
116
- shouldReassign = false;
117
- }
137
+ this.metadata.getTopicPartitionLeaderIds(),
138
+ ),
139
+ ).map(([nodeId, assignment]) => ({
140
+ nodeId: parseInt(nodeId),
141
+ assignment: Object.fromEntries(
142
+ Object.entries(assignment).map(([topic, partitions]) => [
143
+ topic,
144
+ Object.keys(partitions).map(Number),
145
+ ]),
146
+ ),
147
+ }));
148
+
149
+ const numPartitions = Object.values(this.metadata.getAssignment()).flat().length;
150
+ const numProcessors = Math.min(concurrency, numPartitions);
151
+
152
+ this.fetchManager = new FetchManager({
153
+ fetch: this.fetch.bind(this),
154
+ process: this.process.bind(this),
155
+ metadata: this.metadata,
156
+ consumerGroup: this.consumerGroup,
157
+ nodeAssignments,
158
+ batchGranularity,
159
+ concurrency: numProcessors,
160
+ });
118
161
 
119
162
  try {
120
- for (const { nodeId, assignment } of nodeAssignments) {
121
- const batch = await this.fetch(nodeId, assignment);
122
- const messages = batch.responses.flatMap(({ topicId, partitions }) =>
123
- partitions.flatMap(({ partitionIndex, records }) =>
124
- records.flatMap(({ baseTimestamp, baseOffset, records }) =>
125
- records.map(
126
- (message): Required<Message> => ({
127
- topic: this.metadata.getTopicNameById(topicId),
128
- partition: partitionIndex,
129
- key: message.key ?? null,
130
- value: message.value ?? null,
131
- headers: Object.fromEntries(
132
- message.headers.map(({ key, value }) => [key, value]),
133
- ),
134
- timestamp: baseTimestamp + BigInt(message.timestampDelta),
135
- offset: baseOffset + BigInt(message.offsetDelta),
136
- }),
137
- ),
138
- ),
139
- ),
140
- );
141
-
142
- if ("onBatch" in options) {
143
- await retrier(() => options.onBatch(messages));
144
-
145
- messages.forEach(({ topic, partition, offset }) =>
146
- this.offsetManager.resolve(topic, partition, offset + 1n),
147
- );
148
- } else if ("onMessage" in options) {
149
- for (const message of messages) {
150
- await retrier(() => options.onMessage(message));
151
-
152
- const { topic, partition, offset } = message;
153
- this.offsetManager.resolve(topic, partition, offset + 1n);
154
- }
155
- }
156
- await this.consumerGroup?.offsetCommit();
157
- await this.consumerGroup?.handleLastHeartbeat();
158
- }
163
+ await this.fetchManager.start();
159
164
 
160
165
  if (!nodeAssignments.length) {
161
- console.debug("No partitions assigned. Waiting for reassignment...");
166
+ log.debug('No partitions assigned. Waiting for reassignment...');
162
167
  await delay(this.options.maxWaitMs);
163
- await this.consumerGroup?.handleLastHeartbeat();
168
+ this.consumerGroup?.handleLastHeartbeat();
164
169
  }
165
170
  } catch (error) {
171
+ await this.fetchManager.stop();
172
+
166
173
  if ((error as KafkaTSApiError).errorCode === API_ERROR.REBALANCE_IN_PROGRESS) {
167
- console.debug("Rebalance in progress...");
168
- shouldReassign = true;
174
+ log.debug('Rebalance in progress...');
169
175
  continue;
170
176
  }
171
177
  if ((error as KafkaTSApiError).errorCode === API_ERROR.FENCED_INSTANCE_ID) {
172
- console.debug("New consumer with the same groupInstanceId joined. Exiting the consumer...");
178
+ log.debug('New consumer with the same groupInstanceId joined. Exiting the consumer...');
173
179
  this.close();
174
180
  break;
175
181
  }
@@ -177,28 +183,45 @@ export class Consumer {
177
183
  error instanceof ConnectionError ||
178
184
  (error instanceof KafkaTSApiError && error.errorCode === API_ERROR.NOT_COORDINATOR)
179
185
  ) {
180
- console.debug(`${error.message}. Restarting consumer...`);
186
+ log.debug(`${error.message}. Restarting consumer...`);
181
187
  this.close().then(() => this.start());
182
188
  break;
183
189
  }
184
- console.error(error);
185
- await this.consumerGroup?.offsetCommit();
190
+ log.error((error as Error).message, error);
191
+ this.close();
186
192
  break;
187
193
  }
188
194
  }
189
195
  this.stopHook?.();
190
- };
196
+ }
191
197
 
192
- public async close(force = false): Promise<void> {
193
- if (!force) {
194
- await new Promise<void>((resolve) => {
195
- this.stopHook = resolve;
196
- });
198
+ @trace((messages) => ({ count: messages.length }))
199
+ private async process(messages: Required<Message>[]) {
200
+ const { options } = this;
201
+
202
+ const topicPartitions: Record<string, Set<number>> = {};
203
+ for (const { topic, partition } of messages) {
204
+ topicPartitions[topic] ??= new Set();
205
+ topicPartitions[topic].add(partition);
197
206
  }
198
- await this.consumerGroup
199
- ?.leaveGroup()
200
- .catch((error) => console.warn(`Failed to leave group: ${error.message}`));
201
- await this.cluster.disconnect().catch((error) => console.warn(`Failed to disconnect: ${error.message}`));
207
+
208
+ if ('onBatch' in options) {
209
+ await options.onBatch(messages);
210
+
211
+ messages.forEach(({ topic, partition, offset }) =>
212
+ this.offsetManager.resolve(topic, partition, offset + 1n),
213
+ );
214
+ } else if ('onMessage' in options) {
215
+ for (const message of messages) {
216
+ await options.onMessage(message);
217
+
218
+ const { topic, partition, offset } = message;
219
+ this.offsetManager.resolve(topic, partition, offset + 1n);
220
+ }
221
+ }
222
+
223
+ await this.consumerGroup?.offsetCommit(topicPartitions);
224
+ this.offsetManager.flush(topicPartitions);
202
225
  }
203
226
 
204
227
  private fetch(nodeId: number, assignment: Assignment) {