@platformatic/kafka 2.11.0 → 2.12.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.
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 +114 -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 +5 -2
@@ -2,18 +2,17 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
3
3
  import { FetchIsolationLevels, FindCoordinatorKeyTypes } from "../../apis/enumerations.js";
4
4
  import { consumerCommitsChannel, consumerConsumesChannel, consumerFetchesChannel, consumerGroupChannel, consumerHeartbeatChannel, consumerLagChannel, consumerOffsetsChannel, createDiagnosticContext } from "../../diagnostic.js";
5
- import { findErrorBy, NetworkError, protocolErrors, UserError } from "../../errors.js";
6
- import { INT32_SIZE } from "../../protocol/definitions.js";
7
- import { Reader } from "../../protocol/reader.js";
5
+ import { findErrorBy, MultipleErrors, NetworkError, protocolErrors, UserError } from "../../errors.js";
8
6
  import { IS_CONTROL } from "../../protocol/records.js";
9
7
  import { Writer } from "../../protocol/writer.js";
10
- import { kAutocommit, kGetFetchNode, kRefreshOffsetsAndFetch } from "../../symbols.js";
8
+ import { kAutocommit, kGetFetchNode, kRefreshOffsetsAndFetch, kUpdateCommittedOffset } from "../../symbols.js";
11
9
  import { emitExperimentalApiWarning } from "../../utils.js";
12
10
  import { Base, kAfterCreate, kCheckNotClosed, kClosed, kConnections, kCreateConnectionPool, kFormatValidationErrors, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kPrometheus, kValidateOptions } from "../base/base.js";
13
11
  import { ensureMetric } from "../metrics.js";
14
12
  import { MessagesStream } from "./messages-stream.js";
13
+ import { CONSUMER_PROTOCOL_HIGHEST_VERSION, decodeCooperativeStickyGeneration, decodeConsumerProtocolAssignment, decodeConsumerProtocolSubscription, encodeConsumerProtocolAssignment, encodeConsumerProtocolSubscription } from "./consumer-protocol.js";
15
14
  import { commitOptionsValidator, consumeOptionsValidator, consumerOptionsValidator, defaultConsumerOptions, fetchOptionsValidator, getLagOptionsValidator, groupIdAndOptionsValidator, groupOptionsValidator, listCommitsOptionsValidator, listOffsetsOptionsValidator } from "./options.js";
16
- import { roundRobinAssigner } from "./partitions-assigners.js";
15
+ import { COOPERATIVE_STICKY_ASSIGNOR, cooperativeStickyAssigner, roundRobinAssigner } from "./partitions-assigners.js";
17
16
  import { TopicsMap } from "./topics-map.js";
18
17
  import { nextFetchEpoch, partitionKey } from "./utils.js";
19
18
  export class Consumer extends Base {
@@ -170,7 +169,7 @@ export class Consumer extends Base {
170
169
  return false;
171
170
  }
172
171
  if (this.#useConsumerGroupProtocol) {
173
- return !!this.memberId && this.#memberEpoch >= 0;
172
+ return !!this.memberId && this.#memberEpoch > 0;
174
173
  }
175
174
  // We consider the group ready if we have a groupId, a memberId and heartbeat interval
176
175
  return this.#membershipActive && Boolean(this.groupId) && Boolean(this.memberId) && this.#heartbeatInterval !== null;
@@ -637,7 +636,14 @@ export class Consumer extends Base {
637
636
  findErrorBy(error, 'apiId', 'FETCH_SESSION_TOPIC_ID_ERROR')) {
638
637
  this.#fetchSessions.delete(node);
639
638
  }
640
- if (findErrorBy(error, 'apiId', 'FENCED_LEADER_EPOCH')) {
639
+ // NOT_LEADER_OR_FOLLOWER/LEADER_NOT_AVAILABLE mean leadership just moved (broker
640
+ // rebalance/leader election); without a refresh here, kPerformWithRetry repeats
641
+ // the same request against the stale cached leader until retries exhaust.
642
+ // UNKNOWN_TOPIC_OR_PARTITION also carries hasStaleMetadata but is deliberately
643
+ // excluded: broadening this to it breaks deleted-topic handling (see git history).
644
+ if (findErrorBy(error, 'apiId', 'FENCED_LEADER_EPOCH') ||
645
+ findErrorBy(error, 'apiId', 'NOT_LEADER_OR_FOLLOWER') ||
646
+ findErrorBy(error, 'apiId', 'LEADER_NOT_AVAILABLE')) {
641
647
  this.#fetchSessions.delete(node);
642
648
  this.clearMetadata();
643
649
  for (const topic of options.topics) {
@@ -717,6 +723,16 @@ export class Consumer extends Base {
717
723
  });
718
724
  return;
719
725
  }
726
+ if (!error) {
727
+ for (const { topic, partition, offset } of options.offsets) {
728
+ const key = partitionKey(topic, partition);
729
+ for (const stream of this.#streams) {
730
+ if (stream.offsetsCommitted.has(key)) {
731
+ stream[kUpdateCommittedOffset](topic, partition, offset);
732
+ }
733
+ }
734
+ }
735
+ }
720
736
  callback(error);
721
737
  });
