@platformatic/kafka 2.12.0 → 2.13.0

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.
@@ -648,29 +648,34 @@ export class Admin extends Base {
648
648
  members: new Map(),
649
649
  authorizedOperations: raw.authorizedOperations
650
650
  };
651
- for (const member of raw.members) {
652
- let memberMetadata;
653
- let memberAssignments;
654
- if (member.memberMetadata.length > 0) {
655
- const subscription = decodeConsumerProtocolSubscription(member.memberMetadata);
656
- memberMetadata = {
657
- version: subscription.version,
658
- topics: subscription.topics,
659
- metadata: subscription.userData,
660
- ownedPartitions: subscription.ownedPartitions,
661
- generationId: subscription.generationId,
662
- rackId: subscription.rackId
663
- };
664
- memberAssignments = new Map(decodeConsumerProtocolAssignment(member.memberAssignment).assignedPartitions.map(assignment => [assignment.topic, assignment]));
651
+ try {
652
+ for (const member of raw.members) {
653
+ let memberMetadata;
654
+ let memberAssignments;
655
+ if (raw.protocolType === 'consumer' && member.memberMetadata.length > 0) {
656
+ const subscription = decodeConsumerProtocolSubscription(member.memberMetadata);
657
+ memberMetadata = {
658
+ version: subscription.version,
659
+ topics: subscription.topics,
660
+ metadata: subscription.userData,
661
+ ownedPartitions: subscription.ownedPartitions,
662
+ generationId: subscription.generationId,
663
+ rackId: subscription.rackId
664
+ };
665
+ memberAssignments = new Map(decodeConsumerProtocolAssignment(member.memberAssignment).assignedPartitions.map(assignment => [assignment.topic, assignment]));
666
+ }
667
+ group.members.set(member.memberId, {
668
+ id: member.memberId,
669
+ groupInstanceId: member.groupInstanceId,
670
+ clientId: member.clientId,
671
+ clientHost: member.clientHost,
672
+ metadata: memberMetadata,
673
+ assignments: memberAssignments
674
+ });
665
675
  }
666
- group.members.set(member.memberId, {
667
- id: member.memberId,
668
- groupInstanceId: member.groupInstanceId,
669
- clientId: member.clientId,
670
- clientHost: member.clientHost,
671
- metadata: memberMetadata,
672
- assignments: memberAssignments
673
- });
676
+ }
677
+ catch (e) {
678
+ group.error = e;
674
679
  }
675
680
  groups.set(group.id, group);
676
681
  }
@@ -40,6 +40,7 @@ export interface Group extends Omit<GroupBase, 'groupType'> {
40
40
  protocol: string;
41
41
  members: Map<string, GroupMember>;
42
42
  authorizedOperations: number;
43
+ error?: Error;
43
44
  }
44
45
  export interface AdminOptions extends BaseOptions {
45
46
  }
@@ -108,6 +108,19 @@ export declare const baseOptionsSchema: {
108
108
  type: string;
109
109
  enum: ("PLAIN" | "SCRAM-SHA-256" | "SCRAM-SHA-512" | "OAUTHBEARER" | "GSSAPI")[];
110
110
  };
111
+ reauthFraction: {
112
+ type: string;
113
+ exclusiveMinimum: number;
114
+ maximum: number;
115
+ default: number;
116
+ };
117
+ reauthLeadTime: {
118
+ type: string;
119
+ minimum: number;
120
+ };
121
+ lazyReauthentication: {
122
+ type: string;
123
+ };
111
124
  username: {
112
125
  oneOf: ({
113
126
  type: string;
@@ -40,6 +40,9 @@ export const baseOptionsSchema = {
40
40
  type: 'object',
41
41
  properties: {
42
42
  mechanism: { type: 'string', enum: allowedSASLMechanisms },
43
+ reauthFraction: { type: 'number', exclusiveMinimum: 0, maximum: 1, default: 0.8 },
44
+ reauthLeadTime: { type: 'number', minimum: 0 },
45
+ lazyReauthentication: { type: 'boolean' },
43
46
  username: { oneOf: [{ type: 'string' }, { function: true }] },
44
47
  password: { oneOf: [{ type: 'string' }, { function: true }] },
45
48
  token: { oneOf: [{ type: 'string' }, { function: true }] },
@@ -1,3 +1,4 @@
1
+ import { UnsupportedFormatError } from "../../errors.js";
1
2
  import { Reader } from "../../protocol/reader.js";
2
3
  import { Writer } from "../../protocol/writer.js";
3
4
  export const CONSUMER_PROTOCOL_LOWEST_VERSION = 0;
@@ -48,26 +49,33 @@ export function encodeConsumerProtocolSubscription(data) {
48
49
  }
49
50
  export function decodeConsumerProtocolSubscription(buffer) {
50
51
  const reader = Reader.from(buffer);
51
- const encodedVersion = reader.readInt16();
52
- const version = supportedVersion(encodedVersion);
53
- const subscription = {
54
- version: encodedVersion,
55
- topics: reader.readArray(r => r.readString(false), false, false),
56
- userData: reader.readBytes(false),
57
- ownedPartitions: [],
58
- generationId: -1,
59
- rackId: null
60
- };
61
- if (version >= 1) {
62
- subscription.ownedPartitions = readTopicPartitions(reader);
52
+ try {
53
+ const encodedVersion = reader.readInt16();
54
+ const version = supportedVersion(encodedVersion);
55
+ const subscription = {
56
+ version: encodedVersion,
57
+ topics: reader.readArray(r => r.readString(false), false, false),
58
+ userData: reader.readBytes(false),
59
+ ownedPartitions: [],
60
+ generationId: -1,
61
+ rackId: null
62
+ };
63
+ // KafkaJS and Platformatic <= 2.11 may advertise newer versions with a v0 body.
64
+ // Default absent trailing fields, but still reject partially encoded fields.
65
+ if (version >= 1 && reader.remaining > 0) {
66
+ subscription.ownedPartitions = readTopicPartitions(reader);
67
+ }
68
+ if (version >= 2 && reader.remaining > 0) {
69
+ subscription.generationId = reader.readInt32();
70
+ }
71
+ if (version >= 3 && reader.remaining > 0) {
72
+ subscription.rackId = reader.readNullableString(false);
73
+ }
74
+ return subscription;
63
75
  }
64
- if (version >= 2) {
65
- subscription.generationId = reader.readInt32();
76
+ catch (e) {
77
+ throw new UnsupportedFormatError('Malformed consumer protocol subscription', { cause: e });
66
78
  }
67
- if (version >= 3) {
68
- subscription.rackId = reader.readNullableString(false);
69
- }
70
- return subscription;
71
79
  }
72
80
  export function decodeCooperativeStickyGeneration(userData) {
73
81
  if (userData.length < 4) {
@@ -96,19 +104,24 @@ export function decodeConsumerProtocolAssignment(buffer) {
96
104
  };
97
105
  }
98
106
  const reader = Reader.from(buffer);
99
- const encodedVersion = reader.readInt16();
100
- if (reader.remaining === 0) {
107
+ try {
108
+ const encodedVersion = reader.readInt16();
109
+ if (reader.remaining === 0) {
110
+ return {
111
+ version: encodedVersion,
112
+ assignedPartitions: [],
113
+ userData: Buffer.alloc(0)
114
+ };
115
+ }
116
+ const assignedPartitions = readTopicPartitions(reader);
117
+ const userData = reader.remaining > 0 ? reader.readBytes(false) : Buffer.alloc(0);
101
118
  return {
102
119
  version: encodedVersion,
103
- assignedPartitions: [],
104
- userData: Buffer.alloc(0)
120
+ assignedPartitions,
121
+ userData
105
122
  };
106
123
  }
107
- const assignedPartitions = readTopicPartitions(reader);
108
- const userData = reader.remaining > 0 ? reader.readBytes(false) : Buffer.alloc(0);
109
- return {
110
- version: encodedVersion,
111
- assignedPartitions,
112
- userData
113
- };
124
+ catch (e) {
125
+ throw new UnsupportedFormatError('Malformed consumer protocol assignment', { cause: e });
126
+ }
114
127
  }
@@ -6,7 +6,7 @@ import { Base, type BaseEvents, kCreateConnectionPool } from '../base/base.ts';
6
6
  import { type ClusterMetadata } from '../base/types.ts';
7
7
  import { MessagesStream } from './messages-stream.ts';
8
8
  import { TopicsMap } from './topics-map.ts';
9
- import { type CommitOptions, type ConsumeOptions, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupAutocommitErrorPayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerHeartbeatStalledPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
9
+ import { type CommitOptions, type ConsumeOptions, type ConsumerGroupAutocommitErrorPayload, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerHeartbeatStalledPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from './types.ts';
10
10
  export interface ConsumerEvents extends BaseEvents {
11
11
  'consumer:group:join': (payload: ConsumerGroupJoinPayload) => void;
12
12
  'consumer:group:leave': (payload: ConsumerGroupLeavePayload) => void;
@@ -9,8 +9,8 @@ import { kAutocommit, kGetFetchNode, kRefreshOffsetsAndFetch, kUpdateCommittedOf
9
9
  import { emitExperimentalApiWarning } from "../../utils.js";
10
10
  import { Base, kAfterCreate, kCheckNotClosed, kClosed, kConnections, kCreateConnectionPool, kFormatValidationErrors, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kPrometheus, kValidateOptions } from "../base/base.js";
11
11
  import { ensureMetric } from "../metrics.js";
12
+ import { CONSUMER_PROTOCOL_HIGHEST_VERSION, decodeConsumerProtocolAssignment, decodeConsumerProtocolSubscription, decodeCooperativeStickyGeneration, encodeConsumerProtocolAssignment, encodeConsumerProtocolSubscription } from "./consumer-protocol.js";
12
13
  import { MessagesStream } from "./messages-stream.js";
13
- import { CONSUMER_PROTOCOL_HIGHEST_VERSION, decodeCooperativeStickyGeneration, decodeConsumerProtocolAssignment, decodeConsumerProtocolSubscription, encodeConsumerProtocolAssignment, encodeConsumerProtocolSubscription } from "./consumer-protocol.js";
14
14
  import { commitOptionsValidator, consumeOptionsValidator, consumerOptionsValidator, defaultConsumerOptions, fetchOptionsValidator, getLagOptionsValidator, groupIdAndOptionsValidator, groupOptionsValidator, listCommitsOptionsValidator, listOffsetsOptionsValidator } from "./options.js";
15
15
  import { COOPERATIVE_STICKY_ASSIGNOR, cooperativeStickyAssigner, roundRobinAssigner } from "./partitions-assigners.js";
16
16
  import { TopicsMap } from "./topics-map.js";
@@ -418,6 +418,12 @@ export class Consumer extends Base {
418
418
  if (!options.protocolsMetadata && protocolsMetadata) {
419
419
  options.protocolsMetadata = protocolsMetadata;
420
420
  }
421
+ if (!options.partitionAssigner && this[kOptions].partitionAssigner) {
422
+ options.partitionAssigner = this[kOptions].partitionAssigner;
423
+ }
424
+ if (!options.partitionAssignerTopicsSelector && this[kOptions].partitionAssignerTopicsSelector) {
425
+ options.partitionAssignerTopicsSelector = this[kOptions].partitionAssignerTopicsSelector;
426
+ }
421
427
  this.#validateGroupOptions(options);
422
428
  this.#membershipActive = true;
423
429
  this.#joinGroup(options, callback);
@@ -922,8 +928,8 @@ export class Consumer extends Base {
922
928
  #leaveGroupClassicProtocol(force, callback) {
923
929
  consumerGroupChannel.traceCallback(this.#performLeaveGroup, 1, createDiagnosticContext({ client: this, operation: 'leaveGroup', force }), this, force, callback);
924
930
  }
925
- #syncGroup(partitionsAssigner, callback) {
926
- consumerGroupChannel.traceCallback(this.#performSyncGroup, 2, createDiagnosticContext({ client: this, operation: 'syncGroup' }), this, partitionsAssigner, null, callback);
931
+ #syncGroup(partitionsAssigner, partitionAssignerTopicsSelector, callback) {
932
+ consumerGroupChannel.traceCallback(this.#performSyncGroup, 3, createDiagnosticContext({ client: this, operation: 'syncGroup' }), this, partitionsAssigner, partitionAssignerTopicsSelector, null, callback);
927
933
  }
928
934
  #heartbeat(options) {
929
935
  const eventPayload = { groupId: this.groupId, memberId: this.memberId, generationId: this.generationId };
@@ -1425,7 +1431,7 @@ export class Consumer extends Base {
1425
1431
  this.#members.set(member.memberId, this.#decodeProtocolSubscriptionMetadata(member.memberId, member.metadata));
1426
1432
  }
1427
1433
  // Send a syncGroup request
1428
- this.#syncGroup(options.partitionAssigner, (error, response) => {
1434
+ this.#syncGroup(options.partitionAssigner, options.partitionAssignerTopicsSelector, (error, response) => {
1429
1435
  if (!this.#membershipActive) {
1430
1436
  callback(null);
1431
1437
  return;
@@ -1619,26 +1625,43 @@ export class Consumer extends Base {
1619
1625
  }
1620
1626
  this.#performDeduplicateGroupOperaton('leaveGroup', operation, onLeave);
1621
1627
  }
1622
- #performSyncGroup(partitionsAssigner, assignments, callback) {
1628
+ #performSyncGroup(partitionsAssigner, partitionAssignerTopicsSelector, assignments, callback) {
1623
1629
  if (!this.#membershipActive) {
1624
1630
  callback(null, []);
1625
1631
  return;
1626
1632
  }
1627
1633
  if (!Array.isArray(assignments)) {
1628
1634
  if (this.#isLeader) {
1629
- // Get all the metadata for the topics the consumer are listening to, then compute the assignments
1630
- const topicsSubscriptions = new Map();
1631
- for (const subscription of this.#members.values()) {
1632
- for (const topic of subscription.topics) {
1633
- let topicSubscriptions = topicsSubscriptions.get(topic);
1634
- if (!topicSubscriptions) {
1635
- topicSubscriptions = [];
1636
- topicsSubscriptions.set(topic, topicSubscriptions);
1635
+ const customTopics = !!(partitionsAssigner && partitionAssignerTopicsSelector);
1636
+ let subscribedTopics;
1637
+ if (customTopics) {
1638
+ try {
1639
+ subscribedTopics = partitionAssignerTopicsSelector(this.#members.get(this.memberId), this.#members);
1640
+ }
1641
+ catch (error) {
1642
+ callback(this.#handleError(new UserError('partitionAssignerTopicsSelector failed.', { cause: error })));
1643
+ return;
1644
+ }
1645
+ if (!Array.isArray(subscribedTopics) || subscribedTopics.some(topic => typeof topic !== 'string')) {
1646
+ callback(this.#handleError(new UserError('partitionAssignerTopicsSelector must return an array of topic names.')));
1647
+ return;
1648
+ }
1649
+ }
1650
+ else {
1651
+ // Get all the metadata for the topics the consumers are listening to, then compute the assignments.
1652
+ const topicsSubscriptions = new Map();
1653
+ for (const subscription of this.#members.values()) {
1654
+ for (const topic of subscription.topics) {
1655
+ let topicSubscriptions = topicsSubscriptions.get(topic);
1656
+ if (!topicSubscriptions) {
1657
+ topicSubscriptions = [];
1658
+ topicsSubscriptions.set(topic, topicSubscriptions);
1659
+ }
1660
+ topicSubscriptions.push(subscription);
1637
1661
  }
1638
- topicSubscriptions.push(subscription);
1639
1662
  }
1663
+ subscribedTopics = Array.from(topicsSubscriptions.keys());
1640
1664
  }
1641
- const subscribedTopics = Array.from(topicsSubscriptions.keys());
1642
1665
  this[kMetadata]({ topics: subscribedTopics }, (error, metadata) => {
1643
1666
  if (error) {
1644
1667
  callback(this.#handleError(error));
@@ -1649,7 +1672,7 @@ export class Consumer extends Base {
1649
1672
  callback(this.#handleError(new NetworkError(`Cannot compute group assignments: metadata for subscribed topic "${missingTopic}" is not available in the current cluster metadata.`, { canRetry: true, hasStaleMetadata: true })));
1650
1673
  return;
1651
1674
  }
1652
- this.#performSyncGroup(partitionsAssigner, this.#createAssignments(partitionsAssigner, metadata), callback);
1675
+ this.#performSyncGroup(partitionsAssigner, partitionAssignerTopicsSelector, this.#createAssignments(partitionsAssigner, metadata), callback);
1653
1676
  });
1654
1677
  return;
1655
1678
  }
@@ -1781,9 +1804,7 @@ export class Consumer extends Base {
1781
1804
  const ownedPartitions = cooperative ? this.#ownedPartitionsForSubscription(topics) : [];
1782
1805
  let userData = typeof metadata.metadata === 'string' ? Buffer.from(metadata.metadata) : metadata.metadata;
1783
1806
  if (cooperative && !userData && metadata.version < 2) {
1784
- userData = Writer.create()
1785
- .appendInt32(this.memberId && ownedPartitions.length > 0 ? this.generationId : -1)
1786
- .buffer;
1807
+ userData = Writer.create().appendInt32(this.memberId && ownedPartitions.length > 0 ? this.generationId : -1).buffer;
1787
1808
  }
1788
1809
  return encodeConsumerProtocolSubscription({
1789
1810
  version: metadata.version,
@@ -1832,7 +1853,7 @@ export class Consumer extends Base {
1832
1853
  }
1833
1854
  // We are the only member of the group, assign all partitions to us
1834
1855
  const membersSize = this.#members.size;
1835
- if (membersSize === 1) {
1856
+ if (membersSize === 1 && !partitionsAssigner) {
1836
1857
  const assignments = [];
1837
1858
  for (const topic of this.topics.current) {
1838
1859
  const partitionsCount = metadata.topics.get(topic).partitionsCount;
@@ -42,6 +42,7 @@ export class MessagesStream extends Readable {
42
42
  #refreshOffsetsInflight;
43
43
  #refreshOffsetsPending;
44
44
  #refreshOffsetsDestroyOnError;
45
+ #offsetsEpoch;
45
46
  #fetches;
46
47
  #maxFetches;
47
48
  #options;
@@ -88,7 +89,7 @@ export class MessagesStream extends Readable {
88
89
  constructor(consumer, options) {
89
90
  const { autocommit, mode, fallbackMode, maxFetches, offsets, context, deserializers, onCorruptedMessage, onDeserializationError, registry, beforeDeserialization,
90
91
  // The options below are only destructured to avoid being part of structuredClone below
91
- partitionAssigner: _partitionAssigner, protocolsMetadata: _protocolsMetadata, ...otherOptions } = options;
92
+ partitionAssigner: _partitionAssigner, partitionAssignerTopicsSelector: _partitionAssignerTopicsSelector, protocolsMetadata: _protocolsMetadata, ...otherOptions } = options;
92
93
  if (onCorruptedMessage && onDeserializationError) {
93
94
  throw new UserError('Cannot specify both onCorruptedMessage and onDeserializationError.');
94
95
  }
@@ -116,6 +117,7 @@ export class MessagesStream extends Readable {
116
117
  this.#refreshOffsetsInflight = false;
117
118
  this.#refreshOffsetsPending = false;
118
119
  this.#refreshOffsetsDestroyOnError = false;
120
+ this.#offsetsEpoch = 0;
119
121
  this.#fetches = 0;
120
122
  this.#maxFetches = maxFetches ?? 0;
121
123
  this.#topics = structuredClone(options.topics);
@@ -352,7 +354,11 @@ export class MessagesStream extends Readable {
352
354
  if (this.readableFlowing === null || this.#paused || this.#refreshOffsetsInflight || this.#refreshOffsetsPending) {
353
355
  return;
354
356
  }
357
+ const offsetsEpoch = this.#offsetsEpoch;
355
358
  this.#consumer.metadata({ topics: this.#consumer.topics.current }, (error, metadata) => {
359
+ if (offsetsEpoch !== this.#offsetsEpoch) {
360
+ return;
361
+ }
356
362
  if (error) {
357
363
  this.emit('fetch');
358
364
  // The stream has been closed, ignore any error
@@ -373,8 +379,8 @@ export class MessagesStream extends Readable {
373
379
  // Remove stale inflight entries that have been pending for too long.
374
380
  // This prevents permanent partition starvation if a fetch callback is never invoked.
375
381
  const now = Date.now();
376
- for (const [node, timestamp] of this.#inflightNodes) {
377
- if (now - timestamp > 120_000) {
382
+ for (const [node, inflight] of this.#inflightNodes) {
383
+ if (now - inflight.startedAt > 120_000) {
378
384
  this.#inflightNodes.delete(node);
379
385
  }
380
386
  }
@@ -423,10 +429,29 @@ export class MessagesStream extends Readable {
423
429
  return;
424
430
  }
425
431
  for (const [node, nodeRequests] of requests) {
426
- this.#inflightNodes.set(node, Date.now());
432
+ const inflight = { startedAt: Date.now() };
433
+ this.#inflightNodes.set(node, inflight);
427
434
  this.#consumer.fetch({ ...this.#options, node, topics: nodeRequests, connectionPool: this[kConnections] }, (error, response) => {
428
- this.#inflightNodes.delete(node);
435
+ const ownsNode = this.#inflightNodes.get(node) === inflight;
436
+ if (ownsNode) {
437
+ this.#inflightNodes.delete(node);
438
+ }
429
439
  this.emit('fetch');
440
+ // A refresh (including a rejoin) invalidates requests issued before it.
441
+ // Never let an old response or error undo the restored offsets.
442
+ if (offsetsEpoch !== this.#offsetsEpoch || !ownsNode) {
443
+ if (this.#closed || this.closed || this.destroyed) {
444
+ if (this.#inflightNodes.size === 0) {
445
+ this.push(null);
446
+ }
447
+ }
448
+ else if (ownsNode) {
449
+ // The refresh may have finished while this node was still inflight.
450
+ // Now that it is free, resume fetching from the restored position.
451
+ process.nextTick(() => this.#fetch());
452
+ }
453
+ return;
454
+ }
430
455
  if (error) {
431
456
  // The stream has been closed, ignore the error
432
457
  /* c8 ignore next 4 - Hard to test */
@@ -464,7 +489,7 @@ export class MessagesStream extends Readable {
464
489
  }
465
490
  return;
466
491
  }
467
- this.#pushRecordsOperation(metadata, topicIds, response, requestedOffsets);
492
+ this.#pushRecordsOperation(metadata, topicIds, response, requestedOffsets, offsetsEpoch);
468
493
  });
469
494
  }
470
495
  });
@@ -553,7 +578,10 @@ export class MessagesStream extends Readable {
553
578
  }
554
579
  return recoveredOffsets.length > 0;
555
580
  }
556
- #pushRecords(metadata, topicIds, response, requestedOffsets) {
581
+ #pushRecords(metadata, topicIds, response, requestedOffsets, offsetsEpoch) {
582
+ if (offsetsEpoch !== this.#offsetsEpoch) {
583
+ return;
584
+ }
557
585
  const autocommit = this.#autocommitEnabled;
558
586
  const keyDeserializer = this.#keyDeserializer;
559
587
  const valueDeserializer = this.#valueDeserializer;
@@ -995,6 +1023,8 @@ export class MessagesStream extends Readable {
995
1023
  });
996
1024
  }
997
1025
  #scheduleRefreshOffsetsAndFetch(destroyOnError = true) {
1026
+ // Invalidate outstanding fetches immediately, not only after restoration completes.
1027
+ this.#offsetsEpoch++;
998
1028
  this.#refreshOffsetsDestroyOnError ||= destroyOnError;
999
1029
  /* c8 ignore next 4 - Hard to test */
1000
1030
  if (this.#refreshOffsetsInflight) {
@@ -1078,7 +1108,7 @@ export class MessagesStream extends Readable {
1078
1108
  [kInspect](...args) {
1079
1109
  this.#consumer[kInspect](...args);
1080
1110
  }
1081
- #beforeDeserialization(hook, metadata, topicIds, response, requestedOffsets) {
1111
+ #beforeDeserialization(hook, metadata, topicIds, response, requestedOffsets, offsetsEpoch) {
1082
1112
  const requests = [];
1083
1113
  // Create the pre-deserialization requests
1084
1114
  for (const topicResponse of response.responses) {
@@ -1113,7 +1143,7 @@ export class MessagesStream extends Readable {
1113
1143
  }
1114
1144
  }
1115
1145
  if (requests.length === 0) {
1116
- this.#pushRecords(metadata, topicIds, response, requestedOffsets);
1146
+ this.#pushRecords(metadata, topicIds, response, requestedOffsets, offsetsEpoch);
1117
1147
  return;
1118
1148
  }
1119
1149
  runAsyncSeries((request, cb) => {
@@ -1137,11 +1167,14 @@ export class MessagesStream extends Readable {
1137
1167
  result.then(() => onHookSettled(null), onHookSettled);
1138
1168
  }
1139
1169
  }, requests, 0, error => {
1170
+ if (offsetsEpoch !== this.#offsetsEpoch) {
1171
+ return;
1172
+ }
1140
1173
  if (error) {
1141
1174
  this.destroy(error);
1142
1175
  return;
1143
1176
  }
1144
- this.#pushRecords(metadata, topicIds, response, requestedOffsets);
1177
+ this.#pushRecords(metadata, topicIds, response, requestedOffsets, offsetsEpoch);
1145
1178
  });
1146
1179
  }
1147
1180
  }
@@ -63,6 +63,9 @@ export declare const groupOptionsProperties: {
63
63
  partitionAssigner: {
64
64
  function: boolean;
65
65
  };
66
+ partitionAssignerTopicsSelector: {
67
+ function: boolean;
68
+ };
66
69
  };
67
70
  export declare const groupOptionsAdditionalValidations: {
68
71
  rebalanceTimeout: {
@@ -212,6 +215,9 @@ export declare const groupOptionsSchema: {
212
215
  partitionAssigner: {
213
216
  function: boolean;
214
217
  };
218
+ partitionAssignerTopicsSelector: {
219
+ function: boolean;
220
+ };
215
221
  };
216
222
  additionalProperties: boolean;
217
223
  };
@@ -339,6 +345,9 @@ export declare const consumeOptionsSchema: {
339
345
  partitionAssigner: {
340
346
  function: boolean;
341
347
  };
348
+ partitionAssignerTopicsSelector: {
349
+ function: boolean;
350
+ };
342
351
  topics: {
343
352
  type: string;
344
353
  items: {
@@ -515,6 +524,9 @@ export declare const consumerOptionsSchema: {
515
524
  partitionAssigner: {
516
525
  function: boolean;
517
526
  };
527
+ partitionAssignerTopicsSelector: {
528
+ function: boolean;
529
+ };
518
530
  groupId: {
519
531
  type: string;
520
532
  pattern: string;
@@ -649,6 +661,9 @@ export declare const fetchOptionsSchema: {
649
661
  partitionAssigner: {
650
662
  function: boolean;
651
663
  };
664
+ partitionAssignerTopicsSelector: {
665
+ function: boolean;
666
+ };
652
667
  node: {
653
668
  type: string;
654
669
  minimum: number;
@@ -27,7 +27,8 @@ export const groupOptionsProperties = {
27
27
  }
28
28
  },
29
29
  protocolsMetadata: { function: true },
30
- partitionAssigner: { function: true }
30
+ partitionAssigner: { function: true },
31
+ partitionAssignerTopicsSelector: { function: true }
31
32
  };
32
33
  export const groupOptionsAdditionalValidations = {
33
34
  rebalanceTimeout: {
@@ -200,7 +201,7 @@ export const listOffsetsOptionsSchema = {
200
201
  items: { type: 'number', minimum: 0 }
201
202
  }
202
203
  },
203
- isolationLevel: { type: 'string', enum: allowedFetchIsolationLevels },
204
+ isolationLevel: { type: 'number', enum: allowedFetchIsolationLevels },
204
205
  timestamp: { bigint: true }
205
206
  },
206
207
  required: ['topics'],
@@ -1,5 +1,5 @@
1
- import { type Callback } from '../../apis/definitions.ts';
2
1
  import { type FetchRequestTopic } from '../../apis/consumer/fetch-v17.ts';
2
+ import { type Callback } from '../../apis/definitions.ts';
3
3
  import { type GroupProtocols } from '../../apis/enumerations.ts';
4
4
  import { type ConnectionPool } from '../../network/connection-pool.ts';
5
5
  import { type KafkaRecord, type Message } from '../../protocol/records.ts';
@@ -65,6 +65,7 @@ export type DeserializationErrorHandler = (context: DeserializationErrorContext)
65
65
  */
66
66
  export type CorruptedMessageHandler = (record: KafkaRecord, topic: string, partition: number, firstTimestamp: bigint, firstOffset: bigint, commit: Message['commit'], error: unknown) => boolean;
67
67
  export type GroupPartitionsAssigner = (current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata) => GroupPartitionsAssignments[];
68
+ export type GroupPartitionsAssignerTopicsSelector = (leader: ExtendedGroupProtocolSubscription, members: Map<string, ExtendedGroupProtocolSubscription>) => string[];
68
69
  export type GroupProtocolsMetadataCallback = (protocols: GroupProtocolSubscription[], topics: string[], metadata: ClusterMetadata, callback: Callback<Buffer>) => Buffer | Promise<Buffer> | undefined;
69
70
  export declare const MessagesStreamModes: {
70
71
  readonly LATEST: "latest";
@@ -93,6 +94,7 @@ export interface GroupOptions {
93
94
  protocols?: GroupProtocolSubscription[];
94
95
  protocolsMetadata?: GroupProtocolsMetadataCallback;
95
96
  partitionAssigner?: GroupPartitionsAssigner;
97
+ partitionAssignerTopicsSelector?: GroupPartitionsAssignerTopicsSelector;
96
98
  assignmentUserData?: Buffer;
97
99
  }
98
100
  export interface ConsumerGroupOptions {
@@ -295,7 +295,7 @@ export class Producer extends Base {
295
295
  const brokers = {};
296
296
  for (const [topic, partitions] of topicsPartitions) {
297
297
  for (const rawPartition of partitions) {
298
- const partition = rawPartition & metadata.topics.get(topic).partitionsCount;
298
+ const partition = rawPartition % metadata.topics.get(topic).partitionsCount;
299
299
  const leader = metadata.topics.get(topic).partitions[partition].leader;
300
300
  const broker = metadata.brokers.get(leader);
301
301
  brokers[`${topic}:${partition}`] = broker;
package/dist/errors.d.ts CHANGED
@@ -7,6 +7,7 @@ export type ErrorCode = (typeof errorCodes)[number];
7
7
  export type ErrorProperties = {
8
8
  cause?: Error;
9
9
  } & Record<string, any>;
10
+ export declare function findNestedErrorBy(error: Error, property: string, value: unknown, visited: Set<Error>): Error | null;
10
11
  export declare class GenericError extends Error {
11
12
  code: string;
12
13
  [index: string]: any;
package/dist/errors.js CHANGED
@@ -19,6 +19,30 @@ export const errorCodes = [
19
19
  'PLT_KFK_UNSUPPORTED',
20
20
  'PLT_KFK_USER'
21
21
  ];
22
+ export function findNestedErrorBy(error, property, value, visited) {
23
+ if (!error || visited.has(error)) {
24
+ return null;
25
+ }
26
+ visited.add(error);
27
+ if (error[property] === value) {
28
+ return error;
29
+ }
30
+ // Visit aggregate entries before the cause, preserving the existing search order.
31
+ if (MultipleErrors.isMultipleErrors(error)) {
32
+ for (const nested of error.errors) {
33
+ if (nested && typeof nested === 'object') {
34
+ const found = findNestedErrorBy(nested, property, value, visited);
35
+ if (found) {
36
+ return found;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ if (error.cause && typeof error.cause === 'object') {
42
+ return findNestedErrorBy(error.cause, property, value, visited);
43
+ }
44
+ return null;
45
+ }
22
46
  export class GenericError extends Error {
23
47
  code;
24
48
  [kGenericError];
@@ -40,10 +64,7 @@ export class GenericError extends Error {
40
64
  Reflect.defineProperty(this, kGenericError, { value: true, enumerable: false });
41
65
  }
42
66
  findBy(property, value) {
43
- if (this[property] === value) {
44
- return this;
45
- }
46
- return null;
67
+ return findNestedErrorBy(this, property, value, new Set());
47
68
  }
48
69
  toString() {
49
70
  return `Error ${this.code}: ${this.message}`;
@@ -77,22 +98,7 @@ export class MultipleErrors extends AggregateError {
77
98
  Reflect.defineProperty(this, kMultipleErrors, { value: true, enumerable: false });
78
99
  }
79
100
  findBy(property, value) {
80
- if (this[property] === value) {
81
- return this;
82
- }
83
- for (const error of this.errors) {
84
- if (!error) {
85
- continue;
86
- }
87
- if (error[property] === value) {
88
- return error;
89
- }
90
- const found = error[kGenericError] ? error.findBy(property, value) : undefined;
91
- if (found) {
92
- return found;
93
- }
94
- }
95
- return null;
101
+ return findNestedErrorBy(this, property, value, new Set());
96
102
  }
97
103
  toString() {
98
104
  return `Error ${this.code}: ${this.message} (caused by: ${this.errors.map(error => error.toString()).join(', ')})`;
@@ -121,7 +127,8 @@ export class NetworkError extends GenericError {
121
127
  static code = 'PLT_KFK_NETWORK';
122
128
  static isRetryable(error) {
123
129
  const networkError = findErrorBy(error, 'code', NetworkError.code);
124
- return networkError?.canRetry === true;
130
+ // Require a retryable network error, but reject any non-retriable error in the whole tree.
131
+ return networkError?.canRetry === true && !findErrorBy(error, 'canRetry', false);
125
132
  }
126
133
  constructor(message, properties = {}) {
127
134
  super(NetworkError.code, message, { canRetry: true, ...properties });
@@ -35,6 +35,9 @@ export interface SASLOptions {
35
35
  oauthBearerExtensions?: Record<string, string> | CredentialProvider<Record<string, string>>;
36
36
  authenticate?: SASLCustomAuthenticator;
37
37
  authBytesValidator?: (authBytes: Buffer, callback: CallbackWithPromise<Buffer>) => void;
38
+ reauthFraction?: number;
39
+ reauthLeadTime?: number;
40
+ lazyReauthentication?: boolean;
38
41
  }
39
42
  export interface ConnectionOptions {
40
43
  connectTimeout?: number;
@@ -4,8 +4,8 @@ import { createConnection } from 'node:net';
4
4
  import { connect as createTLSConnection } from 'node:tls';
5
5
  import { createPromisifiedCallback, kCallbackPromise } from "../apis/callbacks.js";
6
6
  import { allowedSASLMechanisms, SASLMechanisms } from "../apis/enumerations.js";
7
- import { clientSoftwareName, clientSoftwareVersion } from "../clients/base/options.js";
8
7
  import { apiVersionsV1, saslAuthenticateV0, saslAuthenticateV1, saslAuthenticateV2, saslHandshakeV1 } from "../apis/index.js";
8
+ import { clientSoftwareName, clientSoftwareVersion } from "../clients/base/options.js";
9
9
  import { connectionsApiChannel, connectionsConnectsChannel, createDiagnosticContext, notifyCreation } from "../diagnostic.js";
10
10
  import { AuthenticationError, NetworkError, TimeoutError, UnexpectedCorrelationIdError, UserError } from "../errors.js";
11
11
  import { TypedEventEmitter } from "../events.js";
@@ -54,6 +54,7 @@ export class Connection extends TypedEventEmitter {
54
54
  #socketMustBeDrained;
55
55
  #detectMissingTLS;
56
56
  #reauthenticationTimeout;
57
+ #reauthenticationDue = false;
57
58
  // Negotiated once per connection: reauthentication runs the same path again.
58
59
  #saslAuthenticateApi;
59
60
  constructor(clientId, options = {}) {
@@ -239,7 +240,7 @@ export class Connection extends TypedEventEmitter {
239
240
  if (!callback) {
240
241
  callback = createPromisifiedCallback();
241
242
  }
242
- clearInterval(this.#reauthenticationTimeout);
243
+ clearTimeout(this.#reauthenticationTimeout);
243
244
  if (this.#status === ConnectionStatuses.CLOSED ||
244
245
  this.#status === ConnectionStatuses.ERROR ||
245
246
  this.#status === ConnectionStatuses.NONE) {
@@ -266,6 +267,25 @@ export class Connection extends TypedEventEmitter {
266
267
  return callback[kCallbackPromise];
267
268
  }
268
269
  send(apiKey, apiVersion, createPayload, responseParser, hasRequestHeaderTaggedFields, hasResponseHeaderTaggedFields, callback) {
270
+ if ((this.#status === ConnectionStatuses.REAUTHENTICATING ||
271
+ (this.#status === ConnectionStatuses.CONNECTED && this.#reauthenticationDue)) &&
272
+ // SASL requests must still pass through send() while reauthentication is in progress.
273
+ apiKey !== apiVersionsV1.api.key &&
274
+ apiKey !== saslHandshakeV1.api.key &&
275
+ apiKey !== saslAuthenticateV2.api.key) {
276
+ this.ready(error => {
277
+ if (error) {
278
+ callback(error);
279
+ }
280
+ else {
281
+ this.send(apiKey, apiVersion, createPayload, responseParser, hasRequestHeaderTaggedFields, hasResponseHeaderTaggedFields, callback);
282
+ }
283
+ });
284
+ if (this.#reauthenticationDue) {
285
+ this.reauthenticate();
286
+ }
287
+ return;
288
+ }
269
289
  // Correlation ID is a 32-bit integer in the protocol, so we need to wrap around after 2^31 - 1
270
290
  const correlationId = (this.#correlationId + 1) & 0x7fffffff;
271
291
  this.#correlationId = correlationId;
@@ -358,6 +378,7 @@ export class Connection extends TypedEventEmitter {
358
378
  const host = this.#host;
359
379
  const port = this.#port;
360
380
  const diagnosticContext = createDiagnosticContext({ connection: this, operation: 'reauthenticate', host, port });
381
+ this.#reauthenticationDue = false;
361
382
  this.#status = ConnectionStatuses.REAUTHENTICATING;
362
383
  clearTimeout(this.#reauthenticationTimeout);
363
384
  this.#authenticate(host, port, diagnosticContext);
@@ -537,7 +558,18 @@ export class Connection extends TypedEventEmitter {
537
558
  return;
538
559
  }
539
560
  if (sessionLifetimeMs > 0) {
540
- this.#reauthenticationTimeout = setTimeout(this.reauthenticate.bind(this), Number(sessionLifetimeMs) * 0.8);
561
+ const lifetime = Number(sessionLifetimeMs);
562
+ const leadTime = lifetime - (this.#options.sasl?.reauthLeadTime ?? 0);
563
+ const fractionTime = lifetime * (this.#options.sasl?.reauthFraction ?? 0.8);
564
+ // Ignore a non-positive lead-time delay to avoid an immediate reauthentication loop.
565
+ this.#reauthenticationTimeout = setTimeout(() => {
566
+ if (this.#options.sasl?.lazyReauthentication) {
567
+ this.#reauthenticationDue = true;
568
+ }
569
+ else {
570
+ this.reauthenticate();
571
+ }
572
+ }, Math.min(fractionTime, leadTime > 0 ? leadTime : fractionTime));
541
573
  }
542
574
  const isReauthenticating = this.#status === ConnectionStatuses.REAUTHENTICATING;
543
575
  if (this.#status === ConnectionStatuses.CONNECTED || isReauthenticating) {
@@ -40,6 +40,7 @@ export interface Group extends Omit<GroupBase, 'groupType'> {
40
40
  protocol: string;
41
41
  members: Map<string, GroupMember>;
42
42
  authorizedOperations: number;
43
+ error?: Error;
43
44
  }
44
45
  export interface AdminOptions extends BaseOptions {}
45
46
  export interface CreateTopicsTopicOptions {
@@ -108,6 +108,19 @@ export declare const baseOptionsSchema: {
108
108
  type: string;
109
109
  enum: ("PLAIN" | "SCRAM-SHA-256" | "SCRAM-SHA-512" | "OAUTHBEARER" | "GSSAPI")[];
110
110
  };
111
+ reauthFraction: {
112
+ type: string;
113
+ exclusiveMinimum: number;
114
+ maximum: number;
115
+ default: number;
116
+ };
117
+ reauthLeadTime: {
118
+ type: string;
119
+ minimum: number;
120
+ };
121
+ lazyReauthentication: {
122
+ type: string;
123
+ };
111
124
  username: {
112
125
  oneOf: ({
113
126
  type: string;
@@ -6,7 +6,7 @@ import { Base, type BaseEvents, kCreateConnectionPool } from "../base/base";
6
6
  import { type ClusterMetadata } from "../base/types";
7
7
  import { MessagesStream } from "./messages-stream";
8
8
  import { TopicsMap } from "./topics-map";
9
- import { type CommitOptions, type ConsumeOptions, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupAutocommitErrorPayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerHeartbeatStalledPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from "./types";
9
+ import { type CommitOptions, type ConsumeOptions, type ConsumerGroupAutocommitErrorPayload, type ConsumerGroupJoinPayload, type ConsumerGroupLeavePayload, type ConsumerGroupRebalancePayload, type ConsumerHeartbeatErrorPayload, type ConsumerHeartbeatPayload, type ConsumerHeartbeatStalledPayload, type ConsumerOptions, type FetchOptions, type GetLagOptions, type GroupAssignment, type GroupOptions, type ListCommitsOptions, type ListOffsetsOptions, type Offsets, type OffsetsWithTimestamps } from "./types";
10
10
  export interface ConsumerEvents extends BaseEvents {
11
11
  'consumer:group:join': (payload: ConsumerGroupJoinPayload) => void;
12
12
  'consumer:group:leave': (payload: ConsumerGroupLeavePayload) => void;
@@ -63,6 +63,9 @@ export declare const groupOptionsProperties: {
63
63
  partitionAssigner: {
64
64
  function: boolean;
65
65
  };
66
+ partitionAssignerTopicsSelector: {
67
+ function: boolean;
68
+ };
66
69
  };
67
70
  export declare const groupOptionsAdditionalValidations: {
68
71
  rebalanceTimeout: {
@@ -212,6 +215,9 @@ export declare const groupOptionsSchema: {
212
215
  partitionAssigner: {
213
216
  function: boolean;
214
217
  };
218
+ partitionAssignerTopicsSelector: {
219
+ function: boolean;
220
+ };
215
221
  };
216
222
  additionalProperties: boolean;
217
223
  };
@@ -339,6 +345,9 @@ export declare const consumeOptionsSchema: {
339
345
  partitionAssigner: {
340
346
  function: boolean;
341
347
  };
348
+ partitionAssignerTopicsSelector: {
349
+ function: boolean;
350
+ };
342
351
  topics: {
343
352
  type: string;
344
353
  items: {
@@ -515,6 +524,9 @@ export declare const consumerOptionsSchema: {
515
524
  partitionAssigner: {
516
525
  function: boolean;
517
526
  };
527
+ partitionAssignerTopicsSelector: {
528
+ function: boolean;
529
+ };
518
530
  groupId: {
519
531
  type: string;
520
532
  pattern: string;
@@ -649,6 +661,9 @@ export declare const fetchOptionsSchema: {
649
661
  partitionAssigner: {
650
662
  function: boolean;
651
663
  };
664
+ partitionAssignerTopicsSelector: {
665
+ function: boolean;
666
+ };
652
667
  node: {
653
668
  type: string;
654
669
  minimum: number;
@@ -1,5 +1,5 @@
1
- import { type Callback } from "../../apis/definitions";
2
1
  import { type FetchRequestTopic } from "../../apis/consumer/fetch-v17";
2
+ import { type Callback } from "../../apis/definitions";
3
3
  import { type GroupProtocols } from "../../apis/enumerations";
4
4
  import { type ConnectionPool } from "../../network/connection-pool";
5
5
  import { type KafkaRecord, type Message } from "../../protocol/records";
@@ -65,6 +65,7 @@ export type DeserializationErrorHandler = (context: DeserializationErrorContext)
65
65
  */
66
66
  export type CorruptedMessageHandler = (record: KafkaRecord, topic: string, partition: number, firstTimestamp: bigint, firstOffset: bigint, commit: Message['commit'], error: unknown) => boolean;
67
67
  export type GroupPartitionsAssigner = (current: string, members: Map<string, ExtendedGroupProtocolSubscription>, topics: Set<string>, metadata: ClusterMetadata) => GroupPartitionsAssignments[];
68
+ export type GroupPartitionsAssignerTopicsSelector = (leader: ExtendedGroupProtocolSubscription, members: Map<string, ExtendedGroupProtocolSubscription>) => string[];
68
69
  export type GroupProtocolsMetadataCallback = (protocols: GroupProtocolSubscription[], topics: string[], metadata: ClusterMetadata, callback: Callback<Buffer>) => Buffer | Promise<Buffer> | undefined;
69
70
  export declare const MessagesStreamModes: {
70
71
  readonly LATEST: "latest";
@@ -93,6 +94,7 @@ export interface GroupOptions {
93
94
  protocols?: GroupProtocolSubscription[];
94
95
  protocolsMetadata?: GroupProtocolsMetadataCallback;
95
96
  partitionAssigner?: GroupPartitionsAssigner;
97
+ partitionAssignerTopicsSelector?: GroupPartitionsAssignerTopicsSelector;
96
98
  assignmentUserData?: Buffer;
97
99
  }
98
100
  export interface ConsumerGroupOptions {
@@ -7,6 +7,7 @@ export type ErrorCode = (typeof errorCodes)[number];
7
7
  export type ErrorProperties = {
8
8
  cause?: Error;
9
9
  } & Record<string, any>;
10
+ export declare function findNestedErrorBy(error: Error, property: string, value: unknown, visited: Set<Error>): Error | null;
10
11
  export declare class GenericError extends Error {
11
12
  code: string;
12
13
  [index: string]: any;
@@ -35,6 +35,9 @@ export interface SASLOptions {
35
35
  oauthBearerExtensions?: Record<string, string> | CredentialProvider<Record<string, string>>;
36
36
  authenticate?: SASLCustomAuthenticator;
37
37
  authBytesValidator?: (authBytes: Buffer, callback: CallbackWithPromise<Buffer>) => void;
38
+ reauthFraction?: number;
39
+ reauthLeadTime?: number;
40
+ lazyReauthentication?: boolean;
38
41
  }
39
42
  export interface ConnectionOptions {
40
43
  connectTimeout?: number;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = "@platformatic/kafka";
2
- export const version = "2.12.0";
2
+ export const version = "2.13.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Modern and performant client for Apache Kafka",
5
5
  "homepage": "https://github.com/platformatic/kafka",
6
6
  "author": "Platformatic Inc. <oss@platformatic.dev> (https://platformatic.dev)",
@@ -95,12 +95,13 @@
95
95
  "test": "c8 -c test/config/c8-local.json ./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/*.test.ts' 'test/*/*.test.ts' 'test/*/*/*.test.ts'",
96
96
  "test:ci": "./scripts/node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=90 --test-reporter=cleaner-spec-reporter 'test/*.test.ts' 'test/*/*.test.ts' 'test/*/*/*.test.ts'",
97
97
  "test:integrity": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/integrity/*.test.ts'",
98
- "test:performance": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'regression/performance/*.test.ts'",
98
+ "test:performance": "./scripts/node --test --test-concurrency=1 --test-reporter=cleaner-spec-reporter 'regression/performance/*.test.ts'",
99
99
  "test:regression": "npm run test:integrity",
100
100
  "test:regression:all": "npm run test:integrity && npm run test:performance",
101
101
  "test:all": "npm test && npm run test:regression:all",
102
102
  "test:compat": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/integration/*.compat-test.ts'",
103
103
  "test:memory": "NODE_OPTIONS='--expose-gc' ./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/**/*.memory-test.ts'",
104
+ "test:e2e:upgrade": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/e2e/client-upgrade.e2e-test.ts'",
104
105
  "test:e2e:redpanda": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/e2e/redpanda.e2e-test.ts'",
105
106
  "test:e2e:eventhubs": "./scripts/node --test --test-reporter=cleaner-spec-reporter 'test/e2e/eventhubs.e2e-test.ts'",
106
107
  "test:docker:up": "docker-compose up -d --wait",