@platformatic/kafka 2.11.0 → 2.12.1

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 (68) hide show
  1. package/README.md +6 -0
  2. package/dist/apis/definitions.d.ts +1 -1
  3. package/dist/apis/producer/produce-v10.d.ts +1 -0
  4. package/dist/apis/producer/produce-v10.js +26 -1
  5. package/dist/apis/producer/produce-v11.d.ts +1 -0
  6. package/dist/apis/producer/produce-v11.js +26 -1
  7. package/dist/apis/producer/produce-v7.d.ts +1 -0
  8. package/dist/apis/producer/produce-v7.js +23 -1
  9. package/dist/apis/producer/produce-v8.d.ts +1 -0
  10. package/dist/apis/producer/produce-v8.js +23 -1
  11. package/dist/apis/producer/produce-v9.d.ts +1 -0
  12. package/dist/apis/producer/produce-v9.js +26 -1
  13. package/dist/apis/producer/utils.d.ts +11 -0
  14. package/dist/apis/producer/utils.js +31 -0
  15. package/dist/clients/admin/admin.js +10 -13
  16. package/dist/clients/base/base.d.ts +1 -1
  17. package/dist/clients/base/base.js +6 -6
  18. package/dist/clients/base/options.d.ts +4 -0
  19. package/dist/clients/base/options.js +2 -1
  20. package/dist/clients/base/types.d.ts +1 -0
  21. package/dist/clients/consumer/consumer-protocol.d.ts +32 -0
  22. package/dist/clients/consumer/consumer-protocol.js +116 -0
  23. package/dist/clients/consumer/consumer.d.ts +3 -2
  24. package/dist/clients/consumer/consumer.js +305 -93
  25. package/dist/clients/consumer/index.d.ts +2 -0
  26. package/dist/clients/consumer/index.js +2 -0
  27. package/dist/clients/consumer/messages-stream.d.ts +4 -2
  28. package/dist/clients/consumer/messages-stream.js +126 -16
  29. package/dist/clients/consumer/partitions-assigners.d.ts +3 -0
  30. package/dist/clients/consumer/partitions-assigners.js +259 -0
  31. package/dist/clients/consumer/types.d.ts +11 -0
  32. package/dist/clients/producer/producer.d.ts +1 -1
  33. package/dist/clients/producer/producer.js +1 -1
  34. package/dist/errors.js +12 -1
  35. package/dist/network/connection.d.ts +1 -1
  36. package/dist/network/connection.js +83 -40
  37. package/dist/protocol/compression.d.ts +6 -0
  38. package/dist/protocol/compression.js +14 -1
  39. package/dist/protocol/records.d.ts +1 -0
  40. package/dist/protocol/records.js +40 -17
  41. package/dist/registries/confluent-schema-registry.d.ts +2 -1
  42. package/dist/registries/confluent-schema-registry.js +2 -1
  43. package/dist/symbols.d.ts +1 -0
  44. package/dist/symbols.js +1 -0
  45. package/dist/typescript-4/dist/apis/definitions.d.ts +1 -1
  46. package/dist/typescript-4/dist/apis/producer/produce-v10.d.ts +1 -0
  47. package/dist/typescript-4/dist/apis/producer/produce-v11.d.ts +1 -0
  48. package/dist/typescript-4/dist/apis/producer/produce-v7.d.ts +1 -0
  49. package/dist/typescript-4/dist/apis/producer/produce-v8.d.ts +1 -0
  50. package/dist/typescript-4/dist/apis/producer/produce-v9.d.ts +1 -0
  51. package/dist/typescript-4/dist/apis/producer/utils.d.ts +11 -0
  52. package/dist/typescript-4/dist/clients/base/base.d.ts +1 -1
  53. package/dist/typescript-4/dist/clients/base/options.d.ts +4 -0
  54. package/dist/typescript-4/dist/clients/base/types.d.ts +1 -0
  55. package/dist/typescript-4/dist/clients/consumer/consumer-protocol.d.ts +32 -0
  56. package/dist/typescript-4/dist/clients/consumer/consumer.d.ts +3 -2
  57. package/dist/typescript-4/dist/clients/consumer/index.d.ts +2 -0
  58. package/dist/typescript-4/dist/clients/consumer/messages-stream.d.ts +4 -2
  59. package/dist/typescript-4/dist/clients/consumer/partitions-assigners.d.ts +4 -1
  60. package/dist/typescript-4/dist/clients/consumer/types.d.ts +11 -0
  61. package/dist/typescript-4/dist/clients/producer/producer.d.ts +1 -1
  62. package/dist/typescript-4/dist/network/connection.d.ts +1 -1
  63. package/dist/typescript-4/dist/protocol/compression.d.ts +7 -1
  64. package/dist/typescript-4/dist/protocol/records.d.ts +1 -0
  65. package/dist/typescript-4/dist/registries/confluent-schema-registry.d.ts +2 -1
  66. package/dist/typescript-4/dist/symbols.d.ts +1 -0
  67. package/dist/version.js +1 -1
  68. package/package.json +6 -3
