apache-iggy 0.10.0-edge.1 → 0.10.0-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.
@@ -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.
@@ -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({
@@ -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
@@ -7,7 +7,15 @@
7
7
  */
8
8
  /** Size of every consensus header, both directions. */
9
9
  export declare const HEADER_SIZE = 256;
10
- /** `RequestHeader` field offsets the client writes. */
10
+ /**
11
+ * `RequestHeader` field offsets the client writes.
12
+ *
13
+ * The client wire carries no routing namespace: the server derives the
14
+ * consensus group (plane from `operation`, partition target from the payload)
15
+ * and stamps it into its own internal header. Everything that followed the
16
+ * removed field therefore sits eight bytes earlier than in the pre-derivation
17
+ * layout.
18
+ */
11
19
  export declare const REQUEST_OFFSET: {
12
20
  readonly size: 48;
13
21
  readonly command: 60;
@@ -15,17 +23,15 @@ export declare const REQUEST_OFFSET: {
15
23
  readonly timestamp: 160;
16
24
  readonly request: 168;
17
25
  readonly operation: 176;
18
- readonly namespace: 184;
19
- readonly session: 192;
20
- readonly reserved: 204;
26
+ readonly session: 184;
27
+ readonly reserved: 196;
21
28
  };
22
29
  /** `ReplyHeader` field offsets the client reads. */
23
30
  export declare const REPLY_OFFSET: {
24
31
  readonly size: 48;
25
32
  readonly command: 60;
26
33
  readonly operation: 208;
27
- readonly namespace: 216;
28
- readonly status: 224;
34
+ readonly status: 216;
29
35
  };
30
36
  /** `EvictionHeader` field offsets the client reads. */
31
37
  export declare const EVICTION_OFFSET: {
@@ -73,15 +79,13 @@ export type RequestHeaderFields = {
73
79
  request: bigint;
74
80
  /** `Operation` discriminant. */
75
81
  operation: number;
76
- /** Routing namespace (u64). */
77
- namespace: bigint;
78
82
  /** Bound session (u64), or 0n. */
79
83
  session: bigint;
80
84
  /** Command code for `NonReplicated`, placed in `reserved[0..4]`. */
81
85
  nonReplicatedCode?: number;
82
86
  };
83
87
  /**
84
- * Encodes a 256-byte request header. Only the seven fields the server reads
88
+ * Encodes a 256-byte request header. Only the six fields the server reads
85
89
  * are written; the checksums stay zero, matching the Rust SDK's contract
86
90
  * with the VSR server.
87
91
  */
@@ -24,7 +24,15 @@
24
24
  */
25
25
  /** Size of every consensus header, both directions. */
26
26
  export const HEADER_SIZE = 256;
27
- /** `RequestHeader` field offsets the client writes. */
27
+ /**
28
+ * `RequestHeader` field offsets the client writes.
29
+ *
30
+ * The client wire carries no routing namespace: the server derives the
31
+ * consensus group (plane from `operation`, partition target from the payload)
32
+ * and stamps it into its own internal header. Everything that followed the
33
+ * removed field therefore sits eight bytes earlier than in the pre-derivation
34
+ * layout.
35
+ */
28
36
  export const REQUEST_OFFSET = {
29
37
  size: 48,
30
38
  command: 60,
@@ -32,17 +40,15 @@ export const REQUEST_OFFSET = {
32
40
  timestamp: 160,
33
41
  request: 168,
34
42
  operation: 176,
35
- namespace: 184,
36
- session: 192,
37
- reserved: 204
43
+ session: 184,
44
+ reserved: 196
38
45
  };
39
46
  /** `ReplyHeader` field offsets the client reads. */
40
47
  export const REPLY_OFFSET = {
41
48
  size: 48,
42
49
  command: 60,
43
50
  operation: 208,
44
- namespace: 216,
45
- status: 224
51
+ status: 216
46
52
  };
47
53
  /** `EvictionHeader` field offsets the client reads. */
48
54
  export const EVICTION_OFFSET = {
@@ -82,7 +88,7 @@ export const EvictionReason = {
82
88
  };
83
89
  const U64_MASK = 0xffffffffffffffffn;
84
90
  /**
85
- * Encodes a 256-byte request header. Only the seven fields the server reads
91
+ * Encodes a 256-byte request header. Only the six fields the server reads
86
92
  * are written; the checksums stay zero, matching the Rust SDK's contract
87
93
  * with the VSR server.
88
94
  */
@@ -95,7 +101,6 @@ export const encodeRequestHeader = (fields) => {
95
101
  header.writeBigUInt64LE(fields.client >> 64n, REQUEST_OFFSET.client + 8);
96
102
  header.writeBigUInt64LE(fields.request, REQUEST_OFFSET.request);
97
103
  header.writeUInt8(fields.operation, REQUEST_OFFSET.operation);
98
- header.writeBigUInt64LE(fields.namespace, REQUEST_OFFSET.namespace);
99
104
  header.writeBigUInt64LE(fields.session, REQUEST_OFFSET.session);
100
105
  if (fields.nonReplicatedCode !== undefined)
101
106
  header.writeUInt32LE(fields.nonReplicatedCode, REQUEST_OFFSET.reserved);
@@ -25,7 +25,6 @@ describe('VSR request header', () => {
25
25
  client,
26
26
  request: 0x0102030405060708n,
27
27
  operation: 2,
28
- namespace: 0x8877665544332211n,
29
28
  session: 0x1020304050607080n,
30
29
  nonReplicatedCode: 60_001
31
30
  });
@@ -36,7 +35,6 @@ describe('VSR request header', () => {
36
35
  assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.client + 8), 0x1122334455667788n);
37
36
  assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.request), 0x0102030405060708n);
38
37
  assert.equal(header.readUInt8(REQUEST_OFFSET.operation), 2);
39
- assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.namespace), 0x8877665544332211n);
40
38
  assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.session), 0x1020304050607080n);
41
39
  assert.equal(header.readUInt32LE(REQUEST_OFFSET.reserved), 60_001);
42
40
  assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.timestamp), 0n);
