apache-iggy 0.10.0 → 0.10.1-edge.2

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 (157) hide show
  1. package/README.md +248 -28
  2. package/dist/client/client.config.d.ts +2 -0
  3. package/dist/client/client.config.js +13 -2
  4. package/dist/client/client.config.test.js +15 -2
  5. package/dist/client/client.connection-string.js +13 -1
  6. package/dist/client/client.connection-string.test.js +43 -1
  7. package/dist/client/client.connection.d.ts +33 -135
  8. package/dist/client/client.connection.js +190 -453
  9. package/dist/client/client.connection.test.js +244 -669
  10. package/dist/client/client.d.ts +4 -35
  11. package/dist/client/client.js +8 -101
  12. package/dist/client/client.operation.d.ts +120 -0
  13. package/dist/client/client.operation.js +428 -0
  14. package/dist/client/client.operation.test.d.ts +2 -0
  15. package/dist/client/client.operation.test.js +285 -0
  16. package/dist/client/client.socket.d.ts +92 -209
  17. package/dist/client/client.socket.js +1205 -981
  18. package/dist/client/client.socket.test.js +4865 -679
  19. package/dist/client/client.type.d.ts +65 -22
  20. package/dist/client/index.d.ts +1 -0
  21. package/dist/client/index.js +1 -0
  22. package/dist/client.e2e.js +3 -5
  23. package/dist/e2e/resource-options.e2e.d.ts +2 -0
  24. package/dist/{wire/stream/purge-stream.command.js → e2e/resource-options.e2e.js} +12 -20
  25. package/dist/e2e/resource-options.suite.d.ts +15 -0
  26. package/dist/e2e/resource-options.suite.js +272 -0
  27. package/dist/e2e/tcp.cluster.e2e.js +1 -0
  28. package/dist/e2e/tcp.consumer-stream.e2e.js +4 -0
  29. package/dist/e2e/tcp.parallel.e2e.js +5 -6
  30. package/dist/e2e/tcp.stream.e2e.js +0 -3
  31. package/dist/e2e/tcp.system.e2e.js +10 -1
  32. package/dist/e2e/tcp.token.e2e.js +8 -2
  33. package/dist/e2e/tcp.topic.e2e.js +0 -3
  34. package/dist/e2e/tcp.user.e2e.js +46 -3
  35. package/dist/e2e/tls.system.e2e.js +21 -12
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +1 -1
  38. package/dist/stream/consumer-stream.js +97 -12
  39. package/dist/wire/cluster/cluster.type.d.ts +2 -2
  40. package/dist/wire/cluster/cluster.utils.d.ts +1 -0
  41. package/dist/wire/cluster/cluster.utils.js +4 -1
  42. package/dist/wire/command-set.d.ts +8 -8
  43. package/dist/wire/command-set.js +18 -9
  44. package/dist/wire/command.code.d.ts +3 -10
  45. package/dist/wire/command.code.js +9 -11
  46. package/dist/wire/command.utils.d.ts +3 -2
  47. package/dist/wire/command.utils.js +3 -2
  48. package/dist/wire/error.code.js +17 -1
  49. package/dist/wire/error.code.test.js +28 -1
  50. package/dist/wire/error.utils.d.ts +4 -2
  51. package/dist/wire/error.utils.js +6 -3
  52. package/dist/wire/identifier.utils.d.ts +2 -0
  53. package/dist/wire/identifier.utils.js +2 -0
  54. package/dist/wire/index.d.ts +1 -0
  55. package/dist/wire/index.js +1 -0
  56. package/dist/wire/message/header.type.d.ts +1 -1
  57. package/dist/wire/message/message-batch.test.js +11 -5
  58. package/dist/wire/message/message.utils.d.ts +0 -10
  59. package/dist/wire/message/message.utils.js +1 -22
  60. package/dist/wire/message/poll-messages.command.d.ts +12 -1
  61. package/dist/wire/message/poll-messages.command.js +64 -16
  62. package/dist/wire/message/poll-messages.command.test.js +143 -5
  63. package/dist/wire/message/poll.utils.d.ts +4 -0
  64. package/dist/wire/message/poll.utils.js +6 -1
  65. package/dist/wire/message/send-messages.command.d.ts +3 -3
  66. package/dist/wire/message/send-messages.command.js +103 -3
  67. package/dist/wire/message/send-messages.command.test.js +113 -2
  68. package/dist/wire/offset/delete-offset.command.d.ts +11 -4
  69. package/dist/wire/offset/delete-offset.command.js +1 -1
  70. package/dist/wire/offset/get-offset.command.d.ts +1 -1
  71. package/dist/wire/offset/offset-write.command.test.d.ts +2 -0
  72. package/dist/wire/offset/offset-write.command.test.js +43 -0
  73. package/dist/wire/offset/offset.utils.js +10 -6
  74. package/dist/wire/offset/store-offset.command.d.ts +10 -0
  75. package/dist/wire/offset/store-offset.command.js +1 -1
  76. package/dist/wire/options.utils.d.ts +46 -0
  77. package/dist/wire/options.utils.js +57 -0
  78. package/dist/wire/partition/partition-route.d.ts +12 -0
  79. package/dist/wire/partition/partition-route.js +40 -0
  80. package/dist/wire/partition/partition-route.test.d.ts +2 -0
  81. package/dist/wire/partition/partition-route.test.js +42 -0
  82. package/dist/wire/session/login-with-token.command.d.ts +2 -2
  83. package/dist/wire/session/login-with-token.command.js +7 -2
  84. package/dist/wire/session/login.command.d.ts +2 -2
  85. package/dist/wire/session/login.command.js +7 -2
  86. package/dist/wire/stream/create-stream.command.d.ts +5 -2
  87. package/dist/wire/stream/create-stream.command.js +20 -2
  88. package/dist/wire/stream/index.d.ts +1 -1
  89. package/dist/wire/stream/index.js +1 -1
  90. package/dist/wire/stream/stream-options.d.ts +77 -0
  91. package/dist/wire/stream/stream-options.js +101 -0
  92. package/dist/wire/stream/stream-options.test.d.ts +2 -0
  93. package/dist/wire/stream/stream-options.test.js +103 -0
  94. package/dist/wire/stream/stream.utils.d.ts +9 -0
  95. package/dist/wire/stream/stream.utils.js +17 -3
  96. package/dist/wire/stream/update-stream.command.d.ts +23 -5
  97. package/dist/wire/stream/update-stream.command.js +10 -17
  98. package/dist/wire/system/describe-options.command.d.ts +23 -6
  99. package/dist/wire/system/describe-options.command.js +21 -2
  100. package/dist/wire/system/get-stats.command.d.ts +17 -0
  101. package/dist/wire/system/get-stats.command.js +69 -16
  102. package/dist/wire/system/get-stats.command.test.d.ts +2 -0
  103. package/dist/wire/system/get-stats.command.test.js +97 -0
  104. package/dist/wire/system/index.d.ts +2 -0
  105. package/dist/wire/system/index.js +2 -0
  106. package/dist/wire/system/snapshot.command.d.ts +64 -0
  107. package/dist/wire/system/snapshot.command.js +76 -0
  108. package/dist/wire/system/snapshot.command.test.d.ts +2 -0
  109. package/dist/wire/system/snapshot.command.test.js +86 -0
  110. package/dist/wire/system/update-resource.command.d.ts +53 -0
  111. package/dist/wire/system/update-resource.command.js +130 -0
  112. package/dist/wire/system/update-resource.command.test.d.ts +2 -0
  113. package/dist/wire/system/update-resource.command.test.js +497 -0
  114. package/dist/wire/token/create-token.command.d.ts +1 -1
  115. package/dist/wire/token/create-token.command.js +1 -1
  116. package/dist/wire/token/create-token.command.test.js +6 -0
  117. package/dist/wire/topic/create-topic.command.d.ts +20 -12
  118. package/dist/wire/topic/create-topic.command.js +11 -4
  119. package/dist/wire/topic/index.d.ts +1 -2
  120. package/dist/wire/topic/index.js +1 -2
  121. package/dist/wire/topic/topic.utils.d.ts +14 -5
  122. package/dist/wire/topic/topic.utils.js +26 -11
  123. package/dist/wire/topic/topic.utils.test.js +13 -13
  124. package/dist/wire/topic/update-topic.command.d.ts +22 -15
  125. package/dist/wire/topic/update-topic.command.js +12 -34
  126. package/dist/wire/user/create-user.command.d.ts +12 -2
  127. package/dist/wire/user/create-user.command.js +7 -3
  128. package/dist/wire/user/index.d.ts +1 -0
  129. package/dist/wire/user/index.js +1 -0
  130. package/dist/wire/user/update-user.command.d.ts +25 -7
  131. package/dist/wire/user/update-user.command.js +18 -31
  132. package/dist/wire/user/user.utils.d.ts +2 -0
  133. package/dist/wire/user/user.utils.js +5 -3
  134. package/dist/wire/vsr/header.d.ts +23 -8
  135. package/dist/wire/vsr/header.js +32 -10
  136. package/dist/wire/vsr/header.test.js +5 -1
  137. package/dist/wire/vsr/index.d.ts +8 -2
  138. package/dist/wire/vsr/index.js +44 -11
  139. package/dist/wire/vsr/operation.d.ts +18 -6
  140. package/dist/wire/vsr/operation.js +46 -16
  141. package/dist/wire/vsr/operation.test.js +32 -8
  142. package/dist/wire/vsr/register.d.ts +10 -6
  143. package/dist/wire/vsr/register.js +24 -7
  144. package/dist/wire/vsr/register.test.js +28 -8
  145. package/dist/wire/vsr/reply.d.ts +4 -1
  146. package/dist/wire/vsr/reply.js +42 -7
  147. package/dist/wire/vsr/reply.test.js +11 -1
  148. package/dist/wire/vsr/session.d.ts +9 -10
  149. package/dist/wire/vsr/session.js +32 -23
  150. package/dist/wire/vsr/session.test.js +33 -7
  151. package/dist/wire/vsr/vsr.test.js +57 -6
  152. package/package.json +6 -7
  153. package/dist/wire/message/flush-unsaved-buffers.command.d.ts +0 -26
  154. package/dist/wire/message/flush-unsaved-buffers.command.js +0 -36
  155. package/dist/wire/stream/purge-stream.command.d.ts +0 -22
  156. package/dist/wire/topic/purge-topic.command.d.ts +0 -24
  157. package/dist/wire/topic/purge-topic.command.js +0 -39