@@ -5,7 +5,7 @@ import { consumerReceivesChannel, createDiagnosticContext, notifyCreation } from
5
5
  import { findErrorBy, protocolErrors, UserError } from "../../errors.js";
6
6
  import { IS_CONTROL } from "../../protocol/records.js";
7
7
  import { runAsyncSeries } from "../../registries/abstract.js";
8
- import { kAutocommit, kDeserializationError, kGetFetchNode, kInstance, kRefreshOffsetsAndFetch } from "../../symbols.js";
8
+ import { kAutocommit, kDeserializationError, kGetFetchNode, kInstance, kRefreshOffsetsAndFetch, kUpdateCommittedOffset } from "../../symbols.js";
9
9
  import { kConnections, kCreateConnectionPool, kInspect, kPrometheus } from "../base/base.js";
10
10
  import { ensureMetric } from "../metrics.js";
11
11
  import { defaultConsumerOptions } from "./options.js";
@@ -49,6 +49,7 @@ export class MessagesStream extends Readable {
49
49
  #offsetsToFetch;
50
50
  #offsetsToCommit;
51
51
  #offsetsCommitted;
52
+ #commitWaiters;
52
53
  #partitionsEpochs;
53
54
  #inflightNodes;
54
55
  #keyDeserializer;
@@ -109,6 +110,7 @@ export class MessagesStream extends Readable {
109
110
  this.#fallbackMode = fallbackMode ?? MessagesStreamFallbackModes.LATEST;
110
111
  this.#offsetsToCommit = new Map();
111
112
  this.#offsetsCommitted = new Map();
113
+ this.#commitWaiters = new Map();
112
114
  this.#partitionsEpochs = new Map();
113
115
  this.#paused = false;
114
116
  this.#refreshOffsetsInflight = false;
@@ -135,6 +137,13 @@ export class MessagesStream extends Readable {
135
137
  this.#offsetsCommitted.clear();
136
138
  this.#partitionsEpochs.clear();
137
139
  this.#scheduleRefreshOffsetsAndFetch();
140
+ // [kAutocommit] skips flushing while mid-rejoin to avoid triggering a rejoin storm (see
141
+ // below), leaving any queued offset waiting for the next tick of the autocommit timer.
142
+ // With autocommit disabled there is no such timer, so a manual commit requested during a
143
+ // rebalance would otherwise never resolve. Flush now that the rejoin has completed.
144
+ if (this.#offsetsToCommit.size > 0) {
145
+ this[kAutocommit]();
146
+ }
138
147
  };
139
148
  this.#onBrokerDisconnect = () => {
140
149
  this.#partitionsEpochs.clear();
@@ -159,7 +168,8 @@ export class MessagesStream extends Readable {
159
168
  this.#options = structuredClone(otherOptions);
160
169
  // Start the autocommit interval
161
170
  if (typeof autocommit === 'number' && autocommit > 0) {
162
- this.#autocommitInterval = setInterval(this[kAutocommit].bind(this), autocommit);
171
+ // Invoke without a callback so the timer path keeps the mid-rejoin skip in [kAutocommit].
172
+ this.#autocommitInterval = setInterval(() => this[kAutocommit](), autocommit);
163
173
  }
164
174
  else {
165
175
  this.#autocommitInterval = null;
@@ -769,31 +779,89 @@ export class MessagesStream extends Readable {
769
779
  this.push(null);
770
780
  }
771
781
  }
772
- #updateCommittedOffset(topic, partition, offset) {
782
+ [kUpdateCommittedOffset](topic, partition, offset) {
773
783
  const key = partitionKey(topic, partition);
774
784
  const previous = this.#offsetsCommitted.get(key);
775
785
  if (typeof previous === 'undefined' || previous < offset) {
776
786
  this.#offsetsCommitted.set(key, offset);
777
787
  }
778
788
  }
779
- // This could optimized to only schedule once per tick on a topic-partition and only commit the latest offset
789
+ // Manual commits share offsetsToCommit/autocommitInflight with kAutocommit instead of
790
+ // calling Consumer#commit directly. A raw one-shot call here has no way to stop two
791
+ // concurrent commits on the same partition from racing: whichever response the broker
792
+ // processes last wins regardless of offset value, so a lower offset can silently overwrite
793
+ // a higher one that already landed. Funnelling through the same single-flight/monotonic-merge
794
+ // machinery as autocommit coalesces same-partition calls into one in-flight request.
780
795
  #commit(topic, partition, offset, leaderEpoch, callback) {
781
796
  if (!callback) {
782
797
  callback = createPromisifiedCallback();
783
798
  }
784
- this.#consumer.commit({ offsets: [{ topic, partition, offset, leaderEpoch }] }, error => {
785
- /* c8 ignore next 4 - Hard to test */
786
- if (error) {
787
- callback(error);
788
- return;
789
- }
790
- this.#updateCommittedOffset(topic, partition, offset);
791
- callback(null);
792
- });
799
+ const key = partitionKey(topic, partition);
800
+ const current = this.#offsetsToCommit.get(key);
801
+ if (!current || current.offset < offset) {
802
+ this.#offsetsToCommit.set(key, { topic, partition, offset, leaderEpoch });
803
+ }
804
+ let waiters = this.#commitWaiters.get(key);
805
+ if (!waiters) {
806
+ waiters = [];
807
+ this.#commitWaiters.set(key, waiters);
808
+ }
809
+ waiters.push({ offset, callback: callback });
810
+ this[kAutocommit]();
793
811
  return callback[kCallbackPromise];
794
812
  }