@@ -47,7 +45,6 @@ describe('VSR request header', () => {
47
45
  client: 1n,
48
46
  request: 0n,
49
47
  operation: 1,
50
- namespace: 1n << 63n,
51
48
  session: 0n
52
49
  });
53
50
  const expected = Buffer.alloc(HEADER_SIZE);
@@ -55,7 +52,6 @@ describe('VSR request header', () => {
55
52
  expected.writeUInt8(Command2.Request, REQUEST_OFFSET.command);
56
53
  expected.writeBigUInt64LE(1n, REQUEST_OFFSET.client);
57
54
  expected.writeUInt8(1, REQUEST_OFFSET.operation);
58
- expected.writeBigUInt64LE(1n << 63n, REQUEST_OFFSET.namespace);
59
55
  assert.deepEqual(header, expected);
60
56
  });
61
57
  it('supports maximum-width unsigned request fields', () => {
@@ -65,7 +61,6 @@ describe('VSR request header', () => {
65
61
  client: maximum << 64n | maximum,
66
62
  request: maximum,
67
63
  operation: 160,
68
- namespace: maximum,
69
64
  session: maximum
70
65
  });
71
66
  assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.request), maximum);
@@ -19,7 +19,6 @@ import { createRequire } from 'node:module';
19
19
  import { COMMAND_CODE } from '../command.code.js';
20
20
  import { responseError } from '../error.utils.js';
21
21
  import { HEADER_SIZE, encodeRequestHeader } from './header.js';
22
- import { namespaceForRequest } from './namespace.js';
23
22
  import { Operation, isPartition, operationForCode, } from './operation.js';
24
23
  import { deserializeLoginRegister, serializeLoginRegister, serializeLoginRegisterWithPat, } from './register.js';
25
24
  import { decodeResponse } from './reply.js';
