Distributed & Decentralized Systems Curriculum
Reflection Real Systems ยท Redis Cluster
/**
 * redis-cluster.ts โ€” Redis Cluster: Hash Slot Routing & Async Replication
 *
 * Run with: npx ts-node redis-cluster.ts
 *
 * Demonstrates Redis Cluster's approach to distribution:
 * - 16384 fixed hash slots (CRC16 mod 16384)
 * - Asynchronous master-replica replication
 * - Cluster bus gossip
 * - MOVED redirects on topology change
 */

const CRC16_TABLE: number[] = []

function initCRC16Table() {
  for (let i = 0; i < 256; i++) {
    let crc = i
    for (let j = 0; j < 8; j++) {
      if ((crc & 1) !== 0) crc = (crc >> 1) ^ 0xa001
      else crc = crc >> 1
    }
    CRC16_TABLE[i] = crc
  }
}
initCRC16Table()

function crc16(buf: Buffer): number {
  let crc = 0
  for (let i = 0; i < buf.length; i++) {
    crc = (crc >> 8) ^ CRC16_TABLE[(crc ^ buf[i]) & 0xff]
  }
  return crc
}

function hashSlot(key: string): number {
  // Redis hash slot: CRC16(key) mod 16384
  // With hash tags: only the part inside {} is hashed
  const tagMatch = key.match(/\{(.+?)\}/)
  const effective = tagMatch ? tagMatch[1] : key
  return crc16(Buffer.from(effective, 'utf-8')) % 16384
}

// --- Cluster Node ---

type NodeRole = 'master' | 'replica'

class RedisClusterNode {
  id: string
  host: string
  port: number
  role: NodeRole
  master: RedisClusterNode | null = null
  replicas: RedisClusterNode[] = []
  slots: number[] = []          // slots this node is responsible for
  data: Map<string, string> = new Map()
  epoch: number = 0             // cluster config epoch (Raft-like term)
  isAlive: boolean = true

  constructor(id: string, host: string, port: number, role: NodeRole) {
    this.id = id
    this.host = host
    this.port = port
    this.role = role
  }
}

// --- Cluster Router ---

class RedisCluster {
  nodes: Map<string, RedisClusterNode> = new Map()
  slotTable: (RedisClusterNode | null)[] = new Array(16384).fill(null)

  addNode(node: RedisClusterNode) {
    this.nodes.set(node.id, node)
  }

  assignSlots(node: RedisClusterNode, slots: number[]) {
    // Clear previous assignments for these slots
    for (const slot of slots) {
      const old = this.slotTable[slot]
      if (old) old.slots = old.slots.filter(s => s !== slot)
      this.slotTable[slot] = node
      node.slots.push(slot)
    }
  }

  setReplica(replica: RedisClusterNode, master: RedisClusterNode) {
    replica.master = master
    master.replicas.push(replica)
  }

  getNodeForKey(key: string): RedisClusterNode | null {
    const slot = hashSlot(key)
    return this.slotTable[slot]
  }

  // --- Key Operations ---

  set(key: string, value: string): { ok: boolean, redirect?: string } {
    const node = this.getNodeForKey(key)
    if (!node || !node.isAlive) return { ok: false }
    node.data.set(key, value)
    return { ok: true }
  }

  get(key: string): { value?: string, redirect?: string } {
    const node = this.getNodeForKey(key)
    if (!node || !node.isAlive) return {}
    const value = node.data.get(key)
    return { value }
  }

  // --- Failover Simulation ---

  failNode(nodeId: string) {
    const node = this.nodes.get(nodeId)
    if (!node) return
    node.isAlive = false
    console.log(`\n๐Ÿ’ฅ Node ${nodeId} FAILED`)

    if (node.role === 'master') {
      // Promote a replica (simplified: pick first alive replica)
      const replica = node.replicas.find(r => r.isAlive)
      if (replica) {
        this.promoteReplica(replica)
      } else {
        console.log(`   No replica available for ${nodeId}`)
      }
    }
  }

  promoteReplica(replica: RedisClusterNode) {
    console.log(`   Promoting replica ${replica.id} to master`)
    replica.role = 'master'
    replica.master = null
    // Reassign its master's slots
    const oldMaster = [...this.nodes.values()]
      .find(n => n.replicas.includes(replica))
    if (oldMaster) {
      oldMaster.replicas = oldMaster.replicas.filter(r => r.id !== replica.id)
      this.assignSlots(replica, [...oldMaster.slots])
    }
  }