722
738
  }
@@ -780,7 +796,16 @@ export class Consumer extends Base {
780
796
  }, concurrentCallback, 0);
781
797
  }, (error, responses) => {
782
798
  if (error) {
783
- if (!forceUpdateMetadata && NetworkError.isRetryable(error)) {
799
+ // Mirrors the fetch() handling: right after a topic is created or its leadership moves,
800
+ // the cached leader is briefly stale and the broker answers with one of these three
801
+ // errors. Retrying once against fresh metadata clears the race instead of surfacing it.
802
+ // UNKNOWN_TOPIC_OR_PARTITION also carries hasStaleMetadata but is deliberately excluded,
803
+ // same as in fetch(), to avoid masking deleted-topic handling.
804
+ if (!forceUpdateMetadata &&
805
+ (NetworkError.isRetryable(error) ||
806
+ findErrorBy(error, 'apiId', 'FENCED_LEADER_EPOCH') ||
807
+ findErrorBy(error, 'apiId', 'NOT_LEADER_OR_FOLLOWER') ||
808
+ findErrorBy(error, 'apiId', 'LEADER_NOT_AVAILABLE'))) {
784
809
  this.clearMetadata();
785
810
  this.#listOffsets(withTimestamps, options, callback, true);
786
811
  return;
@@ -931,9 +956,7 @@ export class Consumer extends Base {
931
956
  // - a rejoin that never completes still surfaces as a stall
932
957
  this.#resetHeartbeatStall();
933
958
  this.#trackHeartbeatStall(options, error, options.rebalanceTimeout + this.#heartbeatStallTimeoutFor(options));
934
- this[kPerformWithRetry]('rejoinGroup', retryCallback => {
935
- this.#joinGroup(options, retryCallback);
936
- }, error => {
959
+ this.#joinGroup(options, error => {
937
960
  if (error) {
938
961
  this.emitWithDebug(null, 'error', error);
939
962
  if (!this.#heartbeatStalled) {
@@ -949,7 +972,7 @@ export class Consumer extends Base {
949
972
  this.#resetHeartbeatStall();
950
973
  }
951
974
  this.emitWithDebug('consumer', 'rejoin');
952
- }, 0);
975
+ });
953
976
  return;
954
977
  }
955
978
  // The error was not group related, so we schedule another heartbeat.
@@ -1107,6 +1130,22 @@ export class Consumer extends Base {
1107
1130
  }
1108
1131
  return result;
1109
1132
  }
1133
+ #diffGroupAssignments(A, B) {
1134
+ const result = [];
1135
+ for (const a of A) {
1136
+ const b = B.find(assignment => assignment.topic === a.topic);
1137
+ if (!b) {
1138
+ result.push({ topic: a.topic, partitions: [...a.partitions] });
1139
+ }
1140
+ else {
1141
+ const diff = a.partitions.filter(partition => !b.partitions.includes(partition));
1142
+ if (diff.length > 0) {
1143
+ result.push({ topic: a.topic, partitions: diff });
1144
+ }
1145
+ }
1146
+ }
1147
+ return result;
1148
+ }
1110
1149
  #revokePartitions(newAssignment) {
1111
1150
  const toRevoke = this.#diffAssignments(this.#assignments, newAssignment);
1112
1151
  if (toRevoke.length === 0) {
@@ -1132,6 +1171,14 @@ export class Consumer extends Base {
1132
1171
  #assignPartitions(newAssignment) {
1133
1172
  const toAssign = this.#diffAssignments(newAssignment, this.#assignments);
1134
1173
  if (toAssign.length === 0) {
1174
+ // An explicit empty initial assignment confirms an idle member, even though
1175
+ // there are no partitions to add. An omitted assignment must remain unknown.
1176
+ if (this.assignments === null && newAssignment.length === 0) {
1177
+ this.assignments = [];
1178
+ for (const stream of this.#streams) {
1179
+ stream[kRefreshOffsetsAndFetch]();
1180
+ }
1181
+ }
1135
1182
  return;
1136
1183
  }
1137
1184
  this.#updateAssignments(newAssignment, error => {
@@ -1210,9 +1257,18 @@ export class Consumer extends Base {
1210
1257
  });
1211
1258
  return;
1212
1259
  }
1260
+ const finish = () => {
1261
+ this.emitWithDebug('consumer', 'group:leave', { groupId: this.groupId, memberId: this.memberId });
1262
+ this.memberId = null;
1263
+ this.#memberEpoch = -1;
1264
+ this.#assignments = [];
1265
+ this.assignments = [];
1266
+ this.#syncPreferredReadReplicas();
1267
+ callback(null);
1268
+ };
1213
1269
  // Leave by sending a heartbeat with memberEpoch = -1
1214
1270
  this.#cancelHeartbeat();
1215
- this.#performDeduplicateGroupOperaton('leaveGroupConsumerProtocol', (connection, groupCallback) => {
1271
+ const operation = (connection, groupCallback) => {
1216
1272
  this[kGetApi]('ConsumerGroupHeartbeat', (error, api) => {
1217
1273
  if (error) {
1218
1274
  groupCallback(error);
@@ -1226,15 +1282,18 @@ export class Consumer extends Base {
1226
1282
  this.#groupRemoteAssignor, [], // topicPartitions
1227
1283
  groupCallback);
1228
1284
  });
1229
- }, _error => {
1230
- this.emitWithDebug('consumer', 'group:leave', { groupId: this.groupId, memberId: this.memberId });
1231
- this.memberId = null;
1232
- this.#memberEpoch = -1;
1233
- this.#assignments = [];
1234
- this.assignments = [];
1235
- this.#syncPreferredReadReplicas();
1236
- callback(null);
1237
- });
1285
+ };
1286
+ if (this[kClosed]) {
1287
+ const connection = this.#getGroupCoordinatorConnection();
1288
+ if (connection) {
1289
+ operation(connection, finish);
1290
+ }
1291
+ else {
1292
+ finish();
1293
+ }
1294
+ return;
1295
+ }
1296
+ this.#performDeduplicateGroupOperaton('leaveGroupConsumerProtocol', operation, finish);
1238
1297
  }
