apache-iggy 0.10.0-edge.1 → 0.10.0-edge.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/client/client.config.d.ts +7 -0
- package/dist/client/client.config.js +17 -0
- package/dist/client/client.config.test.js +18 -1
- package/dist/client/client.socket.d.ts +29 -2
- package/dist/client/client.socket.js +98 -24
- package/dist/client/client.socket.test.js +178 -21
- package/dist/client/client.type.d.ts +6 -1
- package/dist/debug.js +2 -2
- package/dist/e2e/tcp.topic.e2e.js +1 -2
- package/dist/e2e/tls.system.e2e.js +1 -3
- package/dist/wire/command-set.d.ts +1 -0
- package/dist/wire/command-set.js +3 -1
- package/dist/wire/command.code.d.ts +1 -0
- package/dist/wire/command.code.js +1 -0
- package/dist/wire/message/header.type.d.ts +1 -1
- package/dist/wire/options.utils.d.ts +62 -0
- package/dist/wire/options.utils.js +144 -0
- package/dist/wire/options.utils.test.d.ts +2 -0
- package/dist/wire/options.utils.test.js +102 -0
- package/dist/wire/stream/stream.utils.d.ts +3 -0
- package/dist/wire/stream/stream.utils.js +4 -2
- package/dist/wire/system/describe-options.command.d.ts +33 -0
- package/dist/wire/system/describe-options.command.js +63 -0
- package/dist/wire/system/index.d.ts +1 -0
- package/dist/wire/system/index.js +1 -0
- package/dist/wire/topic/create-topic.command.d.ts +31 -5
- package/dist/wire/topic/create-topic.command.js +52 -10
- package/dist/wire/topic/create-topic.command.test.js +63 -12
- package/dist/wire/topic/topic.utils.d.ts +20 -4
- package/dist/wire/topic/topic.utils.js +35 -15
- package/dist/wire/topic/topic.utils.test.js +50 -13
- package/dist/wire/topic/update-topic.command.d.ts +17 -5
- package/dist/wire/topic/update-topic.command.js +27 -9
- package/dist/wire/user/user.utils.d.ts +3 -0
- package/dist/wire/user/user.utils.js +4 -1
- 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
package/README.md
CHANGED
|
@@ -71,7 +71,13 @@ new session. Transient not-committed responses retry the exact encoded request
|
|
|
71
71
|
within one bounded deadline. A disconnected mutation is never replayed under a
|
|
72
72
|
new session.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
The client pings every `heartbeatInterval` milliseconds, 5000 by default, which
|
|
75
|
+
keeps an idle session alive when the server's `[heartbeat]` eviction is enabled.
|
|
76
|
+
The server evicts a connection silent for 36 s, which is 1.2 x its 30 s
|
|
77
|
+
heartbeat interval. Raising the client interval past that window, or setting it
|
|
78
|
+
to 0 to disable client heartbeats, exposes an idle consumer-group member to
|
|
79
|
+
eviction; a connection holding no group membership is left alone. Any other
|
|
80
|
+
unusable value is rejected instead of silently disabling the heartbeat.
|
|
75
81
|
|
|
76
82
|
`sendBinaryRequest(code, payload)` sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.
|
|
77
83
|
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ClientConfig } from './client.type.js';
|
|
2
2
|
export declare const DEFAULT_MAX_RESPONSE_FRAME_SIZE: number;
|
|
3
|
+
/**
|
|
4
|
+
* The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
|
|
5
|
+
* interval). 5 s matches the other SDKs and survives several skipped pings.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_HEARTBEAT_INTERVAL: number;
|
|
8
|
+
/** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
|
|
9
|
+
export declare const MAX_HEARTBEAT_INTERVAL = 2147483647;
|
|
3
10
|
export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig;
|
|
4
11
|
//# sourceMappingURL=client.config.d.ts.map
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
// specific language governing permissions and limitations
|
|
16
16
|
// under the License.
|
|
17
17
|
export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
|
|
20
|
+
* interval). 5 s matches the other SDKs and survives several skipped pings.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_HEARTBEAT_INTERVAL = 5 * 1000;
|
|
23
|
+
/** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
|
|
24
|
+
export const MAX_HEARTBEAT_INTERVAL = 2_147_483_647;
|
|
18
25
|
export const normalizeClientConfig = (config) => {
|
|
19
26
|
const maxResponseFrameSize = config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
|
|
20
27
|
if (!Number.isSafeInteger(maxResponseFrameSize) ||
|
|
@@ -22,10 +29,20 @@ export const normalizeClientConfig = (config) => {
|
|
|
22
29
|
throw new TypeError('maxResponseFrameSize must be a safe integer of at least 256 bytes');
|
|
23
30
|
if ((config.poolSize?.min ?? 1) > 1 || (config.poolSize?.max ?? 1) > 1)
|
|
24
31
|
throw new TypeError('VSR clients currently support exactly one pooled connection');
|
|
32
|
+
// Only 0 disables the heartbeat. Anything else unusable has to throw here:
|
|
33
|
+
// the default below is nullish-only, so a value that is negative, fractional
|
|
34
|
+
// or above MAX_HEARTBEAT_INTERVAL reaches setInterval, which clamps it to
|
|
35
|
+
// 1 ms and floods the server.
|
|
36
|
+
const heartbeatInterval = config.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
37
|
+
if (!Number.isSafeInteger(heartbeatInterval) ||
|
|
38
|
+
heartbeatInterval < 0 ||
|
|
39
|
+
heartbeatInterval > MAX_HEARTBEAT_INTERVAL)
|
|
40
|
+
throw new TypeError(`heartbeatInterval must be a safe integer of milliseconds between 0 and ${MAX_HEARTBEAT_INTERVAL} (0 disables heartbeats)`);
|
|
25
41
|
return {
|
|
26
42
|
...config,
|
|
27
43
|
options: { ...config.options },
|
|
28
44
|
maxResponseFrameSize,
|
|
45
|
+
heartbeatInterval,
|
|
29
46
|
poolSize: { min: 1, max: 1 }
|
|
30
47
|
};
|
|
31
48
|
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// under the License.
|
|
17
17
|
import assert from 'node:assert/strict';
|
|
18
18
|
import { describe, it } from 'node:test';
|
|
19
|
-
import { DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
|
|
19
|
+
import { DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
|
|
20
20
|
const config = () => ({
|
|
21
21
|
transport: 'TCP',
|
|
22
22
|
options: { host: '127.0.0.1', port: 8090 },
|
|
@@ -27,6 +27,23 @@ describe('normalizeClientConfig', () => {
|
|
|
27
27
|
const normalized = normalizeClientConfig(config());
|
|
28
28
|
assert.equal(normalized.maxResponseFrameSize, DEFAULT_MAX_RESPONSE_FRAME_SIZE);
|
|
29
29
|
});
|
|
30
|
+
it('enables the heartbeat by default and honours an explicit interval', () => {
|
|
31
|
+
assert.equal(DEFAULT_HEARTBEAT_INTERVAL, 5000);
|
|
32
|
+
assert.equal(normalizeClientConfig(config()).heartbeatInterval, DEFAULT_HEARTBEAT_INTERVAL);
|
|
33
|
+
assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 1000 })
|
|
34
|
+
.heartbeatInterval, 1000);
|
|
35
|
+
assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 0 })
|
|
36
|
+
.heartbeatInterval, 0);
|
|
37
|
+
});
|
|
38
|
+
it('rejects unusable heartbeat intervals', () => {
|
|
39
|
+
for (const heartbeatInterval of [
|
|
40
|
+
-1, -5000, Number.NaN, 1.5, 2_147_483_648, 2 ** 32, Number.MAX_VALUE
|
|
41
|
+
])
|
|
42
|
+
assert.throws(() => normalizeClientConfig({
|
|
43
|
+
...config(),
|
|
44
|
+
heartbeatInterval
|
|
45
|
+
}), /heartbeatInterval/);
|
|
46
|
+
});
|
|
30
47
|
it('restricts the client to one pooled connection', () => {
|
|
31
48
|
const normalized = normalizeClientConfig(config());
|
|
32
49
|
assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
|
|
@@ -18,6 +18,12 @@ export declare class CommandResponseStream extends EventEmitter {
|
|
|
18
18
|
private vsrSession;
|
|
19
19
|
/** Shared authentication attempt for concurrent callers */
|
|
20
20
|
private authenticationPromise?;
|
|
21
|
+
/** Whether a login is already being moved to the leader */
|
|
22
|
+
private settlingLeader;
|
|
23
|
+
/** How long a leaderless roster is polled before settling in place */
|
|
24
|
+
private leaderlessWaitBudget;
|
|
25
|
+
/** Delay between roster reads while the cluster elects */
|
|
26
|
+
private leaderlessPollInterval;
|
|
21
27
|
/** Calls that have acquired this stream but have not fully settled */
|
|
22
28
|
private pendingSubmissions;
|
|
23
29
|
/** Whether the stream is currently processing a command */
|
|
@@ -42,7 +48,7 @@ export declare class CommandResponseStream extends EventEmitter {
|
|
|
42
48
|
_init(): void;
|
|
43
49
|
/**
|
|
44
50
|
* Sends a command to the server.
|
|
45
|
-
* Automatically handles connection and
|
|
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.
|
|
@@ -448,6 +519,9 @@ export class CommandResponseStream extends EventEmitter {
|
|
|
448
519
|
}
|
|
449
520
|
}
|
|
450
521
|
}, interval);
|
|
522
|
+
// A pending heartbeat must not be the reason the process stays up: a script
|
|
523
|
+
// that never calls destroy() would otherwise hang on exit.
|
|
524
|
+
this.heartbeatIntervalHandler.unref();
|
|
451
525
|
}
|
|
452
526
|
/**
|
|
453
527
|
* Returns the underlying socket as a readable stream.
|
|
@@ -166,6 +166,12 @@ const vsrConfig = (port) => ({
|
|
|
166
166
|
credentials: { username: 'iggy', password: 'iggy' },
|
|
167
167
|
reconnect: { enabled: false, interval: 100, maxRetries: 1 }
|
|
168
168
|
});
|
|
169
|
+
/** Shrinks the leaderless poll so a test observes it without waiting on it. */
|
|
170
|
+
const compressLeaderlessPoll = (client, budget) => {
|
|
171
|
+
const settlement = client;
|
|
172
|
+
settlement.leaderlessWaitBudget = budget;
|
|
173
|
+
settlement.leaderlessPollInterval = 1;
|
|
174
|
+
};
|
|
169
175
|
describe('VSR client socket', () => {
|
|
170
176
|
it('exchanges VSR frames over TLS', async () => {
|
|
171
177
|
const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket), 'TLS');
|
|
@@ -197,13 +203,15 @@ describe('VSR client socket', () => {
|
|
|
197
203
|
assert.equal(response.status, 0);
|
|
198
204
|
const operations = server.frames.map((frame) => frame.readUInt8(REQUEST_OFFSET.operation));
|
|
199
205
|
assert.deepEqual(operations, [
|
|
200
|
-
Operation.NonReplicated,
|
|
201
206
|
Operation.Register,
|
|
207
|
+
Operation.NonReplicated,
|
|
202
208
|
Operation.NonReplicated
|
|
203
209
|
]);
|
|
204
|
-
const register = server.frames[
|
|
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({
|
|
@@ -125,7 +125,12 @@ export type ClientConfig = {
|
|
|
125
125
|
poolSize?: PoolSizeOption;
|
|
126
126
|
/** Automatic reconnection configuration */
|
|
127
127
|
reconnect?: ReconnectOption;
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Interval for sending heartbeat pings in milliseconds, as an integer
|
|
130
|
+
* between 0 and Node's timer ceiling. Defaults to 5000. Set to 0 to disable
|
|
131
|
+
* client heartbeats; any other unusable value is rejected rather than
|
|
132
|
+
* silently disabling them.
|
|
133
|
+
*/
|
|
129
134
|
heartbeatInterval?: number;
|
|
130
135
|
/** Maximum accepted response frame size in bytes */
|
|
131
136
|
maxResponseFrameSize?: number;
|
package/dist/debug.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// under the License.
|
|
17
17
|
//
|
|
18
18
|
import assert from 'node:assert/strict';
|
|
19
|
-
import { Client } from './client/index.js';
|
|
19
|
+
import { Client, DEFAULT_HEARTBEAT_INTERVAL } from './client/index.js';
|
|
20
20
|
import { uuidv7, uuidv4 } from 'uuidv7';
|
|
21
21
|
import { groupConsumerStream } from './stream/consumer-stream.js';
|
|
22
22
|
import { PollingStrategy } from './wire/index.js';
|
|
@@ -41,7 +41,7 @@ const opt = {
|
|
|
41
41
|
interval: 10 * 1000,
|
|
42
42
|
maxRetries: 10
|
|
43
43
|
},
|
|
44
|
-
heartbeatInterval:
|
|
44
|
+
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL
|
|
45
45
|
};
|
|
46
46
|
const c = new Client(opt);
|
|
47
47
|
const cleanup = async () => {
|
|
@@ -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
|
|
@@ -82,6 +82,7 @@ type MessageAPI = ReturnType<typeof messageAPI>;
|
|
|
82
82
|
declare const systemAPI: (c: ClientProvider) => {
|
|
83
83
|
ping: (arg: void) => Promise<boolean>;
|
|
84
84
|
getStats: (arg: void) => Promise<import("./system/get-stats.command.js").Stats>;
|
|
85
|
+
describeOptions: (arg: import("./system/describe-options.command.js").DescribeOptions) => Promise<import("./system/describe-options.command.js").OptionSpec[]>;
|
|
85
86
|
};
|
|
86
87
|
type SystemAPI = ReturnType<typeof systemAPI>;
|
|
87
88
|
declare const clusterAPI: (c: ClientProvider) => {
|