@@ -47,7 +46,6 @@ export class VsrSession {
47
46
  const operation = registerCommand(command)
48
47
  ? Operation.Register
49
48
  : operationForCode(command);
50
- const namespace = namespaceForRequest(command, payload, operation);
51
49
  const size = HEADER_SIZE + payload.length;
52
50
  if (size > MAX_U32)
53
51
  throw new RangeError('VSR request exceeds the u32 frame-size limit');
@@ -74,7 +72,6 @@ export class VsrSession {
74
72
  client: this.state.clientId,
75
73
  request,
76
74
  operation,
77
- namespace,
78
75
  session,
79
76
  nonReplicatedCode: operation === Operation.NonReplicated ? command : undefined,
80
77
  });
@@ -33,13 +33,6 @@ describe('VSR custom request framing', () => {
33
33
  assert.equal(frame.readUInt32LE(REQUEST_OFFSET.reserved), 60_000);
34
34
  assert.deepEqual(frame.subarray(256), payload);
35
35
  });
36
- it('does not consume a request ID when local routing fails', () => {
37
- const session = new VsrSession(7n);
38
- session.bind(42n);
39
- assert.throws(() => session.encode(COMMAND_CODE.SendMessages, Buffer.alloc(0)));
40
- const frame = session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0));
41
- assert.equal(frame.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
42
- });
43
36
  it('rejects an unbound replicated request with a typed error', () => {
44
37
  const session = new VsrSession();
45
38
  assert.throws(() => session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0)), (error) => error instanceof ResponseError && error.errorCode === 40);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apache-iggy",
3
3
  "type": "module",
4
- "version": "0.10.0-edge.1",
4
+ "version": "0.10.0-edge.2",
5
5
  "description": "Official Apache Iggy NodeJS SDK",