1239
1298
  #getConsumerGroupHeartbeatMemberId(apiVersion) {
1240
1299
  if (this.memberId) {
@@ -1322,6 +1381,13 @@ export class Consumer extends Base {
1322
1381
  }, callback);
1323
1382
  }
1324
1383
  #performJoinGroup(options, callback) {
1384
+ if (!this.#membershipActive) {
1385
+ callback(null);
1386
+ return;
1387
+ }
1388
+ this.#performJoinGroupAttempt(options, callback, 0, []);
1389
+ }
1390
+ #performJoinGroupAttempt(options, callback, attempt, attemptErrors) {
1325
1391
  if (!this.#membershipActive) {
1326
1392
  callback(null);
1327
1393
  return;
@@ -1329,7 +1395,7 @@ export class Consumer extends Base {
1329
1395
  this.#cancelHeartbeat();
1330
1396
  this.#createJoinGroupProtocols(options, (error, protocols) => {
1331
1397
  if (error) {
1332
- callback(error);
1398
+ this.#retryJoinGroup(options, callback, attempt, attemptErrors, error);
1333
1399
  return;
1334
1400
  }
1335
1401
  this.#performDeduplicateGroupOperaton('joinGroup', (connection, groupCallback) => {
@@ -1346,11 +1412,7 @@ export class Consumer extends Base {
1346
1412
  return;
1347
1413
  }
1348
1414
  if (error) {
1349
- if (this.#getRejoinError(error)) {
1350
- this.#performJoinGroup(options, callback);
1351
- return;
1352
- }
1353
- callback(error);
1415
+ this.#retryJoinGroup(options, callback, attempt, attemptErrors, error);
1354
1416
  return;
1355
1417
  }
1356
1418
  // This is for Azure Event Hubs compatibility, which does not respond with an error on the first join
@@ -1369,31 +1431,120 @@ export class Consumer extends Base {
1369
1431
  return;
1370
1432
  }
1371
1433
  if (error) {
1372
- if (this.#getRejoinError(error)) {
1373
- this.#performJoinGroup(options, callback);
1374
- return;
1375
- }
1376
- callback(error);
1434
+ this.#retryJoinGroup(options, callback, attempt, attemptErrors, error);
1377
1435
  return;
1378
1436
  }
1379
- this.assignments = response;
1380
- this.#syncPreferredReadReplicas();
1381
- this.#cancelHeartbeat();
1382
- this.#heartbeatInterval = setTimeout(() => {
1383
- this.#heartbeat(options);
1384
- }, options.heartbeatInterval);
1385
- this.emitWithDebug('consumer', 'group:join', {
1386
- groupId: this.groupId,
1387
- memberId: this.memberId,
1388
- generationId: this.generationId,
1389
- isLeader: this.#isLeader,
1390
- assignments: this.assignments
1391
- });
1392
- callback(null, this.memberId);
1437
+ this.#completeClassicSyncGroup(options, response, callback);
1393
1438
  });
