Files
Baradb/src/barabadb/core/raft.nim
T

1194 lines
42 KiB
Nim

## Raft Consensus — leader election + log replication
import std/tables
import std/sets
import std/deques
import std/random
import std/monotimes
import std/asyncdispatch
import std/asyncnet
import std/streams
import std/strutils
import std/endians
import std/os
import logging
import ../protocol/wire
import ../protocol/ssl
type
RaftState* = enum
rsFollower
rsCandidate
rsLeader
LogEntry* = object
term*: uint64
index*: uint64
command*: string
data*: seq[byte]
## Counters / gauges for Prometheus (/metrics). Updated on the raft/async
## path; HTTP reads them without locks (best-effort consistency).
RaftMetrics* = ref object
electionsTotal*: int64 # times this node became leader
termChangesTotal*: int64 # currentTerm increases
appendsTotal*: int64 # appendLog successes
commitWaitsTotal*: int64 # successful wait-for-commit finishes
commitWaitMsTotal*: int64 # sum of wait durations (ms)
commitTimeoutsTotal*: int64 # raft commit timeout
lostLeadershipTotal*: int64 # append returned index 0
forwardsTotal*: int64 # follower→leader SQL forwards
forwardErrorsTotal*: int64 # failed forwards
appliesTotal*: int64 # applyCommand invocations
compactionsTotal*: int64 # compactLog that actually dropped entries
RaftNode* = ref object
id*: string
state*: RaftState
currentTerm*: uint64
votedFor*: string
log*: seq[LogEntry]
commitIndex*: uint64
lastApplied*: uint64
## Compacted prefix: log entries with index <= lastSnapshotIndex are gone.
## Safe compaction only discards entries every peer has already matched
## (leader) or that this node has applied (follower), so catch-up via
## AppendEntries still works without InstallSnapshot payloads.
lastSnapshotIndex*: uint64
lastSnapshotTerm*: uint64
## Trigger compaction when log.len exceeds this (0 = default 256).
logMaxEntries*: int
metrics*: RaftMetrics
# State machine callback
applyCommand*: proc(cmd: string, data: seq[byte]) {.gcsafe.}
# Distributed transaction callbacks (for raft→disttxn integration)
onDistTxnPrepare*: proc(txnId: uint64, nodes: seq[string]): bool {.gcsafe.}
onDistTxnCommit*: proc(txnId: uint64) {.gcsafe.}
onDistTxnRollback*: proc(txnId: uint64) {.gcsafe.}
# Leader state
nextIndex*: Table[string, uint64]
matchIndex*: Table[string, uint64]
# Cluster
peers*: seq[string]
leaderId*: string
# Timing
electionTimeout*: int
heartbeatTimeout*: int
votesReceived*: HashSet[string]
peerAddrs*: Table[string, tuple[host: string, port: int]]
raftPort*: int
dataDir*: string
## InstallSnapshot follower receive. snapChunkBytes caps a single chunk
## (from BARADB_RAFT_SNAP_CHUNK_KB, default 262144); snapIncomingId /
## snapIncomingFile track the archive currently being assembled under
## dataDir/snap_incoming/.
snapChunkBytes*: int
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string
## Leader InstallSnapshot send. buildSnapshot archives the current data
## dir into destPath (wired in baradadb.nim via backupDataDir).
## snapRejectStreak counts consecutive floor-level AppendEntries rejects
## per peer; at 2 the peer is queued in snapPending and the network layer
## (processMessage) kicks off sendSnapshot. snapSending is the
## single-flight guard: at most one snapshot transfer per peer.
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int]
snapPending*: HashSet[string]
snapSending*: HashSet[string]
RaftMessageKind* = enum
rmkRequestVote
rmkRequestVoteReply
rmkAppendEntries
rmkAppendEntriesReply
rmkInstallSnapshot
rmkInstallSnapshotReply
RaftMessage* = object
kind*: RaftMessageKind
term*: uint64
senderId*: string
# RequestVote
lastLogIndex*: uint64
lastLogTerm*: uint64
# AppendEntries
prevLogIndex*: uint64
prevLogTerm*: uint64
entries*: seq[LogEntry]
leaderCommit*: uint64
# Reply
success*: bool
matchIdx*: uint64
# InstallSnapshot (prevLogIndex/prevLogTerm reuse: snapshot base index/term;
# reply uses success/matchIdx as usual)
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
RaftCluster* = ref object
nodes*: Table[string, RaftNode]
messageQueue*: Deque[RaftMessage]
const RaftStateFile = "raft_state.bin"
proc saveState(node: RaftNode) =
if node.dataDir.len == 0: return
createDir(node.dataDir)
let path = node.dataDir / RaftStateFile
let tmpPath = path & ".tmp"
var s = newFileStream(tmpPath, fmWrite)
if s == nil: return
s.write(node.currentTerm)
s.write(uint32(node.votedFor.len))
s.write(node.votedFor)
s.write(uint32(node.log.len))
for entry in node.log:
s.write(entry.term)
s.write(entry.index)
s.write(uint32(entry.command.len))
s.write(entry.command)
s.write(uint32(entry.data.len))
if entry.data.len > 0:
s.writeData(addr entry.data[0], entry.data.len)
# Snapshot base (appended for backward-compatible load of older files)
s.write(node.lastSnapshotIndex)
s.write(node.lastSnapshotTerm)
s.close()
moveFile(tmpPath, path)
proc loadState(node: RaftNode) =
if node.dataDir.len == 0: return
let path = node.dataDir / RaftStateFile
if not fileExists(path): return
var s = newFileStream(path, fmRead)
if s == nil: return
try:
node.currentTerm = s.readUint64()
let votedForLen = int(s.readUint32())
if votedForLen > 0:
node.votedFor = s.readStr(votedForLen)
let logLen = int(s.readUint32())
if logLen > 1_000_000:
raise newException(ValueError, "Raft log length too large")
node.log = newSeq[LogEntry](logLen)
for i in 0..<logLen:
let term = s.readUint64()
let index = s.readUint64()
let cmdLen = int(s.readUint32())
if cmdLen > 1_000_000:
raise newException(ValueError, "Raft command length too large")
let cmd = s.readStr(cmdLen)
let dataLen = int(s.readUint32())
if dataLen > 10_000_000:
raise newException(ValueError, "Raft data length too large")
var data = newSeq[byte](dataLen)
if dataLen > 0:
if s.readData(addr data[0], dataLen) != dataLen:
raise newException(IOError, "Incomplete Raft log data read")
node.log[i] = LogEntry(term: term, index: index, command: cmd, data: data)
# Optional trailing snapshot fields (absent in pre-compaction state files)
if not s.atEnd:
node.lastSnapshotIndex = s.readUint64()
if not s.atEnd:
node.lastSnapshotTerm = s.readUint64()
# lastApplied/commitIndex must not sit below the compacted base
if node.lastApplied < node.lastSnapshotIndex:
node.lastApplied = node.lastSnapshotIndex
if node.commitIndex < node.lastSnapshotIndex:
node.commitIndex = node.lastSnapshotIndex
except IOError, OSError:
echo "[WARN] Failed to load Raft state from ", path, ": ", getCurrentExceptionMsg()
s.close()
proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
dataDir: string = ""): RaftNode =
randomize()
result = RaftNode(
id: id,
state: rsFollower,
currentTerm: 0,
votedFor: "",
log: @[],
commitIndex: 0,
lastApplied: 0,
lastSnapshotIndex: 0,
lastSnapshotTerm: 0,
logMaxEntries: 256,
metrics: RaftMetrics(),
nextIndex: initTable[string, uint64](),
matchIndex: initTable[string, uint64](),
peers: peers,
leaderId: "",
electionTimeout: 150 + rand(150),
heartbeatTimeout: 50,
votesReceived: initHashSet[string](),
peerAddrs: initTable[string, tuple[host: string, port: int]](),
raftPort: raftPort,
dataDir: dataDir,
snapChunkBytes: 262144,
snapIncomingId: 0,
snapIncomingFile: "",
snapRejectStreak: initTable[string, int](),
snapPending: initHashSet[string](),
snapSending: initHashSet[string](),
)
result.loadState()
proc newRaftCluster*(): RaftCluster =
RaftCluster(
nodes: initTable[string, RaftNode](),
messageQueue: initDeque[RaftMessage](),
)
proc addNode*(cluster: RaftCluster, id: string) =
var peers: seq[string] = @[]
for existingId in cluster.nodes.keys:
peers.add(existingId)
cluster.nodes[existingId].peers.add(id)
cluster.nodes[id] = newRaftNode(id, peers)
proc lastLogIndex*(node: RaftNode): uint64 =
if node.log.len == 0:
return node.lastSnapshotIndex
return node.log[^1].index
proc lastLogTerm*(node: RaftNode): uint64 =
if node.log.len == 0:
return node.lastSnapshotTerm
return node.log[^1].term
proc findLogEntryByIndex(node: RaftNode, index: uint64): int =
## Find array position for a logical log index.
## Returns -1 if not found. Does NOT assume index - 1 == array position.
for i, entry in node.log:
if entry.index == index:
return i
return -1
proc termAtIndex(node: RaftNode, index: uint64): uint64 =
## Term of the log entry (or snapshot base) at `index`, or 0 if unknown.
if index == 0: return 0
if index == node.lastSnapshotIndex: return node.lastSnapshotTerm
let pos = node.findLogEntryByIndex(index)
if pos >= 0: return node.log[pos].term
return 0
proc compactLog*(node: RaftNode) =
## Drop a fully-replicated / applied log prefix so the in-memory log stays
## bounded. Leader: never discard past any peer's matchIndex (catch-up via
## AppendEntries remains possible). Follower: discard through lastApplied.
let maxEntries = if node.logMaxEntries > 0: node.logMaxEntries else: 256
if node.log.len <= maxEntries:
return
var through = node.lastApplied
if node.state == rsLeader and node.peers.len > 0:
var minMatch = through
for peer in node.peers:
let m = node.matchIndex.getOrDefault(peer, 0'u64)
if m < minMatch: minMatch = m
through = minMatch
if through <= node.lastSnapshotIndex:
return
let pos = node.findLogEntryByIndex(through)
if pos < 0:
return
node.lastSnapshotTerm = node.log[pos].term
node.lastSnapshotIndex = through
if pos + 1 < node.log.len:
node.log = node.log[(pos + 1) .. ^1]
else:
node.log = @[]
# Keep lastApplied/commit at least at the snapshot base
if node.lastApplied < node.lastSnapshotIndex:
node.lastApplied = node.lastSnapshotIndex
if node.commitIndex < node.lastSnapshotIndex:
node.commitIndex = node.lastSnapshotIndex
if node.metrics != nil:
inc node.metrics.compactionsTotal
node.saveState()
proc applyCommitted(node: RaftNode) =
while node.lastApplied < node.commitIndex:
inc node.lastApplied
# Entries at/below the snapshot base were already applied before compact.
if node.lastApplied <= node.lastSnapshotIndex:
continue
let pos = node.findLogEntryByIndex(node.lastApplied)
if pos >= 0:
let entry = node.log[pos]
# Handle distributed transaction commands
if entry.command.startsWith("DISTTXN:"):
let parts = entry.command.split(":")
if parts.len >= 3:
let action = parts[1]
let txnId = try: parseUInt(parts[2]) except CatchableError: 0'u64
if action == "PREPARE" and node.onDistTxnPrepare != nil:
discard node.onDistTxnPrepare(txnId, @[])
elif action == "COMMIT" and node.onDistTxnCommit != nil:
node.onDistTxnCommit(txnId)
elif action == "ROLLBACK" and node.onDistTxnRollback != nil:
node.onDistTxnRollback(txnId)
else:
if node.applyCommand != nil:
node.applyCommand(entry.command, entry.data)
if node.metrics != nil:
inc node.metrics.appliesTotal
node.compactLog()
proc becomeFollower*(node: RaftNode, term: uint64) =
if term > node.currentTerm and node.metrics != nil:
inc node.metrics.termChangesTotal
node.state = rsFollower
node.currentTerm = term
node.votedFor = ""
node.votesReceived.clear()
node.nextIndex.clear()
node.matchIndex.clear()
# Leader-only snapshot-send state is meaningless once we step down
node.snapRejectStreak.clear()
node.snapPending.clear()
node.saveState()
proc becomeCandidate*(node: RaftNode) =
node.state = rsCandidate
inc node.currentTerm
if node.metrics != nil:
inc node.metrics.termChangesTotal
node.votedFor = node.id
node.votesReceived.clear()
node.votesReceived.incl(node.id)
node.saveState()
proc becomeLeader*(node: RaftNode) =
node.state = rsLeader
node.leaderId = node.id
if node.metrics != nil:
inc node.metrics.electionsTotal
info("Raft node " & node.id & " became leader for term " & $node.currentTerm)
for peer in node.peers:
node.nextIndex[peer] = node.lastLogIndex + 1
node.matchIndex[peer] = 0
node.snapRejectStreak.clear()
node.snapPending.clear()
proc handleRequestVote*(node: RaftNode, msg: RaftMessage): RaftMessage =
var reply = RaftMessage(
kind: rmkRequestVoteReply,
term: node.currentTerm,
senderId: node.id,
success: false,
)
if msg.term < node.currentTerm:
return reply
if msg.term > node.currentTerm:
node.becomeFollower(msg.term)
let canVote = node.votedFor == "" or node.votedFor == msg.senderId
let logOk = msg.lastLogTerm > node.lastLogTerm or
(msg.lastLogTerm == node.lastLogTerm and msg.lastLogIndex >= node.lastLogIndex)
if canVote and logOk:
node.votedFor = msg.senderId
node.saveState()
reply.success = true
reply.term = node.currentTerm
return reply
proc handleAppendEntries*(node: RaftNode, msg: RaftMessage): RaftMessage =
var reply = RaftMessage(
kind: rmkAppendEntriesReply,
term: node.currentTerm,
senderId: node.id,
success: false,
matchIdx: 0,
)
if msg.term < node.currentTerm:
return reply
if msg.term > node.currentTerm:
node.becomeFollower(msg.term)
node.leaderId = msg.senderId
# Check if log contains entry at prevLogIndex with prevLogTerm
if msg.prevLogIndex > 0:
if msg.prevLogIndex < node.lastSnapshotIndex:
# Leader is behind our snapshot base — reject
return reply
if msg.prevLogIndex == node.lastSnapshotIndex:
if msg.prevLogTerm != node.lastSnapshotTerm:
return reply
else:
let prevPos = node.findLogEntryByIndex(msg.prevLogIndex)
if prevPos < 0:
return reply
if node.log[prevPos].term != msg.prevLogTerm:
# Delete conflicting entries
node.log.setLen(prevPos)
return reply
# Append new entries
var logChanged = false
for entry in msg.entries:
let pos = node.findLogEntryByIndex(entry.index)
if pos >= 0:
if node.log[pos].term != entry.term:
node.log.setLen(pos)
node.log.add(entry)
logChanged = true
else:
node.log.add(entry)
logChanged = true
if logChanged:
node.saveState()
# Update commit index
if msg.leaderCommit > node.commitIndex:
node.commitIndex = min(msg.leaderCommit, node.lastLogIndex)
node.applyCommitted()
reply.success = true
reply.matchIdx = node.lastLogIndex
return reply
proc handleInstallSnapshot*(node: RaftNode, msg: RaftMessage): RaftMessage =
## Follower side of InstallSnapshot: assemble the chunk stream into a temp
## archive under `dataDir/snap_incoming/`, then hand the completed archive
## to the restoreSnapshot callback. Chunks arrive in order from a single
## leader over one socket, so we append sequentially and only sanity-check
## that snapOffset equals the number of bytes assembled so far.
##
## NOTE: this runs on the async event loop and restoreSnapshot performs
## blocking disk I/O (archive extract + DB reopen). Implementations must be
## fast, or defer the heavy work; the baradadb.nim wiring decides.
var reply = RaftMessage(
kind: rmkInstallSnapshotReply,
term: node.currentTerm,
senderId: node.id,
success: false,
matchIdx: node.lastSnapshotIndex,
)
if msg.term < node.currentTerm:
return reply
if msg.term > node.currentTerm:
node.becomeFollower(msg.term)
node.leaderId = msg.senderId
# Chunk size cap (deferred from the wire-protocol task).
if msg.snapData.len > node.snapChunkBytes or node.dataDir.len == 0:
return reply
let snapDir = node.dataDir / "snap_incoming"
if msg.snapId != node.snapIncomingId:
# New snapshot generation: discard any partial assembly and restart.
if msg.snapOffset != 0:
return reply
createDir(snapDir)
node.snapIncomingId = msg.snapId
node.snapIncomingFile = snapDir / "snap_" & $msg.snapId & ".tar.gz"
let f = open(node.snapIncomingFile, fmWrite) # truncate any leftover
f.close()
if node.snapIncomingFile.len == 0:
return reply
let assembled = getFileSize(node.snapIncomingFile)
if msg.snapOffset != uint64(assembled):
# Gap or overlap: reset so the leader restarts the transfer.
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
if msg.snapData.len > 0:
let f = open(node.snapIncomingFile, fmAppend)
try:
discard f.writeBuffer(addr msg.snapData[0], msg.snapData.len)
finally:
f.close()
if not msg.snapDone:
reply.success = true
return reply
# Transfer complete: restore the data dir and adopt the snapshot base.
if node.restoreSnapshot == nil or
not node.restoreSnapshot(node.snapIncomingFile,
msg.prevLogIndex, msg.prevLogTerm):
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
node.lastSnapshotIndex = msg.prevLogIndex
node.lastSnapshotTerm = msg.prevLogTerm
node.commitIndex = node.lastSnapshotIndex
node.lastApplied = node.lastSnapshotIndex
node.log = @[]
node.snapIncomingId = 0
node.snapIncomingFile = ""
node.saveState()
reply.success = true
reply.matchIdx = node.lastSnapshotIndex
return reply
proc requestVote*(node: RaftNode): seq[RaftMessage] =
result = @[]
for peer in node.peers:
result.add(RaftMessage(
kind: rmkRequestVote,
term: node.currentTerm,
senderId: node.id,
lastLogIndex: node.lastLogIndex,
lastLogTerm: node.lastLogTerm,
))
proc appendEntries*(node: RaftNode, peerId: string): RaftMessage =
var nextIdx = node.nextIndex.getOrDefault(peerId, node.lastLogIndex + 1)
# Never try to send entries already discarded by our snapshot base.
if nextIdx <= node.lastSnapshotIndex:
nextIdx = node.lastSnapshotIndex + 1
node.nextIndex[peerId] = nextIdx
let prevIdx = nextIdx - 1
let prevTerm = node.termAtIndex(prevIdx)
var entries: seq[LogEntry] = @[]
let startPos = node.findLogEntryByIndex(nextIdx)
if startPos >= 0:
for i in startPos..<node.log.len:
entries.add(node.log[i])
return RaftMessage(
kind: rmkAppendEntries,
term: node.currentTerm,
senderId: node.id,
prevLogIndex: prevIdx,
prevLogTerm: prevTerm,
entries: entries,
leaderCommit: node.commitIndex,
)
proc appendLog*(node: RaftNode, command: string, data: seq[byte] = @[]): LogEntry =
if node.state != rsLeader:
return LogEntry()
result = LogEntry(
term: node.currentTerm,
index: node.lastLogIndex + 1,
command: command,
data: data,
)
node.log.add(result)
if node.metrics != nil:
inc node.metrics.appendsTotal
node.saveState()
proc handleVoteReply*(node: RaftNode, reply: RaftMessage) =
if reply.term > node.currentTerm:
node.becomeFollower(reply.term)
return
if reply.term < node.currentTerm:
return
if node.state != rsCandidate:
return
if reply.success:
node.votesReceived.incl(reply.senderId)
if node.votesReceived.len > (node.peers.len + 1) div 2:
node.becomeLeader()
proc handleAppendReply*(node: RaftNode, peerId: string, reply: RaftMessage) =
if reply.term > node.currentTerm:
node.becomeFollower(reply.term)
return
if reply.term < node.currentTerm:
return
if node.state != rsLeader:
return
if reply.success:
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
# Update commit index using true majority calculation
let majority = (node.peers.len + 1 + 1) div 2 # majority of cluster (peers + leader)
var newCommitIdx = node.commitIndex
# Walk logical indices high→low via findLogEntryByIndex (log may be compacted).
for idx in countdown(int(node.lastLogIndex), int(node.commitIndex) + 1):
if idx <= 0:
break
let pos = node.findLogEntryByIndex(uint64(idx))
if pos < 0:
continue
# Only commit entries from current term (Raft safety property)
if node.log[pos].term == node.currentTerm:
var count = 1 # Leader itself
for peerId2, mIdx in node.matchIndex:
if mIdx >= uint64(idx):
inc count
if count >= majority:
newCommitIdx = uint64(idx)
break
if newCommitIdx > node.commitIndex:
node.commitIndex = newCommitIdx
node.applyCommitted()
else:
let floor = node.lastSnapshotIndex + 1
if node.nextIndex.getOrDefault(peerId, 1) > floor:
dec node.nextIndex[peerId]
# Not a floor-level reject, so it breaks any floor-reject streak.
node.snapRejectStreak.del(peerId)
else:
node.nextIndex[peerId] = floor
# Stuck at the compaction floor: the entries the follower needs have
# been compacted away, so AppendEntries can never catch it up. Count
# consecutive floor rejects; at 2, queue an InstallSnapshot transfer
# (the network layer picks this up after handleAppendReply returns).
node.snapRejectStreak[peerId] =
node.snapRejectStreak.getOrDefault(peerId, 0) + 1
if node.snapRejectStreak[peerId] >= 2:
node.snapPending.incl(peerId)
proc handleInstallSnapshotReply*(node: RaftNode, peerId: string,
reply: RaftMessage) =
## Leader side: follower's answer to a completed InstallSnapshot transfer.
## success=true adopts the snapshot base (reply.matchIdx) as the peer's
## match point; success=false leaves all state alone — the normal
## AppendEntries reject path re-triggers another snapshot if the peer is
## still stuck at the floor.
if reply.term > node.currentTerm:
node.becomeFollower(reply.term)
return
if reply.term < node.currentTerm:
return
if node.state != rsLeader:
return
if reply.success and reply.matchIdx >= node.lastSnapshotIndex:
# The follower has actually adopted the snapshot base. Intermediate chunk
# replies (the T8 follower acks every non-done chunk with success=true and
# matchIdx = its OLD lastSnapshotIndex, below ours) fall through here and
# must be ignored: applying them would regress matchIndex/nextIndex and
# clear the reject streak mid-transfer, causing state flapping until the
# final reply lands.
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
proc state*(node: RaftNode): RaftState = node.state
proc isLeader*(node: RaftNode): bool = node.state == rsLeader
proc leaderId*(node: RaftNode): string = node.leaderId
proc logLen*(node: RaftNode): int = node.log.len
proc applyLag*(node: RaftNode): uint64 =
## commitIndex - lastApplied (0 when caught up).
if node.commitIndex > node.lastApplied:
return node.commitIndex - node.lastApplied
return 0
proc prometheusText*(node: RaftNode): string =
## Prometheus exposition lines for this raft node (gauges + counters).
let m = if node.metrics != nil: node.metrics else: RaftMetrics()
let isLead = if node.isLeader: 1 else: 0
let role = case node.state
of rsLeader: "leader"
of rsCandidate: "candidate"
of rsFollower: "follower"
result = ""
result.add("# HELP baradb_raft_is_leader 1 if this node is the raft leader\n")
result.add("# TYPE baradb_raft_is_leader gauge\n")
result.add("baradb_raft_is_leader{node=\"" & node.id & "\",role=\"" & role & "\"} " & $isLead & "\n")
result.add("# HELP baradb_raft_term Current raft term\n")
result.add("# TYPE baradb_raft_term gauge\n")
result.add("baradb_raft_term{node=\"" & node.id & "\"} " & $node.currentTerm & "\n")
result.add("# HELP baradb_raft_log_entries In-memory raft log length\n")
result.add("# TYPE baradb_raft_log_entries gauge\n")
result.add("baradb_raft_log_entries{node=\"" & node.id & "\"} " & $node.log.len & "\n")
result.add("# HELP baradb_raft_commit_index Raft commit index\n")
result.add("# TYPE baradb_raft_commit_index gauge\n")
result.add("baradb_raft_commit_index{node=\"" & node.id & "\"} " & $node.commitIndex & "\n")
result.add("# HELP baradb_raft_last_applied Raft lastApplied index\n")
result.add("# TYPE baradb_raft_last_applied gauge\n")
result.add("baradb_raft_last_applied{node=\"" & node.id & "\"} " & $node.lastApplied & "\n")
result.add("# HELP baradb_raft_apply_lag commitIndex - lastApplied\n")
result.add("# TYPE baradb_raft_apply_lag gauge\n")
result.add("baradb_raft_apply_lag{node=\"" & node.id & "\"} " & $node.applyLag & "\n")
result.add("# HELP baradb_raft_snapshot_index lastSnapshotIndex (compacted base)\n")
result.add("# TYPE baradb_raft_snapshot_index gauge\n")
result.add("baradb_raft_snapshot_index{node=\"" & node.id & "\"} " & $node.lastSnapshotIndex & "\n")
result.add("# HELP baradb_raft_elections_total Times this node became leader\n")
result.add("# TYPE baradb_raft_elections_total counter\n")
result.add("baradb_raft_elections_total{node=\"" & node.id & "\"} " & $m.electionsTotal & "\n")
result.add("# HELP baradb_raft_term_changes_total Term increases observed\n")
result.add("# TYPE baradb_raft_term_changes_total counter\n")
result.add("baradb_raft_term_changes_total{node=\"" & node.id & "\"} " & $m.termChangesTotal & "\n")
result.add("# HELP baradb_raft_appends_total Log appends on this node\n")
result.add("# TYPE baradb_raft_appends_total counter\n")
result.add("baradb_raft_appends_total{node=\"" & node.id & "\"} " & $m.appendsTotal & "\n")
result.add("# HELP baradb_raft_commit_waits_total Successful wait-for-commit completions\n")
result.add("# TYPE baradb_raft_commit_waits_total counter\n")
result.add("baradb_raft_commit_waits_total{node=\"" & node.id & "\"} " & $m.commitWaitsTotal & "\n")
result.add("# HELP baradb_raft_commit_wait_ms_total Sum of commit-wait durations in ms\n")
result.add("# TYPE baradb_raft_commit_wait_ms_total counter\n")
result.add("baradb_raft_commit_wait_ms_total{node=\"" & node.id & "\"} " & $m.commitWaitMsTotal & "\n")
result.add("# HELP baradb_raft_commit_timeouts_total Raft commit wait timeouts\n")
result.add("# TYPE baradb_raft_commit_timeouts_total counter\n")
result.add("baradb_raft_commit_timeouts_total{node=\"" & node.id & "\"} " & $m.commitTimeoutsTotal & "\n")
result.add("# HELP baradb_raft_lost_leadership_total Appends rejected (not leader)\n")
result.add("# TYPE baradb_raft_lost_leadership_total counter\n")
result.add("baradb_raft_lost_leadership_total{node=\"" & node.id & "\"} " & $m.lostLeadershipTotal & "\n")
result.add("# HELP baradb_raft_forwards_total Follower SQL forwards to leader\n")
result.add("# TYPE baradb_raft_forwards_total counter\n")
result.add("baradb_raft_forwards_total{node=\"" & node.id & "\"} " & $m.forwardsTotal & "\n")
result.add("# HELP baradb_raft_forward_errors_total Failed leader forwards\n")
result.add("# TYPE baradb_raft_forward_errors_total counter\n")
result.add("baradb_raft_forward_errors_total{node=\"" & node.id & "\"} " & $m.forwardErrorsTotal & "\n")
result.add("# HELP baradb_raft_applies_total State-machine applyCommand calls\n")
result.add("# TYPE baradb_raft_applies_total counter\n")
result.add("baradb_raft_applies_total{node=\"" & node.id & "\"} " & $m.appliesTotal & "\n")
result.add("# HELP baradb_raft_compactions_total Log prefix compactions\n")
result.add("# TYPE baradb_raft_compactions_total counter\n")
result.add("baradb_raft_compactions_total{node=\"" & node.id & "\"} " & $m.compactionsTotal & "\n")
if m.commitWaitsTotal > 0:
let avg = m.commitWaitMsTotal div m.commitWaitsTotal
result.add("# HELP baradb_raft_commit_wait_ms_avg Average commit-wait latency (ms)\n")
result.add("# TYPE baradb_raft_commit_wait_ms_avg gauge\n")
result.add("baradb_raft_commit_wait_ms_avg{node=\"" & node.id & "\"} " & $avg & "\n")
# Leader election timer loop
type
ElectionTimer* = ref object
node: RaftNode
timeoutMs: int
lastHeartbeat: int64
running: bool
proc newElectionTimer*(node: RaftNode, timeoutMs: int = 150): ElectionTimer =
ElectionTimer(
node: node,
timeoutMs: timeoutMs,
lastHeartbeat: getMonoTime().ticks(),
running: false,
)
proc resetTimeout*(timer: ElectionTimer) =
timer.lastHeartbeat = getMonoTime().ticks()
proc checkTimeout*(timer: ElectionTimer): bool =
let elapsed = (getMonoTime().ticks() - timer.lastHeartbeat) div 1_000_000
return elapsed > timer.timeoutMs
proc stop*(timer: ElectionTimer) =
timer.running = false
# ---------------------------------------------------------------------------
# Network Transport — async TCP communication for Raft
# ---------------------------------------------------------------------------
const
RaftMagic = "RAFT"
RaftProtoVersion = 1'u32
proc writeString(s: Stream, str: string) =
s.write(uint32(str.len))
if str.len > 0:
s.writeData(str[0].unsafeAddr, str.len)
proc readString(s: Stream): string =
let len = int(s.readUint32())
if len > 0:
result = newString(len)
if s.readData(result[0].addr, len) != len:
raise newException(IOError, "Incomplete string read from stream")
else:
result = ""
proc writeLogEntry(s: Stream, entry: LogEntry) =
s.write(entry.term)
s.write(entry.index)
s.writeString(entry.command)
s.write(uint32(entry.data.len))
if entry.data.len > 0:
for b in entry.data:
s.write(char(b))
proc readLogEntry(s: Stream): LogEntry =
result.term = s.readUint64()
result.index = s.readUint64()
result.command = s.readString()
let dataLen = int(s.readUint32())
result.data = newSeq[byte](dataLen)
for i in 0 ..< dataLen:
result.data[i] = byte(s.readChar())
proc serialize*(msg: RaftMessage): seq[byte] =
let stream = newStringStream()
stream.write(RaftMagic)
stream.write(RaftProtoVersion)
stream.write(uint32(ord(msg.kind)))
stream.write(msg.term)
stream.writeString(msg.senderId)
stream.write(msg.lastLogIndex)
stream.write(msg.lastLogTerm)
stream.write(msg.prevLogIndex)
stream.write(msg.prevLogTerm)
stream.write(uint32(msg.entries.len))
for entry in msg.entries:
stream.writeLogEntry(entry)
stream.write(msg.leaderCommit)
stream.write(char(if msg.success: 1 else: 0))
stream.write(msg.matchIdx)
# InstallSnapshot trailing fields (appended for wire backward compatibility;
# pre-v1.3 peers stop reading at matchIdx and ignore these bytes)
stream.write(msg.snapId)
stream.write(msg.snapOffset)
stream.write(uint32(msg.snapData.len))
if msg.snapData.len > 0:
stream.writeData(addr msg.snapData[0], msg.snapData.len)
stream.write(char(if msg.snapDone: 1 else: 0))
let strData = stream.data
result = newSeq[byte](strData.len)
for i in 0 ..< strData.len:
result[i] = byte(strData[i])
stream.close()
proc deserializeRaftMessage*(data: seq[byte]): RaftMessage =
let stream = newStringStream(cast[string](data))
let magic = stream.readStr(4)
if magic != RaftMagic:
raise newException(ValueError, "Invalid Raft magic bytes")
let version = stream.readUint32()
if version != RaftProtoVersion:
raise newException(ValueError, "Unsupported Raft protocol version")
result.kind = RaftMessageKind(stream.readUint32())
result.term = stream.readUint64()
result.senderId = stream.readString()
result.lastLogIndex = stream.readUint64()
result.lastLogTerm = stream.readUint64()
result.prevLogIndex = stream.readUint64()
result.prevLogTerm = stream.readUint64()
let entryCount = int(stream.readUint32())
result.entries = newSeq[LogEntry](entryCount)
for i in 0 ..< entryCount:
result.entries[i] = stream.readLogEntry()
result.leaderCommit = stream.readUint64()
result.success = stream.readChar() != '\0'
result.matchIdx = stream.readUint64()
# Optional trailing InstallSnapshot fields (absent in pre-v1.3 buffers)
if not stream.atEnd:
result.snapId = stream.readUint64()
if not stream.atEnd:
result.snapOffset = stream.readUint64()
if not stream.atEnd:
let dataLen = int(stream.readUint32())
result.snapData = newSeq[byte](dataLen)
if dataLen > 0:
if stream.readData(addr result.snapData[0], dataLen) != dataLen:
raise newException(IOError, "Incomplete snapshot data read from stream")
if not stream.atEnd:
result.snapDone = stream.readChar() != '\0'
stream.close()
# ---------------------------------------------------------------------------
# RaftNetwork — async TCP transport
# ---------------------------------------------------------------------------
type
RaftNetwork* = ref object
node*: RaftNode
socket*: AsyncSocket
running*: bool
peerSockets*: Table[string, AsyncSocket]
timer*: ElectionTimer
## Optional TLS context; nil = plaintext (default, pre-TLS behavior).
tls*: TLSContext
proc newRaftNetwork*(node: RaftNode, tls: TLSContext = nil): RaftNetwork =
RaftNetwork(
node: node,
running: false,
peerSockets: initTable[string, AsyncSocket](),
timer: newElectionTimer(node, node.electionTimeout),
tls: tls,
)
const RaftConnectTimeoutMs = 200
proc connectToPeer(net: RaftNetwork, peerId: string) {.async.} =
## Dial a peer with a short timeout so a dead peer cannot stall the whole
## heartbeat / RequestVote fan-out (default TCP connect can hang for many
## seconds, which lets live followers trip their election timers).
if peerId notin net.node.peerAddrs:
return
let (host, port) = net.node.peerAddrs[peerId]
var sock: AsyncSocket = nil
try:
sock = newAsyncSocket()
let ok = await withTimeout(sock.connect(host, Port(port)), RaftConnectTimeoutMs)
if not ok:
sock.close()
return
if net.tls != nil:
try:
net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
net.peerSockets[peerId] = sock
except CatchableError:
if sock != nil:
try: sock.close() except CatchableError: discard
proc send*(net: RaftNetwork, peerId: string, msg: RaftMessage) {.async.} =
if peerId notin net.peerSockets:
await net.connectToPeer(peerId)
if peerId in net.peerSockets:
let data = serialize(msg)
let payloadLen = uint32(data.len)
var header = newSeq[byte](4)
bigEndian32(addr header[0], unsafeAddr payloadLen)
try:
await net.peerSockets[peerId].send(cast[string](header) & cast[string](data))
except CatchableError:
try: net.peerSockets[peerId].close() except CatchableError: discard
net.peerSockets.del(peerId)
proc broadcast*(net: RaftNetwork, msgs: seq[RaftMessage]) {.async.} =
for i, peer in net.node.peers:
if i < msgs.len:
await net.send(peer, msgs[i])
proc sendSnapshot*(net: RaftNetwork, peerId: string) {.async.} =
## Leader side of InstallSnapshot: build an archive of the current data dir
## via the buildSnapshot callback and stream it to a lagging peer in
## snapChunkBytes chunks. Triggered (via asyncCheck from processMessage)
## when handleAppendReply queues the peer in snapPending after consecutive
## floor-level rejects. Single-flight per peer via node.snapSending.
##
## Runs on the raft event loop; buildSnapshot performs blocking disk I/O
## (tar+gzip). Snapshot sends are rare, so we accept the stall rather than
## adding a worker round-trip (same trade-off as restoreSnapshot).
let node = net.node
if peerId in node.snapSending:
return
if node.state != rsLeader or node.buildSnapshot == nil or
node.dataDir.len == 0:
return
let snapId = node.lastSnapshotIndex
if snapId == 0:
# snapId 0 can never be accepted (a follower's initial snapIncomingId is
# 0), and sends only trigger after compaction anyway — guard regardless.
warn("sendSnapshot: lastSnapshotIndex is 0; skipping snapshot send to " & peerId)
return
node.snapSending.incl(peerId)
defer: node.snapSending.excl(peerId)
let baseIndex = node.lastSnapshotIndex
let baseTerm = node.lastSnapshotTerm
let destPath = node.dataDir / ("snap_out_" & $snapId & ".tar.gz")
defer:
if fileExists(destPath):
removeFile(destPath)
if not node.buildSnapshot(destPath):
warn("sendSnapshot: buildSnapshot failed; aborting snapshot send to " & peerId)
return
var f: File
if not open(f, destPath, fmRead):
warn("sendSnapshot: cannot open built archive " & destPath)
return
defer: f.close()
let total = uint64(getFileSize(destPath))
var offset = 0'u64
while true:
var chunk = newSeq[byte](node.snapChunkBytes)
let n = f.readBytes(chunk, 0, chunk.len)
let done = offset + uint64(n) >= total
await net.send(peerId, RaftMessage(
kind: rmkInstallSnapshot,
term: node.currentTerm,
senderId: node.id,
prevLogIndex: baseIndex, # snapshot base index/term (T7 wire layout)
prevLogTerm: baseTerm,
snapId: snapId,
snapOffset: offset,
snapData: chunk[0 ..< n],
snapDone: done,
))
if done:
break
offset += uint64(n)
proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
case msg.kind
of rmkRequestVote:
let reply = net.node.handleRequestVote(msg)
await net.send(msg.senderId, reply)
of rmkRequestVoteReply:
net.node.handleVoteReply(msg)
of rmkAppendEntries:
# A plausible current leader (same acceptance condition as
# handleAppendEntries) resets the election timer; stale-term
# messages must not.
if msg.term >= net.node.currentTerm:
net.timer.resetTimeout()
let reply = net.node.handleAppendEntries(msg)
await net.send(msg.senderId, reply)
of rmkAppendEntriesReply:
net.node.handleAppendReply(msg.senderId, msg)
# Floor-reject streak reached the threshold: this peer needs a snapshot.
if msg.senderId in net.node.snapPending:
net.node.snapPending.excl(msg.senderId)
asyncCheck net.sendSnapshot(msg.senderId)
of rmkInstallSnapshot:
# Same election-timer rule as AppendEntries: only a plausible current
# leader resets it.
if msg.term >= net.node.currentTerm:
net.timer.resetTimeout()
let reply = net.node.handleInstallSnapshot(msg)
await net.send(msg.senderId, reply)
of rmkInstallSnapshotReply:
net.node.handleInstallSnapshotReply(msg.senderId, msg)
proc recvExact*(client: AsyncSocket, size: int): Future[string] {.async.} =
## Reads exactly `size` bytes from `client`. A short return means the peer
## disconnected mid-frame (EOF); callers must treat it as end of stream.
var buf = ""
while buf.len < size:
let chunk = await client.recv(size - buf.len)
if chunk.len == 0:
break
buf.add(chunk)
return buf
proc receiveLoop(net: RaftNetwork, client: AsyncSocket) {.async.} =
try:
while net.running:
let lenData = await recvExact(client, 4)
if lenData.len < 4:
break
var pos = 0
let payloadLen = int(readUint32(cast[seq[byte]](lenData), pos))
let payloadStr = await recvExact(client, payloadLen)
if payloadStr.len < payloadLen:
break
var payload = newSeq[byte](payloadLen)
for i in 0 ..< payloadLen:
payload[i] = byte(payloadStr[i])
let msg = deserializeRaftMessage(payload)
try:
await net.processMessage(msg)
except CatchableError:
discard
except CatchableError:
discard
finally:
client.close()
proc heartbeatLoop(net: RaftNetwork) {.async.} =
## Fan out heartbeats in parallel so a slow/dead peer cannot delay
## AppendEntries to the rest of the cluster.
while net.running:
if net.node.state == rsLeader:
var futs: seq[Future[void]] = @[]
for peer in net.node.peers:
let msg = net.node.appendEntries(peer)
futs.add(net.send(peer, msg))
for f in futs:
try:
await f
except CatchableError:
discard
await sleepAsync(net.node.heartbeatTimeout)
proc timerLoop*(net: RaftNetwork) {.async.}
proc run*(net: RaftNetwork) {.async.} =
net.socket = newAsyncSocket()
net.socket.setSockOpt(OptReuseAddr, true)
net.socket.bindAddr(Port(net.node.raftPort))
net.socket.listen()
net.running = true
net.timer.resetTimeout()
asyncCheck net.heartbeatLoop()
asyncCheck net.timerLoop()
while net.running:
try:
let client = await net.socket.accept()
if net.tls != nil:
try:
net.tls.wrapServer(client)
except CatchableError:
# Handshake failed (e.g. plaintext dial) — drop, no protocol effect.
client.close()
continue
asyncCheck net.receiveLoop(client)
except CatchableError:
break
proc stop*(net: RaftNetwork) =
net.running = false
net.timer.stop()
if net.socket != nil:
net.socket.close()
for peerId, sock in net.peerSockets:
sock.close()
net.peerSockets.clear()
# ---------------------------------------------------------------------------
# ElectionTimer integration with network transport
# ---------------------------------------------------------------------------
proc startElection*(timer: ElectionTimer, net: RaftNetwork) =
if timer.node.state != rsCandidate:
timer.node.becomeCandidate()
if net != nil:
let msgs = timer.node.requestVote()
for i, peer in timer.node.peers:
if i < msgs.len:
asyncCheck net.send(peer, msgs[i])
proc tick*(timer: ElectionTimer, net: RaftNetwork = nil) =
case timer.node.state
of rsFollower:
if timer.checkTimeout():
timer.startElection(net)
timer.resetTimeout()
of rsCandidate:
if timer.checkTimeout():
# Election timed out — restart
timer.node.becomeCandidate()
if net != nil:
let msgs = timer.node.requestVote()
for i, peer in timer.node.peers:
if i < msgs.len:
asyncCheck net.send(peer, msgs[i])
timer.resetTimeout()
of rsLeader:
timer.resetTimeout() # Keep alive
proc timerLoop*(net: RaftNetwork) {.async.} =
## Production election timer: ticks the node's ElectionTimer until the
## network transport is stopped.
while net.running:
tick(net.timer, net)
await sleepAsync(50)