Consensus Fault Tolerance ยท Raft
/**
* raft.ts โ Simplified Raft Consensus Algorithm (Leader Election + Log Replication)
*
* Run with: npx ts-node raft.ts
*
* This is a pedagogical implementation. It simulates a 3-node cluster
* going through leader election and replicating a single log entry.
*/
// --- Types ---
type ServerState = 'follower' | 'candidate' | 'leader'
interface LogEntry {
term: number
command: string
}
interface RequestVoteArgs {
term: number
candidateId: number
lastLogIndex: number
lastLogTerm: number
}
interface RequestVoteReply {
term: number
voteGranted: boolean
}
interface AppendEntriesArgs {
term: number
leaderId: number
prevLogIndex: number
prevLogTerm: number
entries: LogEntry[]
leaderCommit: number
}
interface AppendEntriesReply {
term: number
success: boolean
}
// --- Raft Node ---
class RaftNode {
id: number
peers: RaftNode[] = []
// Persistent state
currentTerm: number = 0
votedFor: number | null = null
log: LogEntry[] = []
// Volatile state
state: ServerState = 'follower'
commitIndex: number = 0
lastApplied: number = 0
// Leader-only volatile state
nextIndex: number[] = []
matchIndex: number[] = []
// Election timer
electionTimeout: number
private electionTimer: ReturnType<typeof setTimeout> | null = null
constructor(id: number) {
this.id = id
this.electionTimeout = 150 + Math.random() * 150 // 150-300ms
}
setPeers(peers: RaftNode[]) {
this.peers = peers.filter(p => p.id !== this.id)
}
// --- Step 1: Start an Election ---
startElection() {
this.state = 'candidate'
this.currentTerm++
this.votedFor = this.id
const lastLogIndex = this.log.length - 1
const lastLogTerm = this.log.length > 0 ? this.log[lastLogIndex].term : 0
console.log(`\n๐ข Node ${this.id} starts election for term ${this.currentTerm}`)
const args: RequestVoteArgs = {
term: this.currentTerm,
candidateId: this.id,
lastLogIndex,
lastLogTerm,
}
let votesReceived = 1 // vote for self
const peers = [...this.peers]
for (const peer of peers) {
const reply = peer.handleRequestVote(args)
if (reply.voteGranted) {
votesReceived++
console.log(` โ
Node ${this.id} โ vote from Node ${peer.id}`)
} else {
console.log(` โ Node ${this.id} โ rejected by Node ${peer.id} (they're at term ${reply.term})`)
}
}
const majority = Math.floor((this.peers.length + 1) / 2) + 1
if (votesReceived >= majority && this.state === 'candidate') {
this.becomeLeader()
}
}
// --- Step 2: Handle Vote Requests ---
handleRequestVote(args: RequestVoteArgs): RequestVoteReply {
if (args.term < this.currentTerm) {
return { term: this.currentTerm, voteGranted: false }
}
if (args.term > this.currentTerm) {
this.currentTerm = args.term
this.state = 'follower'
this.votedFor = null
}
// Check if log is at least as up-to-date
const myLastIndex = this.log.length - 1
const myLastTerm = this.log.length > 0 ? this.log[myLastIndex].term : 0
const logOk =
args.lastLogTerm > myLastTerm ||
(args.lastLogTerm === myLastTerm && args.lastLogIndex >= myLastIndex)
if (
(this.votedFor === null || this.votedFor === args.candidateId) &&
logOk
) {
this.votedFor = args.candidateId
return { term: this.currentTerm, voteGranted: true }
}
return { term: this.currentTerm, voteGranted: false }
}
// --- Step 3: Become Leader ---
becomeLeader() {
this.state = 'leader'
console.log(`\n๐ Node ${this.id} BECOMES LEADER for term ${this.currentTerm}`)
const lastIndex = this.log.length
this.nextIndex = this.peers.map(() => lastIndex)
this.matchIndex = this.peers.map(() => -1)
}
// --- Step 4: Replicate a Log Entry ---
replicateCommand(command: string) {
if (this.state !== 'leader') {
console.log(` โ ๏ธ Node ${this.id} is not the leader, cannot accept command`)
return false
}
const entry: LogEntry = { term: this.currentTerm, command }
this.log.push(entry)
console.log(`\n๐ Leader ${this.id} appends entry: "${command}" (term ${this.currentTerm})`)
// Send AppendEntries to all followers
let successCount = 1 // leader already has it
for (let i = 0; i < this.peers.length; i++) {
const peer = this.peers[i]
const prevLogIndex = this.nextIndex[i] - 1
const prevLogTerm = prevLogIndex >= 0 ? this.log[prevLogIndex].term : 0
const args: AppendEntriesArgs = {
term: this.currentTerm,
leaderId: this.id,
prevLogIndex,
prevLogTerm,
entries: [entry],
leaderCommit: this.commitIndex,
}
const reply = peer.handleAppendEntries(args)
if (reply.success) {
successCount++
this.nextIndex[i] = this.log.length
this.matchIndex[i] = this.log.length - 1
console.log(` โ
Leader ${this.id} โ success from Node ${peer.id}`)
} else {
// Back off nextIndex for this peer
this.nextIndex[i] = Math.max(0, this.nextIndex[i] - 1)
console.log(` โ Leader ${this.id} โ failed at Node ${peer.id} (term ${reply.term})`)
}
}
// Commit if replicated to majority
const majority = Math.floor((this.peers.length + 1) / 2) + 1
if (successCount >= majority) {
this.commitIndex = this.log.length - 1
console.log(` โ
Entry "${command}" COMMITTED (majority: ${successCount}/${majority})`)
} else {
console.log(` โณ Entry "${command}" not yet committed (majority: ${successCount}/${majority})`)
}
return true
}
// --- Step 5: Handle AppendEntries (Heartbeat + Replication) ---
handleAppendEntries(args: AppendEntriesArgs): AppendEntriesReply {
// 1. Reply false if term < currentTerm
if (args.term < this.currentTerm) {
return { term: this.currentTerm, success: false }
}
// 2. If leader's term >= currentTerm, step down
if (args.term > this.currentTerm) {
this.currentTerm = args.term
this.state = 'follower'
this.votedFor = null
}
// Reset election timer (simulated by acknowledging leader)
this.state = 'follower'
// 3. Reply false if log doesn't contain an entry at prevLogIndex matching prevLogTerm
if (this.log.length <= args.prevLogIndex) {
return { term: this.currentTerm, success: false }
}
if (args.prevLogIndex >= 0 && this.log[args.prevLogIndex].term !== args.prevLogTerm) {
return { term: this.currentTerm, success: false }
}
// 4. Append new entries (simplified โ in real Raft, delete conflicts first)
for (const entry of args.entries) {
this.log.push(entry)
}
// 5. Update commitIndex
if (args.leaderCommit > this.commitIndex) {
this.commitIndex = Math.min(args.leaderCommit, this.log.length - 1)
}
return { term: this.currentTerm, success: true }
}
// --- Print State ---
printState() {
const logStr = this.log.length > 0
? this.log.map(e => `"${e.command}"@T${e.term}`).join(', ')
: '(empty)'
console.log(` Node ${this.id} | ${this.state.padEnd(10)} | Term ${this.currentTerm} | Log: [${logStr}]`)
}
}
// --- Simulation ---
function simulateRaft() {
console.log('=== RAFT CONSENSUS ALGORITHM โ STEP-BY-STEP SIMULATION ===\n')
// Create 3 nodes
const node1 = new RaftNode(1)
const node2 = new RaftNode(2)
const node3 = new RaftNode(3)
// Wire them together
node1.setPeers([node2, node3])
node2.setPeers([node1, node3])
node3.setPeers([node1, node2])
console.log('๐ง Initial Cluster (term 0):')
node1.printState()
node2.printState()
node3.printState()
// Node 1 starts an election (simulates its timer expiring first)
console.log('\n=== PHASE 1: Leader Election ===')
node1.startElection()
console.log('\n=== Cluster State After Election ===')
node1.printState()
node2.printState()
node3.printState()
if (node1.state === 'leader') {
console.log('\n=== PHASE 2: Log Replication ===')
node1.replicateCommand('SET x = 42')
node1.replicateCommand('SET y = hello')
}
console.log('\n=== FINAL CLUSTER STATE ===')
node1.printState()
node2.printState()
node3.printState()
console.log('\nโ
Simulation complete. Raft ensures all nodes converge to the same log.')
}
simulateRaft()
โฌ Download raft.ts