1394
1439
  });
1395
1440
  });
1396
1441
  }
1442
+ #retryJoinGroup(options, callback, attempt, attemptErrors, error) {
1443
+ const retries = this[kOptions].retries;
1444
+ const memberId = this.memberId;
1445
+ const rejoinError = this.#getRejoinError(error);
1446
+ // MEMBER_ID_REQUIRED is the normal first-join handshake. The broker assigns a member ID and
1447
+ // expects the JoinGroup request to be sent again; this progress must not consume a retry.
1448
+ if (rejoinError?.apiId === 'MEMBER_ID_REQUIRED' && this.memberId !== memberId) {
1449
+ this.#performJoinGroupAttempt(options, callback, attempt, attemptErrors);
1450
+ return;
1451
+ }
1452
+ attemptErrors.push(error);
1453
+ if (!rejoinError) {
1454
+ if (error instanceof MultipleErrors && error.errors.length > 0) {
1455
+ const nestedMessages = error.errors.map(error => error.message).join(' ');
1456
+ callback(new MultipleErrors(`${error.message} ${nestedMessages}`, error.errors));
1457
+ }
1458
+ else {
1459
+ callback(error);
1460
+ }
1461
+ return;
1462
+ }
1463
+ if (attempt >= retries) {
1464
+ callback(attempt > 0 ? new MultipleErrors(`joinGroup failed ${attempt + 1} times.`, attemptErrors) : error);
1465
+ return;
1466
+ }
1467
+ let delay = this[kOptions].retryDelay;
1468
+ if (typeof delay === 'function') {
1469
+ delay = delay(this, 'joinGroup', attempt + 1, retries, error);
1470
+ }
1471
+ function onClose() {
1472
+ clearTimeout(timeout);
1473
+ attemptErrors.push(new UserError('Client closed while retrying joinGroup.'));
1474
+ callback(new MultipleErrors(`joinGroup failed ${attempt + 1} times.`, attemptErrors));
1475
+ }
1476
+ const timeout = setTimeout(() => {
1477
+ this.removeListener('client:close', onClose);
1478
+ this.#performJoinGroupAttempt(options, callback, attempt + 1, attemptErrors);
1479
+ }, delay);
1480
+ this.once('client:close', onClose);
1481
+ }
1482
+ #completeClassicSyncGroup(options, assignments, callback) {
1483
+ if (this.#isCooperativeProtocol()) {
1484
+ const previousAssignments = this.assignments ?? [];
1485
+ const revoked = this.#diffGroupAssignments(previousAssignments, assignments);
1486
+ const addedOnRevokeRound = this.#diffGroupAssignments(assignments, previousAssignments);
1487
+ if (revoked.length > 0) {
1488
+ for (const stream of this.#streams) {
1489
+ stream.pause();
1490
+ }
1491
+ // Snapshot the set: a failing autocommit destroys its stream, which removes it from
1492
+ // #streams while the concurrent callbacks are still iterating.
1493
+ runConcurrentCallbacks('Autocommit before cooperative rebalance failed.', [...this.#streams], (stream, concurrentCallback) => {
1494
+ // A destroyed stream never settles an autocommit, so do not wait on one.
1495
+ if (stream.destroyed || stream.closed) {
1496
+ concurrentCallback(null);
1497
+ return;
1498
+ }
1499
+ stream[kAutocommit](concurrentCallback);
1500
+ }, autocommitError => {
1501
+ // Mirror Kafka's reference client: capture autocommit errors but
1502
+ // continue the rebalance so the group does not get stuck mid-cycle.
1503
+ // Per-stream autocommit errors are already surfaced through the
1504
+ // `autocommit` event on each MessagesStream.
1505
+ if (autocommitError) {
1506
+ this.emitWithDebug('consumer', 'group:autocommit:error', {
1507
+ groupId: this.groupId,
1508
+ error: autocommitError
1509
+ });
1510
+ }
1511
+ this.assignments = assignments;
1512
+ this.#syncPreferredReadReplicas();
1513
+ for (const stream of this.#streams) {
1514
+ stream.resume();
1515
+ }
1516
+ if (addedOnRevokeRound.length > 0) {
1517
+ for (const stream of this.#streams) {
1518
+ stream[kRefreshOffsetsAndFetch]();
1519
+ }
1520
+ }
1521
+ this.emitWithDebug('consumer', 'group:rebalance', { groupId: this.groupId });
1522
+ this.#performJoinGroup(options, callback);
1523
+ });
1524
+ return;
1525
+ }
1526
+ }
1527
+ const added = this.#diffGroupAssignments(assignments, this.assignments ?? []);
1528
+ this.assignments = assignments;
1529
+ this.#syncPreferredReadReplicas();
1530
+ this.#cancelHeartbeat();
1531
+ this.#heartbeatInterval = setTimeout(() => {
1532
+ this.#heartbeat(options);
1533
+ }, options.heartbeatInterval);
1534
+ this.emitWithDebug('consumer', 'group:join', {
1535
+ groupId: this.groupId,
1536
+ memberId: this.memberId,
1537
+ generationId: this.generationId,
1538
+ isLeader: this.#isLeader,
1539
+ assignments: this.assignments
1540
+ });
1541
+ if (this.#isCooperativeProtocol() && added.length > 0) {
1542
+ for (const stream of this.#streams) {
1543
+ stream[kRefreshOffsetsAndFetch]();
1544
+ }
1545
+ }
1546
+ callback(null, this.memberId);
1547
+ }
1397
1548
  #performLeaveGroup(force, callback) {
