raptiye 0.1.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/README.md +171 -0
- package/bench/bench-batch-curve.js +121 -0
- package/bench/bench-event-loop.js +91 -0
- package/bench/bench-failover.js +172 -0
- package/bench/bench-memory.js +107 -0
- package/bench/bench-partition.js +134 -0
- package/bench/bench-pipeline.js +94 -0
- package/bench/bench-recovery.js +134 -0
- package/bench/bench-replication.js +107 -0
- package/bench/bench-single-node.js +77 -0
- package/bench/bench-slow-follower.js +96 -0
- package/bench/harness.js +87 -0
- package/bench/run-all.js +83 -0
- package/bench-results.json +598 -0
- package/index.js +30 -0
- package/package.json +24 -0
- package/src/core/engine.js +907 -0
- package/src/core/invariants.js +146 -0
- package/src/node/raptiye.js +296 -0
- package/src/node/stats.js +82 -0
- package/src/protocol/checksum.js +66 -0
- package/src/protocol/wire.js +551 -0
- package/src/replication/pipeline.js +235 -0
- package/src/sim/cluster.js +316 -0
- package/src/sim/prng.js +52 -0
- package/src/sim/virtual-clock.js +82 -0
- package/src/storage/file-log.js +394 -0
- package/src/storage/interface.js +113 -0
- package/src/storage/memory-log.js +183 -0
- package/src/transport/interface.js +47 -0
- package/src/transport/memory-transport.js +67 -0
- package/src/transport/tcp-transport.js +200 -0
- package/src/types.js +87 -0
- package/test/chaos/chaos.test.js +112 -0
- package/test/integration/cluster.test.js +192 -0
- package/test/integration/node.test.js +78 -0
- package/test/integration/replication-pipeline.test.js +45 -0
- package/test/unit/core.test.js +163 -0
- package/test/unit/protocol.test.js +181 -0
- package/test/unit/storage.test.js +127 -0
- package/test/unit/transport.test.js +90 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure deterministic consensus state machine for Raptiye.
|
|
3
|
+
* Zero external side-effects: input event -> output effects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Role, MessageType, EntryType, EffectType, EventType } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export class ConsensusEngine {
|
|
9
|
+
constructor({
|
|
10
|
+
id,
|
|
11
|
+
peers = [],
|
|
12
|
+
storage,
|
|
13
|
+
maxBatchBytes = 64 * 1024,
|
|
14
|
+
maxBatchEntries = 256,
|
|
15
|
+
maxInflightBatches = 16,
|
|
16
|
+
maxInflightBytes = 4 * 1024 * 1024
|
|
17
|
+
}) {
|
|
18
|
+
this.id = Number(id);
|
|
19
|
+
this.peers = peers.map(Number).filter(p => p !== this.id);
|
|
20
|
+
this.storage = storage;
|
|
21
|
+
|
|
22
|
+
this.maxBatchBytes = maxBatchBytes;
|
|
23
|
+
this.maxBatchEntries = maxBatchEntries;
|
|
24
|
+
this.maxInflightBatches = maxInflightBatches;
|
|
25
|
+
this.maxInflightBytes = maxInflightBytes;
|
|
26
|
+
|
|
27
|
+
// Persistent state
|
|
28
|
+
const hardState = storage.getHardState();
|
|
29
|
+
this.currentTerm = hardState ? BigInt(hardState.currentTerm) : 0n;
|
|
30
|
+
this.votedFor = hardState && hardState.votedFor !== null ? Number(hardState.votedFor) : null;
|
|
31
|
+
|
|
32
|
+
// Volatile state
|
|
33
|
+
this.role = Role.FOLLOWER;
|
|
34
|
+
this.commitIndex = 0n;
|
|
35
|
+
this.lastApplied = 0n;
|
|
36
|
+
this.leaderId = null;
|
|
37
|
+
|
|
38
|
+
// Tracking for invariants
|
|
39
|
+
this._prevCheckedCommitIndex = 0n;
|
|
40
|
+
|
|
41
|
+
// Candidate election state
|
|
42
|
+
this.preVotes = new Set();
|
|
43
|
+
this.votes = new Set();
|
|
44
|
+
|
|
45
|
+
// Leader volatile state
|
|
46
|
+
this.nextIndex = new Map();
|
|
47
|
+
this.matchIndex = new Map();
|
|
48
|
+
this.inflightBatches = new Map();
|
|
49
|
+
this.inflightBytes = new Map();
|
|
50
|
+
|
|
51
|
+
// Leadership transfer
|
|
52
|
+
this.transferTarget = null;
|
|
53
|
+
|
|
54
|
+
this._initPeerProgress();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_initPeerProgress() {
|
|
58
|
+
const last = this.storage.lastIndex();
|
|
59
|
+
for (const peer of this.peers) {
|
|
60
|
+
this.nextIndex.set(peer, last + 1n);
|
|
61
|
+
this.matchIndex.set(peer, 0n);
|
|
62
|
+
this.inflightBatches.set(peer, 0);
|
|
63
|
+
this.inflightBytes.set(peer, 0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
quorumSize() {
|
|
68
|
+
return Math.floor((this.peers.length + 1) / 2) + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Main state machine entry point.
|
|
73
|
+
* Pure function: takes an event, mutates internal FSM, and returns an array of effects.
|
|
74
|
+
*
|
|
75
|
+
* @param {{ type: string, [key: string]: any }} event
|
|
76
|
+
* @returns {Array<{ type: string, [key: string]: any }>} effects
|
|
77
|
+
*/
|
|
78
|
+
step(event) {
|
|
79
|
+
const effects = [];
|
|
80
|
+
|
|
81
|
+
switch (event.type) {
|
|
82
|
+
case EventType.ELECTION_TIMEOUT:
|
|
83
|
+
case EventType.TIMEOUT_NOW:
|
|
84
|
+
this._handleElectionTimeout(event, effects);
|
|
85
|
+
break;
|
|
86
|
+
|
|
87
|
+
case EventType.HEARTBEAT_TIMEOUT:
|
|
88
|
+
this._handleHeartbeatTimeout(effects);
|
|
89
|
+
break;
|
|
90
|
+
|
|
91
|
+
case EventType.SUBMIT:
|
|
92
|
+
this._handleSubmit(event, effects);
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case EventType.MESSAGE:
|
|
96
|
+
this._handleMessage(event.message, effects);
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case EventType.TRANSFER_LEADERSHIP:
|
|
100
|
+
this._handleTransferLeadership(event.targetPeer, effects);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return effects;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_handleElectionTimeout(event, effects) {
|
|
108
|
+
if (this.role === Role.LEADER) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Single-node cluster becomes leader immediately
|
|
113
|
+
if (this.peers.length === 0) {
|
|
114
|
+
this._becomeLeader(effects);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isTimeoutNow = event.type === EventType.TIMEOUT_NOW;
|
|
119
|
+
|
|
120
|
+
if (isTimeoutNow || this.role === Role.FOLLOWER || this.role === Role.PRE_CANDIDATE) {
|
|
121
|
+
// Step 1: Pre-Vote phase (prevents disruptive term increases from isolated nodes)
|
|
122
|
+
this.role = Role.PRE_CANDIDATE;
|
|
123
|
+
this.preVotes.clear();
|
|
124
|
+
this.preVotes.add(this.id);
|
|
125
|
+
|
|
126
|
+
const candidateTerm = this.currentTerm + 1n;
|
|
127
|
+
const lastIndex = this.storage.lastIndex();
|
|
128
|
+
const lastTerm = this.storage.lastTerm();
|
|
129
|
+
|
|
130
|
+
for (const peer of this.peers) {
|
|
131
|
+
effects.push({
|
|
132
|
+
type: EffectType.SEND,
|
|
133
|
+
to: peer,
|
|
134
|
+
message: {
|
|
135
|
+
type: MessageType.PRE_VOTE_REQUEST,
|
|
136
|
+
term: candidateTerm,
|
|
137
|
+
sourceNode: this.id,
|
|
138
|
+
destNode: peer,
|
|
139
|
+
candidateId: this.id,
|
|
140
|
+
lastLogIndex: lastIndex,
|
|
141
|
+
lastLogTerm: lastTerm
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_handleHeartbeatTimeout(effects) {
|
|
151
|
+
if (this.role !== Role.LEADER) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Send heartbeats / pipeline pending entries to all peers
|
|
156
|
+
for (const peer of this.peers) {
|
|
157
|
+
this._replicateToPeer(peer, effects, true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
effects.push({ type: EffectType.RESET_HEARTBEAT_TIMER });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_handleSubmit(event, effects) {
|
|
164
|
+
if (this.role !== Role.LEADER) {
|
|
165
|
+
effects.push({
|
|
166
|
+
type: 'ERROR',
|
|
167
|
+
error: 'NOT_LEADER',
|
|
168
|
+
leaderId: this.leaderId
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.transferTarget !== null) {
|
|
174
|
+
effects.push({
|
|
175
|
+
type: 'ERROR',
|
|
176
|
+
error: 'LEADERSHIP_TRANSFERRING',
|
|
177
|
+
target: this.transferTarget
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const payload = event.payload || new Uint8Array(0);
|
|
183
|
+
const index = this.storage.lastIndex() + 1n;
|
|
184
|
+
const entry = {
|
|
185
|
+
term: this.currentTerm,
|
|
186
|
+
index,
|
|
187
|
+
type: event.entryType || EntryType.NORMAL,
|
|
188
|
+
payload
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
this.storage.append(entry);
|
|
192
|
+
effects.push({
|
|
193
|
+
type: EffectType.PERSIST_ENTRIES,
|
|
194
|
+
entries: [entry]
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Single-node cluster commit
|
|
198
|
+
if (this.peers.length === 0) {
|
|
199
|
+
this.commitIndex = index;
|
|
200
|
+
this._advanceApplied(effects);
|
|
201
|
+
effects.push({
|
|
202
|
+
type: EffectType.NOTIFY_COMMITTED,
|
|
203
|
+
index
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Replicate to all peers
|
|
209
|
+
for (const peer of this.peers) {
|
|
210
|
+
this._replicateToPeer(peer, effects, false);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_handleTransferLeadership(targetPeer, effects) {
|
|
215
|
+
if (this.role !== Role.LEADER) return;
|
|
216
|
+
const peer = Number(targetPeer);
|
|
217
|
+
if (!this.peers.includes(peer)) return;
|
|
218
|
+
|
|
219
|
+
this.transferTarget = peer;
|
|
220
|
+
const targetMatch = this.matchIndex.get(peer) || 0n;
|
|
221
|
+
const lastIndex = this.storage.lastIndex();
|
|
222
|
+
|
|
223
|
+
if (targetMatch >= lastIndex) {
|
|
224
|
+
// Follower is fully caught up! Send TIMEOUT_NOW immediately
|
|
225
|
+
effects.push({
|
|
226
|
+
type: EffectType.SEND,
|
|
227
|
+
to: peer,
|
|
228
|
+
message: {
|
|
229
|
+
type: MessageType.TIMEOUT_NOW,
|
|
230
|
+
term: this.currentTerm,
|
|
231
|
+
sourceNode: this.id,
|
|
232
|
+
destNode: peer,
|
|
233
|
+
leaderId: this.id
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
// Replicate urgently to target
|
|
238
|
+
this._replicateToPeer(peer, effects, false);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_handleMessage(msg, effects) {
|
|
243
|
+
// If msg has higher term, revert to follower
|
|
244
|
+
if (msg.term > this.currentTerm) {
|
|
245
|
+
if (msg.type !== MessageType.PRE_VOTE_REQUEST && msg.type !== MessageType.PRE_VOTE_RESPONSE) {
|
|
246
|
+
this._becomeFollower(msg.term, null, effects);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
switch (msg.type) {
|
|
251
|
+
case MessageType.PRE_VOTE_REQUEST:
|
|
252
|
+
this._handlePreVoteRequest(msg, effects);
|
|
253
|
+
break;
|
|
254
|
+
|
|
255
|
+
case MessageType.PRE_VOTE_RESPONSE:
|
|
256
|
+
this._handlePreVoteResponse(msg, effects);
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case MessageType.VOTE_REQUEST:
|
|
260
|
+
this._handleVoteRequest(msg, effects);
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case MessageType.VOTE_RESPONSE:
|
|
264
|
+
this._handleVoteResponse(msg, effects);
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case MessageType.APPEND_REQUEST:
|
|
268
|
+
this._handleAppendRequest(msg, effects);
|
|
269
|
+
break;
|
|
270
|
+
|
|
271
|
+
case MessageType.APPEND_RESPONSE:
|
|
272
|
+
this._handleAppendResponse(msg, effects);
|
|
273
|
+
break;
|
|
274
|
+
|
|
275
|
+
case MessageType.HEARTBEAT:
|
|
276
|
+
this._handleHeartbeat(msg, effects);
|
|
277
|
+
break;
|
|
278
|
+
|
|
279
|
+
case MessageType.TIMEOUT_NOW:
|
|
280
|
+
// Immediate election trigger
|
|
281
|
+
this._handleElectionTimeout({ type: EventType.TIMEOUT_NOW }, effects);
|
|
282
|
+
break;
|
|
283
|
+
|
|
284
|
+
case MessageType.SNAPSHOT_BEGIN:
|
|
285
|
+
case MessageType.SNAPSHOT_CHUNK:
|
|
286
|
+
case MessageType.SNAPSHOT_END:
|
|
287
|
+
this._handleSnapshotMessage(msg, effects);
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
case MessageType.SNAPSHOT_RESPONSE:
|
|
291
|
+
this._handleSnapshotResponse(msg, effects);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_handlePreVoteRequest(msg, effects) {
|
|
297
|
+
let voteGranted = false;
|
|
298
|
+
const candidateTerm = BigInt(msg.term);
|
|
299
|
+
|
|
300
|
+
// Pre-vote condition: candidate term must be greater than currentTerm,
|
|
301
|
+
// and candidate log must be at least as up-to-date as receiver log
|
|
302
|
+
if (candidateTerm > this.currentTerm) {
|
|
303
|
+
const myLastIndex = this.storage.lastIndex();
|
|
304
|
+
const myLastTerm = this.storage.lastTerm();
|
|
305
|
+
const candidateLastIndex = BigInt(msg.lastLogIndex);
|
|
306
|
+
const candidateLastTerm = BigInt(msg.lastLogTerm);
|
|
307
|
+
|
|
308
|
+
const logUpToDate = candidateLastTerm > myLastTerm ||
|
|
309
|
+
(candidateLastTerm === myLastTerm && candidateLastIndex >= myLastIndex);
|
|
310
|
+
|
|
311
|
+
if (logUpToDate) {
|
|
312
|
+
voteGranted = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
effects.push({
|
|
317
|
+
type: EffectType.SEND,
|
|
318
|
+
to: msg.candidateId,
|
|
319
|
+
message: {
|
|
320
|
+
type: MessageType.PRE_VOTE_RESPONSE,
|
|
321
|
+
term: this.currentTerm,
|
|
322
|
+
sourceNode: this.id,
|
|
323
|
+
destNode: msg.candidateId,
|
|
324
|
+
voteGranted
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_handlePreVoteResponse(msg, effects) {
|
|
330
|
+
if (this.role !== Role.PRE_CANDIDATE) return;
|
|
331
|
+
|
|
332
|
+
if (msg.voteGranted) {
|
|
333
|
+
this.preVotes.add(msg.sourceNode);
|
|
334
|
+
if (this.preVotes.size >= this.quorumSize()) {
|
|
335
|
+
// Pre-vote quorum achieved -> Transition to Candidate & begin real election
|
|
336
|
+
this._startElection(effects);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_startElection(effects) {
|
|
342
|
+
this.role = Role.CANDIDATE;
|
|
343
|
+
this.currentTerm += 1n;
|
|
344
|
+
this.votedFor = this.id;
|
|
345
|
+
this.votes.clear();
|
|
346
|
+
this.votes.add(this.id);
|
|
347
|
+
|
|
348
|
+
this.storage.setHardState({
|
|
349
|
+
currentTerm: this.currentTerm,
|
|
350
|
+
votedFor: this.votedFor
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
effects.push({
|
|
354
|
+
type: EffectType.PERSIST_HARD_STATE,
|
|
355
|
+
currentTerm: this.currentTerm,
|
|
356
|
+
votedFor: this.votedFor
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
360
|
+
|
|
361
|
+
const lastIndex = this.storage.lastIndex();
|
|
362
|
+
const lastTerm = this.storage.lastTerm();
|
|
363
|
+
|
|
364
|
+
for (const peer of this.peers) {
|
|
365
|
+
effects.push({
|
|
366
|
+
type: EffectType.SEND,
|
|
367
|
+
to: peer,
|
|
368
|
+
message: {
|
|
369
|
+
type: MessageType.VOTE_REQUEST,
|
|
370
|
+
term: this.currentTerm,
|
|
371
|
+
sourceNode: this.id,
|
|
372
|
+
destNode: peer,
|
|
373
|
+
candidateId: this.id,
|
|
374
|
+
lastLogIndex: lastIndex,
|
|
375
|
+
lastLogTerm: lastTerm
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_handleVoteRequest(msg, effects) {
|
|
382
|
+
let voteGranted = false;
|
|
383
|
+
const msgTerm = BigInt(msg.term);
|
|
384
|
+
|
|
385
|
+
if (msgTerm >= this.currentTerm) {
|
|
386
|
+
if (msgTerm > this.currentTerm) {
|
|
387
|
+
this._becomeFollower(msgTerm, null, effects);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const canVote = (this.votedFor === null || this.votedFor === msg.candidateId);
|
|
391
|
+
if (canVote) {
|
|
392
|
+
const myLastIndex = this.storage.lastIndex();
|
|
393
|
+
const myLastTerm = this.storage.lastTerm();
|
|
394
|
+
const candidateLastIndex = BigInt(msg.lastLogIndex);
|
|
395
|
+
const candidateLastTerm = BigInt(msg.lastLogTerm);
|
|
396
|
+
|
|
397
|
+
const logUpToDate = candidateLastTerm > myLastTerm ||
|
|
398
|
+
(candidateLastTerm === myLastTerm && candidateLastIndex >= myLastIndex);
|
|
399
|
+
|
|
400
|
+
if (logUpToDate) {
|
|
401
|
+
voteGranted = true;
|
|
402
|
+
this.votedFor = msg.candidateId;
|
|
403
|
+
this.storage.setHardState({
|
|
404
|
+
currentTerm: this.currentTerm,
|
|
405
|
+
votedFor: this.votedFor
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
effects.push({
|
|
409
|
+
type: EffectType.PERSIST_HARD_STATE,
|
|
410
|
+
currentTerm: this.currentTerm,
|
|
411
|
+
votedFor: this.votedFor
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
effects.push({
|
|
420
|
+
type: EffectType.SEND,
|
|
421
|
+
to: msg.candidateId,
|
|
422
|
+
message: {
|
|
423
|
+
type: MessageType.VOTE_RESPONSE,
|
|
424
|
+
term: this.currentTerm,
|
|
425
|
+
sourceNode: this.id,
|
|
426
|
+
destNode: msg.candidateId,
|
|
427
|
+
voteGranted
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_handleVoteResponse(msg, effects) {
|
|
433
|
+
if (this.role !== Role.CANDIDATE || BigInt(msg.term) !== this.currentTerm) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (msg.voteGranted) {
|
|
438
|
+
this.votes.add(msg.sourceNode);
|
|
439
|
+
if (this.votes.size >= this.quorumSize()) {
|
|
440
|
+
this._becomeLeader(effects);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
_becomeLeader(effects) {
|
|
446
|
+
this.role = Role.LEADER;
|
|
447
|
+
this.leaderId = this.id;
|
|
448
|
+
this.transferTarget = null;
|
|
449
|
+
this._initPeerProgress();
|
|
450
|
+
|
|
451
|
+
effects.push({ type: EffectType.BECOME_LEADER });
|
|
452
|
+
effects.push({ type: EffectType.RESET_HEARTBEAT_TIMER });
|
|
453
|
+
|
|
454
|
+
// Append a NOOP entry to establish leadership and commit entries from prior terms
|
|
455
|
+
const noopIndex = this.storage.lastIndex() + 1n;
|
|
456
|
+
const noopEntry = {
|
|
457
|
+
term: this.currentTerm,
|
|
458
|
+
index: noopIndex,
|
|
459
|
+
type: EntryType.NOOP,
|
|
460
|
+
payload: new Uint8Array(0)
|
|
461
|
+
};
|
|
462
|
+
this.storage.append(noopEntry);
|
|
463
|
+
|
|
464
|
+
effects.push({
|
|
465
|
+
type: EffectType.PERSIST_ENTRIES,
|
|
466
|
+
entries: [noopEntry]
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (this.peers.length === 0) {
|
|
470
|
+
this.commitIndex = noopIndex;
|
|
471
|
+
this._advanceApplied(effects);
|
|
472
|
+
effects.push({
|
|
473
|
+
type: EffectType.NOTIFY_COMMITTED,
|
|
474
|
+
index: noopIndex
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Broadcast AppendEntries to all followers
|
|
480
|
+
for (const peer of this.peers) {
|
|
481
|
+
this._replicateToPeer(peer, effects, false);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
_becomeFollower(term, leaderId, effects) {
|
|
486
|
+
const prevRole = this.role;
|
|
487
|
+
this.role = Role.FOLLOWER;
|
|
488
|
+
this.currentTerm = BigInt(term);
|
|
489
|
+
this.votedFor = null;
|
|
490
|
+
this.leaderId = leaderId;
|
|
491
|
+
this.transferTarget = null;
|
|
492
|
+
|
|
493
|
+
this.storage.setHardState({
|
|
494
|
+
currentTerm: this.currentTerm,
|
|
495
|
+
votedFor: null
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
effects.push({
|
|
499
|
+
type: EffectType.PERSIST_HARD_STATE,
|
|
500
|
+
currentTerm: this.currentTerm,
|
|
501
|
+
votedFor: null
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
effects.push({
|
|
505
|
+
type: EffectType.BECOME_FOLLOWER,
|
|
506
|
+
term: this.currentTerm,
|
|
507
|
+
leaderId
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
_handleHeartbeat(msg, effects) {
|
|
514
|
+
if (BigInt(msg.term) < this.currentTerm) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
this.leaderId = msg.leaderId;
|
|
519
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
520
|
+
|
|
521
|
+
// Advance commit index if leaderCommit > commitIndex
|
|
522
|
+
const leaderCommit = BigInt(msg.leaderCommit);
|
|
523
|
+
if (leaderCommit > this.commitIndex) {
|
|
524
|
+
const lastIndex = this.storage.lastIndex();
|
|
525
|
+
this.commitIndex = leaderCommit < lastIndex ? leaderCommit : lastIndex;
|
|
526
|
+
this._advanceApplied(effects);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_handleAppendRequest(msg, effects) {
|
|
531
|
+
const msgTerm = BigInt(msg.term);
|
|
532
|
+
|
|
533
|
+
if (msgTerm < this.currentTerm) {
|
|
534
|
+
effects.push({
|
|
535
|
+
type: EffectType.SEND,
|
|
536
|
+
to: msg.leaderId,
|
|
537
|
+
message: {
|
|
538
|
+
type: MessageType.APPEND_RESPONSE,
|
|
539
|
+
term: this.currentTerm,
|
|
540
|
+
sourceNode: this.id,
|
|
541
|
+
destNode: msg.leaderId,
|
|
542
|
+
followerId: this.id,
|
|
543
|
+
success: false,
|
|
544
|
+
matchIndex: 0n,
|
|
545
|
+
lastLogIndex: this.storage.lastIndex()
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
this.leaderId = msg.leaderId;
|
|
552
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
553
|
+
|
|
554
|
+
const prevLogIndex = BigInt(msg.prevLogIndex);
|
|
555
|
+
const prevLogTerm = BigInt(msg.prevLogTerm);
|
|
556
|
+
|
|
557
|
+
// Verify log matching property at prevLogIndex
|
|
558
|
+
if (prevLogIndex > 0n) {
|
|
559
|
+
if (prevLogIndex > this.storage.lastIndex()) {
|
|
560
|
+
// Missing entry
|
|
561
|
+
effects.push({
|
|
562
|
+
type: EffectType.SEND,
|
|
563
|
+
to: msg.leaderId,
|
|
564
|
+
message: {
|
|
565
|
+
type: MessageType.APPEND_RESPONSE,
|
|
566
|
+
term: this.currentTerm,
|
|
567
|
+
sourceNode: this.id,
|
|
568
|
+
destNode: msg.leaderId,
|
|
569
|
+
followerId: this.id,
|
|
570
|
+
success: false,
|
|
571
|
+
matchIndex: 0n,
|
|
572
|
+
lastLogIndex: this.storage.lastIndex()
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const termAtPrev = this.storage.term(prevLogIndex);
|
|
579
|
+
if (termAtPrev === null || termAtPrev !== prevLogTerm) {
|
|
580
|
+
// Conflict at prevLogIndex: truncate from here
|
|
581
|
+
if (prevLogIndex >= this.storage.firstIndex()) {
|
|
582
|
+
this.storage.truncateFrom(prevLogIndex);
|
|
583
|
+
}
|
|
584
|
+
effects.push({
|
|
585
|
+
type: EffectType.SEND,
|
|
586
|
+
to: msg.leaderId,
|
|
587
|
+
message: {
|
|
588
|
+
type: MessageType.APPEND_RESPONSE,
|
|
589
|
+
term: this.currentTerm,
|
|
590
|
+
sourceNode: this.id,
|
|
591
|
+
destNode: msg.leaderId,
|
|
592
|
+
followerId: this.id,
|
|
593
|
+
success: false,
|
|
594
|
+
matchIndex: 0n,
|
|
595
|
+
lastLogIndex: this.storage.lastIndex()
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Append entries, handling any conflicts
|
|
603
|
+
const entries = msg.entries || [];
|
|
604
|
+
const entriesToPersist = [];
|
|
605
|
+
|
|
606
|
+
for (let i = 0; i < entries.length; i++) {
|
|
607
|
+
const entry = entries[i];
|
|
608
|
+
const entryIndex = BigInt(entry.index);
|
|
609
|
+
const entryTerm = BigInt(entry.term);
|
|
610
|
+
|
|
611
|
+
if (entryIndex <= this.storage.lastIndex()) {
|
|
612
|
+
const existingTerm = this.storage.term(entryIndex);
|
|
613
|
+
if (existingTerm !== entryTerm) {
|
|
614
|
+
// Truncate conflict
|
|
615
|
+
this.storage.truncateFrom(entryIndex);
|
|
616
|
+
this.storage.append(entry);
|
|
617
|
+
entriesToPersist.push(entry);
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
this.storage.append(entry);
|
|
621
|
+
entriesToPersist.push(entry);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (entriesToPersist.length > 0) {
|
|
626
|
+
effects.push({
|
|
627
|
+
type: EffectType.PERSIST_ENTRIES,
|
|
628
|
+
entries: entriesToPersist
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Update commit index
|
|
633
|
+
const leaderCommit = BigInt(msg.leaderCommit);
|
|
634
|
+
if (leaderCommit > this.commitIndex) {
|
|
635
|
+
const lastIndex = this.storage.lastIndex();
|
|
636
|
+
this.commitIndex = leaderCommit < lastIndex ? leaderCommit : lastIndex;
|
|
637
|
+
this._advanceApplied(effects);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
effects.push({
|
|
641
|
+
type: EffectType.SEND,
|
|
642
|
+
to: msg.leaderId,
|
|
643
|
+
message: {
|
|
644
|
+
type: MessageType.APPEND_RESPONSE,
|
|
645
|
+
term: this.currentTerm,
|
|
646
|
+
sourceNode: this.id,
|
|
647
|
+
destNode: msg.leaderId,
|
|
648
|
+
followerId: this.id,
|
|
649
|
+
success: true,
|
|
650
|
+
matchIndex: this.storage.lastIndex(),
|
|
651
|
+
lastLogIndex: this.storage.lastIndex()
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
_handleAppendResponse(msg, effects) {
|
|
657
|
+
if (this.role !== Role.LEADER || BigInt(msg.term) !== this.currentTerm) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const peer = msg.followerId;
|
|
662
|
+
const curInflightBatches = this.inflightBatches.get(peer) || 0;
|
|
663
|
+
if (curInflightBatches > 0) {
|
|
664
|
+
this.inflightBatches.set(peer, curInflightBatches - 1);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (msg.success) {
|
|
668
|
+
const match = BigInt(msg.matchIndex);
|
|
669
|
+
this.matchIndex.set(peer, match);
|
|
670
|
+
this.nextIndex.set(peer, match + 1n);
|
|
671
|
+
|
|
672
|
+
// Check if leadership transfer target has caught up
|
|
673
|
+
if (this.transferTarget === peer && match >= this.storage.lastIndex()) {
|
|
674
|
+
effects.push({
|
|
675
|
+
type: EffectType.SEND,
|
|
676
|
+
to: peer,
|
|
677
|
+
message: {
|
|
678
|
+
type: MessageType.TIMEOUT_NOW,
|
|
679
|
+
term: this.currentTerm,
|
|
680
|
+
sourceNode: this.id,
|
|
681
|
+
destNode: peer,
|
|
682
|
+
leaderId: this.id
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
this.transferTarget = null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Check if commitIndex can advance
|
|
689
|
+
this._maybeAdvanceCommitIndex(effects);
|
|
690
|
+
|
|
691
|
+
// Continue replication if follower is still behind
|
|
692
|
+
if (this.nextIndex.get(peer) <= this.storage.lastIndex()) {
|
|
693
|
+
this._replicateToPeer(peer, effects, false);
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// Rejection: back up nextIndex
|
|
697
|
+
const curNext = this.nextIndex.get(peer) || (this.storage.lastIndex() + 1n);
|
|
698
|
+
const followerLast = BigInt(msg.lastLogIndex);
|
|
699
|
+
|
|
700
|
+
let newNext = curNext > 1n ? curNext - 1n : 1n;
|
|
701
|
+
if (followerLast < newNext) {
|
|
702
|
+
newNext = followerLast + 1n;
|
|
703
|
+
}
|
|
704
|
+
this.nextIndex.set(peer, newNext);
|
|
705
|
+
|
|
706
|
+
// Check if follower is behind compacted log -> snapshot required
|
|
707
|
+
if (newNext < this.storage.firstIndex()) {
|
|
708
|
+
this._sendSnapshotToPeer(peer, effects);
|
|
709
|
+
} else {
|
|
710
|
+
this._replicateToPeer(peer, effects, false);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
_maybeAdvanceCommitIndex(effects) {
|
|
716
|
+
const matches = [this.storage.lastIndex()];
|
|
717
|
+
for (const peer of this.peers) {
|
|
718
|
+
matches.push(this.matchIndex.get(peer) || 0n);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Sort descending
|
|
722
|
+
matches.sort((a, b) => (b > a ? 1 : b < a ? -1 : 0));
|
|
723
|
+
|
|
724
|
+
// Quorum match index is at index: quorumSize - 1
|
|
725
|
+
const N = matches[this.quorumSize() - 1];
|
|
726
|
+
|
|
727
|
+
if (N > this.commitIndex) {
|
|
728
|
+
// Raft safety rule: only commit entries from currentTerm by counting replicas
|
|
729
|
+
const termAtN = this.storage.term(N);
|
|
730
|
+
if (termAtN === this.currentTerm) {
|
|
731
|
+
const prevCommit = this.commitIndex;
|
|
732
|
+
this.commitIndex = N;
|
|
733
|
+
this._advanceApplied(effects);
|
|
734
|
+
|
|
735
|
+
for (let idx = prevCommit + 1n; idx <= N; idx++) {
|
|
736
|
+
effects.push({
|
|
737
|
+
type: EffectType.NOTIFY_COMMITTED,
|
|
738
|
+
index: idx
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
_advanceApplied(effects) {
|
|
746
|
+
while (this.lastApplied < this.commitIndex) {
|
|
747
|
+
this.lastApplied += 1n;
|
|
748
|
+
const entries = this.storage.entries(this.lastApplied, this.lastApplied);
|
|
749
|
+
if (entries.length > 0) {
|
|
750
|
+
effects.push({
|
|
751
|
+
type: EffectType.APPLY,
|
|
752
|
+
entry: entries[0]
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
_replicateToPeer(peer, effects, isHeartbeat = false) {
|
|
759
|
+
const next = this.nextIndex.get(peer) || (this.storage.lastIndex() + 1n);
|
|
760
|
+
|
|
761
|
+
// Check if behind snapshot
|
|
762
|
+
if (next < this.storage.firstIndex()) {
|
|
763
|
+
this._sendSnapshotToPeer(peer, effects);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const inflight = this.inflightBatches.get(peer) || 0;
|
|
768
|
+
if (!isHeartbeat && inflight >= this.maxInflightBatches) {
|
|
769
|
+
// Inflight limit reached, wait for ACK
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const prevLogIndex = next - 1n;
|
|
774
|
+
const prevLogTerm = this.storage.term(prevLogIndex) || 0n;
|
|
775
|
+
|
|
776
|
+
const last = this.storage.lastIndex();
|
|
777
|
+
let entries = [];
|
|
778
|
+
|
|
779
|
+
if (next <= last) {
|
|
780
|
+
const maxTo = next + BigInt(this.maxBatchEntries) - 1n;
|
|
781
|
+
const to = maxTo < last ? maxTo : last;
|
|
782
|
+
entries = this.storage.entries(next, to, this.maxBatchBytes);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (entries.length === 0 && !isHeartbeat) {
|
|
786
|
+
return; // Nothing new to replicate
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
this.inflightBatches.set(peer, inflight + 1);
|
|
790
|
+
|
|
791
|
+
effects.push({
|
|
792
|
+
type: EffectType.SEND,
|
|
793
|
+
to: peer,
|
|
794
|
+
message: {
|
|
795
|
+
type: MessageType.APPEND_REQUEST,
|
|
796
|
+
term: this.currentTerm,
|
|
797
|
+
sourceNode: this.id,
|
|
798
|
+
destNode: peer,
|
|
799
|
+
leaderId: this.id,
|
|
800
|
+
prevLogIndex,
|
|
801
|
+
prevLogTerm,
|
|
802
|
+
leaderCommit: this.commitIndex,
|
|
803
|
+
entries
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
_sendSnapshotToPeer(peer, effects) {
|
|
809
|
+
const snap = this.storage.getSnapshot();
|
|
810
|
+
if (!snap) return;
|
|
811
|
+
|
|
812
|
+
effects.push({
|
|
813
|
+
type: EffectType.SEND,
|
|
814
|
+
to: peer,
|
|
815
|
+
message: {
|
|
816
|
+
type: MessageType.SNAPSHOT_BEGIN,
|
|
817
|
+
term: this.currentTerm,
|
|
818
|
+
sourceNode: this.id,
|
|
819
|
+
destNode: peer,
|
|
820
|
+
leaderId: this.id,
|
|
821
|
+
snapshotIndex: snap.index,
|
|
822
|
+
snapshotTerm: snap.term,
|
|
823
|
+
totalBytes: BigInt(snap.data.byteLength)
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
effects.push({
|
|
828
|
+
type: EffectType.SEND,
|
|
829
|
+
to: peer,
|
|
830
|
+
message: {
|
|
831
|
+
type: MessageType.SNAPSHOT_CHUNK,
|
|
832
|
+
term: this.currentTerm,
|
|
833
|
+
sourceNode: this.id,
|
|
834
|
+
destNode: peer,
|
|
835
|
+
snapshotIndex: snap.index,
|
|
836
|
+
offset: 0n,
|
|
837
|
+
chunkData: snap.data
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
effects.push({
|
|
842
|
+
type: EffectType.SEND,
|
|
843
|
+
to: peer,
|
|
844
|
+
message: {
|
|
845
|
+
type: MessageType.SNAPSHOT_END,
|
|
846
|
+
term: this.currentTerm,
|
|
847
|
+
sourceNode: this.id,
|
|
848
|
+
destNode: peer,
|
|
849
|
+
snapshotIndex: snap.index,
|
|
850
|
+
totalChecksum: 0
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
_handleSnapshotMessage(msg, effects) {
|
|
856
|
+
if (BigInt(msg.term) < this.currentTerm) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
this.leaderId = msg.leaderId;
|
|
861
|
+
effects.push({ type: EffectType.RESET_ELECTION_TIMER });
|
|
862
|
+
|
|
863
|
+
if (msg.type === MessageType.SNAPSHOT_CHUNK) {
|
|
864
|
+
this._pendingSnapshot = {
|
|
865
|
+
index: BigInt(msg.snapshotIndex),
|
|
866
|
+
term: BigInt(msg.term),
|
|
867
|
+
data: msg.chunkData
|
|
868
|
+
};
|
|
869
|
+
} else if (msg.type === MessageType.SNAPSHOT_END) {
|
|
870
|
+
if (this._pendingSnapshot) {
|
|
871
|
+
this.storage.saveSnapshot(this._pendingSnapshot);
|
|
872
|
+
if (this._pendingSnapshot.index > this.commitIndex) {
|
|
873
|
+
this.commitIndex = this._pendingSnapshot.index;
|
|
874
|
+
this.lastApplied = this._pendingSnapshot.index;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
effects.push({
|
|
878
|
+
type: EffectType.SEND,
|
|
879
|
+
to: msg.sourceNode,
|
|
880
|
+
message: {
|
|
881
|
+
type: MessageType.SNAPSHOT_RESPONSE,
|
|
882
|
+
term: this.currentTerm,
|
|
883
|
+
sourceNode: this.id,
|
|
884
|
+
destNode: msg.sourceNode,
|
|
885
|
+
followerId: this.id,
|
|
886
|
+
success: true,
|
|
887
|
+
lastIndex: this.storage.lastIndex()
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
this._pendingSnapshot = null;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
_handleSnapshotResponse(msg, effects) {
|
|
896
|
+
if (this.role !== Role.LEADER || BigInt(msg.term) !== this.currentTerm) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (msg.success) {
|
|
901
|
+
const match = BigInt(msg.lastIndex);
|
|
902
|
+
this.matchIndex.set(msg.followerId, match);
|
|
903
|
+
this.nextIndex.set(msg.followerId, match + 1n);
|
|
904
|
+
this._replicateToPeer(msg.followerId, effects, false);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|