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,534 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
createRaftNode,
|
|
5
|
+
isNotLeaderError,
|
|
6
|
+
type AppendEntriesRequest,
|
|
7
|
+
type AppendEntriesResponse,
|
|
8
|
+
type RaftLogEntry,
|
|
9
|
+
type RaftStorage,
|
|
10
|
+
type RequestVoteRequest,
|
|
11
|
+
type RequestVoteResponse
|
|
12
|
+
} from '../../lib/raft/index.ts';
|
|
13
|
+
|
|
14
|
+
function createScriptedTransport(options?: {
|
|
15
|
+
requestVote?: (
|
|
16
|
+
fromNodeId: string,
|
|
17
|
+
toNodeId: string,
|
|
18
|
+
request: RequestVoteRequest
|
|
19
|
+
) => Promise<RequestVoteResponse>;
|
|
20
|
+
appendEntries?: (
|
|
21
|
+
fromNodeId: string,
|
|
22
|
+
toNodeId: string,
|
|
23
|
+
request: AppendEntriesRequest<string>
|
|
24
|
+
) => Promise<AppendEntriesResponse>;
|
|
25
|
+
}) {
|
|
26
|
+
return {
|
|
27
|
+
requestVote:
|
|
28
|
+
options?.requestVote ??
|
|
29
|
+
(async (_fromNodeId, _toNodeId, request) => ({
|
|
30
|
+
term: request.term,
|
|
31
|
+
voteGranted: true
|
|
32
|
+
})),
|
|
33
|
+
appendEntries:
|
|
34
|
+
options?.appendEntries ??
|
|
35
|
+
(async (_fromNodeId, _toNodeId, request) => ({
|
|
36
|
+
term: request.term,
|
|
37
|
+
success: true,
|
|
38
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
39
|
+
}))
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('[unit/raft-coverage] stopped node paths and startElection no-op before start', async () => {
|
|
44
|
+
const raft = createRaftNode<string>({
|
|
45
|
+
nodeId: 'node-a',
|
|
46
|
+
peerIds: ['node-b'],
|
|
47
|
+
transport: createScriptedTransport(),
|
|
48
|
+
applyCommand: () => undefined,
|
|
49
|
+
electionTimeoutMs: 10_000
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await raft.startElection();
|
|
53
|
+
assert.equal(raft.getState().currentTerm, 0);
|
|
54
|
+
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
() =>
|
|
57
|
+
raft.onRequestVote('node-b', {
|
|
58
|
+
term: 1,
|
|
59
|
+
candidateId: 'node-b',
|
|
60
|
+
lastLogIndex: 0,
|
|
61
|
+
lastLogTerm: 0
|
|
62
|
+
}),
|
|
63
|
+
/is stopped/i
|
|
64
|
+
);
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() =>
|
|
67
|
+
raft.onAppendEntries('node-b', {
|
|
68
|
+
term: 1,
|
|
69
|
+
leaderId: 'node-b',
|
|
70
|
+
prevLogIndex: 0,
|
|
71
|
+
prevLogTerm: 0,
|
|
72
|
+
entries: [],
|
|
73
|
+
leaderCommit: 0
|
|
74
|
+
}),
|
|
75
|
+
/is stopped/i
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('[unit/raft-coverage] raft event bus on/off duplicate handler paths', async () => {
|
|
80
|
+
const raft = createRaftNode<string>({
|
|
81
|
+
nodeId: 'node-events',
|
|
82
|
+
peerIds: [],
|
|
83
|
+
transport: createScriptedTransport(),
|
|
84
|
+
applyCommand: () => undefined,
|
|
85
|
+
electionTimeoutMs: 10_000
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let leaderEvents = 0;
|
|
89
|
+
const leaderHandler = (): void => {
|
|
90
|
+
leaderEvents += 1;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
raft.off('leader', leaderHandler);
|
|
94
|
+
raft.on('leader', leaderHandler);
|
|
95
|
+
raft.on('leader', leaderHandler);
|
|
96
|
+
|
|
97
|
+
await raft.start();
|
|
98
|
+
await raft.startElection();
|
|
99
|
+
|
|
100
|
+
raft.off('leader', leaderHandler);
|
|
101
|
+
raft.off('leader', leaderHandler);
|
|
102
|
+
|
|
103
|
+
assert.ok(leaderEvents >= 1);
|
|
104
|
+
await raft.stop();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('[unit/raft-coverage] storage load failures and malformed logs are handled', async () => {
|
|
108
|
+
const loadFailStorage: RaftStorage<string> = {
|
|
109
|
+
load: async () => {
|
|
110
|
+
throw new Error('load failure');
|
|
111
|
+
},
|
|
112
|
+
save: async () => undefined
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const raftWithLoadFailure = createRaftNode<string>({
|
|
116
|
+
nodeId: 'node-load-fail',
|
|
117
|
+
peerIds: [],
|
|
118
|
+
transport: createScriptedTransport(),
|
|
119
|
+
storage: loadFailStorage,
|
|
120
|
+
applyCommand: () => undefined,
|
|
121
|
+
electionTimeoutMs: 10_000
|
|
122
|
+
});
|
|
123
|
+
await raftWithLoadFailure.start();
|
|
124
|
+
assert.equal(raftWithLoadFailure.getMetrics().persistenceLoadFailures, 1);
|
|
125
|
+
await raftWithLoadFailure.stop();
|
|
126
|
+
|
|
127
|
+
const emptyLogStorage: RaftStorage<string> = {
|
|
128
|
+
load: async () => ({
|
|
129
|
+
currentTerm: 3,
|
|
130
|
+
votedFor: null,
|
|
131
|
+
log: [],
|
|
132
|
+
commitIndex: 0
|
|
133
|
+
}),
|
|
134
|
+
save: async () => undefined
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const raftWithEmptyLog = createRaftNode<string>({
|
|
138
|
+
nodeId: 'node-empty-log',
|
|
139
|
+
peerIds: [],
|
|
140
|
+
transport: createScriptedTransport(),
|
|
141
|
+
storage: emptyLogStorage,
|
|
142
|
+
applyCommand: () => undefined,
|
|
143
|
+
electionTimeoutMs: 10_000
|
|
144
|
+
});
|
|
145
|
+
await raftWithEmptyLog.start();
|
|
146
|
+
assert.equal(raftWithEmptyLog.getState().lastLogIndex, 0);
|
|
147
|
+
await raftWithEmptyLog.stop();
|
|
148
|
+
|
|
149
|
+
const applied: RaftLogEntry<string>[] = [];
|
|
150
|
+
const noSentinelStorage: RaftStorage<string> = {
|
|
151
|
+
load: async () => ({
|
|
152
|
+
currentTerm: 2,
|
|
153
|
+
votedFor: null,
|
|
154
|
+
log: [{ index: 1, term: 2, command: 'loaded' }],
|
|
155
|
+
commitIndex: 1
|
|
156
|
+
}),
|
|
157
|
+
save: async () => undefined
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const raftWithNoSentinel = createRaftNode<string>({
|
|
161
|
+
nodeId: 'node-no-sentinel',
|
|
162
|
+
peerIds: [],
|
|
163
|
+
transport: createScriptedTransport(),
|
|
164
|
+
storage: noSentinelStorage,
|
|
165
|
+
applyCommand: (entry) => {
|
|
166
|
+
applied.push(entry);
|
|
167
|
+
},
|
|
168
|
+
electionTimeoutMs: 10_000
|
|
169
|
+
});
|
|
170
|
+
await raftWithNoSentinel.start();
|
|
171
|
+
assert.equal(raftWithNoSentinel.getState().lastLogIndex, 1);
|
|
172
|
+
assert.equal(applied.length, 1);
|
|
173
|
+
assert.equal(applied[0].command, 'loaded');
|
|
174
|
+
await raftWithNoSentinel.stop();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('[unit/raft-coverage] start is idempotent and save failures increment metrics', async () => {
|
|
178
|
+
const failingSaveStorage: RaftStorage<string> = {
|
|
179
|
+
load: async () => null,
|
|
180
|
+
save: async () => {
|
|
181
|
+
throw new Error('save failed');
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const raft = createRaftNode<string>({
|
|
186
|
+
nodeId: 'node-save-fail',
|
|
187
|
+
peerIds: [],
|
|
188
|
+
transport: createScriptedTransport(),
|
|
189
|
+
storage: failingSaveStorage,
|
|
190
|
+
applyCommand: () => undefined,
|
|
191
|
+
electionTimeoutMs: 10_000
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await raft.start();
|
|
195
|
+
await raft.start();
|
|
196
|
+
await raft.startElection();
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
198
|
+
|
|
199
|
+
const metrics = raft.getMetrics();
|
|
200
|
+
assert.ok(metrics.persistenceSaveFailures >= 1);
|
|
201
|
+
await raft.stop();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('[unit/raft-coverage] election handles higher term responses and vote transport failures', async () => {
|
|
205
|
+
const raft = createRaftNode<string>({
|
|
206
|
+
nodeId: 'node-1',
|
|
207
|
+
peerIds: ['node-2', 'node-3'],
|
|
208
|
+
transport: createScriptedTransport({
|
|
209
|
+
requestVote: async (_fromNodeId, toNodeId, request) => {
|
|
210
|
+
if (toNodeId === 'node-2') {
|
|
211
|
+
throw new Error('network down');
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
term: request.term + 1,
|
|
215
|
+
voteGranted: false
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}),
|
|
219
|
+
applyCommand: () => undefined,
|
|
220
|
+
electionTimeoutMs: 10_000
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await raft.start();
|
|
224
|
+
await raft.startElection();
|
|
225
|
+
|
|
226
|
+
const state = raft.getState();
|
|
227
|
+
assert.equal(state.role, 'follower');
|
|
228
|
+
assert.ok(state.currentTerm >= 2);
|
|
229
|
+
const metrics = raft.getMetrics();
|
|
230
|
+
assert.ok(metrics.requestVoteFailed >= 1);
|
|
231
|
+
await raft.stop();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('[unit/raft-coverage] election handles non-granted votes and newer candidate logs', async () => {
|
|
235
|
+
const raft = createRaftNode<string>({
|
|
236
|
+
nodeId: 'node-1',
|
|
237
|
+
peerIds: ['node-2'],
|
|
238
|
+
transport: createScriptedTransport({
|
|
239
|
+
requestVote: async (_fromNodeId, _toNodeId, request) => ({
|
|
240
|
+
term: request.term,
|
|
241
|
+
voteGranted: false
|
|
242
|
+
})
|
|
243
|
+
}),
|
|
244
|
+
applyCommand: () => undefined,
|
|
245
|
+
electionTimeoutMs: 10_000
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await raft.start();
|
|
249
|
+
try {
|
|
250
|
+
await raft.startElection();
|
|
251
|
+
assert.equal(raft.isLeader(), false);
|
|
252
|
+
|
|
253
|
+
const vote = await raft.onRequestVote('node-2', {
|
|
254
|
+
term: raft.getState().currentTerm + 1,
|
|
255
|
+
candidateId: 'node-2',
|
|
256
|
+
lastLogIndex: 99,
|
|
257
|
+
lastLogTerm: 99
|
|
258
|
+
});
|
|
259
|
+
assert.equal(vote.voteGranted, true);
|
|
260
|
+
} finally {
|
|
261
|
+
await raft.stop();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('[unit/raft-coverage] append entries stale term and log mismatch responses', async () => {
|
|
266
|
+
const raft = createRaftNode<string>({
|
|
267
|
+
nodeId: 'node-1',
|
|
268
|
+
peerIds: [],
|
|
269
|
+
transport: createScriptedTransport(),
|
|
270
|
+
applyCommand: () => undefined,
|
|
271
|
+
electionTimeoutMs: 10_000
|
|
272
|
+
});
|
|
273
|
+
await raft.start();
|
|
274
|
+
await raft.startElection();
|
|
275
|
+
assert.equal(raft.isLeader(), true);
|
|
276
|
+
|
|
277
|
+
const stale = await raft.onAppendEntries('node-x', {
|
|
278
|
+
term: 0,
|
|
279
|
+
leaderId: 'node-x',
|
|
280
|
+
prevLogIndex: 0,
|
|
281
|
+
prevLogTerm: 0,
|
|
282
|
+
entries: [],
|
|
283
|
+
leaderCommit: 0
|
|
284
|
+
});
|
|
285
|
+
assert.equal(stale.success, false);
|
|
286
|
+
|
|
287
|
+
const mismatch = await raft.onAppendEntries('node-x', {
|
|
288
|
+
term: raft.getState().currentTerm,
|
|
289
|
+
leaderId: 'node-x',
|
|
290
|
+
prevLogIndex: 5,
|
|
291
|
+
prevLogTerm: 99,
|
|
292
|
+
entries: [],
|
|
293
|
+
leaderCommit: 0
|
|
294
|
+
});
|
|
295
|
+
assert.equal(mismatch.success, false);
|
|
296
|
+
await raft.stop();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('[unit/raft-coverage] readBarrier demotes leader on higher term response', async () => {
|
|
300
|
+
let appendEntriesCalls = 0;
|
|
301
|
+
const raft = createRaftNode<string>({
|
|
302
|
+
nodeId: 'node-1',
|
|
303
|
+
peerIds: ['node-2'],
|
|
304
|
+
transport: createScriptedTransport({
|
|
305
|
+
appendEntries: async (_fromNodeId, _toNodeId, request) => {
|
|
306
|
+
appendEntriesCalls += 1;
|
|
307
|
+
if (appendEntriesCalls <= 2) {
|
|
308
|
+
return {
|
|
309
|
+
term: request.term,
|
|
310
|
+
success: true,
|
|
311
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
term: request.term + 1,
|
|
317
|
+
success: false,
|
|
318
|
+
matchIndex: 0
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}),
|
|
322
|
+
applyCommand: () => undefined,
|
|
323
|
+
electionTimeoutMs: 10_000,
|
|
324
|
+
proposalTimeoutMs: 500
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await raft.start();
|
|
328
|
+
try {
|
|
329
|
+
await raft.startElection();
|
|
330
|
+
assert.equal(raft.isLeader(), true);
|
|
331
|
+
|
|
332
|
+
await assert.rejects(
|
|
333
|
+
() => raft.readBarrier(),
|
|
334
|
+
(error: unknown) => isNotLeaderError(error)
|
|
335
|
+
);
|
|
336
|
+
} finally {
|
|
337
|
+
await raft.stop();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('[unit/raft-coverage] readBarrier callback exits when leadership changes mid-flight', async () => {
|
|
342
|
+
let appendEntriesCalls = 0;
|
|
343
|
+
const raft = createRaftNode<string>({
|
|
344
|
+
nodeId: 'node-1',
|
|
345
|
+
peerIds: ['node-2', 'node-3'],
|
|
346
|
+
transport: createScriptedTransport({
|
|
347
|
+
appendEntries: async (_fromNodeId, toNodeId, request) => {
|
|
348
|
+
appendEntriesCalls += 1;
|
|
349
|
+
if (appendEntriesCalls <= 2) {
|
|
350
|
+
return {
|
|
351
|
+
term: request.term,
|
|
352
|
+
success: true,
|
|
353
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (toNodeId === 'node-2') {
|
|
358
|
+
return {
|
|
359
|
+
term: request.term + 1,
|
|
360
|
+
success: false,
|
|
361
|
+
matchIndex: 0
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
366
|
+
return {
|
|
367
|
+
term: request.term,
|
|
368
|
+
success: true,
|
|
369
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}),
|
|
373
|
+
applyCommand: () => undefined,
|
|
374
|
+
electionTimeoutMs: 10_000
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await raft.start();
|
|
378
|
+
try {
|
|
379
|
+
await raft.startElection();
|
|
380
|
+
await assert.rejects(
|
|
381
|
+
() => raft.readBarrier(),
|
|
382
|
+
(error: unknown) => isNotLeaderError(error)
|
|
383
|
+
);
|
|
384
|
+
} finally {
|
|
385
|
+
await raft.stop();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('[unit/raft-coverage] stop rejects pending proposal promises', async () => {
|
|
390
|
+
const raft = createRaftNode<string>({
|
|
391
|
+
nodeId: 'node-1',
|
|
392
|
+
peerIds: ['node-2'],
|
|
393
|
+
transport: createScriptedTransport({
|
|
394
|
+
appendEntries: async (fromNodeId, toNodeId, request) => {
|
|
395
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
396
|
+
return {
|
|
397
|
+
term: request.term,
|
|
398
|
+
success: false,
|
|
399
|
+
matchIndex: 0
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}),
|
|
403
|
+
applyCommand: () => undefined,
|
|
404
|
+
electionTimeoutMs: 10_000,
|
|
405
|
+
proposalTimeoutMs: 600
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await raft.start();
|
|
409
|
+
await raft.startElection();
|
|
410
|
+
assert.equal(raft.isLeader(), true);
|
|
411
|
+
|
|
412
|
+
const pending = raft.propose('blocked');
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
414
|
+
await raft.stop();
|
|
415
|
+
|
|
416
|
+
await assert.rejects(
|
|
417
|
+
() => pending,
|
|
418
|
+
/Node stopped before proposal committed/i
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('[unit/raft-coverage] propose fails when leadership changes before commit', async () => {
|
|
423
|
+
let raft: ReturnType<typeof createRaftNode<string>> | null = null;
|
|
424
|
+
let appendEntriesCalls = 0;
|
|
425
|
+
const transport = createScriptedTransport({
|
|
426
|
+
appendEntries: async (_fromNodeId, _toNodeId, request) => {
|
|
427
|
+
appendEntriesCalls += 1;
|
|
428
|
+
if (appendEntriesCalls <= 2) {
|
|
429
|
+
return {
|
|
430
|
+
term: request.term,
|
|
431
|
+
success: true,
|
|
432
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (raft) {
|
|
437
|
+
await raft.onAppendEntries('node-x', {
|
|
438
|
+
term: request.term + 1,
|
|
439
|
+
leaderId: 'node-x',
|
|
440
|
+
prevLogIndex: 0,
|
|
441
|
+
prevLogTerm: 0,
|
|
442
|
+
entries: [],
|
|
443
|
+
leaderCommit: 0
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
term: request.term,
|
|
449
|
+
success: false,
|
|
450
|
+
matchIndex: 0
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
raft = createRaftNode<string>({
|
|
456
|
+
nodeId: 'node-1',
|
|
457
|
+
peerIds: ['node-2'],
|
|
458
|
+
transport,
|
|
459
|
+
applyCommand: () => undefined,
|
|
460
|
+
electionTimeoutMs: 10_000,
|
|
461
|
+
proposalTimeoutMs: 300
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await raft.start();
|
|
465
|
+
try {
|
|
466
|
+
await raft.startElection();
|
|
467
|
+
assert.equal(raft.isLeader(), true);
|
|
468
|
+
await assert.rejects(
|
|
469
|
+
() => raft.propose('will-step-down'),
|
|
470
|
+
(error: unknown) => isNotLeaderError(error)
|
|
471
|
+
);
|
|
472
|
+
} finally {
|
|
473
|
+
await raft.stop();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('[unit/raft-coverage] replication stepdown handles higher-term append response path', async () => {
|
|
478
|
+
let appendEntriesCalls = 0;
|
|
479
|
+
const raft = createRaftNode<string>({
|
|
480
|
+
nodeId: 'node-1',
|
|
481
|
+
peerIds: ['node-2'],
|
|
482
|
+
transport: createScriptedTransport({
|
|
483
|
+
appendEntries: async (_fromNodeId, _toNodeId, request) => {
|
|
484
|
+
appendEntriesCalls += 1;
|
|
485
|
+
if (appendEntriesCalls <= 2) {
|
|
486
|
+
return {
|
|
487
|
+
term: request.term,
|
|
488
|
+
success: true,
|
|
489
|
+
matchIndex: request.prevLogIndex + request.entries.length
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
term: request.term + 1,
|
|
495
|
+
success: false,
|
|
496
|
+
matchIndex: 0
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}),
|
|
500
|
+
applyCommand: () => undefined,
|
|
501
|
+
electionTimeoutMs: 10_000,
|
|
502
|
+
heartbeatMs: 5
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await raft.start();
|
|
506
|
+
try {
|
|
507
|
+
await raft.startElection();
|
|
508
|
+
assert.equal(raft.isLeader(), true);
|
|
509
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
510
|
+
assert.equal(raft.isLeader(), false);
|
|
511
|
+
} finally {
|
|
512
|
+
await raft.stop();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('[unit/raft-coverage] follower readBarrier is rejected immediately', async () => {
|
|
517
|
+
const raft = createRaftNode<string>({
|
|
518
|
+
nodeId: 'node-follower',
|
|
519
|
+
peerIds: ['node-2'],
|
|
520
|
+
transport: createScriptedTransport(),
|
|
521
|
+
applyCommand: () => undefined,
|
|
522
|
+
electionTimeoutMs: 10_000
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await raft.start();
|
|
526
|
+
try {
|
|
527
|
+
await assert.rejects(
|
|
528
|
+
() => raft.readBarrier(),
|
|
529
|
+
(error: unknown) => isNotLeaderError(error)
|
|
530
|
+
);
|
|
531
|
+
} finally {
|
|
532
|
+
await raft.stop();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { isNotLeaderError } from '../../lib/index.ts';
|
|
4
|
+
import { createTestCluster, forceLeader, sleep } from '../support/helpers.ts';
|
|
5
|
+
|
|
6
|
+
test('[unit/election] elects one leader in stable 3-node cluster', async () => {
|
|
7
|
+
const cluster = createTestCluster();
|
|
8
|
+
await cluster.start();
|
|
9
|
+
try {
|
|
10
|
+
await forceLeader(cluster.nodes['node-1']);
|
|
11
|
+
|
|
12
|
+
assert.equal(cluster.nodes['node-1'].isLeader(), true);
|
|
13
|
+
assert.equal(cluster.nodes['node-2'].isLeader(), false);
|
|
14
|
+
assert.equal(cluster.nodes['node-3'].isLeader(), false);
|
|
15
|
+
|
|
16
|
+
assert.equal(cluster.nodes['node-2'].leaderId(), 'node-1');
|
|
17
|
+
assert.equal(cluster.nodes['node-3'].leaderId(), 'node-1');
|
|
18
|
+
} finally {
|
|
19
|
+
await cluster.stop();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('[unit/election] does not elect two leaders in same term', async () => {
|
|
24
|
+
const cluster = createTestCluster();
|
|
25
|
+
await cluster.start();
|
|
26
|
+
try {
|
|
27
|
+
await forceLeader(cluster.nodes['node-1']);
|
|
28
|
+
const originalLeaderTerm = cluster.nodes['node-1'].getRaftNode().getState().currentTerm;
|
|
29
|
+
await cluster.nodes['node-2'].forceElection();
|
|
30
|
+
await sleep(40);
|
|
31
|
+
|
|
32
|
+
const leaderCount = Object.values(cluster.nodes).filter((node) => node.isLeader()).length;
|
|
33
|
+
assert.equal(leaderCount, 1);
|
|
34
|
+
|
|
35
|
+
const leadersByTerm = new Map<number, number>();
|
|
36
|
+
for (const node of Object.values(cluster.nodes)) {
|
|
37
|
+
const state = node.getRaftNode().getState();
|
|
38
|
+
if (!node.isLeader()) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
leadersByTerm.set(state.currentTerm, (leadersByTerm.get(state.currentTerm) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const [, count] of leadersByTerm.entries()) {
|
|
45
|
+
assert.equal(count, 1);
|
|
46
|
+
}
|
|
47
|
+
assert.ok(cluster.nodes['node-1'].getRaftNode().getState().currentTerm >= originalLeaderTerm);
|
|
48
|
+
} finally {
|
|
49
|
+
await cluster.stop();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('[unit/election] follower rejects vote for stale term', async () => {
|
|
54
|
+
const cluster = createTestCluster();
|
|
55
|
+
await cluster.start();
|
|
56
|
+
try {
|
|
57
|
+
await forceLeader(cluster.nodes['node-1']);
|
|
58
|
+
const follower = cluster.nodes['node-2'].getRaftNode();
|
|
59
|
+
|
|
60
|
+
const vote = await follower.onRequestVote('node-3', {
|
|
61
|
+
term: 0,
|
|
62
|
+
candidateId: 'node-3',
|
|
63
|
+
lastLogIndex: 0,
|
|
64
|
+
lastLogTerm: 0
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.equal(vote.voteGranted, false);
|
|
68
|
+
assert.ok(vote.term >= 1);
|
|
69
|
+
} finally {
|
|
70
|
+
await cluster.stop();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('[unit/election] leader steps down when receiving higher term append', async () => {
|
|
75
|
+
const cluster = createTestCluster();
|
|
76
|
+
await cluster.start();
|
|
77
|
+
try {
|
|
78
|
+
await forceLeader(cluster.nodes['node-1']);
|
|
79
|
+
const leaderRaft = cluster.nodes['node-1'].getRaftNode();
|
|
80
|
+
|
|
81
|
+
const response = await leaderRaft.onAppendEntries('node-2', {
|
|
82
|
+
term: leaderRaft.getState().currentTerm + 1,
|
|
83
|
+
leaderId: 'node-2',
|
|
84
|
+
prevLogIndex: 0,
|
|
85
|
+
prevLogTerm: 0,
|
|
86
|
+
entries: [],
|
|
87
|
+
leaderCommit: leaderRaft.getState().commitIndex
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(response.success, true);
|
|
91
|
+
assert.equal(cluster.nodes['node-1'].isLeader(), false);
|
|
92
|
+
assert.equal(cluster.nodes['node-1'].leaderId(), 'node-2');
|
|
93
|
+
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => cluster.nodes['node-1'].set('k', 'v'),
|
|
96
|
+
(error: unknown) => isNotLeaderError(error)
|
|
97
|
+
);
|
|
98
|
+
} finally {
|
|
99
|
+
await cluster.stop();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
createInMemoryRaftNetwork,
|
|
8
|
+
diskeyval,
|
|
9
|
+
type SetCommand
|
|
10
|
+
} from '../../lib/index.ts';
|
|
11
|
+
import { forceLeader, waitForCondition } from '../support/helpers.ts';
|
|
12
|
+
|
|
13
|
+
test('[unit/phase5] persists committed state across node restart', async () => {
|
|
14
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'diskeyval-persist-'));
|
|
15
|
+
const network = createInMemoryRaftNetwork<SetCommand>();
|
|
16
|
+
|
|
17
|
+
const createNode = () =>
|
|
18
|
+
diskeyval({
|
|
19
|
+
nodeId: 'node-1',
|
|
20
|
+
peers: [],
|
|
21
|
+
transport: network,
|
|
22
|
+
persistence: {
|
|
23
|
+
dir: dataDir,
|
|
24
|
+
compactEvery: 2
|
|
25
|
+
},
|
|
26
|
+
electionTimeoutMs: 10_000,
|
|
27
|
+
heartbeatMs: 20,
|
|
28
|
+
proposalTimeoutMs: 400
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const firstNode = createNode();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await firstNode.start();
|
|
35
|
+
await forceLeader(firstNode);
|
|
36
|
+
|
|
37
|
+
await firstNode.set('persisted-key', 'persisted-value');
|
|
38
|
+
await firstNode.end();
|
|
39
|
+
|
|
40
|
+
const restartedNode = createNode();
|
|
41
|
+
await restartedNode.start();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
assert.equal(restartedNode.state['persisted-key'], 'persisted-value');
|
|
45
|
+
|
|
46
|
+
await forceLeader(restartedNode);
|
|
47
|
+
const value = await restartedNode.get('persisted-key');
|
|
48
|
+
assert.equal(value, 'persisted-value');
|
|
49
|
+
} finally {
|
|
50
|
+
await restartedNode.end();
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(dataDir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('[unit/phase5] exposes raft metrics and increments critical counters', async () => {
|
|
58
|
+
const network = createInMemoryRaftNetwork<SetCommand>();
|
|
59
|
+
const node = diskeyval({
|
|
60
|
+
nodeId: 'metrics-node',
|
|
61
|
+
peers: [],
|
|
62
|
+
transport: network,
|
|
63
|
+
electionTimeoutMs: 10_000,
|
|
64
|
+
heartbeatMs: 20,
|
|
65
|
+
proposalTimeoutMs: 400
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await node.start();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await forceLeader(node);
|
|
72
|
+
await node.set('m1', 1);
|
|
73
|
+
await node.get('m1');
|
|
74
|
+
|
|
75
|
+
await waitForCondition(() => node.getMetrics().commitsApplied > 0, {
|
|
76
|
+
timeoutMs: 500,
|
|
77
|
+
message: 'Expected commit metrics to increment'
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const metrics = node.getMetrics();
|
|
81
|
+
assert.ok(metrics.electionsStarted >= 1);
|
|
82
|
+
assert.ok(metrics.leadershipsWon >= 1);
|
|
83
|
+
assert.ok(metrics.proposalsReceived >= 1);
|
|
84
|
+
assert.ok(metrics.proposalsCommitted >= 1);
|
|
85
|
+
assert.ok(metrics.readBarriersRequested >= 1);
|
|
86
|
+
assert.ok(metrics.commitsApplied >= 1);
|
|
87
|
+
} finally {
|
|
88
|
+
await node.end();
|
|
89
|
+
}
|
|
90
|
+
});
|