1398
1549
  if (!this.memberId) {
1399
1550
  callback(null);
@@ -1422,8 +1573,20 @@ export class Consumer extends Base {
1422
1573
  });
1423
1574
  return;
1424
1575
  }
1576
+ const finish = () => {
1577
+ this.emitWithDebug('consumer', 'group:leave', {
1578
+ groupId: this.groupId,
1579
+ memberId: this.memberId,
1580
+ generationId: this.generationId
1581
+ });
1582
+ this.memberId = null;
1583
+ this.generationId = 0;
1584
+ this.assignments = null;
1585
+ this.#syncPreferredReadReplicas();
1586
+ callback(null);
1587
+ };
1425
1588
  this.#cancelHeartbeat();
1426
- this.#performDeduplicateGroupOperaton('leaveGroup', (connection, groupCallback) => {
1589
+ const operation = (connection, groupCallback) => {
1427
1590
  this[kGetApi]('LeaveGroup', (error, api) => {
1428
1591
  if (error) {
1429
1592
  groupCallback(error);
@@ -1431,26 +1594,30 @@ export class Consumer extends Base {
1431
1594
  }
1432
1595
  api(connection, this.groupId, [{ memberId: this.memberId }], groupCallback);
1433
1596
  });
1434
- }, error => {
1597
+ };
1598
+ const onLeave = (error) => {
1435
1599
  if (error) {
1436
1600
  const unknownMemberError = findErrorBy(error, 'unknownMemberId', true);
1601
+ const unavailableBrokerWhileClosing = this[kClosed] && NetworkError.isRetryable(error);
1437
1602
  // This is to avoid throwing an error if a group join was cancelled.
1438
- if (!unknownMemberError) {
1603
+ if (!unknownMemberError && !unavailableBrokerWhileClosing) {
1439
1604
  callback(error);
1440
1605
  return;
1441
1606
  }
1442
1607
  }
1443
- this.emitWithDebug('consumer', 'group:leave', {
1444
- groupId: this.groupId,
1445
- memberId: this.memberId,
1446
- generationId: this.generationId
1447
- });
1448
- this.memberId = null;
1449
- this.generationId = 0;
1450
- this.assignments = null;
1451
- this.#syncPreferredReadReplicas();
1452
- callback(null);
1453
- });
1608
+ finish();
1609
+ };
1610
+ if (this[kClosed]) {
1611
+ const connection = this.#getGroupCoordinatorConnection();
1612
+ if (connection) {
1613
+ operation(connection, onLeave);
1614
+ }
1615
+ else {
1616
+ finish();
1617
+ }
1618
+ return;
1619
+ }
1620
+ this.#performDeduplicateGroupOperaton('leaveGroup', operation, onLeave);
1454
1621
  }
