kafka-ts 0.0.1-beta → 0.0.3-beta

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 (188) hide show
  1. package/.github/workflows/release.yml +17 -0
  2. package/.prettierrc +3 -2
  3. package/LICENSE +1 -1
  4. package/README.md +48 -35
  5. package/docker-compose.yml +102 -102
  6. package/examples/package-lock.json +28 -27
  7. package/examples/package.json +12 -12
  8. package/examples/src/client.ts +6 -6
  9. package/examples/src/consumer.ts +9 -8
  10. package/examples/src/create-topic.ts +23 -16
  11. package/examples/src/producer.ts +7 -7
  12. package/examples/src/replicator.ts +4 -4
  13. package/examples/src/utils/delay.ts +1 -0
  14. package/examples/src/utils/json.ts +1 -1
  15. package/examples/tsconfig.json +2 -2
  16. package/package.json +21 -15
  17. package/src/__snapshots__/request-handler.test.ts.snap +9 -718
  18. package/src/api/api-versions.ts +2 -2
  19. package/src/api/create-topics.ts +2 -2
  20. package/src/api/delete-topics.ts +2 -2
  21. package/src/api/fetch.ts +3 -3
  22. package/src/api/find-coordinator.ts +2 -2
  23. package/src/api/heartbeat.ts +2 -2
  24. package/src/api/index.ts +18 -18
  25. package/src/api/init-producer-id.ts +2 -2
  26. package/src/api/join-group.ts +3 -3
  27. package/src/api/leave-group.ts +2 -2
  28. package/src/api/list-offsets.ts +3 -3
  29. package/src/api/metadata.ts +3 -3
  30. package/src/api/offset-commit.ts +2 -2
  31. package/src/api/offset-fetch.ts +2 -2
  32. package/src/api/produce.ts +3 -3
  33. package/src/api/sasl-authenticate.ts +2 -2
  34. package/src/api/sasl-handshake.ts +2 -2
  35. package/src/api/sync-group.ts +2 -2
  36. package/src/broker.ts +9 -9
  37. package/src/client.ts +6 -6
  38. package/src/{request-handler.test.ts → cluster.test.ts} +72 -69
  39. package/src/cluster.ts +7 -7
  40. package/src/connection.ts +17 -15
  41. package/src/consumer/consumer-group.ts +14 -14
  42. package/src/consumer/consumer-metadata.ts +2 -2
  43. package/src/consumer/consumer.ts +84 -82
  44. package/src/consumer/fetch-manager.ts +179 -0
  45. package/src/consumer/fetcher.ts +57 -0
  46. package/src/consumer/offset-manager.ts +6 -6
  47. package/src/consumer/processor.ts +47 -0
  48. package/src/distributors/assignments-to-replicas.test.ts +7 -7
  49. package/src/distributors/assignments-to-replicas.ts +1 -1
  50. package/src/distributors/messages-to-topic-partition-leaders.test.ts +6 -6
  51. package/src/index.ts +4 -3
  52. package/src/metadata.ts +4 -4
  53. package/src/producer/producer.ts +8 -8
  54. package/src/types.ts +2 -0
  55. package/src/utils/api.ts +4 -4
  56. package/src/utils/debug.ts +2 -2
  57. package/src/utils/decoder.ts +4 -4
  58. package/src/utils/encoder.ts +6 -6
  59. package/src/utils/error.ts +3 -3
  60. package/src/utils/retrier.ts +1 -1
  61. package/src/utils/tracer.ts +7 -4
  62. package/tsconfig.json +16 -16
  63. package/dist/api/api-versions.d.ts +0 -9
  64. package/dist/api/api-versions.js +0 -24
  65. package/dist/api/create-topics.d.ts +0 -38
  66. package/dist/api/create-topics.js +0 -53
  67. package/dist/api/delete-topics.d.ts +0 -18
  68. package/dist/api/delete-topics.js +0 -33
  69. package/dist/api/fetch.d.ts +0 -77
  70. package/dist/api/fetch.js +0 -106
  71. package/dist/api/find-coordinator.d.ts +0 -21
  72. package/dist/api/find-coordinator.js +0 -39
  73. package/dist/api/heartbeat.d.ts +0 -11
  74. package/dist/api/heartbeat.js +0 -27
  75. package/dist/api/index.d.ts +0 -573
  76. package/dist/api/index.js +0 -164
  77. package/dist/api/init-producer-id.d.ts +0 -13
  78. package/dist/api/init-producer-id.js +0 -29
  79. package/dist/api/join-group.d.ts +0 -34
  80. package/dist/api/join-group.js +0 -51
  81. package/dist/api/leave-group.d.ts +0 -19
  82. package/dist/api/leave-group.js +0 -39
  83. package/dist/api/list-offsets.d.ts +0 -29
  84. package/dist/api/list-offsets.js +0 -48
  85. package/dist/api/metadata.d.ts +0 -40
  86. package/dist/api/metadata.js +0 -58
  87. package/dist/api/offset-commit.d.ts +0 -28
  88. package/dist/api/offset-commit.js +0 -48
  89. package/dist/api/offset-fetch.d.ts +0 -33
  90. package/dist/api/offset-fetch.js +0 -57
  91. package/dist/api/produce.d.ts +0 -53
  92. package/dist/api/produce.js +0 -129
  93. package/dist/api/sasl-authenticate.d.ts +0 -11
  94. package/dist/api/sasl-authenticate.js +0 -23
  95. package/dist/api/sasl-handshake.d.ts +0 -6
  96. package/dist/api/sasl-handshake.js +0 -19
  97. package/dist/api/sync-group.d.ts +0 -24
  98. package/dist/api/sync-group.js +0 -36
  99. package/dist/broker.d.ts +0 -29
  100. package/dist/broker.js +0 -60
  101. package/dist/client.d.ts +0 -23
  102. package/dist/client.js +0 -36
  103. package/dist/cluster.d.ts +0 -24
  104. package/dist/cluster.js +0 -72
  105. package/dist/connection.d.ts +0 -25
  106. package/dist/connection.js +0 -155
  107. package/dist/consumer/consumer-group.d.ts +0 -36
  108. package/dist/consumer/consumer-group.js +0 -182
  109. package/dist/consumer/consumer-metadata.d.ts +0 -7
  110. package/dist/consumer/consumer-metadata.js +0 -14
  111. package/dist/consumer/consumer.d.ts +0 -37
  112. package/dist/consumer/consumer.js +0 -178
  113. package/dist/consumer/metadata.d.ts +0 -24
  114. package/dist/consumer/metadata.js +0 -64
  115. package/dist/consumer/offset-manager.d.ts +0 -22
  116. package/dist/consumer/offset-manager.js +0 -56
  117. package/dist/distributors/assignments-to-replicas.d.ts +0 -17
  118. package/dist/distributors/assignments-to-replicas.js +0 -60
  119. package/dist/distributors/assignments-to-replicas.test.d.ts +0 -1
  120. package/dist/distributors/assignments-to-replicas.test.js +0 -40
  121. package/dist/distributors/messages-to-topic-partition-leaders.d.ts +0 -17
  122. package/dist/distributors/messages-to-topic-partition-leaders.js +0 -15
  123. package/dist/distributors/messages-to-topic-partition-leaders.test.d.ts +0 -1
  124. package/dist/distributors/messages-to-topic-partition-leaders.test.js +0 -30
  125. package/dist/examples/src/replicator.js +0 -34
  126. package/dist/examples/src/utils/json.js +0 -5
  127. package/dist/index.d.ts +0 -3
  128. package/dist/index.js +0 -19
  129. package/dist/metadata.d.ts +0 -24
  130. package/dist/metadata.js +0 -89
  131. package/dist/producer/producer.d.ts +0 -19
  132. package/dist/producer/producer.js +0 -111
  133. package/dist/request-handler.d.ts +0 -16
  134. package/dist/request-handler.js +0 -67
  135. package/dist/request-handler.test.d.ts +0 -1
  136. package/dist/request-handler.test.js +0 -340
  137. package/dist/src/api/api-versions.js +0 -18
  138. package/dist/src/api/create-topics.js +0 -46
  139. package/dist/src/api/delete-topics.js +0 -26
  140. package/dist/src/api/fetch.js +0 -95
  141. package/dist/src/api/find-coordinator.js +0 -34
  142. package/dist/src/api/heartbeat.js +0 -22
  143. package/dist/src/api/index.js +0 -38
  144. package/dist/src/api/init-producer-id.js +0 -24
  145. package/dist/src/api/join-group.js +0 -48
  146. package/dist/src/api/leave-group.js +0 -30
  147. package/dist/src/api/list-offsets.js +0 -39
  148. package/dist/src/api/metadata.js +0 -47
  149. package/dist/src/api/offset-commit.js +0 -39
  150. package/dist/src/api/offset-fetch.js +0 -44
  151. package/dist/src/api/produce.js +0 -119
  152. package/dist/src/api/sync-group.js +0 -31
  153. package/dist/src/broker.js +0 -35
  154. package/dist/src/connection.js +0 -21
  155. package/dist/src/consumer/consumer-group.js +0 -131
  156. package/dist/src/consumer/consumer.js +0 -103
  157. package/dist/src/consumer/metadata.js +0 -52
  158. package/dist/src/consumer/offset-manager.js +0 -23
  159. package/dist/src/index.js +0 -19
  160. package/dist/src/producer/producer.js +0 -84
  161. package/dist/src/request-handler.js +0 -57
  162. package/dist/src/request-handler.test.js +0 -321
  163. package/dist/src/types.js +0 -2
  164. package/dist/src/utils/api.js +0 -5
  165. package/dist/src/utils/decoder.js +0 -161
  166. package/dist/src/utils/encoder.js +0 -137
  167. package/dist/src/utils/error.js +0 -10
  168. package/dist/types.d.ts +0 -9
  169. package/dist/types.js +0 -2
  170. package/dist/utils/api.d.ts +0 -9
  171. package/dist/utils/api.js +0 -5
  172. package/dist/utils/debug.d.ts +0 -2
  173. package/dist/utils/debug.js +0 -11
  174. package/dist/utils/decoder.d.ts +0 -29
  175. package/dist/utils/decoder.js +0 -147
  176. package/dist/utils/delay.d.ts +0 -1
  177. package/dist/utils/delay.js +0 -5
  178. package/dist/utils/encoder.d.ts +0 -28
  179. package/dist/utils/encoder.js +0 -122
  180. package/dist/utils/error.d.ts +0 -11
  181. package/dist/utils/error.js +0 -27
  182. package/dist/utils/memo.d.ts +0 -1
  183. package/dist/utils/memo.js +0 -16
  184. package/dist/utils/retrier.d.ts +0 -10
  185. package/dist/utils/retrier.js +0 -22
  186. package/dist/utils/tracer.d.ts +0 -1
  187. package/dist/utils/tracer.js +0 -26
  188. package/examples/node_modules/.package-lock.json +0 -22
