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
package/lib/index.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createInMemoryRaftNetwork,
|
|
3
|
+
type InMemoryRaftNetwork
|
|
4
|
+
} from './raft/in-memory-network.ts';
|
|
5
|
+
import { createRaftNode, type RaftNode } from './raft/node.ts';
|
|
6
|
+
import { createTlsRaftTransport, type TlsPeer } from './raft/tls-transport.ts';
|
|
7
|
+
import {
|
|
8
|
+
createNotLeaderError,
|
|
9
|
+
createQuorumUnavailableError,
|
|
10
|
+
isNotLeaderError,
|
|
11
|
+
isQuorumUnavailableError,
|
|
12
|
+
type ManagedRaftTransport,
|
|
13
|
+
type RaftMetrics,
|
|
14
|
+
type RaftLogEntry,
|
|
15
|
+
type RaftRole,
|
|
16
|
+
type RaftTransport
|
|
17
|
+
} from './raft/types.ts';
|
|
18
|
+
import { createFileRaftStorage } from './storage/file-raft-storage.ts';
|
|
19
|
+
|
|
20
|
+
export type SetCommand = {
|
|
21
|
+
type: 'set';
|
|
22
|
+
key: string;
|
|
23
|
+
value: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ClusterPeer = {
|
|
27
|
+
nodeId: string;
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ReconfigureCommand = {
|
|
33
|
+
type: 'reconfigure';
|
|
34
|
+
peers: ClusterPeer[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DiskeyvalCommand = SetCommand | ReconfigureCommand;
|
|
38
|
+
|
|
39
|
+
export type DiskeyvalOptions = {
|
|
40
|
+
nodeId: string;
|
|
41
|
+
peers: string[] | TlsPeer[];
|
|
42
|
+
transport?: RaftTransport<DiskeyvalCommand>;
|
|
43
|
+
host?: string;
|
|
44
|
+
port?: number;
|
|
45
|
+
tls?: {
|
|
46
|
+
cert: string;
|
|
47
|
+
key: string;
|
|
48
|
+
ca: string;
|
|
49
|
+
servername?: string;
|
|
50
|
+
};
|
|
51
|
+
rpcTimeoutMs?: number;
|
|
52
|
+
persistence?: {
|
|
53
|
+
dir: string;
|
|
54
|
+
compactEvery?: number;
|
|
55
|
+
};
|
|
56
|
+
electionTimeoutMs?: number;
|
|
57
|
+
heartbeatMs?: number;
|
|
58
|
+
proposalTimeoutMs?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type DiskeyvalEvents = {
|
|
62
|
+
change: {
|
|
63
|
+
key: string;
|
|
64
|
+
value: unknown;
|
|
65
|
+
};
|
|
66
|
+
leader: {
|
|
67
|
+
nodeId: string | null;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type EventKey = keyof DiskeyvalEvents;
|
|
72
|
+
|
|
73
|
+
type EventHandler<K extends EventKey> = (payload: DiskeyvalEvents[K]) => void;
|
|
74
|
+
|
|
75
|
+
function createEventBus() {
|
|
76
|
+
const handlers = new Map<EventKey, Set<(payload: unknown) => void>>();
|
|
77
|
+
|
|
78
|
+
const on = <K extends EventKey>(event: K, handler: EventHandler<K>): void => {
|
|
79
|
+
const existing = handlers.get(event);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.add(handler as (payload: unknown) => void);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
handlers.set(event, new Set([(handler as (payload: unknown) => void)]));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const off = <K extends EventKey>(event: K, handler: EventHandler<K>): void => {
|
|
88
|
+
const existing = handlers.get(event);
|
|
89
|
+
if (!existing) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
existing.delete(handler as (payload: unknown) => void);
|
|
93
|
+
if (existing.size === 0) {
|
|
94
|
+
handlers.delete(event);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const emit = <K extends EventKey>(event: K, payload: DiskeyvalEvents[K]): void => {
|
|
99
|
+
const existing = handlers.get(event);
|
|
100
|
+
if (!existing) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const handler of existing) {
|
|
104
|
+
handler(payload);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
on,
|
|
110
|
+
off,
|
|
111
|
+
emit
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type DiskeyvalNode = {
|
|
116
|
+
nodeId: string;
|
|
117
|
+
state: Record<string, unknown>;
|
|
118
|
+
start: () => Promise<void>;
|
|
119
|
+
end: () => Promise<void>;
|
|
120
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
121
|
+
get: <T = unknown>(key: string) => Promise<T | undefined>;
|
|
122
|
+
reconfigure: (peers: ClusterPeer[]) => Promise<void>;
|
|
123
|
+
getPeers: () => ClusterPeer[];
|
|
124
|
+
isLeader: () => boolean;
|
|
125
|
+
leaderId: () => string | null;
|
|
126
|
+
getRaftNode: () => RaftNode<DiskeyvalCommand>;
|
|
127
|
+
getMetrics: () => RaftMetrics;
|
|
128
|
+
forceElection: () => Promise<void>;
|
|
129
|
+
on: <K extends EventKey>(event: K, handler: EventHandler<K>) => void;
|
|
130
|
+
off: <K extends EventKey>(event: K, handler: EventHandler<K>) => void;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type DiskeyvalContext = {
|
|
134
|
+
nodeId: string;
|
|
135
|
+
selfPeer: ClusterPeer;
|
|
136
|
+
state: Record<string, unknown>;
|
|
137
|
+
raft: RaftNode<DiskeyvalCommand>;
|
|
138
|
+
transport: ManagedRaftTransport<DiskeyvalCommand>;
|
|
139
|
+
events: ReturnType<typeof createEventBus>;
|
|
140
|
+
peersById: Map<string, ClusterPeer>;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function extractPeerRecords(peers: DiskeyvalOptions['peers']): ClusterPeer[] {
|
|
144
|
+
if (peers.length === 0) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof peers[0] === 'string') {
|
|
149
|
+
return (peers as string[]).map((nodeId) => ({ nodeId }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (peers as TlsPeer[]).map((peer) => ({
|
|
153
|
+
nodeId: peer.nodeId,
|
|
154
|
+
host: peer.host,
|
|
155
|
+
port: peer.port
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractPeerIds(peers: ClusterPeer[]): string[] {
|
|
160
|
+
return peers.map((peer) => peer.nodeId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizePeers(selfNodeId: string, incoming: ClusterPeer[]): ClusterPeer[] {
|
|
164
|
+
const deduped = new Map<string, ClusterPeer>();
|
|
165
|
+
for (const peer of incoming) {
|
|
166
|
+
if (!peer.nodeId || peer.nodeId === selfNodeId) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
deduped.set(peer.nodeId, {
|
|
170
|
+
nodeId: peer.nodeId,
|
|
171
|
+
host: peer.host,
|
|
172
|
+
port: peer.port
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return Array.from(deduped.values()).sort((a, b) => a.nodeId.localeCompare(b.nodeId));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeMembers(incoming: ClusterPeer[]): ClusterPeer[] {
|
|
180
|
+
const deduped = new Map<string, ClusterPeer>();
|
|
181
|
+
for (const peer of incoming) {
|
|
182
|
+
if (!peer.nodeId) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
deduped.set(peer.nodeId, {
|
|
186
|
+
nodeId: peer.nodeId,
|
|
187
|
+
host: peer.host,
|
|
188
|
+
port: peer.port
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Array.from(deduped.values()).sort((a, b) => a.nodeId.localeCompare(b.nodeId));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveTransport(options: DiskeyvalOptions): ManagedRaftTransport<DiskeyvalCommand> {
|
|
196
|
+
if (options.transport) {
|
|
197
|
+
return options.transport as ManagedRaftTransport<DiskeyvalCommand>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!options.host || !options.port || !options.tls) {
|
|
201
|
+
throw new Error('Either provide transport, or provide host/port/tls for built-in TLS transport');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const peers = options.peers as TlsPeer[];
|
|
205
|
+
return createTlsRaftTransport<DiskeyvalCommand>({
|
|
206
|
+
nodeId: options.nodeId,
|
|
207
|
+
host: options.host,
|
|
208
|
+
port: options.port,
|
|
209
|
+
peers,
|
|
210
|
+
tls: options.tls,
|
|
211
|
+
rpcTimeoutMs: options.rpcTimeoutMs
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function createRpcEndpoint(context: DiskeyvalContext) {
|
|
216
|
+
return {
|
|
217
|
+
onRequestVote: context.raft.onRequestVote,
|
|
218
|
+
onAppendEntries: context.raft.onAppendEntries
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function applyMembership(context: DiskeyvalContext, peers: ClusterPeer[]): void {
|
|
223
|
+
const normalized = normalizePeers(context.nodeId, peers);
|
|
224
|
+
|
|
225
|
+
const nextIds = new Set(normalized.map((peer) => peer.nodeId));
|
|
226
|
+
for (const existingId of context.peersById.keys()) {
|
|
227
|
+
if (nextIds.has(existingId)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
context.peersById.delete(existingId);
|
|
231
|
+
if (typeof context.transport.removePeer === 'function') {
|
|
232
|
+
context.transport.removePeer(existingId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const peer of normalized) {
|
|
237
|
+
context.peersById.set(peer.nodeId, peer);
|
|
238
|
+
if (
|
|
239
|
+
typeof context.transport.upsertPeer === 'function' &&
|
|
240
|
+
typeof peer.host === 'string' &&
|
|
241
|
+
typeof peer.port === 'number'
|
|
242
|
+
) {
|
|
243
|
+
context.transport.upsertPeer({
|
|
244
|
+
nodeId: peer.nodeId,
|
|
245
|
+
host: peer.host,
|
|
246
|
+
port: peer.port
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
context.raft.replacePeers(normalized.map((peer) => peer.nodeId));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function diskeyval(options: DiskeyvalOptions): DiskeyvalNode {
|
|
255
|
+
const events = createEventBus();
|
|
256
|
+
const state: Record<string, unknown> = {};
|
|
257
|
+
let started = false;
|
|
258
|
+
const transport = resolveTransport(options);
|
|
259
|
+
const initialPeers = normalizePeers(options.nodeId, extractPeerRecords(options.peers));
|
|
260
|
+
const peersById = new Map(initialPeers.map((peer) => [peer.nodeId, peer]));
|
|
261
|
+
|
|
262
|
+
const raft = createRaftNode<DiskeyvalCommand>({
|
|
263
|
+
nodeId: options.nodeId,
|
|
264
|
+
peerIds: extractPeerIds(initialPeers),
|
|
265
|
+
transport,
|
|
266
|
+
electionTimeoutMs: options.electionTimeoutMs,
|
|
267
|
+
heartbeatMs: options.heartbeatMs,
|
|
268
|
+
proposalTimeoutMs: options.proposalTimeoutMs,
|
|
269
|
+
storage:
|
|
270
|
+
options.persistence !== undefined
|
|
271
|
+
? createFileRaftStorage<DiskeyvalCommand>({
|
|
272
|
+
dir: options.persistence.dir,
|
|
273
|
+
nodeId: options.nodeId,
|
|
274
|
+
compactEvery: options.persistence.compactEvery
|
|
275
|
+
})
|
|
276
|
+
: undefined,
|
|
277
|
+
applyCommand: (entry) => {
|
|
278
|
+
if (entry.command.type === 'set') {
|
|
279
|
+
state[entry.command.key] = entry.command.value;
|
|
280
|
+
if (started) {
|
|
281
|
+
events.emit('change', { key: entry.command.key, value: entry.command.value });
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (entry.command.type === 'reconfigure') {
|
|
287
|
+
applyMembership(context, entry.command.peers);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const context: DiskeyvalContext = {
|
|
293
|
+
nodeId: options.nodeId,
|
|
294
|
+
selfPeer: {
|
|
295
|
+
nodeId: options.nodeId,
|
|
296
|
+
host: options.host,
|
|
297
|
+
port: options.port
|
|
298
|
+
},
|
|
299
|
+
state,
|
|
300
|
+
raft,
|
|
301
|
+
transport,
|
|
302
|
+
events,
|
|
303
|
+
peersById
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (typeof context.transport.register === 'function') {
|
|
307
|
+
context.transport.register(context.nodeId, createRpcEndpoint(context));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const peer of initialPeers) {
|
|
311
|
+
if (
|
|
312
|
+
typeof context.transport.upsertPeer === 'function' &&
|
|
313
|
+
typeof peer.host === 'string' &&
|
|
314
|
+
typeof peer.port === 'number'
|
|
315
|
+
) {
|
|
316
|
+
context.transport.upsertPeer({
|
|
317
|
+
nodeId: peer.nodeId,
|
|
318
|
+
host: peer.host,
|
|
319
|
+
port: peer.port
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
context.raft.on('leader', (leaderNodeId: string | null) => {
|
|
325
|
+
context.events.emit('leader', { nodeId: leaderNodeId });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const start = async (): Promise<void> => {
|
|
329
|
+
if (typeof context.transport.start === 'function') {
|
|
330
|
+
await context.transport.start(createRpcEndpoint(context));
|
|
331
|
+
}
|
|
332
|
+
await context.raft.start();
|
|
333
|
+
started = true;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const end = async (): Promise<void> => {
|
|
337
|
+
await context.raft.stop();
|
|
338
|
+
if (typeof context.transport.unregister === 'function') {
|
|
339
|
+
context.transport.unregister(context.nodeId);
|
|
340
|
+
}
|
|
341
|
+
if (typeof context.transport.stop === 'function') {
|
|
342
|
+
await context.transport.stop();
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const set = async (key: string, value: unknown): Promise<void> => {
|
|
347
|
+
await context.raft.propose({
|
|
348
|
+
type: 'set',
|
|
349
|
+
key,
|
|
350
|
+
value
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const get = async <T = unknown>(key: string): Promise<T | undefined> => {
|
|
355
|
+
if (!context.raft.isLeader()) {
|
|
356
|
+
throw createNotLeaderError('Reads must go to the leader', context.raft.leaderId());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
await context.raft.readBarrier();
|
|
360
|
+
return context.state[key] as T | undefined;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const reconfigure = async (peers: ClusterPeer[]): Promise<void> => {
|
|
364
|
+
const normalized = normalizeMembers([...peers, context.selfPeer]);
|
|
365
|
+
|
|
366
|
+
await context.raft.propose({
|
|
367
|
+
type: 'reconfigure',
|
|
368
|
+
peers: normalized
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const getPeers = (): ClusterPeer[] => {
|
|
373
|
+
return Array.from(context.peersById.values()).map((peer) => ({ ...peer }));
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
nodeId: context.nodeId,
|
|
378
|
+
state: context.state,
|
|
379
|
+
start,
|
|
380
|
+
end,
|
|
381
|
+
set,
|
|
382
|
+
get,
|
|
383
|
+
reconfigure,
|
|
384
|
+
getPeers,
|
|
385
|
+
isLeader: context.raft.isLeader,
|
|
386
|
+
leaderId: context.raft.leaderId,
|
|
387
|
+
getRaftNode: () => context.raft,
|
|
388
|
+
getMetrics: context.raft.getMetrics,
|
|
389
|
+
forceElection: context.raft.startElection,
|
|
390
|
+
on: context.events.on,
|
|
391
|
+
off: context.events.off
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function createInMemoryDiskeyvalCluster(
|
|
396
|
+
nodeIds: string[],
|
|
397
|
+
options?: {
|
|
398
|
+
electionTimeoutMs?: number;
|
|
399
|
+
heartbeatMs?: number;
|
|
400
|
+
proposalTimeoutMs?: number;
|
|
401
|
+
}
|
|
402
|
+
): {
|
|
403
|
+
network: InMemoryRaftNetwork<DiskeyvalCommand>;
|
|
404
|
+
nodes: Record<string, DiskeyvalNode>;
|
|
405
|
+
start: () => Promise<void>;
|
|
406
|
+
stop: () => Promise<void>;
|
|
407
|
+
} {
|
|
408
|
+
const network = createInMemoryRaftNetwork<DiskeyvalCommand>();
|
|
409
|
+
const nodes: Record<string, DiskeyvalNode> = {};
|
|
410
|
+
|
|
411
|
+
for (const nodeId of nodeIds) {
|
|
412
|
+
const peers = nodeIds
|
|
413
|
+
.filter((candidateId) => candidateId !== nodeId)
|
|
414
|
+
.map((candidateId) => ({ nodeId: candidateId }));
|
|
415
|
+
|
|
416
|
+
nodes[nodeId] = diskeyval({
|
|
417
|
+
nodeId,
|
|
418
|
+
peers,
|
|
419
|
+
transport: network,
|
|
420
|
+
electionTimeoutMs: options?.electionTimeoutMs,
|
|
421
|
+
heartbeatMs: options?.heartbeatMs,
|
|
422
|
+
proposalTimeoutMs: options?.proposalTimeoutMs
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
network,
|
|
428
|
+
nodes,
|
|
429
|
+
start: async () => {
|
|
430
|
+
await Promise.all(Object.values(nodes).map(async (node) => node.start()));
|
|
431
|
+
},
|
|
432
|
+
stop: async () => {
|
|
433
|
+
await Promise.all(Object.values(nodes).map(async (node) => node.end()));
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export { createInMemoryRaftNetwork, createRaftNode, createTlsRaftTransport };
|
|
439
|
+
export { createFileRaftStorage };
|
|
440
|
+
export {
|
|
441
|
+
createNotLeaderError,
|
|
442
|
+
createQuorumUnavailableError,
|
|
443
|
+
isNotLeaderError,
|
|
444
|
+
isQuorumUnavailableError
|
|
445
|
+
};
|
|
446
|
+
export type { RaftLogEntry, RaftRole, TlsPeer };
|
|
447
|
+
|
|
448
|
+
export default diskeyval;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppendEntriesRequest,
|
|
3
|
+
AppendEntriesResponse,
|
|
4
|
+
ManagedRaftTransport,
|
|
5
|
+
RaftRpcEndpoint,
|
|
6
|
+
RequestVoteRequest,
|
|
7
|
+
RequestVoteResponse
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
|
|
10
|
+
function directedEdge(fromNodeId: string, toNodeId: string): string {
|
|
11
|
+
return `${fromNodeId}->${toNodeId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type InMemoryRaftNetwork<T = unknown> = ManagedRaftTransport<T> & {
|
|
15
|
+
register: (nodeId: string, endpoint: RaftRpcEndpoint<T>) => void;
|
|
16
|
+
unregister: (nodeId: string) => void;
|
|
17
|
+
partition: (nodeA: string, nodeB: string) => void;
|
|
18
|
+
heal: (nodeA: string, nodeB: string) => void;
|
|
19
|
+
healAll: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createInMemoryRaftNetwork<T = unknown>(options?: {
|
|
23
|
+
latencyMs?: number;
|
|
24
|
+
}): InMemoryRaftNetwork<T> {
|
|
25
|
+
const endpoints = new Map<string, RaftRpcEndpoint<T>>();
|
|
26
|
+
const blockedEdges = new Set<string>();
|
|
27
|
+
const latencyMs = options?.latencyMs ?? 0;
|
|
28
|
+
|
|
29
|
+
const waitLatency = async (): Promise<void> => {
|
|
30
|
+
if (latencyMs <= 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await new Promise<void>((resolve) => setTimeout(resolve, latencyMs));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const assertReachable = (fromNodeId: string, toNodeId: string): void => {
|
|
37
|
+
if (!blockedEdges.has(directedEdge(fromNodeId, toNodeId))) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Network partition between ${fromNodeId} and ${toNodeId}`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const requestVote = async (
|
|
44
|
+
fromNodeId: string,
|
|
45
|
+
toNodeId: string,
|
|
46
|
+
request: RequestVoteRequest
|
|
47
|
+
): Promise<RequestVoteResponse> => {
|
|
48
|
+
await waitLatency();
|
|
49
|
+
assertReachable(fromNodeId, toNodeId);
|
|
50
|
+
|
|
51
|
+
const endpoint = endpoints.get(toNodeId);
|
|
52
|
+
if (!endpoint) {
|
|
53
|
+
throw new Error(`Unknown node: ${toNodeId}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return endpoint.onRequestVote(fromNodeId, request);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const appendEntries = async (
|
|
60
|
+
fromNodeId: string,
|
|
61
|
+
toNodeId: string,
|
|
62
|
+
request: AppendEntriesRequest<T>
|
|
63
|
+
): Promise<AppendEntriesResponse> => {
|
|
64
|
+
await waitLatency();
|
|
65
|
+
assertReachable(fromNodeId, toNodeId);
|
|
66
|
+
|
|
67
|
+
const endpoint = endpoints.get(toNodeId);
|
|
68
|
+
if (!endpoint) {
|
|
69
|
+
throw new Error(`Unknown node: ${toNodeId}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return endpoint.onAppendEntries(fromNodeId, request);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
requestVote,
|
|
77
|
+
appendEntries,
|
|
78
|
+
register: (nodeId: string, endpoint: RaftRpcEndpoint<T>): void => {
|
|
79
|
+
endpoints.set(nodeId, endpoint);
|
|
80
|
+
},
|
|
81
|
+
unregister: (nodeId: string): void => {
|
|
82
|
+
endpoints.delete(nodeId);
|
|
83
|
+
},
|
|
84
|
+
partition: (nodeA: string, nodeB: string): void => {
|
|
85
|
+
blockedEdges.add(directedEdge(nodeA, nodeB));
|
|
86
|
+
blockedEdges.add(directedEdge(nodeB, nodeA));
|
|
87
|
+
},
|
|
88
|
+
heal: (nodeA: string, nodeB: string): void => {
|
|
89
|
+
blockedEdges.delete(directedEdge(nodeA, nodeB));
|
|
90
|
+
blockedEdges.delete(directedEdge(nodeB, nodeA));
|
|
91
|
+
},
|
|
92
|
+
healAll: (): void => {
|
|
93
|
+
blockedEdges.clear();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { createInMemoryRaftNetwork, type InMemoryRaftNetwork } from './in-memory-network.ts';
|
|
2
|
+
export { createRaftNode, type RaftNode } from './node.ts';
|
|
3
|
+
export { createTlsRaftTransport, type TlsPeer, type TlsRaftTransportOptions } from './tls-transport.ts';
|
|
4
|
+
export {
|
|
5
|
+
createNotLeaderError,
|
|
6
|
+
createQuorumUnavailableError,
|
|
7
|
+
isNotLeaderError,
|
|
8
|
+
isQuorumUnavailableError,
|
|
9
|
+
type ManagedRaftTransport,
|
|
10
|
+
type NotLeaderError,
|
|
11
|
+
type QuorumUnavailableError,
|
|
12
|
+
type RaftMetrics,
|
|
13
|
+
type AppendEntriesRequest,
|
|
14
|
+
type AppendEntriesResponse,
|
|
15
|
+
type RaftLogEntry,
|
|
16
|
+
type RaftNodeOptions,
|
|
17
|
+
type RaftPersistentState,
|
|
18
|
+
type RaftRole,
|
|
19
|
+
type RaftRpcEndpoint,
|
|
20
|
+
type RaftStorage,
|
|
21
|
+
type RaftTransport,
|
|
22
|
+
type RequestVoteRequest,
|
|
23
|
+
type RequestVoteResponse
|
|
24
|
+
} from './types.ts';
|