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.
Files changed (41) hide show
  1. package/README.md +171 -0
  2. package/bench/bench-batch-curve.js +121 -0
  3. package/bench/bench-event-loop.js +91 -0
  4. package/bench/bench-failover.js +172 -0
  5. package/bench/bench-memory.js +107 -0
  6. package/bench/bench-partition.js +134 -0
  7. package/bench/bench-pipeline.js +94 -0
  8. package/bench/bench-recovery.js +134 -0
  9. package/bench/bench-replication.js +107 -0
  10. package/bench/bench-single-node.js +77 -0
  11. package/bench/bench-slow-follower.js +96 -0
  12. package/bench/harness.js +87 -0
  13. package/bench/run-all.js +83 -0
  14. package/bench-results.json +598 -0
  15. package/index.js +30 -0
  16. package/package.json +24 -0
  17. package/src/core/engine.js +907 -0
  18. package/src/core/invariants.js +146 -0
  19. package/src/node/raptiye.js +296 -0
  20. package/src/node/stats.js +82 -0
  21. package/src/protocol/checksum.js +66 -0
  22. package/src/protocol/wire.js +551 -0
  23. package/src/replication/pipeline.js +235 -0
  24. package/src/sim/cluster.js +316 -0
  25. package/src/sim/prng.js +52 -0
  26. package/src/sim/virtual-clock.js +82 -0
  27. package/src/storage/file-log.js +394 -0
  28. package/src/storage/interface.js +113 -0
  29. package/src/storage/memory-log.js +183 -0
  30. package/src/transport/interface.js +47 -0
  31. package/src/transport/memory-transport.js +67 -0
  32. package/src/transport/tcp-transport.js +200 -0
  33. package/src/types.js +87 -0
  34. package/test/chaos/chaos.test.js +112 -0
  35. package/test/integration/cluster.test.js +192 -0
  36. package/test/integration/node.test.js +78 -0
  37. package/test/integration/replication-pipeline.test.js +45 -0
  38. package/test/unit/core.test.js +163 -0
  39. package/test/unit/protocol.test.js +181 -0
  40. package/test/unit/storage.test.js +127 -0
  41. 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
+ }