6
6
  "keywords": [
7
7
  "iggy",
@@ -57,7 +57,7 @@
57
57
  "devDependencies": {
58
58
  "@commitlint/cli": "21.2.1",
59
59
  "@commitlint/config-conventional": "21.2.0",
60
- "@cucumber/cucumber": "13.2.0",
60
+ "@cucumber/cucumber": "13.2.1",
61
61
  "@swc-node/register": "1.12.1",
62
62
  "@types/debug": "4.1.13",
63
63
  "@types/node": "26.1.2",
@@ -1,19 +0,0 @@
1
- /**
2
- * Control-plane requests target the metadata replica (shard 0), selected by
3
- * this exact sentinel. Plain 0 would fall into namespace hashing and land a
4
- * Register on a peer shard.
5
- */
6
- export declare const METADATA_CONSENSUS_NAMESPACE: bigint;
7
- /** Packs stream / topic / partition ids into a routing namespace. */
8
- export declare const packNamespace: (streamId: number, topicId: number, partitionId: number) => bigint;
9
- /**
10
- * Selects the routing namespace for a request. Partition-plane commands
11
- * derive it from their own payload; a named stream or topic identifier
12
- * yields 0 so the server resolves the name.
13
- *
14
- * @throws Error mirroring the Rust SDK: invalid-identifier for an
15
- * out-of-range field, invalid-command for an undecodable payload, and
16
- * feature-unavailable for a partition operation this SDK cannot derive.
17
- */
18
- export declare const namespaceForRequest: (code: number, payload: Buffer, operation: number) => bigint;
19
- //# sourceMappingURL=namespace.d.ts.map
@@ -1,179 +0,0 @@
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
- //
18
- /**
19
- * Namespace packing and the partition-plane payload peeks needed to derive
20
- * it, ported from `core/binary_protocol/src/namespace.rs` and
21
- * `namespace_for_request` in `core/sdk/src/vsr.rs`.
22
- */
23
- import { COMMAND_CODE } from '../command.code.js';
24
- import { responseError } from '../error.utils.js';
25
- import { Operation, isMetadata } from './operation.js';
26
- /** `IggyError::InvalidCommand`. */
27
- const INVALID_COMMAND = 3;
28
- /** `IggyError::FeatureUnavailable`. */
29
- const FEATURE_UNAVAILABLE = 5;
30
- /** `IggyError::InvalidIdentifier`. */
31
- const INVALID_IDENTIFIER = 6;
32
- const MAX_STREAMS = 4096;
33
- const MAX_TOPICS = 4096;
34
- const MAX_PARTITIONS = 1_000_000;
35
- const bitsRequired = (value) => BigInt(BigInt(value).toString(2).length);
36
- const TOPIC_SHIFT = bitsRequired(MAX_PARTITIONS - 1);
37
- const STREAM_SHIFT = TOPIC_SHIFT + bitsRequired(MAX_TOPICS - 1);
38
- /**
39
- * Control-plane requests target the metadata replica (shard 0), selected by
40
- * this exact sentinel. Plain 0 would fall into namespace hashing and land a
41
- * Register on a peer shard.
42
- */
43
- export const METADATA_CONSENSUS_NAMESPACE = 1n << 63n;
44
- /** Packs stream / topic / partition ids into a routing namespace. */
45
- export const packNamespace = (streamId, topicId, partitionId) => {
46
- validateField(streamId, MAX_STREAMS);
47
- validateField(topicId, MAX_TOPICS);
48
- validateField(partitionId, MAX_PARTITIONS);
49
- return (BigInt(streamId) << STREAM_SHIFT) |
50
- (BigInt(topicId) << TOPIC_SHIFT) |
51
- BigInt(partitionId);
52
- };
53
- /**
54
- * Selects the routing namespace for a request. Partition-plane commands
55
- * derive it from their own payload; a named stream or topic identifier
56
- * yields 0 so the server resolves the name.
57
- *
58
- * @throws Error mirroring the Rust SDK: invalid-identifier for an
59
- * out-of-range field, invalid-command for an undecodable payload, and
60
- * feature-unavailable for a partition operation this SDK cannot derive.
61
- */
62
- export const namespaceForRequest = (code, payload, operation) => {
63
- if (operation === Operation.Register || operation === Operation.Logout)
64
- return METADATA_CONSENSUS_NAMESPACE;
65
- if (operation === Operation.NonReplicated || isMetadata(operation))
66
- return 0n;
67
- switch (code) {
68
- case COMMAND_CODE.SendMessages:
69
- return namespaceFromSendMessages(payload);
70
- case COMMAND_CODE.StoreOffset:
71
- case COMMAND_CODE.DeleteConsumerOffset:
72
- case COMMAND_CODE.StoreOffset2:
73
- case COMMAND_CODE.DeleteConsumerOffset2:
74
- return namespaceFromConsumerOffset(payload);
75
- case COMMAND_CODE.DeleteSegments:
76
- return namespaceFromDeleteSegments(payload);
77
- default:
78
- // The guard that keeps custom partition operations unreachable.
79
- throw responseError(code, FEATURE_UNAVAILABLE);
80
- }
81
- };
82
- const IDENTIFIER_KIND_NUMERIC = 1;
83
- const IDENTIFIER_KIND_STRING = 2;
84
- const peekIdentifier = (payload, offset) => {
85
- if (payload.length < offset + 2)
86
- throw responseError(0, INVALID_COMMAND);
87
- const kind = payload.readUInt8(offset);
88
- const length = payload.readUInt8(offset + 1);
89
- if (payload.length < offset + 2 + length)
90
- throw responseError(0, INVALID_COMMAND);
91
- if (kind === IDENTIFIER_KIND_NUMERIC) {
92
- if (length !== 4)
93
- throw responseError(0, INVALID_COMMAND);
94
- return { numeric: payload.readUInt32LE(offset + 2), length: 2 + length };
95
- }
96
- if (kind === IDENTIFIER_KIND_STRING && length > 0)
97
- return { numeric: null, length: 2 + length };
98
- throw responseError(0, INVALID_COMMAND);
99
- };
100
- const validateField = (value, exclusiveMax) => {
101
- if (!Number.isInteger(value) || value < 0 || value >= exclusiveMax)
102
- throw responseError(0, INVALID_IDENTIFIER);
103
- };
104
- const namespaceFromIds = (stream, topic, partitionId) => {
105
- // Named identifiers defer resolution to the server.
106
- if (stream.numeric === null || topic.numeric === null)
107
- return 0n;
108
- return packNamespace(stream.numeric, topic.numeric, partitionId);
109
- };
110
- /**
111
- * `SendMessages`: `[metadata_len u32][stream ident][topic ident]
112
- * [partitioning kind u8, len u8, value]...`. Only explicit `PartitionId`
113
- * partitioning is routable under VSR; the broker never picks a partition.
114
- * TODO(hubcio): Balanced and MessageKey partitioning to be implemented;
115
- * not decided yet whether it'll be on server side or client side.
116
- */
117
- const namespaceFromSendMessages = (payload) => {
118
- if (payload.length < 4)
119
- throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND);
120
- const metadataLength = payload.readUInt32LE(0);
121
- if (payload.length < 4 + metadataLength)
122
- throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND);
123
- // Rust peeks inside payload[4..4 + metadata_length]; a read past the
124
- // declared metadata region must fail rather than spill into message bytes
125
- // and derive a namespace the server would never compute.
126
- const metadata = payload.subarray(4, 4 + metadataLength);
127
- let offset = 0;
128
- const stream = peekIdentifier(metadata, offset);
129
- offset += stream.length;
130
- const topic = peekIdentifier(metadata, offset);
131
- offset += topic.length;
132
- if (metadata.length < offset + 2)
133
- throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND);
134
- const partitioningKind = metadata.readUInt8(offset);
135
- const partitioningLength = metadata.readUInt8(offset + 1);
136
- const PARTITIONING_PARTITION_ID = 2;
137
- if (partitioningKind !== PARTITIONING_PARTITION_ID)
138
- throw responseError(COMMAND_CODE.SendMessages, FEATURE_UNAVAILABLE);
139
- if (partitioningLength !== 4 || metadata.length < offset + 2 + 4)
140
- throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND);
141
- const partitionId = metadata.readUInt32LE(offset + 2);
142
- return namespaceFromIds(stream, topic, partitionId);
143
- };
144
- /**
145
- * Consumer-offset requests: `[consumer kind u8][consumer ident]
146
- * [stream ident][topic ident][partition flag u8][partition u32]...`.
147
- */
148
- const namespaceFromConsumerOffset = (payload) => {
149
- if (payload.length < 1 || (payload.readUInt8(0) !== 1 &&
150
- payload.readUInt8(0) !== 2))
151
- throw responseError(COMMAND_CODE.StoreOffset, INVALID_COMMAND);
152
- let offset = 1;
153
- const consumer = peekIdentifier(payload, offset);
154
- offset += consumer.length;
155
- const stream = peekIdentifier(payload, offset);
156
- offset += stream.length;
157
- const topic = peekIdentifier(payload, offset);
158
- offset += topic.length;
159
- if (payload.length < offset + 5)
160
- throw responseError(COMMAND_CODE.StoreOffset, INVALID_COMMAND);
161
- const hasPartition = payload.readUInt8(offset) === 1;
162
- if (!hasPartition)
163
- throw responseError(COMMAND_CODE.StoreOffset, INVALID_IDENTIFIER);
164
- const partitionId = payload.readUInt32LE(offset + 1);
165
- return namespaceFromIds(stream, topic, partitionId);
166
- };
167
- /** `DeleteSegments`: `[stream ident][topic ident][partition u32]...`. */
168
- const namespaceFromDeleteSegments = (payload) => {
169
- let offset = 0;
170
- const stream = peekIdentifier(payload, offset);
171
- offset += stream.length;
172
- const topic = peekIdentifier(payload, offset);
173
- offset += topic.length;
174
- if (payload.length < offset + 4)
175
- throw responseError(COMMAND_CODE.DeleteSegments, INVALID_COMMAND);
176
- const partitionId = payload.readUInt32LE(offset);
177
- return namespaceFromIds(stream, topic, partitionId);
178
- };
179
- //# sourceMappingURL=namespace.js.map
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=namespace.test.d.ts.map
@@ -1,133 +0,0 @@
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 { serializeIdentifier } from '../identifier.utils.js';
20
- import { serializeSendMessages } from '../message/message.utils.js';
21
- import { Partitioning } from '../message/partitioning.utils.js';
22
- import { Consumer, serializeStoreOffset } from '../offset/offset.utils.js';
23
- import { COMMAND_CODE } from '../command.code.js';
24
- import { ResponseError } from '../error.utils.js';
25
- import { METADATA_CONSENSUS_NAMESPACE, namespaceForRequest, packNamespace } from './namespace.js';
26
- import { Operation } from './operation.js';
27
- describe('VSR namespace routing', () => {
28
- it('routes register and logout to metadata consensus', () => {
29
- for (const operation of [Operation.Register, Operation.Logout])
30
- assert.equal(namespaceForRequest(0, Buffer.alloc(0), operation), METADATA_CONSENSUS_NAMESPACE);
31
- });
32
- it('routes metadata and non-replicated requests to zero', () => {
33
- assert.equal(namespaceForRequest(COMMAND_CODE.CreateStream, Buffer.alloc(0), Operation.CreateStream), 0n);
34
- assert.equal(namespaceForRequest(COMMAND_CODE.GetStats, Buffer.alloc(0), Operation.NonReplicated), 0n);
35
- });
36
- it('packs explicit send-message partition identifiers', () => {
37
- const payload = serializeSendMessages(7, 11, [], Partitioning.PartitionId(13));
38
- assert.equal(namespaceForRequest(COMMAND_CODE.SendMessages, payload, Operation.SendMessages), packNamespace(7, 11, 13));
39
- });
40
- it('defers named stream and topic routing to the server', () => {
41
- const payload = serializeSendMessages('stream', 'topic', [], Partitioning.PartitionId(1));
42
- assert.equal(namespaceForRequest(COMMAND_CODE.SendMessages, payload, Operation.SendMessages), 0n);
43
- });
44
- it('rejects server-selected message partitioning', () => {
45
- const payload = serializeSendMessages(1, 2, [], Partitioning.Balanced);
46
- assert.throws(() => namespaceForRequest(COMMAND_CODE.SendMessages, payload, Operation.SendMessages), (error) => error instanceof ResponseError && error.errorCode === 5);
47
- });
48
- it('routes consumer offsets from their explicit partition', () => {
49
- const payload = serializeStoreOffset(1, 2, Consumer.Single, 3, 99n);
50
- assert.equal(namespaceForRequest(COMMAND_CODE.StoreOffset, payload, Operation.StoreConsumerOffset), packNamespace(1, 2, 3));
51
- });
52
- it('routes delete-segments payloads', () => {
53
- const payload = Buffer.concat([
54
- serializeIdentifier(1),
55
- serializeIdentifier(2),
56
- Buffer.from([3, 0, 0, 0])
57
- ]);
58
- assert.equal(namespaceForRequest(COMMAND_CODE.DeleteSegments, payload, Operation.DeleteSegments), packNamespace(1, 2, 3));
59
- });
60
- it('accepts the maximum packable identifiers', () => {
61
- assert.equal(packNamespace(4095, 4095, 999_999), (4095n << 32n) | (4095n << 20n) | 999999n);
62
- });
63
- it('rejects peeks past the declared send-messages metadata region', () => {
64
- const payload = serializeSendMessages(1, 2, [], Partitioning.PartitionId(3));
65
- const underDeclared = Buffer.from(payload);
66
- // Shrink the declared metadata region so the partitioning bytes sit
67
- // outside it; the peek must fail instead of reading them.
68
- underDeclared.writeUInt32LE(payload.readUInt32LE(0) - 6, 0);
69
- assert.throws(() => namespaceForRequest(COMMAND_CODE.SendMessages, underDeclared, Operation.SendMessages), (error) => error instanceof ResponseError && error.errorCode === 3);
70
- });
71
- it('rejects unknown codes in partition routing', () => {
72
- assert.throws(() => namespaceForRequest(60_001, Buffer.alloc(0), Operation.SendMessages), (error) => error instanceof ResponseError && error.errorCode === 5);
73
- });
74
- it('requires an explicit consumer-offset partition', () => {
75
- const payload = serializeStoreOffset(1, 2, Consumer.Single, 3, 99n);
76
- // [kind u8][consumer 6][stream 6][topic 6] puts the partition flag at 19.
77
- const withoutPartition = Buffer.from(payload);
78
- withoutPartition.writeUInt8(0, 19);
79
- assert.throws(() => namespaceForRequest(COMMAND_CODE.StoreOffset, withoutPartition, Operation.StoreConsumerOffset), (error) => error instanceof ResponseError && error.errorCode === 6);
80
- });
81
- it('rejects namespace fields before masking', () => {
82
- for (const [streamId, topicId, partitionId] of [
83
- [4096, 0, 0],
84
- [0, 4096, 0],
85
- [0, 0, 1_000_000]
86
- ])
87
- assert.throws(() => packNamespace(streamId, topicId, partitionId), (error) => error instanceof ResponseError && error.errorCode === 6);
88
- });
89
- it('rejects negative and non-integer namespace fields', () => {
90
- for (const [streamId, topicId, partitionId] of [
91
- [-1, 0, 0],
92
- [0, -1, 0],
93
- [0, 0, -1],
94
- [0.5, 0, 0],
95
- [0, Number.NaN, 0]
96
- ])
97
- assert.throws(() => packNamespace(streamId, topicId, partitionId), (error) => error instanceof ResponseError && error.errorCode === 6);
98
- });
99
- it('rejects malformed identifiers at every prefix boundary', () => {
100
- const payload = serializeSendMessages(1, 2, [], Partitioning.PartitionId(3));
101
- for (let length = 0; length < payload.length; length += 1)
102
- assert.throws(() => namespaceForRequest(COMMAND_CODE.SendMessages, payload.subarray(0, length), Operation.SendMessages), ResponseError);
103
- const invalidKind = Buffer.from(payload);
104
- invalidKind.writeUInt8(99, 4);
105
- assert.throws(() => namespaceForRequest(COMMAND_CODE.SendMessages, invalidKind, Operation.SendMessages), ResponseError);
106
- });
107
- it('rejects malformed consumer-offset and delete-segment payloads', () => {
108
- const offsetPayload = serializeStoreOffset(1, 2, Consumer.Single, 3, 99n);
109
- const invalidConsumerKind = Buffer.from(offsetPayload);
110
- invalidConsumerKind.writeUInt8(0, 0);
111
- assert.throws(() => namespaceForRequest(COMMAND_CODE.StoreOffset, invalidConsumerKind, Operation.StoreConsumerOffset), ResponseError);
112
- for (let length = 1; length < 20; length += 1)
113
- assert.throws(() => namespaceForRequest(COMMAND_CODE.StoreOffset, offsetPayload.subarray(0, length), Operation.StoreConsumerOffset), ResponseError);
114
- const deletePayload = Buffer.concat([
115
- serializeIdentifier(1),
116
- serializeIdentifier(2),
117
- Buffer.from([3, 0, 0, 0])
118
- ]);
119
- for (let length = 0; length < deletePayload.length; length += 1)
120
- assert.throws(() => namespaceForRequest(COMMAND_CODE.DeleteSegments, deletePayload.subarray(0, length), Operation.DeleteSegments), ResponseError);
121
- });
122
- it('defers named offset and delete-segment routing to the server', () => {
123
- const offsetPayload = serializeStoreOffset('stream', 'topic', Consumer.Single, 3, 99n);
124
- assert.equal(namespaceForRequest(COMMAND_CODE.StoreOffset, offsetPayload, Operation.StoreConsumerOffset), 0n);
125
- const deletePayload = Buffer.concat([
126
- serializeIdentifier('stream'),
127
- serializeIdentifier('topic'),
128
- Buffer.from([3, 0, 0, 0])
129
- ]);
130
- assert.equal(namespaceForRequest(COMMAND_CODE.DeleteSegments, deletePayload, Operation.DeleteSegments), 0n);
131
- });
132
- });
133
- //# sourceMappingURL=namespace.test.js.map