1455
1622
  #performSyncGroup(partitionsAssigner, assignments, callback) {
1456
1623
  if (!this.#membershipActive) {
@@ -1541,7 +1708,7 @@ export class Consumer extends Base {
1541
1708
  });
1542
1709
  }, (error, result) => {
1543
1710
  callback(this.#handleError(error), result);
1544
- });
1711
+ }, 0, [], () => this[kClosed]);
1545
1712
  }
1546
1713
  #validateGroupOptions(options, validator) {
1547
1714
  validator ??= groupOptionsValidator;
@@ -1610,19 +1777,37 @@ export class Consumer extends Base {
1610
1777
  https://github.com/apache/kafka/blob/trunk/clients/src/main/resources/common/message/ConsumerProtocolSubscription.json
1611
1778
  */
1612
1779
  #encodeProtocolSubscriptionMetadata(metadata, topics) {
1613
- return Writer.create()
1614
- .appendInt16(metadata.version)
1615
- .appendArray(topics, (w, t) => w.appendString(t, false), false, false)
1616
- .appendBytes(typeof metadata.metadata === 'string' ? Buffer.from(metadata.metadata) : metadata.metadata, false)
1617
- .buffer;
1780
+ const cooperative = metadata.name === COOPERATIVE_STICKY_ASSIGNOR;
1781
+ const ownedPartitions = cooperative ? this.#ownedPartitionsForSubscription(topics) : [];
1782
+ let userData = typeof metadata.metadata === 'string' ? Buffer.from(metadata.metadata) : metadata.metadata;
1783
+ if (cooperative && !userData && metadata.version < 2) {
1784
+ userData = Writer.create()
1785
+ .appendInt32(this.memberId && ownedPartitions.length > 0 ? this.generationId : -1)
1786
+ .buffer;
1787
+ }
1788
+ return encodeConsumerProtocolSubscription({
1789
+ version: metadata.version,
1790
+ topics,
1791
+ userData,
1792
+ ownedPartitions,
1793
+ generationId: cooperative && this.memberId && ownedPartitions.length > 0 ? this.generationId : -1,
1794
+ rackId: cooperative ? this.#clientRack || null : null
1795
+ });
1618
1796
  }
1619
1797
  #decodeProtocolSubscriptionMetadata(memberId, buffer) {
1620
- const reader = Reader.from(buffer);
1798
+ const subscription = decodeConsumerProtocolSubscription(buffer);
1799
+ const cooperative = this.#protocol === COOPERATIVE_STICKY_ASSIGNOR;
1800
+ const generationId = cooperative && subscription.version < 2
1801
+ ? decodeCooperativeStickyGeneration(subscription.userData)
1802
+ : subscription.generationId;
1621
1803
  return {
1622
1804
  memberId,
1623
- version: reader.readInt16(),
1624
- topics: reader.readArray(r => r.readString(false), false, false),
1625
- metadata: reader.readBytes(false)
1805
+ version: subscription.version,
1806
+ topics: subscription.topics,
1807
+ metadata: subscription.userData,
1808
+ ownedPartitions: subscription.ownedPartitions,
1809
+ generationId,
1810
+ rackId: subscription.rackId
1626
1811
  };
