apache-iggy 0.10.0-edge.1 → 0.10.0-edge.3

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 (46) hide show
  1. package/README.md +7 -1
  2. package/dist/client/client.config.d.ts +7 -0
  3. package/dist/client/client.config.js +17 -0
  4. package/dist/client/client.config.test.js +18 -1
  5. package/dist/client/client.socket.d.ts +29 -2
  6. package/dist/client/client.socket.js +98 -24
  7. package/dist/client/client.socket.test.js +178 -21
  8. package/dist/client/client.type.d.ts +6 -1
  9. package/dist/debug.js +2 -2
  10. package/dist/e2e/tcp.topic.e2e.js +1 -2
  11. package/dist/e2e/tls.system.e2e.js +1 -3
  12. package/dist/wire/command-set.d.ts +1 -0
  13. package/dist/wire/command-set.js +3 -1
  14. package/dist/wire/command.code.d.ts +1 -0
  15. package/dist/wire/command.code.js +1 -0
  16. package/dist/wire/message/header.type.d.ts +1 -1
  17. package/dist/wire/options.utils.d.ts +62 -0
  18. package/dist/wire/options.utils.js +144 -0
  19. package/dist/wire/options.utils.test.d.ts +2 -0
  20. package/dist/wire/options.utils.test.js +102 -0
  21. package/dist/wire/stream/stream.utils.d.ts +3 -0
  22. package/dist/wire/stream/stream.utils.js +4 -2
  23. package/dist/wire/system/describe-options.command.d.ts +33 -0
  24. package/dist/wire/system/describe-options.command.js +63 -0
  25. package/dist/wire/system/index.d.ts +1 -0
  26. package/dist/wire/system/index.js +1 -0
  27. package/dist/wire/topic/create-topic.command.d.ts +31 -5
  28. package/dist/wire/topic/create-topic.command.js +52 -10
  29. package/dist/wire/topic/create-topic.command.test.js +63 -12
  30. package/dist/wire/topic/topic.utils.d.ts +20 -4
  31. package/dist/wire/topic/topic.utils.js +35 -15
  32. package/dist/wire/topic/topic.utils.test.js +50 -13
  33. package/dist/wire/topic/update-topic.command.d.ts +17 -5
  34. package/dist/wire/topic/update-topic.command.js +27 -9
  35. package/dist/wire/user/user.utils.d.ts +3 -0
  36. package/dist/wire/user/user.utils.js +4 -1
  37. package/dist/wire/vsr/header.d.ts +13 -9
  38. package/dist/wire/vsr/header.js +13 -8
  39. package/dist/wire/vsr/header.test.js +0 -5
  40. package/dist/wire/vsr/index.js +0 -3
  41. package/dist/wire/vsr/vsr.test.js +0 -7
  42. package/package.json +2 -2
  43. package/dist/wire/vsr/namespace.d.ts +0 -19
  44. package/dist/wire/vsr/namespace.js +0 -179
  45. package/dist/wire/vsr/namespace.test.d.ts +0 -2
  46. package/dist/wire/vsr/namespace.test.js +0 -133
package/README.md CHANGED
@@ -71,7 +71,13 @@ new session. Transient not-committed responses retry the exact encoded request
71
71
  within one bounded deadline. A disconnected mutation is never replayed under a
72
72
  new session.
73
73
 
74
- When the server's `[heartbeat]` eviction is enabled, configure the client's `heartbeatInterval` below the server heartbeat interval. Client heartbeats are disabled when `heartbeatInterval` is unset.
74
+ The client pings every `heartbeatInterval` milliseconds, 5000 by default, which
75
+ keeps an idle session alive when the server's `[heartbeat]` eviction is enabled.
76
+ The server evicts a connection silent for 36 s, which is 1.2 x its 30 s
77
+ heartbeat interval. Raising the client interval past that window, or setting it
78
+ to 0 to disable client heartbeats, exposes an idle consumer-group member to
79
+ eviction; a connection holding no group membership is left alone. Any other
80
+ unusable value is rejected instead of silently disabling the heartbeat.
75
81
 
76
82
  `sendBinaryRequest(code, payload)` sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.
77
83
 
@@ -1,4 +1,11 @@
1
1
  import type { ClientConfig } from './client.type.js';
2
2
  export declare const DEFAULT_MAX_RESPONSE_FRAME_SIZE: number;