  // --- Print Cluster State ---

  printState() {
    console.log('\n๐Ÿ“Š Cluster State:')
    for (const node of this.nodes.values()) {
      const slotRange = node.slots.length > 0
        ? `${node.slots.length} slots [${node.slots[0]}..${node.slots[node.slots.length - 1]}]`
        : 'no slots'
      const dataSize = node.data.size
      console.log(`   ${node.id} (${node.role}) ${node.isAlive ? 'โœ…' : 'โŒ'} | ${slotRange} | ${dataSize} keys`)
      if (node.role === 'replica' && node.master) {
        console.log(`      โ†ณ master: ${node.master.id}`)
      }
    }
  }
}

// --- Simulation ---

function simulateRedisCluster() {
  console.log('=== REDIS CLUSTER โ€” HASH SLOT ROUTING & REPLICATION ===\n')

  const cluster = new RedisCluster()

  // Create 3 masters
  const m1 = new RedisClusterNode('M1', '10.0.0.1', 6379, 'master')
  const m2 = new RedisClusterNode('M2', '10.0.0.2', 6380, 'master')
  const m3 = new RedisClusterNode('M3', '10.0.0.3', 6381, 'master')
  cluster.addNode(m1)
  cluster.addNode(m2)
  cluster.addNode(m3)

  // Create 3 replicas
  const r1 = new RedisClusterNode('R1', '10.0.0.4', 6382, 'replica')
  const r2 = new RedisClusterNode('R2', '10.0.0.5', 6383, 'replica')
  const r3 = new RedisClusterNode('R3', '10.0.0.6', 6384, 'replica')
  cluster.addNode(r1)
  cluster.addNode(r2)
  cluster.addNode(r3)

  // Assign slots: 16384 / 3 โ‰ˆ 5461 per master
  const slotsPerNode = Math.floor(16384 / 3)
  cluster.assignSlots(m1, Array.from({ length: slotsPerNode }, (_, i) => i))
  cluster.assignSlots(m2, Array.from({ length: slotsPerNode }, (_, i) => i + slotsPerNode))
  cluster.assignSlots(m3, Array.from({ length: 16384 - 2 * slotsPerNode }, (_, i) => i + 2 * slotsPerNode))

  // Set up replication
  cluster.setReplica(r1, m1)
  cluster.setReplica(r2, m2)
  cluster.setReplica(r3, m3)

  cluster.printState()

  // --- Phase 1: Write keys and observe slot routing ---
  console.log('\n=== PHASE 1: Writing keys (slot routing) ===')
  const keys = ['user:100', 'order:abc', 'session:xyz', 'product:42', 'cart:99']
  for (const key of keys) {
    const slot = hashSlot(key)
    const result = cluster.set(key, `value-${key}`)
    if (result.ok) {
      const node = cluster.getNodeForKey(key)
      console.log(`   SET ${key} โ†’ slot ${slot} โ†’ ${node?.id}`)
    }
  }

  // --- Phase 2: Verify key distribution ---
  console.log('\n=== PHASE 2: Key distribution across nodes ===')
  cluster.printState()

  // --- Phase 3: Demonstrate MOVED redirect (slot ownership change) ---
  console.log('\n=== PHASE 3: Reassigning slot 0 and demonstrating redirect ===')
  // Migrate slot 0 from M1 to M2
  cluster.assignSlots(m2, [0])
  const keyInSlot0 = 'user:100' // this maps to slot 0
  const newOwner = cluster.getNodeForKey(keyInSlot0)
  console.log(`   ${keyInSlot0} originally went to M1, now goes to ${newOwner?.id}`)

  // --- Phase 4: Failover simulation ---
  console.log('\n=== PHASE 4: Master failover ===')
  cluster.failNode('M1')
  cluster.printState()

  // Verify keys are still accessible via the promoted replica
  console.log('\n=== PHASE 5: Key access after failover ===')
  for (const key of keys) {
    const result = cluster.get(key)
    const node = cluster.getNodeForKey(key)
    if (result.value) {
      console.log(`   GET ${key} โ†’ ${result.value} (from ${node?.id})`)
    } else {
      console.log(`   GET ${key} โ†’ KEY MISSING from ${node?.id} (async replication gap)`)
    }
  }

  console.log('\nโœ… Redis Cluster simulation complete.')
  console.log('   Key insight: Redis trades strong consistency for performance.')
  console.log('   Async replication means failover can lose recently written keys.')
}

simulateRedisCluster()

โฌ‡ Download redis-cluster.ts