package/src/cluster.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { TcpSocketConnectOpts } from "net";
2
- import { TLSSocketOptions } from "tls";
3
- import { API } from "./api";
4
- import { Broker, SASLOptions } from "./broker";
5
- import { SendRequest } from "./connection";
6
- import { ConnectionError, KafkaTSError } from "./utils/error";
1
+ import { TcpSocketConnectOpts } from 'net';
2
+ import { TLSSocketOptions } from 'tls';
3
+ import { API } from './api';
4
+ import { Broker, SASLOptions } from './broker';
5
+ import { SendRequest } from './connection';
6
+ import { ConnectionError, KafkaTSError } from './utils/error';
7
7
 
8
8
  type ClusterOptions = {
9
9
  clientId: string | null;
@@ -82,6 +82,6 @@ export class Cluster {
82
82
  console.warn(`Failed to connect to seed broker ${options.host}:${options.port}`, error);
83
83
  }
84
84
  }
85
- throw new KafkaTSError("No seed brokers found");
85
+ throw new KafkaTSError('No seed brokers found');
86
86
  }
87
87
  }
package/src/connection.ts CHANGED
@@ -1,12 +1,14 @@
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";
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 { createTracer } from './utils/tracer';
10
+
11
+ const trace = createTracer('Connection');
10
12
 