3
+ /**
4
+ * The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
5
+ * interval). 5 s matches the other SDKs and survives several skipped pings.
6
+ */
7
+ export declare const DEFAULT_HEARTBEAT_INTERVAL: number;
8
+ /** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
9
+ export declare const MAX_HEARTBEAT_INTERVAL = 2147483647;
3
10
  export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig;
4
11
  //# sourceMappingURL=client.config.d.ts.map
@@ -15,6 +15,13 @@
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
17
  export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
18
+ /**
19
+ * The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
20
+ * interval). 5 s matches the other SDKs and survives several skipped pings.
21
+ */
22
+ export const DEFAULT_HEARTBEAT_INTERVAL = 5 * 1000;
23
+ /** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
24
+ export const MAX_HEARTBEAT_INTERVAL = 2_147_483_647;
18
25
  export const normalizeClientConfig = (config) => {
19
26
  const maxResponseFrameSize = config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
20
27
  if (!Number.isSafeInteger(maxResponseFrameSize) ||
@@ -22,10 +29,20 @@ export const normalizeClientConfig = (config) => {
22
29
  throw new TypeError('maxResponseFrameSize must be a safe integer of at least 256 bytes');
23
30
  if ((config.poolSize?.min ?? 1) > 1 || (config.poolSize?.max ?? 1) > 1)
24
31
  throw new TypeError('VSR clients currently support exactly one pooled connection');
32
+ // Only 0 disables the heartbeat. Anything else unusable has to throw here:
33
+ // the default below is nullish-only, so a value that is negative, fractional
34
+ // or above MAX_HEARTBEAT_INTERVAL reaches setInterval, which clamps it to
35
+ // 1 ms and floods the server.
36
+ const heartbeatInterval = config.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
37
+ if (!Number.isSafeInteger(heartbeatInterval) ||
38
+ heartbeatInterval < 0 ||
39
+ heartbeatInterval > MAX_HEARTBEAT_INTERVAL)
40
+ throw new TypeError(`heartbeatInterval must be a safe integer of milliseconds between 0 and ${MAX_HEARTBEAT_INTERVAL} (0 disables heartbeats)`);
25
41
  return {
26
42
  ...config,
27
43
  options: { ...config.options },
28
44
  maxResponseFrameSize,
45
+ heartbeatInterval,
29
46
  poolSize: { min: 1, max: 1 }
30
47
  };
31
48
  };
@@ -16,7 +16,7 @@
16
16
  // under the License.
17
17
  import assert from 'node:assert/strict';
18
18
  import { describe, it } from 'node:test';
19
- import { DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
19
+ import { DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
20
20
  const config = () => ({
21
21
  transport: 'TCP',
22
22
  options: { host: '127.0.0.1', port: 8090 },
@@ -27,6 +27,23 @@ describe('normalizeClientConfig', () => {
27
27
  const normalized = normalizeClientConfig(config());
28
28
  assert.equal(normalized.maxResponseFrameSize, DEFAULT_MAX_RESPONSE_FRAME_SIZE);
29
29
  });
30
+ it('enables the heartbeat by default and honours an explicit interval', () => {
31
+ assert.equal(DEFAULT_HEARTBEAT_INTERVAL, 5000);
32
+ assert.equal(normalizeClientConfig(config()).heartbeatInterval, DEFAULT_HEARTBEAT_INTERVAL);
33
+ assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 1000 })
34
+ .heartbeatInterval, 1000);
35
+ assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 0 })
36
+ .heartbeatInterval, 0);
37
+ });
38
+ it('rejects unusable heartbeat intervals', () => {
39
+ for (const heartbeatInterval of [
40
+ -1, -5000, Number.NaN, 1.5, 2_147_483_648, 2 ** 32, Number.MAX_VALUE
41
+ ])
42
+ assert.throws(() => normalizeClientConfig({
43
+ ...config(),
44
+ heartbeatInterval
45
+ }), /heartbeatInterval/);
46
+ });
30
47
  it('restricts the client to one pooled connection', () => {
31
48
  const normalized = normalizeClientConfig(config());
32
49
  assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
@@ -18,6 +18,12 @@ export declare class CommandResponseStream extends EventEmitter {
18
18
  private vsrSession;
19
19
  /** Shared authentication attempt for concurrent callers */
20
20
  private authenticationPromise?;
21
+ /** Whether a login is already being moved to the leader */
22
+ private settlingLeader;
23
+ /** How long a leaderless roster is polled before settling in place */
24
+ private leaderlessWaitBudget;
25
+ /** Delay between roster reads while the cluster elects */
26
+ private leaderlessPollInterval;
21
27
  /** Calls that have acquired this stream but have not fully settled */