795
- [kAutocommit]() {
813
+ // Settles every waiter whose requested offset is covered by a commit that just landed for
814
+ // its partition (offset is the monotonic max merged from possibly several manual commits, so
815
+ // it covers every waiter queued before this flush's snapshot was taken). A waiter added after
816
+ // the snapshot keeps waiting for the next flush, triggered by the caller of this method.
817
+ #settleCommitWaiters(topic, partition, offset, error) {
818
+ const key = partitionKey(topic, partition);
819
+ const waiters = this.#commitWaiters.get(key);
820
+ if (!waiters) {
821
+ return;
822
+ }
823
+ const remaining = [];
824
+ for (const waiter of waiters) {
825
+ if (waiter.offset <= offset) {
826
+ waiter.callback(error);
827
+ }
828
+ else {
829
+ remaining.push(waiter);
830
+ }
831
+ }
832
+ if (remaining.length > 0) {
833
+ this.#commitWaiters.set(key, remaining);
834
+ }
835
+ else {
836
+ this.#commitWaiters.delete(key);
837
+ }
838
+ }
839
+ [kAutocommit](callback) {
840
+ // A commit is already running: report its outcome to the caller rather than
841
+ // starting a second one, so a cooperative rebalance waits for the in-flight
842
+ // commit instead of proceeding as if nothing was pending.
843
+ if (this.#autocommitInflight) {
844
+ if (callback) {
845
+ // A commit which fails with a rejoin-required error destroys the stream, and a destroyed
846
+ // stream never emits 'autocommit' again. Settle on whichever comes first so an awaiting
847
+ // rebalance cannot deadlock on a stream that is going away.
848
+ const onAutocommit = (error) => {
849
+ this.removeListener('close', onClose);
850
+ callback(error);
851
+ };
852
+ const onClose = () => {
853
+ this.removeListener('autocommit', onAutocommit);
854
+ callback(null);
855
+ };
856
+ this.once('autocommit', onAutocommit);
857
+ this.once('close', onClose);
858
+ }
859
+ // Any offset queued by a manual commit while this flush is in flight is picked up by
860
+ // the follow-up flush triggered right after this one settles (see below).
861
+ return;
862
+ }
796
863
  if (this.#offsetsToCommit.size === 0) {
864
+ callback?.(null);
797
865
  return;
798
866
  }
799
867
  // Skip this tick while the consumer is mid-(re)join: a commit attempted here would fail
@@ -803,7 +871,10 @@ export class MessagesStream extends Readable {
803
871
  // consumers in the group, this can fire fresh JoinGroups fast enough that the group never
804
872
  // completes a rebalance round. Leave the offsets queued; they are committed once the
805
873
  // consumer becomes active again (next tick, or the final flush on close/destroy).
806
- if (!this.#consumer.isActive()) {
874
+ // A caller-supplied callback means an explicitly requested flush (the cooperative rebalance
875
+ // commits before revoking partitions) rather than a timer tick: it awaits this commit
876
+ // instead of rejoining on failure, so it must not be skipped or the revoked offsets are lost.
877
+ if (!callback && !this.#consumer.isActive()) {
807
878
  return;
808
879
  }
809
880
  this.#autocommitInflight = true;
@@ -817,14 +888,53 @@ export class MessagesStream extends Readable {
817
888
  // (ILLEGAL_GENERATION, UNKNOWN_MEMBER_ID, REBALANCE_IN_PROGRESS).
818
889
  // Transient coordinator errors must not tear down the consumption loop.
819
890
  if (findErrorBy(error, 'needsRejoin', true)) {
891
+ // The stream is going away: every pending manual commit, for every partition, must be
892
+ // rejected now, since a destroyed stream never gets another chance to flush them.
893
+ for (const waiters of this.#commitWaiters.values()) {
894
+ for (const waiter of waiters) {
895
+ waiter.callback(error);
896
+ }
897
+ }
898
+ this.#commitWaiters.clear();
820
899
  this.destroy(error);
821
900
  }
901
+ else {
902
+ // Keep offsets produced while the commit was in flight, and retry the failed snapshot
903
+ // without replacing a newer offset for the same partition.
904
+ for (const offset of offsets) {
905
+ const key = partitionKey(offset.topic, offset.partition);
906
+ const current = this.#offsetsToCommit.get(key);
907
+ if (!current || current.offset < offset.offset) {
908
+ this.#offsetsToCommit.set(key, offset);
909
+ }
910
+ }
911
+ // Report the failure to whichever manual commits were part of this snapshot. The
912
+ // offset stays queued above and is retried by the next flush regardless.
913
+ for (const offset of offsets) {
914
+ this.#settleCommitWaiters(offset.topic, offset.partition, offset.offset, error);
915
+ }
916
+ // A manual commit queued for a different, newer offset while this flush was in
917
+ // flight was not part of the failed snapshot and must not wait on this stream's
918
+ // autocommit timer (or never, if autocommit is disabled) to get its own attempt.
919
+ if (this.#commitWaiters.size > 0) {
920
+ this[kAutocommit]();
921
+ }
922
+ }
923
+ callback?.(error);
822
924
  return;
823
925
  }
824
926
  for (const { topic, partition, offset } of offsets) {
825
- this.#updateCommittedOffset(topic, partition, offset);
927
+ this[kUpdateCommittedOffset](topic, partition, offset);
928
+ this.#settleCommitWaiters(topic, partition, offset, null);
826
929
  }
827
930
  this.emit('autocommit', null, offsets);
931
+ callback?.(null);
932
+ // A manual commit queued while this flush was in flight is still waiting: its offset
933
+ // was not part of the snapshot above, so drive another flush now instead of leaving it
934
+ // stranded until the next autocommit tick.
935
+ if (this.#commitWaiters.size > 0) {
936
+ this[kAutocommit]();
937
+ }
828
938
  });
829
939
  }