1627
1812
  }
1628
1813
  /*
@@ -1630,27 +1815,14 @@ export class Consumer extends Base {
1630
1815
  https://github.com/apache/kafka/blob/trunk/clients/src/main/resources/common/message/ConsumerProtocolAssignment.json
1631
1816
  */
1632
1817
  #encodeProtocolAssignment(assignments) {
1633
- const userData = this[kOptions].assignmentUserData;
1634
- const writer = Writer.create()
1635
- .appendInt16(0) // Version information
1636
- .appendArray(assignments, (w, { topic, partitions }) => {
1637
- w.appendString(topic, false).appendArray(partitions, (w, a) => w.appendInt32(a), false, false);
1638
- }, false, false);
1639
- writer.appendBytes(userData ?? null, false);
1640
- return writer.buffer;
1818
+ return encodeConsumerProtocolAssignment({
1819
+ version: this.#isCooperativeProtocol() ? CONSUMER_PROTOCOL_HIGHEST_VERSION : 0,
1820
+ assignedPartitions: assignments,
1821
+ userData: this[kOptions].assignmentUserData ?? null
1822
+ });
1641
1823
  }
1642
1824
  #decodeProtocolAssignment(buffer) {
1643
- const reader = Reader.from(buffer);
1644
- reader.skip(2); // Ignore Version information
1645
- if (reader.remaining < INT32_SIZE) {
1646
- return [];
1647
- }
1648
- return reader.readArray(r => {
1649
- return {
1650
- topic: r.readString(false),
1651
- partitions: r.readArray(r => r.readInt32(), false, false)
1652
- };
1653
- }, false, false);
1825
+ return decodeConsumerProtocolAssignment(buffer).assignedPartitions;
1654
1826
  }
1655
1827
  #createAssignments(partitionsAssigner, metadata) {
1656
1828
  const partitionTracker = new Map();
