Skip to content

distributed/raft_model.py ​

配套源码,运行方法见同目录 README。返回实验总览。

python
"""Deterministic fixed-membership Raft/KV teaching model; no sockets or disk.

Persistent fields survive restart() inside this process. Calls are atomic and
ordered by the test harness. This is not a production Raft implementation.
"""
from dataclasses import dataclass, field
from copy import deepcopy


@dataclass(eq=True)
class Entry:
    term: int
    command: tuple | None


@dataclass
class Node:
    ident: int
    term: int = 0
    voted_for: int | None = None
    log: list = field(default_factory=lambda: [Entry(0, None)])
    commit: int = 0
    applied: int = 0
    role: str = "follower"
    state: dict = field(default_factory=dict)
    results: dict = field(default_factory=dict)
    match: dict = field(default_factory=dict)

    def observe(self, term):
        if term > self.term:
            self.term, self.voted_for, self.role = term, None, "follower"

    def vote(self, term, candidate, last_index, last_term):
        self.observe(term)
        fresh = (last_term, last_index) >= (self.log[-1].term, len(self.log)-1)
        granted = term == self.term and fresh and self.voted_for in (None, candidate)
        if granted:
            self.voted_for = candidate
        return self.term, granted

    def receive(self, term, prev_index, prev_term, entries, leader_commit):
        self.observe(term)
        if term < self.term:
            return self.term, False
        self.role = "follower"
        if prev_index >= len(self.log) or self.log[prev_index].term != prev_term:
            return self.term, False
        for offset, entry in enumerate(entries, prev_index + 1):
            if offset < len(self.log) and self.log[offset].term != entry.term:
                assert offset > self.commit, "a committed prefix must never be replaced"
                del self.log[offset:]
            if offset == len(self.log):
                self.log.append(deepcopy(entry))
        # Limit to the last index proven consistent by THIS AppendEntries.
        self.commit = max(self.commit, min(leader_commit, prev_index + len(entries)))
        self.apply()
        return self.term, True

    def apply(self):
        while self.applied < self.commit:
            self.applied += 1
            command = self.log[self.applied].command
            if command is None:
                continue
            client, seq, op, key, value = command
            request = (client, seq)
            if request in self.results:
                continue
            if op == "put":
                # A log entry is a value snapshot, not mutable state storage.
                self.state[key] = deepcopy(value)
            elif op == "add":
                self.state[key] = self.state.get(key, 0) + value
            elif op != "get":
                raise ValueError(op)
            self.results[request] = deepcopy(self.state.get(key))


class Cluster:
    def __init__(self, count=5):
        self.nodes = [Node(i) for i in range(count)]
        self.links = [[True]*count for _ in range(count)]
        self.alive = [True]*count
        self.majority = count//2+1

    def connected(self, a, b):
        return self.alive[a] and self.alive[b] and self.links[a][b] and self.links[b][a]

    def partition(self, *groups):
        members = [i for group in groups for i in group]
        if sorted(members) != list(range(len(self.nodes))):
            raise ValueError("partition must list every member exactly once")
        self.links = [[any(a in g and b in g for g in groups)
                       for b in range(len(self.nodes))] for a in range(len(self.nodes))]

    def heal(self):
        n = len(self.nodes)
        self.links = [[True]*n for _ in range(n)]

    def elect(self, candidate):
        if not self.alive[candidate]:
            return False
        node = self.nodes[candidate]
        node.term += 1
        node.role, node.voted_for = "candidate", candidate
        term, votes = node.term, 1
        for other in self.nodes:
            if other.ident == candidate or not self.connected(candidate, other.ident):
                continue
            observed, granted = other.vote(term, candidate, len(node.log)-1, node.log[-1].term)
            node.observe(observed)
            votes += granted
        if node.term == term and node.role == "candidate" and votes >= self.majority:
            node.role = "leader"
            node.match = {i: 0 for i in range(len(self.nodes))}
            node.match[candidate] = len(node.log)-1
            return True
        return False

    def replicate(self, leader_id):
        leader = self.nodes[leader_id]
        if not self.alive[leader_id] or leader.role != "leader":
            return False
        # Full-prefix transfer is deliberately inefficient; it exposes conflict
        # deletion without burying the model in nextIndex optimizations.
        for other in self.nodes:
            if other.ident == leader_id or not self.connected(leader_id, other.ident):
                continue
            term, ok = other.receive(leader.term, 0, 0, leader.log[1:], leader.commit)
            leader.observe(term)
            if leader.role != "leader":
                return False
            if ok:
                leader.match[other.ident] = len(leader.log)-1
        leader.match[leader_id] = len(leader.log)-1
        for index in range(len(leader.log)-1, leader.commit, -1):
            votes = sum(v >= index for v in leader.match.values())
            if leader.log[index].term == leader.term and votes >= self.majority:
                leader.commit = index
                break
        leader.apply()
        for other in self.nodes:
            if other.ident != leader_id and self.connected(leader_id, other.ident):
                term, _ = other.receive(leader.term, 0, 0, leader.log[1:], leader.commit)
                leader.observe(term)
        return leader.role == "leader"

    def submit(self, leader_id, command):
        node = self.nodes[leader_id]
        if not self.alive[leader_id] or node.role != "leader":
            return False, None
        # A read is also a log entry here: no stale local-only reads.
        # Model the RPC serialization boundary: callers cannot mutate a log
        # record after submitting it through a shared Python object reference.
        node.log.append(Entry(node.term, deepcopy(command)))
        index = len(node.log)-1
        if not self.replicate(leader_id) or node.commit < index:
            return False, None  # outcome UNKNOWN to caller; not proof of abort
        result = node.results.get(command[:2]) if command is not None else None
        return True, deepcopy(result)

    def restart(self, ident):
        n = self.nodes[ident]
        self.nodes[ident] = Node(ident, n.term, n.voted_for, deepcopy(n.log))
        self.alive[ident] = True


if __name__ == "__main__":
    c = Cluster()
    print("election A:", c.elect(0))
    print("put x=1:", c.submit(0, ("client", 1, "put", "x", 1)))
    c.partition([0, 1], [2, 3, 4])
    print("minority write (unknown):", c.submit(0, ("client", 2, "put", "x", 9)))
    print("election C:", c.elect(2))
    print("majority write:", c.submit(2, ("other", 1, "put", "x", 2)))
    c.heal(); c.replicate(2)
    print("after heal:", [n.state for n in c.nodes])