11
13
  export type ConnectionOptions = {
12
14
  clientId: string | null;
@@ -44,14 +46,14 @@ export class Connection {
44
46
  resolve,
45
47
  )
46
48
  : net.connect(connection, resolve);
47
- this.socket.once("error", reject);
49
+ this.socket.once('error', reject);
48
50
  });
49
- this.socket.removeAllListeners("error");
51
+ this.socket.removeAllListeners('error');
50
52
 
51
- this.socket.on("data", (data) => this.handleData(data));
52
- this.socket.once("close", async () => {
53
+ this.socket.on('data', (data) => this.handleData(data));
54
+ this.socket.once('close', async () => {
53
55
  Object.values(this.queue).forEach(({ reject }) => {
54
- reject(new ConnectionError("Socket closed unexpectedly"));
56
+ reject(new ConnectionError('Socket closed unexpectedly'));
55
57
  });
56
58
  this.queue = {};
57
59
  });
@@ -100,7 +102,7 @@ export class Connection {
100
102
 
101
103
  private write(buffer: Buffer) {
102
104
  return new Promise<void>((resolve, reject) => {
103
- const { stack } = new Error("Write error");
105
+ const { stack } = new Error('Write error');
104
106
  this.socket.write(buffer, (error) => {
105
107
  if (error) {
106
108
  const err = new ConnectionError(error.message);
@@ -1,10 +1,10 @@
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 { 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';
8
8
 
9
9
  type ConsumerGroupOptions = {
10
10
  cluster: Cluster;
@@ -19,9 +19,9 @@ type ConsumerGroupOptions = {
19
19
 
20
20
  export class ConsumerGroup {
21
21
  private coordinatorId = -1;
22
- private memberId = "";
22
+ private memberId = '';
23
23
  private generationId = -1;
24
- private leaderId = "";
24
+ private leaderId = '';
25
25
  private memberIds: string[] = [];
26
26
  private heartbeatInterval: NodeJS.Timeout | null = null;
27
27
  private heartbeatError: KafkaTSError | null = null;
@@ -76,8 +76,8 @@ export class ConsumerGroup {
76
76
  memberId: this.memberId,
77
77
  sessionTimeoutMs,
78
78
  rebalanceTimeoutMs,
79
- protocolType: "consumer",
80
- protocols: [{ name: "RoundRobinAssigner", metadata: { version: 0, topics } }],
79
+ protocolType: 'consumer',
80
+ protocols: [{ name: 'RoundRobinAssigner', metadata: { version: 0, topics } }],
81
81
  reason: null,
82
82
  });
83
83
  this.memberId = response.memberId;
@@ -118,11 +118,11 @@ export class ConsumerGroup {
118
118
  groupInstanceId,
119
119
  memberId: this.memberId,
120
120
  generationId: this.generationId,
121
- protocolType: "consumer",
122
- protocolName: "RoundRobinAssigner",
121
+ protocolType: 'consumer',
122
+ protocolName: 'RoundRobinAssigner',
123
123
  assignments,
124
124
  });
125
- metadata.setAssignment(JSON.parse(response.assignments || "{}") as Assignment);
125
+ metadata.setAssignment(JSON.parse(response.assignments || '{}') as Assignment);
126
126
  }
127
127
 
128
128
  private async offsetFetch() {
@@ -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,16 @@
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 { 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 { FetchManager, Granularity } from './fetch-manager';
13
+ import { OffsetManager } from './offset-manager';
13
14
 
14
15
  export type ConsumerOptions = {
15
16
  topics: string[];
@@ -26,13 +27,16 @@ export type ConsumerOptions = {
26
27
  allowTopicAutoCreation?: boolean;
27
28
  fromBeginning?: boolean;
28
29
  retrier?: Retrier;
29
- } & ({ onMessage: (message: Message) => unknown } | { onBatch: (messages: Message[]) => unknown });
30
+ granularity?: Granularity;
31
+ concurrency?: number;
32
+ } & ({ onBatch: (messages: Required<Message>[]) => unknown } | { onMessage: (message: Required<Message>) => unknown });
30
33
 
31
34
  export class Consumer {
32
35
  private options: Required<ConsumerOptions>;
33
36
  private metadata: ConsumerMetadata;
34
37
  private consumerGroup: ConsumerGroup | undefined;
35
38
  private offsetManager: OffsetManager;
39
+ private fetchManager?: FetchManager;
36
40
  private stopHook: (() => void) | undefined;
37
41
 
38
42
  constructor(
@@ -43,7 +47,7 @@ export class Consumer {
43
47
  ...options,
44
48
  groupId: options.groupId ?? null,
45
49
  groupInstanceId: options.groupInstanceId ?? null,
46
- rackId: options.rackId ?? "",
50
+ rackId: options.rackId ?? '',
47
51
  sessionTimeoutMs: options.sessionTimeoutMs ?? 30_000,
48
52
  rebalanceTimeoutMs: options.rebalanceTimeoutMs ?? 60_000,
49
53
  maxWaitMs: options.maxWaitMs ?? 5000,
@@ -54,6 +58,8 @@ export class Consumer {
54
58
  allowTopicAutoCreation: options.allowTopicAutoCreation ?? false,
55
59
  fromBeginning: options.fromBeginning ?? false,
56
60
  retrier: options.retrier ?? defaultRetrier,
61
+ granularity: options.granularity ?? 'broker',
62
+ concurrency: options.concurrency ?? 1,
57
63
  };
58
64
 
59
65
  this.metadata = new ConsumerMetadata({ cluster: this.cluster });
@@ -95,81 +101,63 @@ export class Consumer {
95
101
  if (this.stopHook) return (this.stopHook as () => void)();
96
102
  return this.close(true).then(() => this.start());
97
103
  }
98
- this.fetchLoop();
104
+ setImmediate(() => this.startFetchManager());
99
105
  }
100
106
 
101
- private fetchLoop = async () => {
102
- const { options } = this;
103
- const { retrier } = options;
107
+ public async close(force = false): Promise<void> {
108
+ if (!force) {
109
+ await new Promise<void>(async (resolve) => {
110
+ this.stopHook = resolve;
111
+ await this.fetchManager?.stop();
112
+ });
113
+ }
114
+ await this.consumerGroup
115
+ ?.leaveGroup()
116
+ .catch((error) => console.warn(`Failed to leave group: ${error.message}`));
117
+ await this.cluster.disconnect().catch((error) => console.warn(`Failed to disconnect: ${error.message}`));
118
+ }
104
119
 
105
- let nodeAssignments: { nodeId: number; assignment: Assignment }[] = [];
106
- let shouldReassign = true;
120
+ private startFetchManager = async () => {
121
+ const { granularity, concurrency } = this.options;
107
122
 
108
123
  while (!this.stopHook) {
109
- if (shouldReassign || !nodeAssignments) {
110
- nodeAssignments = Object.entries(
111
- distributeAssignmentsToNodes(
112
- this.metadata.getAssignment(),
113
- this.metadata.getTopicPartitionReplicaIds(),
114
- ),
115
- ).map(([nodeId, assignment]) => ({ nodeId: parseInt(nodeId), assignment }));
116
- shouldReassign = false;
117
- }
124
+ const nodeAssignments = Object.entries(
125
+ distributeAssignmentsToNodes(
126
+ this.metadata.getAssignment(),
127
+ this.metadata.getTopicPartitionReplicaIds(),
128
+ ),
129
+ ).map(([nodeId, assignment]) => ({ nodeId: parseInt(nodeId), assignment }));
130
+
131
+ const numPartitions = Object.values(this.metadata.getAssignment()).flat().length;
132
+ const numProcessors = Math.min(concurrency, numPartitions);
133
+
134
+ this.fetchManager = new FetchManager({
135
+ fetch: this.fetch.bind(this),
136
+ process: this.process.bind(this),
137
+ metadata: this.metadata,
138
+ consumerGroup: this.consumerGroup,
139
+ nodeAssignments,
140
+ granularity,
141
+ concurrency: numProcessors,
142
+ });
118
143
 
119
144
  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
- }
145
+ await this.fetchManager.start();
159
146
 
160
147
  if (!nodeAssignments.length) {
161
- console.debug("No partitions assigned. Waiting for reassignment...");
148
+ console.debug('No partitions assigned. Waiting for reassignment...');
162
149
  await delay(this.options.maxWaitMs);
163
150
  await this.consumerGroup?.handleLastHeartbeat();
164
151
  }
165
152
  } catch (error) {
153
+ await this.fetchManager.stop();
154
+
166
155
  if ((error as KafkaTSApiError).errorCode === API_ERROR.REBALANCE_IN_PROGRESS) {
167
- console.debug("Rebalance in progress...");
168
- shouldReassign = true;
156
+ console.debug('Rebalance in progress...');
169
157
  continue;
170
158
  }
171
159
  if ((error as KafkaTSApiError).errorCode === API_ERROR.FENCED_INSTANCE_ID) {
172
- console.debug("New consumer with the same groupInstanceId joined. Exiting the consumer...");
160
+ console.debug('New consumer with the same groupInstanceId joined. Exiting the consumer...');
173
161
  this.close();
174
162
  break;
175
163
  }
@@ -182,23 +170,37 @@ export class Consumer {
182
170
  break;
183
171
  }
184
172
  console.error(error);
185
- await this.consumerGroup?.offsetCommit();
173
+ this.close();
186
174
  break;
187
175
  }
188
176
  }
189
177
  this.stopHook?.();
190
178
  };
191
179
 
192
- public async close(force = false): Promise<void> {
193
- if (!force) {
194
- await new Promise<void>((resolve) => {
195
- this.stopHook = resolve;
196
- });
180
+ private async process(messages: Required<Message>[]) {
181
+ const { options } = this;
182
+ const { retrier } = options;
183
+
184
+ if ('onBatch' in options) {
185
+ await retrier(() => options.onBatch(messages));
186
+
187
+ messages.forEach(({ topic, partition, offset }) =>
188
+ this.offsetManager.resolve(topic, partition, offset + 1n),
189
+ );
190
+ } else if ('onMessage' in options) {
191
+ try {
192
+ for (const message of messages) {
193
+ await retrier(() => options.onMessage(message));
194
+
195
+ const { topic, partition, offset } = message;
196
+ this.offsetManager.resolve(topic, partition, offset + 1n);
197
+ }
198
+ } catch (error) {
199
+ await this.consumerGroup?.offsetCommit().catch(() => {});
200
+ throw error;
201
+ }
197
202
  }
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}`));
203
+ await this.consumerGroup?.offsetCommit();
202
204
  }
203
205
 
204
206
  private fetch(nodeId: number, assignment: Assignment) {
@@ -0,0 +1,179 @@
1
+ import EventEmitter from 'events';
2
+ import { API } from '../api';
3
+ import { Assignment } from '../api/sync-group';
4
+ import { Metadata } from '../metadata';
5
+ import { Batch, Message } from '../types';
6
+ import { KafkaTSError } from '../utils/error';
7
+ import { createTracer } from '../utils/tracer';
8
+ import { ConsumerGroup } from './consumer-group';
9
+ import { Fetcher } from './fetcher';
10
+ import { Processor } from './processor';
11
+
12
+ const trace = createTracer('FetchManager');
13
+
14
+ export type Granularity = 'partition' | 'topic' | 'broker';
15
+
16
+ type FetchManagerOptions = {
17
+ fetch: (nodeId: number, assignment: Assignment) => Promise<ReturnType<(typeof API.FETCH)['response']>>;
18
+ process: (batch: Batch) => Promise<void>;
19
+ metadata: Metadata;
20
+ consumerGroup?: ConsumerGroup;
21
+ nodeAssignments: { nodeId: number; assignment: Assignment }[];
22
+ granularity: Granularity;
23
+ concurrency: number;
24
+ };
25
+
26
+ type Checkpoint = { kind: 'checkpoint'; fetcherId: number };
27
+ type Entry = Batch | Checkpoint;
28
+
29
+ export class FetchManager extends EventEmitter<{ data: []; checkpoint: [number]; stop: [] }> {
30
+ private queue: Entry[] = [];
31
+ private isRunning = false;
32
+ private fetchers: Fetcher[];
33
+ private processors: Processor[];
34
+
35
+ constructor(private options: FetchManagerOptions) {
36
+ super();
37
+
38
+ const { fetch, process, consumerGroup, nodeAssignments, concurrency } = this.options;
39
+
40
+ this.fetchers = nodeAssignments.map(
41
+ ({ nodeId, assignment }, index) =>
42
+ new Fetcher(index, {
43
+ nodeId,
44
+ assignment,
45
+ consumerGroup,
46
+ fetch,
47
+ onResponse: this.onResponse.bind(this),
48
+ }),
49
+ );
50
+ this.processors = Array.from({ length: concurrency }).map(
51
+ () => new Processor({ process, poll: this.poll.bind(this) }),
52
+ );
53
+ }
54
+
55
+ public async start() {
56
+ this.queue = [];
57
+ this.isRunning = true;
58
+
59
+ try {
60
+ await Promise.all([
61
+ ...this.fetchers.map((fetcher) => fetcher.loop()),
62
+ ...this.processors.map((processor) => processor.loop()),
63
+ ]);
64
+ } finally {
65
+ this.isRunning = false;
66
+ this.emit('stop');
67
+ }
68
+ }
69
+
70
+ @trace()
71
+ public async stop() {
72
+ this.isRunning = false;
73
+ this.emit('stop');
74
+
75
+ await Promise.all([
76
+ ...this.fetchers.map((fetcher) => fetcher.stop()),
77
+ ...this.processors.map((processor) => processor.stop()),
78
+ ]);
79
+ }
80
+
81
+ @trace()
82
+ public async poll(): Promise<Batch> {
83
+ if (!this.isRunning) {
84
+ return [];
85
+ }
86
+
87
+ const batch = this.queue.shift();
88
+ if (!batch) {
89
+ await new Promise<void>((resolve) => {
90
+ const onData = () => {
91
+ this.removeListener('stop', onStop);
92
+ resolve();
93
+ };
94
+ const onStop = () => {
95
+ this.removeListener('data', onData);
96
+ resolve();
97
+ };
98
+ this.once('data', onData);
99
+ this.once('stop', onStop);
100
+ });
101
+ return this.poll();
102
+ }
103
+
104
+ if ('kind' in batch && batch.kind === 'checkpoint') {
105
+ this.emit('checkpoint', batch.fetcherId);
106
+ return this.poll();
107
+ }
108
+
109
+ return batch as Exclude<Entry, Checkpoint>;
110
+ }
111
+
112
+ private async onResponse(fetcherId: number, response: ReturnType<(typeof API.FETCH)['response']>) {
113
+ const { metadata, granularity } = this.options;
114
+
115
+ const batches = fetchResponseToBatches(response, granularity, metadata);
116
+ if (batches.length) {
117
+ this.queue.push(...batches);
118
+ this.queue.push({ kind: 'checkpoint', fetcherId });
119
+
120
+ this.emit('data');
121
+ await new Promise<void>((resolve) => {
122
+ const onCheckpoint = (id: number) => {
123
+ if (id === fetcherId) {
124
+ this.removeListener('stop', onStop);
125
+ resolve();
126
+ }
127
+ };
128
+ const onStop = () => {
129
+ this.removeListener('checkpoint', onCheckpoint);
130
+ resolve();
131
+ };
132
+ this.once('checkpoint', onCheckpoint);
133
+ this.once('stop', onStop);
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ const fetchResponseToBatches = (
140
+ batch: ReturnType<typeof API.FETCH.response>,
141
+ granularity: Granularity,
142
+ metadata: Metadata,
143
+ ): Batch[] => {
144
+ const brokerTopics = batch.responses.map(({ topicId, partitions }) =>
145
+ partitions.map(({ partitionIndex, records }) =>
146
+ records.flatMap(({ baseTimestamp, baseOffset, records }) =>
147
+ records.map(
148
+ (message): Required<Message> => ({
149
+ topic: metadata.getTopicNameById(topicId),
150
+ partition: partitionIndex,
151
+ key: message.key ?? null,
152
+ value: message.value ?? null,
153
+ headers: Object.fromEntries(message.headers.map(({ key, value }) => [key, value])),
154
+ timestamp: baseTimestamp + BigInt(message.timestampDelta),
155
+ offset: baseOffset + BigInt(message.offsetDelta),
156
+ }),
157
+ ),
158
+ ),
159
+ ),
160
+ );
161
+
162
+ switch (granularity) {
163
+ case 'broker':
164
+ const messages = brokerTopics.flatMap((topicPartition) =>
165
+ topicPartition.flatMap((partitionMessages) => partitionMessages),
166
+ );
167
+ return messages.length ? [messages] : [];
168
+ case 'topic':
169
+ return brokerTopics
170
+ .map((topicPartition) => topicPartition.flatMap((partitionMessages) => partitionMessages))
171
+ .filter((messages) => messages.length);
172
+ case 'partition':
173
+ return brokerTopics.flatMap((topicPartition) =>
174
+ topicPartition.map((partitionMessages) => partitionMessages),
175
+ );
176
+ default:
177
+ throw new KafkaTSError(`Unhandled batch granularity: ${granularity}`);
178
+ }
179
+ };
@@ -0,0 +1,57 @@
1
+ import { EventEmitter } from 'stream';
2
+ import { API } from '../api';
3
+ import { Assignment } from '../api/sync-group';
4
+ import { createTracer } from '../utils/tracer';
5
+ import { ConsumerGroup } from './consumer-group';
6
+
7
+ const trace = createTracer('Fetcher');
8
+
9
+ type FetcherOptions = {
10
+ nodeId: number;
11
+ assignment: Assignment;
12
+ consumerGroup?: ConsumerGroup;
13
+ fetch: (nodeId: number, assignment: Assignment) => Promise<ReturnType<(typeof API.FETCH)['response']>>;
14
+ onResponse: (fetcherId: number, response: ReturnType<(typeof API.FETCH)['response']>) => Promise<void>;
15
+ };
16
+
17
+ export class Fetcher extends EventEmitter<{ stop: []; stopped: []; data: []; drain: [] }> {
18
+ private isRunning = false;
19
+
20
+ constructor(
21
+ private fetcherId: number,
22
+ private options: FetcherOptions,
23
+ ) {
24
+ super();
25
+ }
26
+
27
+ public async loop() {
28
+ const { nodeId, assignment, consumerGroup, fetch, onResponse } = this.options;
29
+
30
+ this.isRunning = true;
31
+ this.once('stop', () => (this.isRunning = false));
32
+
33
+ try {
34
+ while (this.isRunning) {
35
+ const response = await fetch(nodeId, assignment);
36
+ await consumerGroup?.handleLastHeartbeat();
37
+ await onResponse(this.fetcherId, response);
38
+ await consumerGroup?.handleLastHeartbeat();
39
+ }
40
+ } finally {
41
+ this.isRunning = false;
42
+ this.emit('stopped');
43
+ }
44
+ }
45
+
46
+ @trace()
47
+ public async stop() {
48
+ if (!this.isRunning) {
49
+ return;
50
+ }
51
+
52
+ this.emit('stop');
53
+ return new Promise<void>((resolve) => {
54
+ this.once('stopped', resolve);
55
+ });
56
+ }
57
+ }
@@ -1,9 +1,9 @@
1
- import { API } from "../api";
2
- import { IsolationLevel } from "../api/fetch";
3
- import { Assignment } from "../api/sync-group";
4
- import { Cluster } from "../cluster";
5
- import { distributeMessagesToTopicPartitionLeaders } from "../distributors/messages-to-topic-partition-leaders";
6
- import { ConsumerMetadata } from "./consumer-metadata";
1
+ import { API } from '../api';
2
+ import { IsolationLevel } from '../api/fetch';
3
+ import { Assignment } from '../api/sync-group';
4
+ import { Cluster } from '../cluster';
5
+ import { distributeMessagesToTopicPartitionLeaders } from '../distributors/messages-to-topic-partition-leaders';
6
+ import { ConsumerMetadata } from './consumer-metadata';
7
7
 
8
8
  type OffsetManagerOptions = {
9
9
  cluster: Cluster;