Distributed & Decentralized Systems Curriculum
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