@@ -18,11 +18,14 @@ import assert from 'node:assert/strict';
18
18
  import { EventEmitter } from 'node:events';
19
19
  import { Readable } from 'node:stream';
20
20
  import { describe, it } from 'node:test';
21
+ import { EMPTY_PARTITION_CONTEXT } from '../vsr/header.js';
21
22
  import { COMMAND_CODE } from '../command.code.js';
22
23
  import { ResponseError } from '../error.utils.js';
24
+ import { LaneBlockedError, OPERATION_TIMEOUT_MS, OperationUncertainError } from '../../client/client.operation.js';
23
25
  import { Consumer } from '../offset/offset.utils.js';
24
26
  import { PollingStrategy } from './poll.utils.js';
25
27
  import { NO_ASSIGNED_PARTITION, pollMessages } from './poll-messages.command.js';
28
+ const UNAUTHENTICATED = 40;
26
29
  const response = (data) => ({
27
30
  status: 0,
28
31
  length: data.length,
@@ -38,7 +41,7 @@ const assignment = (generation, partitions) => {
38
41
  return response(data);
39
42
  };
40
43
  const pollResponse = (partitionId, count = 0) => {
41
- const data = Buffer.alloc(16);
44
+ const data = Buffer.alloc(40);
42
45
  data.writeUInt32LE(partitionId, 0);
43
46
  data.writeBigUInt64LE(0n, 4);
44
47
  data.writeUInt32LE(count, 12);
@@ -59,8 +62,8 @@ const stubClient = (responses, onCommand) => {
59
62
  const client = {
60
63
  protocol: 'vsr',
61
64
  isAuthenticated: true,
62
- sendCommand: async (command, payload) => {
63
- commands.push({ command, payload });
65
+ sendCommand: async (command, payload, options) => {
66
+ commands.push({ command, payload, options });
64
67
  onCommand?.(command, emitter);
65
68
  const next = responses.shift();
66
69
  if (!next)
@@ -129,12 +132,28 @@ describe('VSR consumer-group polling', () => {
129
132
  const { client, commands } = stubClient([assignment(1n, [])]);
130
133
  assert.deepEqual(await pollMessages(async () => client)(groupRequest), {
131
134
  partitionId: NO_ASSIGNED_PARTITION,
135
+ context: EMPTY_PARTITION_CONTEXT,
132
136
  currentOffset: 0n,
133
137
  count: 0,
134
138
  messages: []
135
139
  });
136
140
  assert.deepEqual(commands.map(({ command }) => command), [COMMAND_CODE.SyncGroup]);
137
141
  });
142
+ it('resynchronizes an empty assignment on the next poll', async () => {
143
+ const { client, commands } = stubClient([
144
+ assignment(1n, []),
145
+ assignment(4n, [7]),
146
+ pollResponse(7)
147
+ ]);
148
+ const poll = pollMessages(async () => client);
149
+ assert.equal((await poll(groupRequest)).partitionId, NO_ASSIGNED_PARTITION);
150
+ assert.equal((await poll(groupRequest)).partitionId, 7);
151
+ assert.deepEqual(commands.map(({ command }) => command), [
152
+ COMMAND_CODE.SyncGroup,
153
+ COMMAND_CODE.SyncGroup,
154
+ COMMAND_CODE.PollMessages
155
+ ]);
156
+ });
138
157
  it('caches a cursor and refreshes it after a heartbeat', async () => {
139
158
  const responses = [
140
159
  assignment(1n, [4, 5]),
@@ -184,7 +203,7 @@ describe('VSR consumer-group polling', () => {
184
203
  ], (command, emitter) => {
185
204
  if (command === COMMAND_CODE.PollMessages && resetPending) {
186
205
  resetPending = false;
187
- emitter.emit('sessionReset');
206
+ emitter.emit('sessionReset', true);
188
207
  }
189
208
  });
190
209
  assert.equal((await pollMessages(async () => client)(groupRequest)).partitionId, 7);
@@ -197,13 +216,70 @@ describe('VSR consumer-group polling', () => {
197
216
  COMMAND_CODE.PollMessages
198
217
  ]);
199
218
  });
219
+ it('returns the refused sign-in of a poll that starts signed out', async () => {
220
+ let resetPending = true;
221
+ const refusal = new ResponseError(COMMAND_CODE.SyncGroup, UNAUTHENTICATED);
222
+ const { client, commands } = stubClient([refusal], (_command, emitter) => {
223
+ // The sign-in that the first command waits for resets a session that was not signed in.
224
+ if (resetPending) {
225
+ resetPending = false;
226
+ emitter.emit('sessionReset', false);
227
+ }
228
+ });
229
+ client.isAuthenticated = false;
230
+ await assert.rejects(pollMessages(async () => client)(groupRequest), (error) => error === refusal);
231
+ assert.deepEqual(commands.map(({ command }) => command), [COMMAND_CODE.SyncGroup]);
232
+ });
233
+ it('retries a poll that starts signed out when the session it signed in to ends', async () => {
234
+ let sessionEnded = false;
235
+ const { client, commands } = stubClient([
236
+ assignment(1n, [4]),
237
+ new Error('connection closed while waiting for response'),
238
+ response(Buffer.alloc(0)),
239
+ response(Buffer.alloc(0)),
240
+ assignment(2n, [7]),
241
+ pollResponse(7)
242
+ ], (command, emitter) => {
243
+ if (!client.isAuthenticated) {
244
+ // The sign-in that the first command waits for resets a session that was not signed in.
245
+ emitter.emit('sessionReset', false);
246
+ client.isAuthenticated = true;
247
+ }
248
+ else if (command === COMMAND_CODE.PollMessages && !sessionEnded) {
249
+ sessionEnded = true;
250
+ emitter.emit('sessionReset', true);
251
+ }
252
+ });
253
+ client.isAuthenticated = false;
254
+ assert.equal((await pollMessages(async () => client)(groupRequest)).partitionId, 7);
255
+ assert.deepEqual(commands.map(({ command }) => command), [
256
+ COMMAND_CODE.SyncGroup, COMMAND_CODE.PollMessages, COMMAND_CODE.SyncGroup,
257
+ COMMAND_CODE.JoinGroup, COMMAND_CODE.SyncGroup, COMMAND_CODE.PollMessages
258
+ ]);
259
+ });
260
+ it('bounds the retries after session ends by one deadline for the whole call', async (context) => {
261
+ // Each attempt takes a third of the deadline, so the third one ends at it.
262
+ const attempts = 3;
263
+ const sessionEnds = attempts + 1;
264
+ context.mock.timers.enable({ apis: ['Date'] });
265
+ const deadline = Date.now() + OPERATION_TIMEOUT_MS;
266
+ const refusal = () => new ResponseError(COMMAND_CODE.SyncGroup, UNAUTHENTICATED);
267
+ const { client, commands } = stubClient(Array.from({ length: sessionEnds }, refusal), (_command, emitter) => {
268
+ if (commands.length <= sessionEnds)
269
+ emitter.emit('sessionReset', true);
270
+ context.mock.timers.tick(OPERATION_TIMEOUT_MS / attempts);
271
+ });
272
+ await assert.rejects(pollMessages(async () => client)(groupRequest), (error) => error instanceof ResponseError && error.errorCode === UNAUTHENTICATED);
273
+ assert.equal(commands.length, attempts);
274
+ assert.ok(commands.every(({ options }) => options?.deadline === deadline));
275
+ });
200
276
  it('does not replay an ambiguous auto-commit poll when its coordinator also resets', async () => {
201
277
  const { client, commands } = stubClient([
202
278
  assignment(1n, [4]),
203
279
  new ResponseError(COMMAND_CODE.PollMessages, 57)
204
280
  ], (command, emitter) => {
205
281
  if (command === COMMAND_CODE.PollMessages)
206
- emitter.emit('sessionReset');
282
+ emitter.emit('sessionReset', true);
207
283
  });
208
284
  await assert.rejects(pollMessages(async () => client)({ ...groupRequest, autocommit: true }), (error) => error instanceof ResponseError && error.errorCode === 57);
209
285
  assert.deepEqual(commands.map(({ command }) => command), [COMMAND_CODE.SyncGroup, COMMAND_CODE.PollMessages]);
@@ -218,6 +294,7 @@ describe('VSR consumer-group polling', () => {
218
294
  ]);
219
295
  assert.deepEqual(await pollMessages(async () => client)(groupRequest), {
220
296
  partitionId: NO_ASSIGNED_PARTITION,
297
+ context: EMPTY_PARTITION_CONTEXT,
221
298
  currentOffset: 0n,
222
299
  count: 0,
223
300
  messages: []
@@ -226,6 +303,37 @@ describe('VSR consumer-group polling', () => {
226
303
  .filter(({ command }) => command === COMMAND_CODE.PollMessages)
227
304
  .map(({ payload }) => payload.readUInt32LE(20)), [1, 2]);
228
305
  });
306
+ it('resolves its uncertain join before synchronizing the group again', async () => {
307
+ const operationId = 17n;
308
+ const { client, commands } = stubClient([
309
+ response(Buffer.alloc(0)),
310
+ new OperationUncertainError(operationId, new Error('lost join reply')),
311
+ assignment(2n, [7]),
312
+ pollResponse(7)
313
+ ]);
314
+ const resolutions = [];
315
+ client.resolveOperation = async (id) => {
316
+ resolutions.push(id);
317
+ if (resolutions.length === 1)
318
+ throw new OperationUncertainError(id, new Error('lost resolve reply'));
319
+ };
320
+ const poll = pollMessages(async () => client);
321
+ await assert.rejects(poll(groupRequest), OperationUncertainError);
322
+ await assert.rejects(poll(groupRequest), OperationUncertainError);
323
+ assert.equal((await poll(groupRequest)).partitionId, 7);
324
+ assert.deepEqual(resolutions, [operationId, operationId]);
325
+ assert.deepEqual(commands.map(({ command }) => command), [
326
+ COMMAND_CODE.SyncGroup, COMMAND_CODE.JoinGroup,
327
+ COMMAND_CODE.SyncGroup, COMMAND_CODE.PollMessages
328
+ ]);
329
+ });
330
+ it('leaves a join blocked by an unrelated operation for the caller to resolve', async () => {
331
+ const { client } = stubClient([
332
+ response(Buffer.alloc(0)), new LaneBlockedError(18n)
333
+ ]);
334
+ client.resolveOperation = async () => { assert.fail('must not resolve an unrelated operation'); };
335
+ await assert.rejects(pollMessages(async () => client)(groupRequest), LaneBlockedError);
336
+ });
229
337
  it('rejoins when a cached poll reports a missing member', async () => {
230
338
  const { client, commands } = stubClient([
231
339
  assignment(1n, [4]),
@@ -308,4 +416,34 @@ describe('VSR consumer-group polling', () => {
308
416
  assert.equal(held, false);
309
417
  });
310
418
  });
419
+ describe('VSR poll context', () => {
420
+ const context = { incarnation: 7n, ownerGeneration: 8n, metadataOp: 9n };
421
+ it('passes the caller context with an explicit partition poll', async () => {
422
+ const { client, commands } = stubClient([pollResponse(4)]);
423
+ await pollMessages(async () => client)({ ...groupRequest, partitionId: 4, context });
424
+ assert.deepEqual(commands.map(({ options }) => options?.context), [context]);
425
+ });
426
+ it('passes the caller context with the partition a group poll selects', async () => {
427
+ const { client, commands } = stubClient([assignment(1n, [4]), pollResponse(4)]);
428
+ await pollMessages(async () => client)({ ...groupRequest, context });
429
+ assert.deepEqual(commands.map(({ options }) => options?.context), [undefined, context]);
430
+ });
431
+ it('surfaces stale ownership under a caller context and resynchronizes next time', async () => {
432
+ const { client, commands } = stubClient([
433
+ assignment(1n, [4]),
434
+ new ResponseError(COMMAND_CODE.PollMessages, 5009),
435
+ assignment(2n, [7]),
436
+ pollResponse(7)
437
+ ]);
438
+ const poll = pollMessages(async () => client);
439
+ await assert.rejects(poll({ ...groupRequest, context }), (error) => error instanceof ResponseError && error.errorCode === 5009);
440
+ assert.equal((await poll(groupRequest)).partitionId, 7);
441
+ assert.deepEqual(commands.map(({ command }) => command), [
442
+ COMMAND_CODE.SyncGroup,
443
+ COMMAND_CODE.PollMessages,
444
+ COMMAND_CODE.SyncGroup,
445
+ COMMAND_CODE.PollMessages
446
+ ]);
447
+ });
448
+ });
311
449
  //# sourceMappingURL=poll-messages.command.test.js.map
@@ -1,3 +1,4 @@
1
+ import { type PartitionContext } from "../vsr/header.js";
1
2
  import { type Id } from "../identifier.utils.js";
2
3
  import { type ValueOf } from "../../type.utils.js";
3
4
  import { type Consumer } from "../offset/offset.utils.js";
@@ -118,6 +119,8 @@ export type Message = {
118
119
  * Response from a poll messages command.
119
120
  */
120
121
  export type PollMessagesResponse = {
122
+ /** Partition incarnation, owner generation and metadata op the reply carries */
123
+ context: PartitionContext;
121
124
  /** Partition the messages came from */
122
125
  partitionId: number;
123
126
  /** Current offset in the partition */
@@ -176,6 +179,7 @@ export declare const deserializeMessages: (b: Buffer, pos?: number) => Message[]
176
179
  */
177
180
  export declare const deserializePollMessages: (r: Buffer, pos?: number) => {
178
181
  partitionId: number;
182
+ context: PartitionContext;
179
183
  currentOffset: bigint;
180
184
  count: number;
181
185
  messages: Message[];
@@ -14,6 +14,7 @@
14
14
  // KIND, either express or implied. See the License for the
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
+ import { deserializePartitionContext, PARTITION_CONTEXT_SIZE } from "../vsr/header.js";
17
18
  import { reverseRecord } from "../../type.utils.js";
18
19
  import { toDate } from "../serialize.utils.js";
19
20
  import { serializeGetOffset } from "../offset/offset.utils.js";
@@ -211,9 +212,13 @@ export const deserializePollMessages = (r, pos = 0) => {
211
212
  const partitionId = r.readUInt32LE(pos);
212
213
  const currentOffset = r.readBigUInt64LE(pos + 4);
213
214
  const count = r.readUInt32LE(pos + 12);
214
- const messages = deserializeMessages(r, pos + 16);
215
+ const context = deserializePartitionContext(r, pos + 16);
216
+ const messages = deserializeMessages(r, pos + 16 + PARTITION_CONTEXT_SIZE);
217
+ if (messages.length !== count)
218
+ throw new Error("poll response count does not match its messages");
215
219
  return {
216
220
  partitionId,
221
+ context,
217
222
  currentOffset,
218
223
  count,
219
224
  messages,
@@ -1,7 +1,7 @@
1
1
  import { type Id } from '../identifier.utils.js';
2
2
  import { type CreateMessage } from './message.utils.js';
3
- import type { Partitioning } from './partitioning.utils.js';
4
- import type { CommandResponse } from '../../client/client.type.js';
3
+ import { Partitioning } from './partitioning.utils.js';
4
+ import type { ClientProvider, CommandResponse } from '../../client/client.type.js';
5
5
  /**
6
6
  * Parameters for the send messages command.
7
7
  */
@@ -54,5 +54,5 @@ export declare const SEND_MESSAGES: {
54
54
  * Executable send messages command function. Resolves to the commit
55
55
  * confirmations of the written partitions, empty against the legacy server.
56
56
  */
57
- export declare const sendMessages: (getClient: import("../../client/client.type.js").ClientProvider) => (arg: SendMessages) => Promise<SendMessagesResponse>;
57
+ export declare const sendMessages: (getClient: ClientProvider) => (request: SendMessages) => Promise<SendMessagesResponse>;
58
58
  //# sourceMappingURL=send-messages.command.d.ts.map
@@ -14,10 +14,14 @@
14
14
  // KIND, either express or implied. See the License for the
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
+ import { setTimeout as delay } from 'node:timers/promises';
18
+ import { xxh32 } from '@node-rs/xxhash';
19
+ import { serializeIdentifier } from '../identifier.utils.js';
17
20
  import { serializeSendMessages } from './message.utils.js';
18
- import { DeserializeError } from '../error.utils.js';
19
- import { wrapCommand } from '../command.utils.js';
21
+ import { Partitioning, PartitionKind, serializeMessageKey } from './partitioning.utils.js';
22
+ import { DeserializeError, ResponseError, responseError } from '../error.utils.js';
20
23
  import { COMMAND_CODE } from '../command.code.js';
24
+ import { deserializePartitionRoute, serializePartitionRoute } from '../partition/partition-route.js';
21
25
  /** Size of the confirmation count prefixing the list. */
22
26
  const CONFIRMATIONS_COUNT_SIZE = 4;
23
27
  /**
@@ -66,9 +70,105 @@ export const SEND_MESSAGES = {
66
70
  },
67
71
  deserialize: (r) => deserializeSendMessages(r.data)
68
72
  };
73
+ /** Rust `calculate_32` hashes message keys with XXH32 under this seed. */
74
+ const MESSAGE_KEY_SEED = 0;
75
+ const TOPIC_ID_NOT_FOUND = 2010;
76
+ const PARTITION_NOT_FOUND = 3007;
77
+ const SEND_TIMEOUT_MS = 30_000;
78
+ const ROUTE_RETRY_INTERVAL_MS = 50;
79
+ const TRANSIENT_NOT_ACCEPTED = 58;
80
+ const NOT_PRIMARY = 89;
81
+ const topicPartitions = new WeakMap();
82
+ const getTopicPartitions = (client, streamId, topicId) => {
83
+ let topics = topicPartitions.get(client);
84
+ if (!topics) {
85
+ const created = new Map();
86
+ topicPartitions.set(client, created);
87
+ // The client's own stream, topic or partition change can alter any
88
+ // count, as it drops the Rust SDK's topic discovery.
89
+ client.on('topicDiscoveryReset', () => {
90
+ for (const topic of created.values())
91
+ topic.count = undefined;
92
+ });
93
+ client.on('partitionRoute', (event) => {
94
+ const { topic, count } = event;
95
+ const cached = created.get(topic);
96
+ if (cached)
97
+ cached.count = count;
98
+ });
99
+ topics = created;
100
+ }
101
+ const key = Buffer.concat([serializeIdentifier(streamId), serializeIdentifier(topicId)]).toString('hex');
102
+ let topic = topics.get(key);
103
+ if (!topic) {
104
+ topic = { cursor: 0 };
105
+ topics.set(key, topic);
106
+ }
107
+ return topic;
108
+ };
109
+ /**
110
+ * Resolves Balanced and MessageKey to an explicit partition id as the Rust SDK
111
+ * does, so the send carries that partition's incarnation.
112
+ */
113
+ const resolvePartitioning = async (client, { streamId, topicId, partition }, call) => {
114
+ if (partition?.kind === PartitionKind.PartitionId)
115
+ return partition;
116
+ const topic = getTopicPartitions(client, streamId, topicId);
117
+ if (!topic.count) {
118
+ for (;;) {
119
+ try {
120
+ const response = await client.sendCommand(COMMAND_CODE.GetPartitionRoute, serializePartitionRoute(streamId, topicId, 0), call);
121
+ topic.count = deserializePartitionRoute(response.data).partitionsCount;
122
+ break;
123
+ }
124
+ catch (error) {
125
+ if (!(error instanceof ResponseError) ||
126
+ (error.errorCode !== TRANSIENT_NOT_ACCEPTED && error.errorCode !== NOT_PRIMARY) ||
127
+ call.deadline - Date.now() <= ROUTE_RETRY_INTERVAL_MS)
128
+ throw error;
129
+ await delay(ROUTE_RETRY_INTERVAL_MS);
130
+ }
131
+ }
132
+ }
133
+ if (!topic.count)
134
+ throw responseError(COMMAND_CODE.SendMessages, TOPIC_ID_NOT_FOUND);
135
+ if (partition?.kind === PartitionKind.MessageKey)
136
+ return Partitioning.PartitionId(xxh32(serializeMessageKey(partition.value), MESSAGE_KEY_SEED) % topic.count);
137
+ const index = topic.cursor % topic.count;
138
+ topic.cursor += 1;
139
+ return Partitioning.PartitionId(index);
140
+ };
69
141
  /**
70
142
  * Executable send messages command function. Resolves to the commit
71
143
  * confirmations of the written partitions, empty against the legacy server.
72
144
  */
73
- export const sendMessages = wrapCommand(SEND_MESSAGES);
145
+ export const sendMessages = (getClient) => async (request) => {
146
+ const client = await getClient();
147
+ const release = client.hold?.();
148
+ try {
149
+ // No command of the call runs under a session that the caller opened after it started.
150
+ const call = { deadline: Date.now() + SEND_TIMEOUT_MS, lineage: client.lineage };
151
+ for (let attempt = 0;; attempt += 1) {
152
+ const partition = await resolvePartitioning(client, request, call);
153
+ try {
154
+ return SEND_MESSAGES.deserialize(await client.sendCommand(SEND_MESSAGES.code, SEND_MESSAGES.serialize({ ...request, partition }), call));
155
+ }
156
+ catch (error) {
157
+ if (attempt !== 0 || request.partition?.kind === PartitionKind.PartitionId ||
158
+ !(error instanceof ResponseError) || error.errorCode !== PARTITION_NOT_FOUND)
159
+ throw error;
160
+ getTopicPartitions(client, request.streamId, request.topicId).count = undefined;
161
+ }
162
+ }
163
+ }
164
+ catch (error) {
165
+ // A refusal can mean the topic changed shape since its count was read.
166
+ if (error instanceof ResponseError)
167
+ getTopicPartitions(client, request.streamId, request.topicId).count = undefined;
168
+ throw error;
169
+ }
170
+ finally {
171
+ release?.();
172
+ }
173
+ };
74
174
  //# sourceMappingURL=send-messages.command.js.map
@@ -16,10 +16,14 @@
16
16
  // under the License.
17
17
  import { describe, it } from "node:test";
18
18
  import assert from "node:assert/strict";
19
+ import { EventEmitter } from "node:events";
19
20
  import { uuidv7, uuidv4 } from "uuidv7";
20
- import { SEND_MESSAGES, } from "./send-messages.command.js";
21
+ import { SEND_MESSAGES, sendMessages, } from "./send-messages.command.js";
21
22
  import { HeaderValue, HeaderKeyFactory } from "./header.utils.js";
22
- import { DeserializeError } from "../error.utils.js";
23
+ import { Partitioning, PartitionKind } from "./partitioning.utils.js";
24
+ import { serializePartitionRoute } from '../partition/partition-route.js';
25
+ import { COMMAND_CODE } from "../command.code.js";
26
+ import { DeserializeError, responseError } from "../error.utils.js";
23
27
  const SUCCESS = 0;
24
28
  const CONFIRMATION_SIZE = 20;
25
29
  const confirmation = (partitionId) => ({
@@ -226,4 +230,111 @@ describe("SendMessages", () => {
226
230
  });
227
231
  });
228
232
  });
233
+ const routeBody = (partitionsCount) => {
234
+ const body = Buffer.alloc(25);
235
+ body.writeUInt32LE(partitionsCount, 20);
236
+ return body;
237
+ };
238
+ const sendClient = (partitionsCount, refuseFirstSend = false) => {
239
+ const sent = [];
240
+ const events = new EventEmitter();
241
+ const client = {
242
+ on: events.on.bind(events),
243
+ emit: events.emit.bind(events),
244
+ sendCommand: async (code, payload) => {
245
+ sent.push({ code, payload });
246
+ if (code === COMMAND_CODE.SendMessages && refuseFirstSend) {
247
+ refuseFirstSend = false;
248
+ throw responseError(code, 87);
249
+ }
250
+ return response(code === COMMAND_CODE.GetPartitionRoute ? routeBody(partitionsCount) : Buffer.alloc(0));
251
+ }
252
+ };
253
+ return { client, sent };
254
+ };
255
+ const sentPartitions = (sent) => sent.filter(({ code }) => code === COMMAND_CODE.SendMessages).map(({ payload }) => {
256
+ assert.equal(payload[16], PartitionKind.PartitionId);
257
+ return payload.readUInt32LE(18);
258
+ });
259
+ const countOf = (sent, code) => sent.filter((entry) => entry.code === code).length;
260
+ const request = { streamId: 1, topicId: 2, messages: [{ payload: "m" }] };
261
+ describe("SendMessages partition resolution", () => {
262
+ it("resolves Balanced sends round-robin from one route read", async () => {
263
+ const { client, sent } = sendClient(3);
264
+ const send = sendMessages(async () => client);
265
+ for (let index = 0; index < 4; index += 1)
266
+ await send(request);
267
+ assert.deepEqual(sentPartitions(sent), [0, 1, 2, 0]);
268
+ assert.equal(countOf(sent, COMMAND_CODE.GetPartitionRoute), 1);
269
+ });
270
+ it("waits for a route to become ready within the original send deadline", async () => {
271
+ const { client } = sendClient(2);
272
+ const sendCommand = client.sendCommand;
273
+ let reads = 0;
274
+ const deadlines = [];
275
+ client.sendCommand = async (code, payload, options) => {
276
+ deadlines.push(options.deadline);
277
+ if (code === COMMAND_CODE.GetPartitionRoute && ++reads === 1)
278
+ throw responseError(code, 58);
279
+ return sendCommand(code, payload, options);
280
+ };
281
+ await sendMessages(async () => client)(request);
282
+ assert.equal(reads, 2);
283
+ assert.equal(new Set(deadlines).size, 1);
284
+ });
285
+ it("uses refreshed route counts for later balanced sends", async () => {
286
+ const { client, sent } = sendClient(2);
287
+ const send = sendMessages(async () => client);
288
+ await send(request);
289
+ client.emit("partitionRoute", {
290
+ topic: serializePartitionRoute(1, 2, 0).subarray(0, -4).toString('hex'), count: 3
291
+ });
292
+ await send(request);
293
+ await send(request);
294
+ assert.deepEqual(sentPartitions(sent), [0, 1, 2]);
295
+ assert.equal(countOf(sent, COMMAND_CODE.GetPartitionRoute), 1);
296
+ });
297
+ it("hashes MessageKey sends with XXH32 seed 0 like the Rust SDK", async () => {
298
+ const { client, sent } = sendClient(4);
299
+ const send = sendMessages(async () => client);
300
+ await send({ ...request, partition: Partitioning.MessageKey("abc") });
301
+ for (let key = 0; key < 64; key += 1)
302
+ await send({ ...request, partition: Partitioning.MessageKey(`key-${key}`) });
303
+ const [first, ...rest] = sentPartitions(sent);
304
+ assert.equal(first, 0x32D153FF % 4); // XXH32("abc", 0)
305
+ assert.ok(rest.every((partition) => partition >= 0 && partition < 4));
306
+ });
307
+ it("reads the partition count again after a refused send", async () => {
308
+ const { client, sent } = sendClient(2, true);
309
+ const send = sendMessages(async () => client);
310
+ await assert.rejects(send(request));
311
+ await send(request);
312
+ assert.equal(countOf(sent, COMMAND_CODE.GetPartitionRoute), 2);
313
+ });
314
+ it("reads the partition count again after the client changes topics", async () => {
315
+ const { client, sent } = sendClient(2);
316
+ const send = sendMessages(async () => client);
317
+ await send(request);
318
+ client.emit("topicDiscoveryReset");
319
+ await send(request);
320
+ assert.equal(countOf(sent, COMMAND_CODE.GetPartitionRoute), 2);
321
+ assert.deepEqual(sentPartitions(sent), [0, 1], "the round-robin position survives");
322
+ });
323
+ it("holds the raw client across the route read and the send", async () => {
324
+ const { client, sent } = sendClient(2);
325
+ let held = false;
326
+ client.hold = () => {
327
+ held = true;
328
+ return () => { held = false; };
329
+ };
330
+ const sendCommand = client.sendCommand;
331
+ client.sendCommand = (code, payload) => {
332
+ assert.equal(held, true);
333
+ return sendCommand(code, payload);
334
+ };
335
+ await sendMessages(async () => client)(request);
336
+ assert.equal(countOf(sent, COMMAND_CODE.GetPartitionRoute), 1);
337
+ assert.equal(held, false);
338
+ });
339
+ });
229
340
  //# sourceMappingURL=send-messages.command.test.js.map
@@ -1,9 +1,16 @@
1
+ import type { PartitionContext } from '../vsr/header.js';
1
2
  import type { GetOffset } from './get-offset.command.js';
2
3
  /**
3
- * Parameters for the delete offset command.
4
- * Same as GetOffset parameters.
4
+ * Parameters for the delete offset command: the GetOffset parameters and an
5
+ * optional partition context. Without a context the client takes the one its
6
+ * cached route reports: after another client deletes and recreates the
7
+ * partition, one delete can fail with 87 (5009 for a group consumer). The
8
+ * failed route is dropped and the next call routes again.
5
9
  */
6
- export type DeleteOffset = GetOffset;
10
+ export type DeleteOffset = GetOffset & {
11
+ /** Context of the poll the delete belongs to (`PollMessagesResponse.context`) */
12
+ context?: PartitionContext;
13
+ };
7
14
  /**
8
15
  * Delete offset command definition.
9
16
  * Removes a stored consumer offset.
@@ -16,5 +23,5 @@ export declare const DELETE_OFFSET: {
16
23
  /**
17
24
  * Executable delete offset command function.
18
25
  */
19
- export declare const deleteOffset: (getClient: import("../../index.js").ClientProvider) => (arg: GetOffset) => Promise<boolean>;
26
+ export declare const deleteOffset: (getClient: import("../../index.js").ClientProvider) => (arg: DeleteOffset) => Promise<boolean>;
20
27
  //# sourceMappingURL=delete-offset.command.d.ts.map
@@ -32,5 +32,5 @@ export const DELETE_OFFSET = {
32
32
  /**
33
33
  * Executable delete offset command function.
34
34
  */
35
- export const deleteOffset = wrapCommand(DELETE_OFFSET);
35
+ export const deleteOffset = wrapCommand(DELETE_OFFSET, ({ context }) => ({ context }));
36
36
  //# sourceMappingURL=delete-offset.command.js.map
@@ -11,7 +11,7 @@ export type GetOffset = {
11
11
  topicId: Id;
12
12
  /** Consumer identifier (single or group) */
13
13
  consumer: Consumer;
14
- /** Partition ID. VSR requires the explicit partition returned by polling. */
14
+ /** Partition ID. A missing partition reads partition zero. */
15
15
  partitionId: number | null;
16
16
  };
17
17
  /**
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=offset-write.command.test.d.ts.map
@@ -0,0 +1,43 @@
1
+ // Licensed to the Apache Software Foundation (ASF) under one
2
+ // or more contributor license agreements. See the NOTICE file
3
+ // distributed with this work for additional information
4
+ // regarding copyright ownership. The ASF licenses this file
5
+ // to you under the Apache License, Version 2.0 (the
6
+ // "License"); you may not use this file except in compliance
7
+ // with the License. You may obtain a copy of the License at
8
+ //
9
+ // http://www.apache.org/licenses/LICENSE-2.0
10
+ //
11
+ // Unless required by applicable law or agreed to in writing,
12
+ // software distributed under the License is distributed on an
13
+ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ // KIND, either express or implied. See the License for the
15
+ // specific language governing permissions and limitations
16
+ // under the License.
17
+ import assert from 'node:assert/strict';
18
+ import { describe, it } from 'node:test';
19
+ import { COMMAND_CODE } from '../command.code.js';
20
+ import { Consumer } from './offset.utils.js';
21
+ import { storeOffset } from './store-offset.command.js';
22
+ import { deleteOffset } from './delete-offset.command.js';
23
+ const context = { incarnation: 7n, ownerGeneration: 8n, metadataOp: 9n };
24
+ const target = { streamId: 1, topicId: 2, consumer: Consumer.Single, partitionId: 3 };
25
+ const recordingClient = (sent) => ({
26
+ sendCommand: async (command, _payload, options) => {
27
+ sent.push({ command, options });
28
+ return { status: 0, length: 0, data: Buffer.alloc(0) };
29
+ }
30
+ });
31
+ describe('offset writes', () => {
32
+ it('pass the caller context to the store and the delete', async () => {
33
+ const sent = [];
34
+ const client = recordingClient(sent);
35
+ await storeOffset(async () => client)({ ...target, offset: 5n, context });
36
+ await deleteOffset(async () => client)({ ...target, context });
37
+ assert.deepEqual(sent, [
38
+ { command: COMMAND_CODE.StoreOffset, options: { context } },
39
+ { command: COMMAND_CODE.DeleteConsumerOffset, options: { context } }
40
+ ]);
41
+ });
42
+ });
43
+ //# sourceMappingURL=offset-write.command.test.js.map
@@ -57,8 +57,6 @@ export const Consumer = {
57
57
  * @throws Error if partitionId is null for single consumer kind
58
58
  */
59
59
  export const serializeGetOffset = (streamId, topicId, consumer, partitionId) => {
60
- if (consumer.kind === ConsumerKind.Single && partitionId === null)
61
- throw new Error('getOffset error: partitionId must be provided for single consumer kind');
62
60
  const streamIdentifier = serializeIdentifier(streamId);
63
61
  const topicIdentifier = serializeIdentifier(topicId);
64
62
  const consumerIdentifier = serializeIdentifier(consumer.id);
@@ -92,6 +90,8 @@ export const serializeGetOffset = (streamId, topicId, consumer, partitionId) =>
92
90
  * @returns Buffer containing serialized store offset request
93
91
  */
94
92
  export const serializeStoreOffset = (streamId, topicId, consumer, partitionId, offset) => {
93
+ if (partitionId === null)
94
+ throw new Error('storeOffset error: partitionId must be provided');
95
95
  const b = Buffer.allocUnsafe(9);
96
96
  b.writeBigUInt64LE(offset, 0);
97
97
  b.writeUInt8(ACK_QUORUM, 8);
@@ -109,8 +109,12 @@ export const serializeStoreOffset = (streamId, topicId, consumer, partitionId, o
109
109
  * @param partitionId - Partition ID. VSR requires an explicit partition.
110
110
  * @returns Buffer containing serialized delete offset request
111
111
  */
112
- export const serializeDeleteOffset = (streamId, topicId, consumer, partitionId) => Buffer.concat([
113
- serializeGetOffset(streamId, topicId, consumer, partitionId),
114
- uint8ToBuf(ACK_QUORUM)
115
- ]);
112
+ export const serializeDeleteOffset = (streamId, topicId, consumer, partitionId) => {
113
+ if (partitionId === null)
114
+ throw new Error('deleteOffset error: partitionId must be provided');
115
+ return Buffer.concat([
116
+ serializeGetOffset(streamId, topicId, consumer, partitionId),
117
+ uint8ToBuf(ACK_QUORUM)
118
+ ]);
119
+ };
116
120
  //# sourceMappingURL=offset.utils.js.map