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.
- package/dist/client/client.socket.d.ts +29 -2
- package/dist/client/client.socket.js +95 -24
- package/dist/client/client.socket.test.js +178 -21
- package/dist/e2e/tls.system.e2e.js +1 -3
- package/dist/wire/vsr/header.d.ts +13 -9
- package/dist/wire/vsr/header.js +13 -8
- package/dist/wire/vsr/header.test.js +0 -5
- package/dist/wire/vsr/index.js +0 -3
- package/dist/wire/vsr/vsr.test.js +0 -7
- package/package.json +2 -2
- package/dist/wire/vsr/namespace.d.ts +0 -19
- package/dist/wire/vsr/namespace.js +0 -179
- package/dist/wire/vsr/namespace.test.d.ts +0 -2
- package/dist/wire/vsr/namespace.test.js +0 -133
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 (
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
299
|
-
if (
|
|
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(
|
|
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, [
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
19
|
-
readonly
|
|
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
|
|
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
|
|
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
|
*/
|
package/dist/wire/vsr/header.js
CHANGED
|
@@ -24,7 +24,15 @@
|
|
|
24
24
|
*/
|
|
25
25
|
/** Size of every consensus header, both directions. */
|
|
26
26
|
export const HEADER_SIZE = 256;
|
|
27
|
-
/**
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/wire/vsr/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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,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
|