830
940
  #refreshOffsets(callback) {
@@ -1,3 +1,6 @@
1
1
  import { type ClusterMetadata } from '../base/types.ts';
2
2
  import { type ExtendedGroupProtocolSubscription, type GroupPartitionsAssignments } from './types.ts';
3
+ export declare const ROUNDROBIN_ASSIGNOR = "roundrobin";
4
+ export declare const COOPERATIVE_STICKY_ASSIGNOR = "cooperative-sticky";
3
5
  export declare function roundRobinAssigner(_current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata): GroupPartitionsAssignments[];
6
+ export declare function cooperativeStickyAssigner(_current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata): GroupPartitionsAssignments[];
@@ -1,3 +1,5 @@
1
+ export const ROUNDROBIN_ASSIGNOR = 'roundrobin';
2
+ export const COOPERATIVE_STICKY_ASSIGNOR = 'cooperative-sticky';
1
3
  export function roundRobinAssigner(_current, members, topics, metadata) {
2
4
  const membersSize = members.size;
3
5
  const assignments = [];
@@ -21,3 +23,260 @@ export function roundRobinAssigner(_current, members, topics, metadata) {
21
23
  }
22
24
  return assignments;
23
25
  }
26
+ function topicPartitionKey({ topic, partition }) {
27
+ return `${topic}:${partition}`;
28
+ }
29
+ function parseTopicPartitionKey(key) {
30
+ const index = key.lastIndexOf(':');
31
+ return {
32
+ topic: key.slice(0, index),
33
+ partition: Number(key.slice(index + 1))
34
+ };
35
+ }
36
+ function compareTopicPartitions(left, right) {
37
+ const topicComparison = left.topic.localeCompare(right.topic);
38
+ return topicComparison === 0 ? left.partition - right.partition : topicComparison;
39
+ }
40
+ function flattenAssignments(assignments) {
41
+ const partitions = [];
42
+ for (const assignment of assignments) {
43
+ for (const partition of assignment.partitions) {
44
+ partitions.push({ topic: assignment.topic, partition });
45
+ }
46
+ }
47
+ return partitions;
48
+ }
49
+ function assignmentSize(assignments) {
50
+ let size = 0;
51
+ for (const assignment of assignments.values()) {
52
+ size += assignment.partitions.length;
53
+ }
54
+ return size;
55
+ }
56
+ function sortedMemberIds(assignments) {
57
+ return [...assignments.keys()].sort();
58
+ }
59
+ function assignedPartitionKeys(assignments) {
60
+ const assigned = new Set();
61
+ for (const memberAssignments of assignments.values()) {
62
+ for (const partition of flattenAssignments(Array.from(memberAssignments.values()))) {
63
+ assigned.add(topicPartitionKey(partition));
64
+ }
65
+ }
66
+ return assigned;
67
+ }
68
+ function addAssignment(assignments, memberId, partition) {
69
+ const memberAssignments = assignments.get(memberId);
70
+ let topicAssignment = memberAssignments.get(partition.topic);
71
+ if (!topicAssignment) {
72
+ topicAssignment = { topic: partition.topic, partitions: [] };
73
+ memberAssignments.set(partition.topic, topicAssignment);
74
+ }
75
+ if (!topicAssignment.partitions.includes(partition.partition)) {
76
+ topicAssignment.partitions.push(partition.partition);
77
+ topicAssignment.partitions.sort((a, b) => a - b);
78
+ }
79
+ }
80
+ function removeAssignment(assignments, memberId, partition) {
81
+ const memberAssignments = assignments.get(memberId);
82
+ const topicAssignment = memberAssignments?.get(partition.topic);
83
+ if (!topicAssignment) {
84
+ return;
85
+ }
86
+ topicAssignment.partitions = topicAssignment.partitions.filter(current => current !== partition.partition);
87
+ if (topicAssignment.partitions.length === 0) {
88
+ memberAssignments.delete(partition.topic);
89
+ }
90
+ }
91
+ function createEmptyAssignments(members) {
92
+ const assignments = new Map();
93
+ for (const memberId of members.keys()) {
94
+ assignments.set(memberId, new Map());
95
+ }
96
+ return assignments;
97
+ }
98
+ function hasEligibleMember(members, topic) {
99
+ for (const member of members.values()) {
100
+ if (member.topics?.includes(topic)) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+ function listAssignablePartitions(members, topics, metadata) {
107
+ const result = [];
108
+ for (const topic of [...topics].sort()) {
109
+ const topicMetadata = metadata.topics.get(topic);
110
+ if (!topicMetadata || !hasEligibleMember(members, topic)) {
111
+ continue;
112
+ }
113
+ for (let partition = 0; partition < topicMetadata.partitionsCount; partition++) {
114
+ result.push({ topic, partition });
115
+ }
116
+ }
117
+ return result;
118
+ }
119
+ function previousOwnership(members, allPartitions) {
120
+ const validPartitions = new Set(allPartitions.map(topicPartitionKey));
121
+ const maxGeneration = Math.max(-1, ...Array.from(members.values(), member => member.generationId ?? -1));
122
+ const claims = new Map();
123
+ for (const [memberId, member] of members) {
124
+ const generationId = member.generationId ?? -1;
125
+ if (maxGeneration >= 0 && generationId < maxGeneration) {
126
+ continue;
127
+ }
128
+ for (const owned of member.ownedPartitions ?? []) {
129
+ if (!member.topics?.includes(owned.topic)) {
130
+ continue;
131
+ }
132
+ for (const partition of owned.partitions) {
133
+ const topicPartition = { topic: owned.topic, partition };
134
+ const key = topicPartitionKey(topicPartition);
135
+ if (!validPartitions.has(key)) {
136
+ continue;
137
+ }
138
+ let partitionClaims = claims.get(key);
139
+ if (!partitionClaims) {
140
+ partitionClaims = [];
141
+ claims.set(key, partitionClaims);
142
+ }
143
+ partitionClaims.push({ memberId, generationId });
144
+ }
145
+ }
146
+ }
147
+ const owners = new Map();
148
+ const contested = new Set();
149
+ for (const [key, partitionClaims] of claims) {
150
+ const highestGeneration = Math.max(...partitionClaims.map(claim => claim.generationId));
151
+ const highestGenerationClaims = partitionClaims.filter(claim => claim.generationId === highestGeneration);
152
+ // If two members claim the same partition in the same generation, neither
153
+ // claim can be trusted. This mirrors the Kafka assignor's safety check.
154
+ if (highestGenerationClaims.length === 1) {
155
+ owners.set(key, highestGenerationClaims[0].memberId);
156
+ }
157
+ else {
158
+ contested.add(key);
159
+ }
160
+ }
161
+ return { owners, contested };
162
+ }
163
+ function leastLoadedEligibleMember(assignments, members, partition) {
164
+ let selected = null;
165
+ let selectedSize = Number.POSITIVE_INFINITY;
166
+ for (const memberId of sortedMemberIds(assignments)) {
167
+ const member = members.get(memberId);
168
+ if (!member.topics?.includes(partition.topic)) {
169
+ continue;
170
+ }
171
+ const size = assignmentSize(assignments.get(memberId));
172
+ if (size < selectedSize) {
173
+ selected = memberId;
174
+ selectedSize = size;
175
+ }
176
+ }
177
+ return selected;
178
+ }
179
+ function removablePartition(assignments, targetMember) {
180
+ const partitions = flattenAssignments(Array.from(assignments.values())).sort(compareTopicPartitions);
181
+ for (const partition of partitions) {
182
+ if (targetMember.topics?.includes(partition.topic)) {
183
+ return partition;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+ function assignUnassignedPartitions(assignments, members, allPartitions) {
189
+ const assigned = assignedPartitionKeys(assignments);
190
+ for (const partition of allPartitions) {
191
+ if (assigned.has(topicPartitionKey(partition))) {
192
+ continue;
193
+ }
194
+ const memberId = leastLoadedEligibleMember(assignments, members, partition);
195
+ if (memberId) {
196
+ addAssignment(assignments, memberId, partition);
197
+ assigned.add(topicPartitionKey(partition));
198
+ }
199
+ }
200
+ }
201
+ function balanceAssignments(assignments, members) {
202
+ const totalPartitions = assignedPartitionKeys(assignments).size;
203
+ if (totalPartitions === 0 || members.size === 0) {
204
+ return;
205
+ }
206
+ const minQuota = Math.floor(totalPartitions / members.size);
207
+ const maxQuota = Math.ceil(totalPartitions / members.size);
208
+ while (true) {
209
+ const underloadedMembers = sortedMemberIds(assignments).filter(memberId => assignmentSize(assignments.get(memberId)) < minQuota);
210
+ const overloadedMembers = sortedMemberIds(assignments)
211
+ .filter(memberId => assignmentSize(assignments.get(memberId)) > maxQuota)
212
+ .sort((left, right) => assignmentSize(assignments.get(right)) - assignmentSize(assignments.get(left)));
213
+ if (underloadedMembers.length === 0 || overloadedMembers.length === 0) {
214
+ return;
215
+ }
216
+ let moved = false;
217
+ for (const underloaded of underloadedMembers) {
218
+ for (const overloaded of overloadedMembers) {
219
+ const partition = removablePartition(assignments.get(overloaded), members.get(underloaded));
220
+ if (!partition) {
221
+ continue;
222
+ }
223
+ removeAssignment(assignments, overloaded, partition);
224
+ addAssignment(assignments, underloaded, partition);
225
+ moved = true;
226
+ break;
227
+ }
228
+ if (moved) {
229
+ break;
230
+ }
231
+ }
232
+ if (!moved) {
233
+ return;
234
+ }
235
+ }
236
+ }
237
+ function adjustCooperativeTransfers(assignments, members, previousOwners, contestedPreviousOwners) {
238
+ const added = new Map();
239
+ const revoked = new Set();
240
+ for (const [memberId, memberAssignments] of assignments) {
241
+ const trustedOwned = new Set(flattenAssignments(members.get(memberId)?.ownedPartitions ?? [])
242
+ .map(topicPartitionKey)
243
+ .filter(key => previousOwners.get(key) === memberId));
244
+ const claimedOwned = new Set(flattenAssignments(members.get(memberId)?.ownedPartitions ?? [])
245
+ .map(topicPartitionKey)
246
+ .filter(key => previousOwners.get(key) === memberId || contestedPreviousOwners.has(key)));
247
+ const assigned = new Set(flattenAssignments(Array.from(memberAssignments.values())).map(topicPartitionKey));
248
+ for (const key of assigned) {
249
+ if (!trustedOwned.has(key)) {
250
+ added.set(key, memberId);
251
+ }
252
+ }
253
+ for (const key of claimedOwned) {
254
+ if (!assigned.has(key)) {
255
+ revoked.add(key);
256
+ }
257
+ }
258
+ }
259
+ for (const [key, memberId] of added) {
260
+ if (revoked.has(key)) {
261
+ removeAssignment(assignments, memberId, parseTopicPartitionKey(key));
262
+ }
263
+ }
264
+ }
265
+ export function cooperativeStickyAssigner(_current, members, topics, metadata) {
266
+ const assignments = createEmptyAssignments(members);
267
+ const allPartitions = listAssignablePartitions(members, topics, metadata);
268
+ const { owners: previousOwners, contested: contestedPreviousOwners } = previousOwnership(members, allPartitions);
269
+ for (const partition of allPartitions) {
270
+ const owner = previousOwners.get(topicPartitionKey(partition));
271
+ if (owner) {
272
+ addAssignment(assignments, owner, partition);
273
+ }
274
+ }
275
+ assignUnassignedPartitions(assignments, members, allPartitions);
276
+ balanceAssignments(assignments, members);
277
+ adjustCooperativeTransfers(assignments, members, previousOwners, contestedPreviousOwners);
278
+ return Array.from(assignments, ([memberId, memberAssignments]) => ({
279
+ memberId,
280
+ assignments: memberAssignments
281
+ }));
282
+ }
@@ -11,6 +11,10 @@ export interface GroupProtocolSubscription {
11
11
  version: number;
12
12
  metadata?: Buffer | string;
13
13
  }
14
+ export interface TopicPartitionAssignment {
15
+ topic: string;
16
+ partition: number;
17
+ }
14
18
  export interface GroupAssignment {
15
19
  topic: string;
16
20
  partitions: number[];
@@ -21,6 +25,9 @@ export interface GroupPartitionsAssignments {
21
25
  }
22
26
  export interface ExtendedGroupProtocolSubscription extends Omit<GroupProtocolSubscription, 'name'> {
23
27
  topics?: string[];
28
+ ownedPartitions?: GroupAssignment[];
29
+ generationId?: number;
30
+ rackId?: string | null;
24
31
  memberId: string;
25
32
  }
26
33
  export type Offsets = Map<string, bigint[]>;
@@ -163,6 +170,10 @@ export interface ConsumerGroupLeavePayload {
163
170
  export interface ConsumerGroupRebalancePayload {
164
171
  groupId: string;
165
172
  }
173
+ export interface ConsumerGroupAutocommitErrorPayload {
174
+ groupId: string;
175
+ error: Error;
176
+ }
166
177
  export interface ConsumerHeartbeatPayload {
167
178
  groupId?: string;
168
179
  memberId?: string | null;
@@ -15,8 +15,8 @@ export declare class Producer<Key = Buffer, Value = Buffer, HeaderKey = Buffer,
15
15
  get transaction(): Transaction<Key, Value, HeaderKey, HeaderValue> | undefined;
16
16
  get coordinatorId(): number | undefined;
17
17
  get streamsCount(): number;
18
- close(force: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
19
18
  close(force?: boolean): Promise<void>;
19
+ close(force: boolean | CallbackWithPromise<void>, callback?: CallbackWithPromise<void>): void;
20
20
  initIdempotentProducer(options: ProduceOptions<Key, Value, HeaderKey, HeaderValue>, callback: CallbackWithPromise<ProducerInfo>): void;
21
21
  initIdempotentProducer(options: ProduceOptions<Key, Value, HeaderKey, HeaderValue>): Promise<ProducerInfo>;
22
22
  send(options: SendOptions<Key, Value, HeaderKey, HeaderValue> & {
@@ -752,7 +752,7 @@ export class Producer extends Base {
752
752
  const { topic, partition } = messages[0];
753
753
  let refreshMetadata = false;
754
754
  this[kPerformWithRetry]('produce', retryCallback => {
755
- this[kMetadata]({ topics, autocreateTopics, forceUpdate: refreshMetadata }, (error, metadata) => {
755
+ this[kMetadata]({ topics, autocreateTopics, forceUpdate: refreshMetadata, retries: 0 }, (error, metadata) => {
756
756
  if (error) {
757
757
  refreshMetadata = NetworkError.isRetryable(error);
758
758
  retryCallback(error);
package/dist/errors.js CHANGED
@@ -142,10 +142,21 @@ export class ProtocolError extends GenericError {
142
142
  'NOT_LEADER_OR_FOLLOWER',
143
143
  'FENCED_LEADER_EPOCH'
144
144
  ].includes(id),
145
- needsRejoin: ['MEMBER_ID_REQUIRED', 'UNKNOWN_MEMBER_ID', 'REBALANCE_IN_PROGRESS', 'ILLEGAL_GENERATION'].includes(id),
145
+ needsRejoin: [
146
+ 'MEMBER_ID_REQUIRED',
147
+ 'UNKNOWN_MEMBER_ID',
148
+ 'REBALANCE_IN_PROGRESS',
149
+ 'ILLEGAL_GENERATION',
150
+ 'FENCED_INSTANCE_ID'
151
+ ].includes(id),
146
152
  producerFenced: id === 'INVALID_PRODUCER_EPOCH',
147
153
  rebalanceInProgress: id === 'REBALANCE_IN_PROGRESS',
148
154
  unknownMemberId: id === 'UNKNOWN_MEMBER_ID',
155
+ // A fenced static member's id is permanently dead — another process already claimed
156
+ // this group.instance.id — so, like UNKNOWN_MEMBER_ID, the cached memberId must be
157
+ // dropped before rejoining. Without this, the client keeps resending the same invalid
158
+ // id and the broker rejects it forever (see #getRejoinError in consumer.ts).
159
+ fencedInstanceId: id === 'FENCED_INSTANCE_ID',
149
160
  memberId: response?.memberId,
150
161
  ...properties
151
162
  });
@@ -94,6 +94,6 @@ export declare class Connection extends TypedEventEmitter<ConnectionEvents> {
94
94
  ready(): Promise<void>;
95
95
  close(callback: CallbackWithPromise<void>): void;
96
96
  close(): Promise<void>;
97
- send<ReturnType>(apiKey: number, apiVersion: number, createPayload: () => Writer, responseParser: ResponseParser<ReturnType>, hasRequestHeaderTaggedFields: boolean, hasResponseHeaderTaggedFields: boolean, callback: Callback<ReturnType>): void;
97
+ send<ReturnType>(apiKey: number, apiVersion: number, createPayload: () => Writer | Promise<Writer>, responseParser: ResponseParser<ReturnType>, hasRequestHeaderTaggedFields: boolean, hasResponseHeaderTaggedFields: boolean, callback: Callback<ReturnType>): void;
98
98
  reauthenticate(): void;
99
99
  }