diskeyval 1.0.0
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/.github/workflows/test.yml +31 -0
- package/LICENSE +7 -0
- package/PLAN.md +169 -0
- package/README.md +119 -0
- package/TESTING.md +94 -0
- package/example.ts +672 -0
- package/lib/index.ts +448 -0
- package/lib/raft/in-memory-network.ts +96 -0
- package/lib/raft/index.ts +24 -0
- package/lib/raft/node.ts +883 -0
- package/lib/raft/tls-transport.ts +451 -0
- package/lib/raft/types.ts +150 -0
- package/lib/storage/file-raft-storage.ts +84 -0
- package/package.json +23 -0
- package/test/e2e/cluster.e2e.test.ts +280 -0
- package/test/sim/cluster.sim.test.ts +105 -0
- package/test/soak/cluster.soak.test.ts +97 -0
- package/test/support/certs.ts +126 -0
- package/test/support/helpers.ts +51 -0
- package/test/support/ports.ts +33 -0
- package/test/support/tls-cluster.ts +84 -0
- package/test/unit/diskeyval.coverage.test.ts +193 -0
- package/test/unit/in-memory-network.coverage.test.ts +111 -0
- package/test/unit/raft.coverage.test.ts +534 -0
- package/test/unit/raft.election.test.ts +101 -0
- package/test/unit/raft.phase5.test.ts +90 -0
- package/test/unit/raft.reads.test.ts +96 -0
- package/test/unit/raft.replication.test.ts +106 -0
- package/test/unit/tls-transport.coverage.test.ts +332 -0
- package/test/unit/tls-transport.internals.coverage.test.ts +209 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
createServer,
|
|
4
|
+
type DetailedPeerCertificate,
|
|
5
|
+
type TLSSocket,
|
|
6
|
+
type TlsOptions
|
|
7
|
+
} from 'node:tls';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import type {
|
|
10
|
+
AppendEntriesRequest,
|
|
11
|
+
AppendEntriesResponse,
|
|
12
|
+
ManagedRaftTransport,
|
|
13
|
+
RaftRpcEndpoint,
|
|
14
|
+
RequestVoteRequest,
|
|
15
|
+
RequestVoteResponse
|
|
16
|
+
} from './types.ts';
|
|
17
|
+
|
|
18
|
+
type RpcMethod = 'requestVote' | 'appendEntries';
|
|
19
|
+
|
|
20
|
+
type RpcRequest<T> = {
|
|
21
|
+
id: string;
|
|
22
|
+
method: RpcMethod;
|
|
23
|
+
fromNodeId: string;
|
|
24
|
+
payload: RequestVoteRequest | AppendEntriesRequest<T>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type RpcSuccessResponse = {
|
|
28
|
+
id: string;
|
|
29
|
+
ok: true;
|
|
30
|
+
result: RequestVoteResponse | AppendEntriesResponse;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type RpcErrorResponse = {
|
|
34
|
+
id: string;
|
|
35
|
+
ok: false;
|
|
36
|
+
error: {
|
|
37
|
+
name: string;
|
|
38
|
+
message: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type RpcResponse = RpcSuccessResponse | RpcErrorResponse;
|
|
43
|
+
|
|
44
|
+
export type TlsPeer = {
|
|
45
|
+
nodeId: string;
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type TlsRaftTransportOptions = {
|
|
51
|
+
nodeId: string;
|
|
52
|
+
host: string;
|
|
53
|
+
port: number;
|
|
54
|
+
peers: TlsPeer[];
|
|
55
|
+
tls: {
|
|
56
|
+
cert: string;
|
|
57
|
+
key: string;
|
|
58
|
+
ca: string;
|
|
59
|
+
servername?: string;
|
|
60
|
+
minVersion?: TlsOptions['minVersion'];
|
|
61
|
+
};
|
|
62
|
+
rpcTimeoutMs?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type TlsTransportContext<T> = {
|
|
66
|
+
nodeId: string;
|
|
67
|
+
host: string;
|
|
68
|
+
port: number;
|
|
69
|
+
peers: Map<string, TlsPeer>;
|
|
70
|
+
cert: string;
|
|
71
|
+
key: string;
|
|
72
|
+
ca: string;
|
|
73
|
+
minVersion: TlsOptions['minVersion'];
|
|
74
|
+
rpcTimeoutMs: number;
|
|
75
|
+
endpoint: RaftRpcEndpoint<T> | null;
|
|
76
|
+
server: ReturnType<typeof createServer> | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function parseNodeIdFromCertificate(cert: DetailedPeerCertificate): string | null {
|
|
80
|
+
const commonName = cert.subject?.CN;
|
|
81
|
+
if (commonName) {
|
|
82
|
+
return commonName;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const subjectAltName = cert.subjectaltname ?? '';
|
|
86
|
+
const uriMatch = subjectAltName.match(/URI:spiffe:\/\/diskeyval\/([^,]+)/);
|
|
87
|
+
if (uriMatch) {
|
|
88
|
+
return uriMatch[1];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dnsMatch = subjectAltName.match(/DNS:([^,]+)/);
|
|
92
|
+
if (dnsMatch) {
|
|
93
|
+
return dnsMatch[1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertCertificateNodeId(cert: DetailedPeerCertificate, expectedNodeId: string): void {
|
|
100
|
+
const certNodeId = parseNodeIdFromCertificate(cert);
|
|
101
|
+
if (certNodeId !== expectedNodeId) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Peer certificate identity mismatch: expected ${expectedNodeId}, received ${certNodeId ?? 'unknown'}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
109
|
+
return new Promise<T>((resolve, reject) => {
|
|
110
|
+
const timeout = setTimeout(() => {
|
|
111
|
+
reject(new Error(message));
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
|
|
114
|
+
promise
|
|
115
|
+
.then((value) => {
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
resolve(value);
|
|
118
|
+
})
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
reject(error);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function serializeMessage(message: RpcRequest<unknown> | RpcResponse): string {
|
|
127
|
+
return `${JSON.stringify(message)}\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseMessage<T>(raw: string): T {
|
|
131
|
+
return JSON.parse(raw) as T;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readSingleLine(socket: TLSSocket, timeoutMs: number): Promise<string> {
|
|
135
|
+
return withTimeout(
|
|
136
|
+
new Promise<string>((resolve, reject) => {
|
|
137
|
+
let buffer = '';
|
|
138
|
+
|
|
139
|
+
const cleanup = (): void => {
|
|
140
|
+
socket.off('data', onData);
|
|
141
|
+
socket.off('error', onError);
|
|
142
|
+
socket.off('end', onEnd);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const onData = (chunk: Buffer): void => {
|
|
146
|
+
buffer += chunk.toString('utf8');
|
|
147
|
+
const newlineIndex = buffer.indexOf('\n');
|
|
148
|
+
if (newlineIndex < 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const line = buffer.slice(0, newlineIndex);
|
|
153
|
+
cleanup();
|
|
154
|
+
resolve(line);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const onError = (error: Error): void => {
|
|
158
|
+
cleanup();
|
|
159
|
+
reject(error);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onEnd = (): void => {
|
|
163
|
+
cleanup();
|
|
164
|
+
reject(new Error('Socket ended before newline-terminated message was received'));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
socket.on('data', onData);
|
|
168
|
+
socket.once('error', onError);
|
|
169
|
+
socket.once('end', onEnd);
|
|
170
|
+
}),
|
|
171
|
+
timeoutMs,
|
|
172
|
+
`Timed out waiting for RPC response after ${timeoutMs}ms`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function connectToPeer<T>(context: TlsTransportContext<T>, peer: TlsPeer): Promise<TLSSocket> {
|
|
177
|
+
return withTimeout(
|
|
178
|
+
new Promise<TLSSocket>((resolve, reject) => {
|
|
179
|
+
const socket = connect({
|
|
180
|
+
host: peer.host,
|
|
181
|
+
port: peer.port,
|
|
182
|
+
cert: context.cert,
|
|
183
|
+
key: context.key,
|
|
184
|
+
ca: context.ca,
|
|
185
|
+
rejectUnauthorized: true,
|
|
186
|
+
minVersion: context.minVersion,
|
|
187
|
+
servername: peer.nodeId
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const cleanup = (): void => {
|
|
191
|
+
socket.off('error', onError);
|
|
192
|
+
socket.off('secureConnect', onSecureConnect);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onError = (error: Error): void => {
|
|
196
|
+
cleanup();
|
|
197
|
+
socket.destroy();
|
|
198
|
+
reject(error);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const onSecureConnect = (): void => {
|
|
202
|
+
try {
|
|
203
|
+
if (!socket.authorized) {
|
|
204
|
+
throw socket.authorizationError ?? new Error('TLS socket is not authorized');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const certificate = socket.getPeerCertificate(true);
|
|
208
|
+
if (!certificate || Object.keys(certificate).length === 0) {
|
|
209
|
+
throw new Error('Peer did not provide a certificate');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
assertCertificateNodeId(certificate as DetailedPeerCertificate, peer.nodeId);
|
|
213
|
+
cleanup();
|
|
214
|
+
resolve(socket);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
cleanup();
|
|
217
|
+
socket.destroy();
|
|
218
|
+
reject(error);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
socket.once('secureConnect', onSecureConnect);
|
|
223
|
+
socket.once('error', onError);
|
|
224
|
+
}),
|
|
225
|
+
context.rpcTimeoutMs,
|
|
226
|
+
`Timed out connecting to peer ${peer.nodeId} after ${context.rpcTimeoutMs}ms`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function sendRpc<T>(
|
|
231
|
+
context: TlsTransportContext<T>,
|
|
232
|
+
fromNodeId: string,
|
|
233
|
+
toNodeId: string,
|
|
234
|
+
method: RpcMethod,
|
|
235
|
+
payload: RequestVoteRequest | AppendEntriesRequest<T>
|
|
236
|
+
): Promise<RequestVoteResponse | AppendEntriesResponse> {
|
|
237
|
+
const peer = context.peers.get(toNodeId);
|
|
238
|
+
if (!peer) {
|
|
239
|
+
throw new Error(`Unknown peer ${toNodeId}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const socket = await connectToPeer(context, peer);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const request: RpcRequest<T> = {
|
|
246
|
+
id: randomUUID(),
|
|
247
|
+
method,
|
|
248
|
+
fromNodeId,
|
|
249
|
+
payload
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
socket.write(serializeMessage(request));
|
|
253
|
+
const rawResponse = await readSingleLine(socket, context.rpcTimeoutMs);
|
|
254
|
+
const response = parseMessage<RpcResponse>(rawResponse);
|
|
255
|
+
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
throw new Error(`${response.error.name}: ${response.error.message}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return response.result;
|
|
261
|
+
} finally {
|
|
262
|
+
socket.end();
|
|
263
|
+
socket.destroy();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleIncomingSocket<T>(context: TlsTransportContext<T>, socket: TLSSocket): Promise<void> {
|
|
268
|
+
try {
|
|
269
|
+
if (!context.endpoint) {
|
|
270
|
+
throw new Error('RPC endpoint is not attached');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!socket.authorized) {
|
|
274
|
+
throw socket.authorizationError ?? new Error('Unauthorized peer certificate');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const certificate = socket.getPeerCertificate(true);
|
|
278
|
+
if (!certificate || Object.keys(certificate).length === 0) {
|
|
279
|
+
throw new Error('Peer did not provide a certificate');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const certNodeId = parseNodeIdFromCertificate(certificate as DetailedPeerCertificate);
|
|
283
|
+
if (!certNodeId) {
|
|
284
|
+
throw new Error('Unable to determine peer identity from certificate');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const requestRaw = await readSingleLine(socket, context.rpcTimeoutMs);
|
|
288
|
+
const request = parseMessage<RpcRequest<T>>(requestRaw);
|
|
289
|
+
|
|
290
|
+
if (request.fromNodeId !== certNodeId) {
|
|
291
|
+
throw new Error(`Peer identity mismatch: cert=${certNodeId}, payload=${request.fromNodeId}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let result: RequestVoteResponse | AppendEntriesResponse;
|
|
295
|
+
|
|
296
|
+
if (request.method === 'requestVote') {
|
|
297
|
+
result = await context.endpoint.onRequestVote(
|
|
298
|
+
request.fromNodeId,
|
|
299
|
+
request.payload as RequestVoteRequest
|
|
300
|
+
);
|
|
301
|
+
} else {
|
|
302
|
+
result = await context.endpoint.onAppendEntries(
|
|
303
|
+
request.fromNodeId,
|
|
304
|
+
request.payload as AppendEntriesRequest<T>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const response: RpcSuccessResponse = {
|
|
309
|
+
id: request.id,
|
|
310
|
+
ok: true,
|
|
311
|
+
result
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
socket.write(serializeMessage(response));
|
|
315
|
+
socket.end();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
const errorResponse: RpcErrorResponse = {
|
|
318
|
+
id: randomUUID(),
|
|
319
|
+
ok: false,
|
|
320
|
+
error: {
|
|
321
|
+
name: error instanceof Error ? error.name : 'Error',
|
|
322
|
+
message: error instanceof Error ? error.message : String(error)
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
socket.write(serializeMessage(errorResponse));
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore secondary transport errors.
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
socket.destroy();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export const __tlsTransportInternals = {
|
|
337
|
+
parseNodeIdFromCertificate,
|
|
338
|
+
assertCertificateNodeId,
|
|
339
|
+
withTimeout,
|
|
340
|
+
readSingleLine,
|
|
341
|
+
handleIncomingSocket
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export function createTlsRaftTransport<T = unknown>(
|
|
345
|
+
options: TlsRaftTransportOptions
|
|
346
|
+
): ManagedRaftTransport<T> {
|
|
347
|
+
const context: TlsTransportContext<T> = {
|
|
348
|
+
nodeId: options.nodeId,
|
|
349
|
+
host: options.host,
|
|
350
|
+
port: options.port,
|
|
351
|
+
peers: new Map(options.peers.map((peer) => [peer.nodeId, peer])),
|
|
352
|
+
cert: options.tls.cert,
|
|
353
|
+
key: options.tls.key,
|
|
354
|
+
ca: options.tls.ca,
|
|
355
|
+
minVersion: options.tls.minVersion ?? 'TLSv1.3',
|
|
356
|
+
rpcTimeoutMs: options.rpcTimeoutMs ?? 3_000,
|
|
357
|
+
endpoint: null,
|
|
358
|
+
server: null
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const start = async (endpoint: RaftRpcEndpoint<T>): Promise<void> => {
|
|
362
|
+
if (context.server) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
context.endpoint = endpoint;
|
|
367
|
+
context.server = createServer(
|
|
368
|
+
{
|
|
369
|
+
cert: context.cert,
|
|
370
|
+
key: context.key,
|
|
371
|
+
ca: context.ca,
|
|
372
|
+
requestCert: true,
|
|
373
|
+
rejectUnauthorized: true,
|
|
374
|
+
minVersion: context.minVersion
|
|
375
|
+
},
|
|
376
|
+
(socket) => {
|
|
377
|
+
void handleIncomingSocket(context, socket);
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
await withTimeout(
|
|
382
|
+
new Promise<void>((resolve, reject) => {
|
|
383
|
+
if (!context.server) {
|
|
384
|
+
reject(new Error('TLS server was not initialized'));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
context.server.once('error', reject);
|
|
389
|
+
context.server.listen(context.port, context.host, () => {
|
|
390
|
+
context.server?.off('error', reject);
|
|
391
|
+
resolve();
|
|
392
|
+
});
|
|
393
|
+
}),
|
|
394
|
+
context.rpcTimeoutMs,
|
|
395
|
+
`Timed out starting TLS transport listener after ${context.rpcTimeoutMs}ms`
|
|
396
|
+
);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const stop = async (): Promise<void> => {
|
|
400
|
+
if (!context.server) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const serverToClose = context.server;
|
|
405
|
+
context.server = null;
|
|
406
|
+
context.endpoint = null;
|
|
407
|
+
|
|
408
|
+
await new Promise<void>((resolve, reject) => {
|
|
409
|
+
serverToClose.close((error) => {
|
|
410
|
+
if (error) {
|
|
411
|
+
reject(error);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
resolve();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const requestVote = async (
|
|
420
|
+
fromNodeId: string,
|
|
421
|
+
toNodeId: string,
|
|
422
|
+
request: RequestVoteRequest
|
|
423
|
+
): Promise<RequestVoteResponse> => {
|
|
424
|
+
return sendRpc(context, fromNodeId, toNodeId, 'requestVote', request) as Promise<RequestVoteResponse>;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const appendEntries = async (
|
|
428
|
+
fromNodeId: string,
|
|
429
|
+
toNodeId: string,
|
|
430
|
+
request: AppendEntriesRequest<T>
|
|
431
|
+
): Promise<AppendEntriesResponse> => {
|
|
432
|
+
return sendRpc(context, fromNodeId, toNodeId, 'appendEntries', request) as Promise<AppendEntriesResponse>;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
start,
|
|
437
|
+
stop,
|
|
438
|
+
requestVote,
|
|
439
|
+
appendEntries,
|
|
440
|
+
upsertPeer: (peer) => {
|
|
441
|
+
context.peers.set(peer.nodeId, {
|
|
442
|
+
nodeId: peer.nodeId,
|
|
443
|
+
host: peer.host,
|
|
444
|
+
port: peer.port
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
removePeer: (nodeId: string) => {
|
|
448
|
+
context.peers.delete(nodeId);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export type RaftRole = 'follower' | 'candidate' | 'leader';
|
|
2
|
+
|
|
3
|
+
export type RaftLogEntry<T = unknown> = {
|
|
4
|
+
index: number;
|
|
5
|
+
term: number;
|
|
6
|
+
command: T;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type RequestVoteRequest = {
|
|
10
|
+
term: number;
|
|
11
|
+
candidateId: string;
|
|
12
|
+
lastLogIndex: number;
|
|
13
|
+
lastLogTerm: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RequestVoteResponse = {
|
|
17
|
+
term: number;
|
|
18
|
+
voteGranted: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AppendEntriesRequest<T = unknown> = {
|
|
22
|
+
term: number;
|
|
23
|
+
leaderId: string;
|
|
24
|
+
prevLogIndex: number;
|
|
25
|
+
prevLogTerm: number;
|
|
26
|
+
entries: RaftLogEntry<T>[];
|
|
27
|
+
leaderCommit: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AppendEntriesResponse = {
|
|
31
|
+
term: number;
|
|
32
|
+
success: boolean;
|
|
33
|
+
matchIndex: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type RaftRpcEndpoint<T = unknown> = {
|
|
37
|
+
onRequestVote: (fromNodeId: string, request: RequestVoteRequest) => Promise<RequestVoteResponse>;
|
|
38
|
+
onAppendEntries: (
|
|
39
|
+
fromNodeId: string,
|
|
40
|
+
request: AppendEntriesRequest<T>
|
|
41
|
+
) => Promise<AppendEntriesResponse>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RaftTransport<T = unknown> = {
|
|
45
|
+
requestVote: (
|
|
46
|
+
fromNodeId: string,
|
|
47
|
+
toNodeId: string,
|
|
48
|
+
request: RequestVoteRequest
|
|
49
|
+
) => Promise<RequestVoteResponse>;
|
|
50
|
+
appendEntries: (
|
|
51
|
+
fromNodeId: string,
|
|
52
|
+
toNodeId: string,
|
|
53
|
+
request: AppendEntriesRequest<T>
|
|
54
|
+
) => Promise<AppendEntriesResponse>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ManagedRaftTransport<T = unknown> = RaftTransport<T> & {
|
|
58
|
+
start?: (endpoint: RaftRpcEndpoint<T>) => Promise<void> | void;
|
|
59
|
+
stop?: () => Promise<void> | void;
|
|
60
|
+
register?: (nodeId: string, endpoint: RaftRpcEndpoint<T>) => void;
|
|
61
|
+
unregister?: (nodeId: string) => void;
|
|
62
|
+
upsertPeer?: (peer: { nodeId: string; host: string; port: number }) => void;
|
|
63
|
+
removePeer?: (nodeId: string) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type RaftNodeOptions<T = unknown> = {
|
|
67
|
+
nodeId: string;
|
|
68
|
+
peerIds: string[];
|
|
69
|
+
transport: RaftTransport<T>;
|
|
70
|
+
applyCommand: (entry: RaftLogEntry<T>) => void;
|
|
71
|
+
electionTimeoutMs?: number;
|
|
72
|
+
heartbeatMs?: number;
|
|
73
|
+
proposalTimeoutMs?: number;
|
|
74
|
+
retryBackoffMs?: number;
|
|
75
|
+
maxReplicationPassesPerRound?: number;
|
|
76
|
+
storage?: RaftStorage<T>;
|
|
77
|
+
random?: () => number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type RaftPersistentState<T = unknown> = {
|
|
81
|
+
currentTerm: number;
|
|
82
|
+
votedFor: string | null;
|
|
83
|
+
log: RaftLogEntry<T>[];
|
|
84
|
+
commitIndex: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type RaftStorage<T = unknown> = {
|
|
88
|
+
load: () => Promise<RaftPersistentState<T> | null>;
|
|
89
|
+
save: (state: RaftPersistentState<T>) => Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type RaftMetrics = {
|
|
93
|
+
electionsStarted: number;
|
|
94
|
+
leadershipsWon: number;
|
|
95
|
+
requestVoteSent: number;
|
|
96
|
+
requestVoteFailed: number;
|
|
97
|
+
appendEntriesSent: number;
|
|
98
|
+
appendEntriesFailed: number;
|
|
99
|
+
appendEntriesRetries: number;
|
|
100
|
+
proposalsReceived: number;
|
|
101
|
+
proposalTimeouts: number;
|
|
102
|
+
proposalsCommitted: number;
|
|
103
|
+
readBarriersRequested: number;
|
|
104
|
+
readBarriersFailed: number;
|
|
105
|
+
commitsApplied: number;
|
|
106
|
+
persistenceSaves: number;
|
|
107
|
+
persistenceSaveFailures: number;
|
|
108
|
+
persistenceLoads: number;
|
|
109
|
+
persistenceLoadFailures: number;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type NotLeaderError = Error & {
|
|
113
|
+
name: 'NotLeaderError';
|
|
114
|
+
leaderId: string | null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type QuorumUnavailableError = Error & {
|
|
118
|
+
name: 'QuorumUnavailableError';
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function createNotLeaderError(message: string, leaderId: string | null): NotLeaderError {
|
|
122
|
+
const error = new Error(message) as NotLeaderError;
|
|
123
|
+
error.name = 'NotLeaderError';
|
|
124
|
+
error.leaderId = leaderId;
|
|
125
|
+
return error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createQuorumUnavailableError(message: string): QuorumUnavailableError {
|
|
129
|
+
const error = new Error(message) as QuorumUnavailableError;
|
|
130
|
+
error.name = 'QuorumUnavailableError';
|
|
131
|
+
return error;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isNotLeaderError(error: unknown): error is NotLeaderError {
|
|
135
|
+
return (
|
|
136
|
+
!!error &&
|
|
137
|
+
typeof error === 'object' &&
|
|
138
|
+
'name' in error &&
|
|
139
|
+
(error as { name?: string }).name === 'NotLeaderError'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isQuorumUnavailableError(error: unknown): error is QuorumUnavailableError {
|
|
144
|
+
return (
|
|
145
|
+
!!error &&
|
|
146
|
+
typeof error === 'object' &&
|
|
147
|
+
'name' in error &&
|
|
148
|
+
(error as { name?: string }).name === 'QuorumUnavailableError'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, truncate, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { RaftPersistentState, RaftStorage } from '../raft/types.ts';
|
|
4
|
+
|
|
5
|
+
type FileWalRecord<T> = {
|
|
6
|
+
state: RaftPersistentState<T>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type FileRaftStorageOptions = {
|
|
10
|
+
dir: string;
|
|
11
|
+
nodeId: string;
|
|
12
|
+
compactEvery?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createFileRaftStorage<T = unknown>(options: FileRaftStorageOptions): RaftStorage<T> {
|
|
16
|
+
const compactEvery = options.compactEvery ?? 64;
|
|
17
|
+
const nodeDir = join(options.dir, options.nodeId);
|
|
18
|
+
const snapshotPath = join(nodeDir, 'snapshot.json');
|
|
19
|
+
const walPath = join(nodeDir, 'wal.ndjson');
|
|
20
|
+
|
|
21
|
+
let saveCount = 0;
|
|
22
|
+
|
|
23
|
+
const ensureDir = async (): Promise<void> => {
|
|
24
|
+
await mkdir(nodeDir, { recursive: true });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const parseSnapshot = async (): Promise<RaftPersistentState<T> | null> => {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(snapshotPath, 'utf8');
|
|
30
|
+
return JSON.parse(raw) as RaftPersistentState<T>;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const parseWal = async (): Promise<RaftPersistentState<T> | null> => {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(walPath, 'utf8');
|
|
39
|
+
const lines = raw.split('\n').filter((line) => line.trim().length > 0);
|
|
40
|
+
|
|
41
|
+
let latest: RaftPersistentState<T> | null = null;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const record = JSON.parse(line) as FileWalRecord<T>;
|
|
44
|
+
latest = record.state;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return latest;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const load = async (): Promise<RaftPersistentState<T> | null> => {
|
|
54
|
+
await ensureDir();
|
|
55
|
+
|
|
56
|
+
const snapshot = await parseSnapshot();
|
|
57
|
+
const walState = await parseWal();
|
|
58
|
+
|
|
59
|
+
return walState ?? snapshot;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const compact = async (state: RaftPersistentState<T>): Promise<void> => {
|
|
63
|
+
const tmpPath = `${snapshotPath}.tmp`;
|
|
64
|
+
await writeFile(tmpPath, JSON.stringify(state), 'utf8');
|
|
65
|
+
await rename(tmpPath, snapshotPath);
|
|
66
|
+
await truncate(walPath, 0);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const save = async (state: RaftPersistentState<T>): Promise<void> => {
|
|
70
|
+
await ensureDir();
|
|
71
|
+
const record: FileWalRecord<T> = { state };
|
|
72
|
+
await appendFile(walPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
73
|
+
|
|
74
|
+
saveCount += 1;
|
|
75
|
+
if (saveCount % compactEvery === 0) {
|
|
76
|
+
await compact(state);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
load,
|
|
82
|
+
save
|
|
83
|
+
};
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "diskeyval",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "",
|
|
6
|
+
"main": "lib/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node lib/index.ts",
|
|
9
|
+
"test": "npm run test:all",
|
|
10
|
+
"test:all": "node --test --test-concurrency=1 test/unit/*.test.ts test/sim/*.test.ts test/e2e/*.test.ts test/soak/*.test.ts",
|
|
11
|
+
"test:unit": "node --test --test-concurrency=1 test/unit/*.test.ts",
|
|
12
|
+
"test:sim": "node --test --test-concurrency=1 test/sim/*.test.ts",
|
|
13
|
+
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.ts",
|
|
14
|
+
"test:soak": "node --test --test-concurrency=1 test/soak/*.test.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": {
|
|
18
|
+
"email": "me@markwylde.com",
|
|
19
|
+
"name": "Mark Wylde",
|
|
20
|
+
"url": "https://github.com/markwylde"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT"
|
|
23
|
+
}
|