@@ -1673,7 +1845,7 @@ export class Consumer extends Base {
1673
1845
  return [{ memberId: this.memberId, assignment: this.#encodeProtocolAssignment(assignments) }];
1674
1846
  }
1675
1847
  const encodedAssignments = [];
1676
- partitionsAssigner ??= this[kOptions].partitionAssigner ?? roundRobinAssigner;
1848
+ partitionsAssigner ??= this.#defaultPartitionsAssigner();
1677
1849
  for (const member of partitionsAssigner(this.memberId, this.#members, new Set(this.topics.current), metadata)) {
1678
1850
  encodedAssignments.push({
1679
1851
  memberId: member.memberId,
@@ -1682,6 +1854,28 @@ export class Consumer extends Base {
1682
1854
  }
1683
1855
  return encodedAssignments;
1684
1856
  }
1857
+ #defaultPartitionsAssigner() {
1858
+ const configuredAssigner = this[kOptions].partitionAssigner;
1859
+ if (configuredAssigner) {
1860
+ return configuredAssigner;
1861
+ }
1862
+ if (this.#isCooperativeProtocol()) {
1863
+ return cooperativeStickyAssigner;
1864
+ }
1865
+ return roundRobinAssigner;
1866
+ }
1867
+ #isCooperativeProtocol() {
1868
+ return this.#protocol === COOPERATIVE_STICKY_ASSIGNOR;
1869
+ }
1870
+ #ownedPartitionsForSubscription(topics) {
1871
+ if (!this.assignments) {
1872
+ return [];
1873
+ }
1874
+ const currentTopics = new Set(topics);
1875
+ return this.assignments
1876
+ .filter(assignment => currentTopics.has(assignment.topic))
1877
+ .map(assignment => ({ topic: assignment.topic, partitions: [...assignment.partitions] }));
1878
+ }
1685
1879
  #filterUncommittedMessages(response) {
1686
1880
  for (const topic of response.responses) {
1687
1881
  for (const partition of topic.partitions) {
@@ -1700,7 +1894,10 @@ export class Consumer extends Base {
1700
1894
  // Now find last offsets
1701
1895
  for (const batch of partition.records) {
1702
1896
  if (batch.attributes & IS_CONTROL && batch.records[0].key?.readInt16BE(2) === 0) {
1703
- abortedRanges.get(batch.producerId)[1] = batch.firstOffset;
1897
+ const abortedRange = abortedRanges.get(batch.producerId);
1898
+ if (abortedRange) {
1899
+ abortedRange[1] = batch.firstOffset;
1900
+ }
1704
1901
  }
1705
1902
  }
1706
1903
  // Filter all records in the aborted ranges
@@ -1722,7 +1919,14 @@ export class Consumer extends Base {
1722
1919
  if (protocolError.rebalanceInProgress) {
1723
1920
  this.emitWithDebug('consumer', 'group:rebalance', { groupId: this.groupId });
1724
1921
  }
1725
- if (protocolError.unknownMemberId) {
1922
+ // A cooperative member which lost its membership or generation no longer owns anything:
1923
+ // clear the assignments so the next JoinGroup advertises an empty ownedPartitions set
1924
+ // instead of re-claiming partitions the group has already reassigned elsewhere.
1925
+ if (this.#isCooperativeProtocol() && ['UNKNOWN_MEMBER_ID', 'ILLEGAL_GENERATION'].includes(protocolError.apiId)) {
1926
+ this.assignments = [];
1927
+ this.#syncPreferredReadReplicas();
1928
+ }
1929
+ if (protocolError.unknownMemberId || protocolError.fencedInstanceId) {
1726
1930
  this.memberId = null;
1727
1931
  }
1728
1932
  else if (protocolError.memberId && !this.memberId) {
@@ -1812,4 +2016,12 @@ export class Consumer extends Base {
1812
2016
  }
1813
2017
  return error;
1814
2018
  }
2019
+ #getGroupCoordinatorConnection() {
2020
+ if (this.#coordinatorId === null) {
2021
+ return null;
2022
+ }
2023
+ const broker = this.currentMetadata?.brokers.get(this.#coordinatorId);
2024
+ const connection = broker ? this[kConnections].getEstablishedConnection(broker) : undefined;
2025
+ return connection?.isConnected() === true ? connection : null;
2026
+ }
1815
2027
  }
@@ -1,5 +1,7 @@
1
1
  export * from './consumer.ts';
2
+ export * from './consumer-protocol.ts';
2
3
  export * from './messages-stream.ts';
3
4
  export * from './options.ts';
5
+ export * from './partitions-assigners.ts';
4
6
  export * from './topics-map.ts';
5
7
  export * from './types.ts';
@@ -1,5 +1,7 @@
1
1
  export * from "./consumer.js";
2
+ export * from "./consumer-protocol.js";
2
3
  export * from "./messages-stream.js";
3
4
  export * from "./options.js";
5
+ export * from "./partitions-assigners.js";
4
6
  export * from "./topics-map.js";
5
7
  export * from "./types.js";
@@ -1,8 +1,9 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import { type CallbackWithPromise } from '../../apis/callbacks.ts';
3
+ import type { Callback } from '../../apis/definitions.ts';
3
4
  import type { ConnectionPool } from '../../network/connection-pool.ts';
4
5
  import { type Message } from '../../protocol/records.ts';
5
- import { kAutocommit, kInstance, kRefreshOffsetsAndFetch } from '../../symbols.ts';
6
+ import { kAutocommit, kInstance, kRefreshOffsetsAndFetch, kUpdateCommittedOffset } from '../../symbols.ts';
6
7
  import { kConnections, kInspect } from '../base/base.ts';
7
8
  import type { Consumer } from './consumer.ts';
8
9
  import { type CommitOptionsPartition, type ConsumeOptions } from './types.ts';
@@ -79,7 +80,8 @@ export declare class MessagesStream<Key, Value, HeaderKey, HeaderValue> extends
79
80
  _construct(callback: (error?: Error) => void): void;
80
81
  _destroy(error: Error | null, callback: (error?: Error | null) => void): void;
81
82
  _read(): void;
82
- [kAutocommit](): void;
83
+ [kUpdateCommittedOffset](topic: string, partition: number, offset: bigint): void;
84
+ [kAutocommit](callback?: Callback<void>): void;
83
85
  [kRefreshOffsetsAndFetch](): void;
84
86
  [kInspect](...args: unknown[]): void;
85
87
  }