@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.
- package/README.md +6 -0
- package/dist/apis/definitions.d.ts +1 -1
- package/dist/apis/producer/produce-v10.d.ts +1 -0
- package/dist/apis/producer/produce-v10.js +26 -1
- package/dist/apis/producer/produce-v11.d.ts +1 -0
- package/dist/apis/producer/produce-v11.js +26 -1
- package/dist/apis/producer/produce-v7.d.ts +1 -0
- package/dist/apis/producer/produce-v7.js +23 -1
- package/dist/apis/producer/produce-v8.d.ts +1 -0
- package/dist/apis/producer/produce-v8.js +23 -1
- package/dist/apis/producer/produce-v9.d.ts +1 -0
- package/dist/apis/producer/produce-v9.js +26 -1
- package/dist/apis/producer/utils.d.ts +11 -0
- package/dist/apis/producer/utils.js +31 -0
- package/dist/clients/admin/admin.js +10 -13
- package/dist/clients/base/base.d.ts +1 -1
- package/dist/clients/base/base.js +6 -6
- package/dist/clients/base/options.d.ts +4 -0
- package/dist/clients/base/options.js +2 -1
- package/dist/clients/base/types.d.ts +1 -0
- package/dist/clients/consumer/consumer-protocol.d.ts +32 -0
- package/dist/clients/consumer/consumer-protocol.js +116 -0
- package/dist/clients/consumer/consumer.d.ts +3 -2
- package/dist/clients/consumer/consumer.js +305 -93
- package/dist/clients/consumer/index.d.ts +2 -0
- package/dist/clients/consumer/index.js +2 -0
- package/dist/clients/consumer/messages-stream.d.ts +4 -2
- package/dist/clients/consumer/messages-stream.js +126 -16
- package/dist/clients/consumer/partitions-assigners.d.ts +3 -0
- package/dist/clients/consumer/partitions-assigners.js +259 -0
- package/dist/clients/consumer/types.d.ts +11 -0
- package/dist/clients/producer/producer.d.ts +1 -1
- package/dist/clients/producer/producer.js +1 -1
- package/dist/errors.js +12 -1
- package/dist/network/connection.d.ts +1 -1
- package/dist/network/connection.js +83 -40
- package/dist/protocol/compression.d.ts +6 -0
- package/dist/protocol/compression.js +14 -1
- package/dist/protocol/records.d.ts +1 -0
- package/dist/protocol/records.js +40 -17
- package/dist/registries/confluent-schema-registry.d.ts +2 -1
- package/dist/registries/confluent-schema-registry.js +2 -1
- package/dist/symbols.d.ts +1 -0
- package/dist/symbols.js +1 -0
- package/dist/typescript-4/dist/apis/definitions.d.ts +1 -1
- package/dist/typescript-4/dist/apis/producer/produce-v10.d.ts +1 -0
- package/dist/typescript-4/dist/apis/producer/produce-v11.d.ts +1 -0
- package/dist/typescript-4/dist/apis/producer/produce-v7.d.ts +1 -0
- package/dist/typescript-4/dist/apis/producer/produce-v8.d.ts +1 -0
- package/dist/typescript-4/dist/apis/producer/produce-v9.d.ts +1 -0
- package/dist/typescript-4/dist/apis/producer/utils.d.ts +11 -0
- package/dist/typescript-4/dist/clients/base/base.d.ts +1 -1
- package/dist/typescript-4/dist/clients/base/options.d.ts +4 -0
- package/dist/typescript-4/dist/clients/base/types.d.ts +1 -0
- package/dist/typescript-4/dist/clients/consumer/consumer-protocol.d.ts +32 -0
- package/dist/typescript-4/dist/clients/consumer/consumer.d.ts +3 -2
- package/dist/typescript-4/dist/clients/consumer/index.d.ts +2 -0
- package/dist/typescript-4/dist/clients/consumer/messages-stream.d.ts +4 -2
- package/dist/typescript-4/dist/clients/consumer/partitions-assigners.d.ts +4 -1
- package/dist/typescript-4/dist/clients/consumer/types.d.ts +11 -0
- package/dist/typescript-4/dist/clients/producer/producer.d.ts +1 -1
- package/dist/typescript-4/dist/network/connection.d.ts +1 -1
- package/dist/typescript-4/dist/protocol/compression.d.ts +7 -1
- package/dist/typescript-4/dist/protocol/records.d.ts +1 -0
- package/dist/typescript-4/dist/registries/confluent-schema-registry.d.ts +2 -1
- package/dist/typescript-4/dist/symbols.d.ts +1 -0
- package/dist/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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
|
|
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
|
}
|