22
28
  private pendingSubmissions;
23
29
  /** Whether the stream is currently processing a command */
@@ -42,7 +48,7 @@ export declare class CommandResponseStream extends EventEmitter {
42
48
  _init(): void;
43
49
  /**
44
50
  * Sends a command to the server.
45
- * Automatically handles connection and authentication if needed.
51
+ * Automatically handles connection, authentication and leader settlement.
46
52
  *
47
53
  * @param command - Command code to send
48
54
  * @param payload - Command payload buffer
@@ -70,7 +76,28 @@ export declare class CommandResponseStream extends EventEmitter {
70
76
  private _processVsr;
71
77
  private _exchange;
72
78
  private isUnloggedCommand;
73
- private _ensureVsrLeader;
79
+ /**
80
+ * Moves a freshly authenticated session to the cluster leader.
81
+ *
82
+ * Only the leader accepts replicated commands, and the roster read is
83
+ * auth-gated, so the topology cannot be inspected before a login binds a
84
+ * session. The redirect drops that session along with the socket, so the
85
+ * login is replayed on the leader and its answer supersedes the one from the
86
+ * node the client dialed. Leadership can move between the roster read and
87
+ * the replay, so each freshly bound hop rechecks the roster under a bounded
88
+ * redirect budget.
89
+ *
90
+ * @returns The leader's login response, or undefined when the client stays
91
+ */
92
+ private _settleOnLeader;
93
+ /**
94
+ * Reads the cluster roster and picks the endpoint to settle on.
95
+ *
96
+ * Best effort: an unreadable roster, `Unauthenticated` included (the session
97
+ * died between the login and this read), keeps the client on its current
98
+ * node instead of failing a login that already succeeded.
99
+ */
100
+ private _readLeaderEndpoint;
74
101
  /**
75
102
  * Fails all queued commands with the given error.
76
103
  *
@@ -26,6 +26,9 @@ import { decodeVsrResponse, prepareVsrCommand, readRegisteredSession, VsrEvictio
26
26
  import { normalizeClientConfig } from './client.config.js';
27
27
  const VSR_RESPONSE_TIMEOUT_MS = 30_000;
28
28
  const VSR_RETRY_INTERVAL_MS = 50;
29
+ const LEADERLESS_WAIT_BUDGET_MS = 5_000;
30
+ const LEADERLESS_POLL_INTERVAL_MS = 250;
31
+ const MAX_LEADER_REDIRECTS = 3;
29
32
  const TRANSIENT_NOT_COMMITTED = 57;
30
33
  const TRANSIENT_NOT_ACCEPTED = 58;
31
34
  /**
@@ -58,6 +61,12 @@ export class CommandResponseStream extends EventEmitter {
58
61
  vsrSession;
59
62
  /** Shared authentication attempt for concurrent callers */
60
63
  authenticationPromise;
64
+ /** Whether a login is already being moved to the leader */
65
+ settlingLeader;
66
+ /** How long a leaderless roster is polled before settling in place */
67
+ leaderlessWaitBudget;
68
+ /** Delay between roster reads while the cluster elects */
69
+ leaderlessPollInterval;
61
70
  /** Calls that have acquired this stream but have not fully settled */
62
71
  pendingSubmissions;
63
72
  /** Whether the stream is currently processing a command */
@@ -85,6 +94,9 @@ export class CommandResponseStream extends EventEmitter {
85
94
  this._execQueue = [];
86
95
  this.vsrSession = new VsrSession();
87
96
  this.authenticationPromise = undefined;
97
+ this.settlingLeader = false;
98
+ this.leaderlessWaitBudget = LEADERLESS_WAIT_BUDGET_MS;
99
+ this.leaderlessPollInterval = LEADERLESS_POLL_INTERVAL_MS;
88
100
  this.pendingSubmissions = 0;
89
101
  this.heartbeatInFlight = false;
90
102
  this._init();
@@ -109,7 +121,7 @@ export class CommandResponseStream extends EventEmitter {
109
121
  }
110
122
  /**
111
123
  * Sends a command to the server.
112
- * Automatically handles connection and authentication if needed.
124
+ * Automatically handles connection, authentication and leader settlement.
113
125
  *
114
126
  * @param command - Command code to send
115
127
  * @param payload - Command payload buffer
@@ -122,11 +134,9 @@ export class CommandResponseStream extends EventEmitter {
122
134
  const { handleResponse = true, last = true } = options;
123
135
  if (!this.connection.connected)
124
136
  await this.connection.connect();
125
- if (isLoginCommand(command))
126
- await this._ensureVsrLeader();
127
137
  if (!this.isAuthenticated && !this.isUnloggedCommand(command))
128
138
  await this.authenticate(this.options.credentials);
129
- return await new Promise((resolve, reject) => {
139
+ const response = await new Promise((resolve, reject) => {
130
140
  const job = {
131
141
  command,
132
142
  payload,
@@ -140,6 +150,16 @@ export class CommandResponseStream extends EventEmitter {
140
150
  this._execQueue.unshift(job);
141
151
  this._processQueue();
142
152
  });
153
+ if (!isLoginCommand(command) || this.settlingLeader)
154
+ return response;
155
+ this.settlingLeader = true;
156
+ try {
157
+ const settled = await this._settleOnLeader(command, payload);
158
+ return settled ?? response;
159
+ }
160
+ finally {
161
+ this.settlingLeader = false;
162
+ }
143
163
  }
144
164
  finally {
145
165
  this.pendingSubmissions -= 1;
@@ -309,31 +329,82 @@ export class CommandResponseStream extends EventEmitter {
309
329
  }
310
330
  });
311
331
  }
332
+ // `GetClusterMetadata` is deliberately absent: the server auth-gates it,
333
+ // so the client authenticates before reading the topology. A login dialed
334
+ // at a backup still succeeds because the server forwards the register to
335
+ // the primary.
312
336
  isUnloggedCommand(command) {
313
- return UNLOGGED_COMMAND_CODE.includes(command) ||
314
- command === COMMAND_CODE.GetClusterMetadata;
337
+ return UNLOGGED_COMMAND_CODE.includes(command);
315
338
  }
316
- async _ensureVsrLeader() {
317
- for (let attempt = 0; attempt < 3; attempt += 1) {
318
- // Queue the metadata fetch instead of writing directly: a bare write
319
- // would race an in-flight exchange and both would wake on the same
320
- // response event.
321
- const response = await this.sendCommand(GET_CLUSTER_METADATA.code, GET_CLUSTER_METADATA.serialize(), { last: false });
322
- const metadata = GET_CLUSTER_METADATA.deserialize(response);
323
- if (metadata.nodes.length <= 1)
324
- return;
325
- const leader = metadata.nodes.find((node) => node.role === 'Leader' && node.status === 'Healthy');
326
- if (!leader) {
327
- await delay(100);
328
- continue;
339
+ /**
340
+ * Moves a freshly authenticated session to the cluster leader.
341
+ *
342
+ * Only the leader accepts replicated commands, and the roster read is
343
+ * auth-gated, so the topology cannot be inspected before a login binds a
344
+ * session. The redirect drops that session along with the socket, so the
345
+ * login is replayed on the leader and its answer supersedes the one from the
346
+ * node the client dialed. Leadership can move between the roster read and
347
+ * the replay, so each freshly bound hop rechecks the roster under a bounded
348
+ * redirect budget.
349
+ *
350
+ * @returns The leader's login response, or undefined when the client stays
351
+ */
352
+ async _settleOnLeader(loginCommand, loginPayload) {
353
+ let settledResponse;
354
+ for (let redirects = 0; redirects < MAX_LEADER_REDIRECTS; redirects += 1) {
355
+ const leader = await this._readLeaderEndpoint();
356
+ if (!leader || this.connection.isConnectedTo(leader.host, leader.port))
357
+ return settledResponse;
358
+ await this.connection.redirect(leader.host, leader.port);
359
+ settledResponse = await this.sendCommand(loginCommand, loginPayload, { last: false });
360
+ }
361
+ debug(`leader settlement reached its ${MAX_LEADER_REDIRECTS}-hop budget, ` +
362
+ 'staying on the current node');
363
+ return settledResponse;
364
+ }
365
+ /**
366
+ * Reads the cluster roster and picks the endpoint to settle on.
367
+ *
368
+ * Best effort: an unreadable roster, `Unauthenticated` included (the session
369
+ * died between the login and this read), keeps the client on its current
370
+ * node instead of failing a login that already succeeded.
371
+ */
372
+ async _readLeaderEndpoint() {
373
+ // A cluster can be transiently leaderless: a restarted node cedes the
374
+ // primaryship its stale view assigns it, and the roster reports no leader
375
+ // until the peers' election completes. That window is roughly one heartbeat
376
+ // timeout, so poll through it rather than settling on a replica that denies
377
+ // every replicated command for its whole retry budget.
378
+ const deadline = Date.now() + this.leaderlessWaitBudget;
379
+ while (true) {
380
+ // Reading without a session would re-enter authentication, which awaits
381
+ // the very login this settlement runs inside of. The session can also die
382
+ // between polls, so this holds for every pass, not just the first.
383
+ if (!this.isAuthenticated)
384
+ return undefined;
385
+ try {
386
+ // Queue the metadata fetch instead of writing directly: a bare write
387
+ // would race an in-flight exchange and both would wake on the same
388
+ // response event.
389
+ const response = await this.sendCommand(GET_CLUSTER_METADATA.code, GET_CLUSTER_METADATA.serialize(), { last: false });
390
+ const metadata = GET_CLUSTER_METADATA.deserialize(response);
391
+ if (metadata.nodes.length <= 1)
392
+ return undefined;
393
+ const leader = metadata.nodes.find((node) => node.role === 'Leader' && node.status === 'Healthy');
394
+ if (leader)
395
+ return { host: leader.ip, port: leader.endpoints.tcp };
396
+ }
397
+ catch (error) {
398
+ debug('cluster metadata is unreadable, staying on this node', error);
399
+ return undefined;
329
400
  }
330
- if (!this.connection.isConnectedTo(leader.ip, leader.endpoints.tcp)) {
331
- await this.connection.redirect(leader.ip, leader.endpoints.tcp);
332
- continue;
401
+ if (Date.now() >= deadline) {
402
+ debug('cluster metadata named no healthy leader within ' +
403
+ `${this.leaderlessWaitBudget} ms, staying on this node`);
404
+ return undefined;
333
405
  }
334
- return;
406
+ await delay(this.leaderlessPollInterval);
335
407
  }
336
- throw new Error('VSR cluster has no healthy leader');
337
408
  }
338
409
  /**
339
410
  * Fails all queued commands with the given error.
@@ -448,6 +519,9 @@ export class CommandResponseStream extends EventEmitter {
448
519
  }
449
520
  }
450
521
  }, interval);
522
+ // A pending heartbeat must not be the reason the process stays up: a script
523
+ // that never calls destroy() would otherwise hang on exit.
524
+ this.heartbeatIntervalHandler.unref();
451
525
  }
452
526
  /**
453
527
  * Returns the underlying socket as a readable stream.
@@ -166,6 +166,12 @@ const vsrConfig = (port) => ({
166
166
  credentials: { username: 'iggy', password: 'iggy' },
167
167
  reconnect: { enabled: false, interval: 100, maxRetries: 1 }
168
168
  });
169
+ /** Shrinks the leaderless poll so a test observes it without waiting on it. */
170
+ const compressLeaderlessPoll = (client, budget) => {
171
+ const settlement = client;
172
+ settlement.leaderlessWaitBudget = budget;
173
+ settlement.leaderlessPollInterval = 1;
174
+ };
169
175
  describe('VSR client socket', () => {
170
176
  it('exchanges VSR frames over TLS', async () => {
171
177
  const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket), 'TLS');
@@ -197,13 +203,15 @@ describe('VSR client socket', () => {
197
203
  assert.equal(response.status, 0);
198
204
  const operations = server.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
199
205
  assert.deepEqual(operations, [
200
- Operation.NonReplicated,
201
206
  Operation.Register,
207
+ Operation.NonReplicated,
202
208
  Operation.NonReplicated
203
209
  ]);
204
- const register = server.frames[1];
210
+ const register = server.frames[0];
205
211
  assert.equal(register.readBigUInt64LE(REQUEST_OFFSET.request), 0n);
206
212
  assert.equal(register.readBigUInt64LE(REQUEST_OFFSET.session), 0n);
213
+ const settlement = server.frames[1];
214
+ assert.equal(settlement.readUInt32LE(REQUEST_OFFSET.reserved), COMMAND_CODE.GetClusterMetadata);
207
215
  const request = server.frames[2];
208
216
  assert.equal(request.readBigUInt64LE(REQUEST_OFFSET.session), TEST_SESSION);
209
217
  assert.equal(request.readUInt32LE(REQUEST_OFFSET.reserved), 60_001);
@@ -292,15 +300,20 @@ describe('VSR client socket', () => {
292
300
  await server.close();
293
301
  }
294
302
  });
295
- it('redirects a direct login to the advertised leader before registering', async () => {
303
+ it('redirects a login to the advertised leader and registers there', async () => {
296
304
  const leader = await startVsrServer((frame, socket) => singleNodeHandler(leader.port)(frame, socket));
297
305
  const follower = await startVsrServer((frame, socket) => {
298
- const code = frame.readUInt32LE(REQUEST_OFFSET.reserved);
299
- if (code === COMMAND_CODE.GetClusterMetadata) {
306
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
307
+ if (operation === Operation.Register) {
308
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
309
+ return;
310
+ }
311
+ if (frame.readUInt32LE(REQUEST_OFFSET.reserved) ===
312
+ COMMAND_CODE.GetClusterMetadata) {
300
313
  socket.write(replyFrame(Operation.NonReplicated, twoNodeMetadataBody(follower.port, leader.port)));
301
314
  return;
302
315
  }
303
- socket.write(replyFrame(frame.readUInt8(REQUEST_OFFSET.operation), Buffer.alloc(0), 3));
316
+ socket.write(replyFrame(operation, Buffer.alloc(0), 58));
304
317
  });
305
318
  const client = new CommandResponseStream(vsrConfig(follower.port));
306
319
  try {
@@ -313,12 +326,19 @@ describe('VSR client socket', () => {
313
326
  assert.equal(response.status, 0);
314
327
  assert.equal(client.isAuthenticated, true);
315
328
  const followerOperations = follower.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
316
- assert.deepEqual(followerOperations, [Operation.NonReplicated]);
329
+ assert.deepEqual(followerOperations, [
330
+ Operation.Register,
331
+ Operation.NonReplicated
332
+ ]);
333
+ await client.sendCommand(60_018, Buffer.alloc(0));
317
334
  const leaderOperations = leader.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
318
335
  assert.deepEqual(leaderOperations, [
336
+ Operation.Register,
319
337
  Operation.NonReplicated,
320
- Operation.Register
338
+ Operation.NonReplicated
321
339
  ]);
340
+ assert.equal(leader.frames[2].readUInt32LE(REQUEST_OFFSET.reserved), 60_018);
341
+ assert.equal(follower.frames.length, 2);
322
342
  }
323
343
  finally {
324
344
  client.destroy();
@@ -326,6 +346,156 @@ describe('VSR client socket', () => {
326
346
  await follower.close();
327
347
  }
328
348
  });
349
+ it('rechecks leadership after a redirected login', async () => {
350
+ const leader = await startVsrServer((frame, socket) => singleNodeHandler(leader.port)(frame, socket));
351
+ const intermediate = await startVsrServer((frame, socket) => {
352
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
353
+ if (operation === Operation.Register) {
354
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
355
+ return;
356
+ }
357
+ socket.write(replyFrame(Operation.NonReplicated, twoNodeMetadataBody(intermediate.port, leader.port)));
358
+ });
359
+ const follower = await startVsrServer((frame, socket) => {
360
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
361
+ if (operation === Operation.Register) {
362
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
363
+ return;
364
+ }
365
+ socket.write(replyFrame(Operation.NonReplicated, twoNodeMetadataBody(follower.port, intermediate.port)));
366
+ });
367
+ const client = new CommandResponseStream(vsrConfig(follower.port));
368
+ try {
369
+ await client.authenticate(vsrConfig(follower.port).credentials);
370
+ await client.sendCommand(60_019, Buffer.alloc(0));
371
+ assert.deepEqual(follower.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation)), [Operation.Register, Operation.NonReplicated]);
372
+ assert.deepEqual(intermediate.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation)), [Operation.Register, Operation.NonReplicated]);
373
+ assert.equal(leader.frames[2].readUInt32LE(REQUEST_OFFSET.reserved), 60_019);
374
+ }
375
+ finally {
376
+ client.destroy();
377
+ await follower.close();
378
+ await intermediate.close();
379
+ await leader.close();
380
+ }
381
+ });
382
+ it('keeps a single-node login on its node', async () => {
383
+ const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket));
384
+ const client = new CommandResponseStream(vsrConfig(server.port));
385
+ try {
386
+ await client.authenticate(vsrConfig(server.port).credentials);
387
+ const operations = server.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
388
+ assert.deepEqual(operations, [
389
+ Operation.Register,
390
+ Operation.NonReplicated
391
+ ]);
392
+ assert.equal(server.frames[1].readUInt32LE(REQUEST_OFFSET.reserved), COMMAND_CODE.GetClusterMetadata);
393
+ assert.equal(client.isAuthenticated, true);
394
+ }
395
+ finally {
396
+ client.destroy();
397
+ await server.close();
398
+ }
399
+ });
400
+ it('polls a leaderless roster before redirecting to the elected leader', async () => {
401
+ const leader = await startVsrServer((frame, socket) => singleNodeHandler(leader.port)(frame, socket));
402
+ let rosterReads = 0;
403
+ const follower = await startVsrServer((frame, socket) => {
404
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
405
+ if (operation === Operation.Register) {
406
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
407
+ return;
408
+ }
409
+ rosterReads += 1;
410
+ // The first answer is mid-election: neither node holds the leader role.
411
+ socket.write(replyFrame(Operation.NonReplicated, rosterReads === 1
412
+ ? twoNodeMetadataBody(follower.port, leader.port, 1)
413
+ : twoNodeMetadataBody(follower.port, leader.port)));
414
+ });
415
+ const client = new CommandResponseStream(vsrConfig(follower.port));
416
+ compressLeaderlessPoll(client, 1_000);
417
+ try {
418
+ await client.authenticate(vsrConfig(follower.port).credentials);
419
+ assert.equal(rosterReads, 2);
420
+ const leaderOperations = leader.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
421
+ assert.deepEqual(leaderOperations, [
422
+ Operation.Register,
423
+ Operation.NonReplicated
424
+ ]);
425
+ assert.equal(client.isAuthenticated, true);
426
+ }
427
+ finally {
428
+ client.destroy();
429
+ await leader.close();
430
+ await follower.close();
431
+ }
432
+ });
433
+ it('keeps a login alive when the session dies mid-poll', async () => {
434
+ let rosterReads = 0;
435
+ const server = await startVsrServer((frame, socket) => {
436
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
437
+ if (operation === Operation.Register) {
438
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
439
+ return;
440
+ }
441
+ rosterReads += 1;
442
+ // The roster stays leaderless and the session dies in the same breath.
443
+ // Polling on without a session would re-enter authentication, which
444
+ // awaits the login being settled, and the login would never return.
445
+ socket.write(Buffer.concat([
446
+ replyFrame(Operation.NonReplicated, twoNodeMetadataBody(server.port, server.port, 1)),
447
+ evictionFrame(EvictionReason.NoSession)
448
+ ]));
449
+ });
450
+ const client = new CommandResponseStream(vsrConfig(server.port));
451
+ compressLeaderlessPoll(client, 1_000);
452
+ try {
453
+ const outcome = await Promise.race([
454
+ client.authenticate(vsrConfig(server.port).credentials)
455
+ .then(() => 'authenticated'),
456
+ // Unreferenced so a passing run is not held open by the stall timer.
457
+ new Promise((resolve) => {
458
+ setTimeout(() => resolve('stalled'), 500).unref();
459
+ })
460
+ ]);
461
+ assert.equal(outcome, 'authenticated');
462
+ assert.equal(rosterReads, 1);
463
+ }
464
+ finally {
465
+ client.destroy();
466
+ await server.close();
467
+ }
468
+ });
469
+ it('keeps a login on its node when no leader appears in the budget', async () => {
470
+ const unavailable = await startVsrServer(() => { });
471
+ const unavailablePort = unavailable.port;
472
+ await unavailable.close();
473
+ // The roster marks its leader-role node unhealthy on a dead port, so a
474
+ // redirect would fail the dial instead of passing unnoticed.
475
+ const server = await startVsrServer((frame, socket) => {
476
+ const operation = frame.readUInt8(REQUEST_OFFSET.operation);
477
+ if (operation === Operation.Register) {
478
+ socket.write(replyFrame(Operation.Register, registerReplyBody()));
479
+ return;
480
+ }
481
+ socket.write(replyFrame(Operation.NonReplicated, twoNodeMetadataBody(server.port, unavailablePort, 0, 1)));
482
+ });
483
+ const client = new CommandResponseStream(vsrConfig(server.port));
484
+ compressLeaderlessPoll(client, 0);
485
+ try {
486
+ await client.authenticate(vsrConfig(server.port).credentials);
487
+ const operations = server.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
488
+ assert.deepEqual(operations, [
489
+ Operation.Register,
490
+ Operation.NonReplicated
491
+ ]);
492
+ assert.equal(client.isAuthenticated, true);
493
+ }
494
+ finally {
495
+ client.destroy();
496
+ await server.close();
497
+ }
498
+ });
329
499
  it('rejects instead of hanging while a connection attempt is unresolved', async () => {
330
500
  const server = await startVsrServer(() => { });
331
501
  const port = server.port;
@@ -502,19 +672,6 @@ describe('VSR client socket', () => {
502
672
  await server.close();
503
673
  }
504
674
  });
505
- it('rejects a cluster without a healthy leader', async () => {
506
- const server = await startVsrServer((frame, socket) => {
507
- socket.write(replyFrame(frame.readUInt8(REQUEST_OFFSET.operation), twoNodeMetadataBody(server.port, server.port, 1)));
508
- });
509
- const client = new CommandResponseStream(vsrConfig(server.port));
510
- try {
511
- await assert.rejects(() => client.sendCommand(60_010, Buffer.alloc(0)), /VSR cluster has no healthy leader/);
512
- }
513
- finally {
514
- client.destroy();
515
- await server.close();
516
- }
517
- });
518
675
  it('shares token authentication between concurrent callers', async () => {
519
676
  const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket));
520
677
  const client = new CommandResponseStream({
@@ -125,7 +125,12 @@ export type ClientConfig = {
125
125
  poolSize?: PoolSizeOption;
126
126
  /** Automatic reconnection configuration */
127
127
  reconnect?: ReconnectOption;
128
- /** Interval for sending heartbeat pings in milliseconds */
128
+ /**
129
+ * Interval for sending heartbeat pings in milliseconds, as an integer
130
+ * between 0 and Node's timer ceiling. Defaults to 5000. Set to 0 to disable
131
+ * client heartbeats; any other unusable value is rejected rather than
132
+ * silently disabling them.
133
+ */
129
134
  heartbeatInterval?: number;
130
135
  /** Maximum accepted response frame size in bytes */
131
136
  maxResponseFrameSize?: number;
package/dist/debug.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // under the License.
17
17
  //
18
18
  import assert from 'node:assert/strict';
19
- import { Client } from './client/index.js';
19
+ import { Client, DEFAULT_HEARTBEAT_INTERVAL } from './client/index.js';
20
20
  import { uuidv7, uuidv4 } from 'uuidv7';
21
21
  import { groupConsumerStream } from './stream/consumer-stream.js';
22
22
  import { PollingStrategy } from './wire/index.js';
@@ -41,7 +41,7 @@ const opt = {
41
41
  interval: 10 * 1000,
42
42
  maxRetries: 10
43
43
  },
44
- heartbeatInterval: 5 * 1000
44
+ heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL
45
45
  };
46
46
  const c = new Client(opt);
47
47
  const cleanup = async () => {
@@ -29,8 +29,7 @@ describe('e2e -> topic', async () => {
29
29
  name: topicName,
30
30
  partitionCount: 0,
31
31
  compressionAlgorithm: 1,
32
- messageExpiry: 0n,
33
- replicationFactor: 1
32
+ messageExpiry: 0n
34
33
  });
35
34
  assert.ok(TOPIC);
36
35
  });
@@ -21,13 +21,11 @@
21
21
  // To run them locally:
22
22
  //
23
23
  // 1. Start the server with TLS:
24
- // TODO(hubcio): change to iggy-server once legacy server is removed
25
- // (core/server has VSR support)
26
24
  // IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
27
25
  // IGGY_TCP_TLS_ENABLED=true \
28
26
  // IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
29
27
  // IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
30
- // cargo r --bin iggy-server-ng --features vsr
28
+ // cargo r --bin iggy-server
31
29
  //
32
30
  // 2. Run the tests:
33
31
  // cd foreign/node
@@ -82,6 +82,7 @@ type MessageAPI = ReturnType<typeof messageAPI>;
82
82
  declare const systemAPI: (c: ClientProvider) => {
83
83
  ping: (arg: void) => Promise<boolean>;
84
84
  getStats: (arg: void) => Promise<import("./system/get-stats.command.js").Stats>;
85
+ describeOptions: (arg: import("./system/describe-options.command.js").DescribeOptions) => Promise<import("./system/describe-options.command.js").OptionSpec[]>;
85
86
  };
86
87
  type SystemAPI = ReturnType<typeof systemAPI>;
87
88
  declare const clusterAPI: (c: ClientProvider) => {