352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Raft Node Implementation
|
|
* Core Raft consensus algorithm implementation
|
|
*/
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.RaftNode = void 0;
|
|
const eventemitter3_1 = __importDefault(require("eventemitter3"));
|
|
const types_js_1 = require("./types.js");
|
|
const state_js_1 = require("./state.js");
|
|
/** Default configuration values */
|
|
const DEFAULT_CONFIG = {
|
|
electionTimeout: [150, 300],
|
|
heartbeatInterval: 50,
|
|
maxEntriesPerRequest: 100,
|
|
};
|
|
/** Raft consensus node */
|
|
class RaftNode extends eventemitter3_1.default {
|
|
constructor(config) {
|
|
super();
|
|
this.nodeState = types_js_1.NodeState.Follower;
|
|
this.leaderId = null;
|
|
this.transport = null;
|
|
this.stateMachine = null;
|
|
this.electionTimer = null;
|
|
this.heartbeatTimer = null;
|
|
this.running = false;
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
this.state = new state_js_1.RaftState(config.nodeId, config.peers);
|
|
}
|
|
/** Get node ID */
|
|
get nodeId() {
|
|
return this.config.nodeId;
|
|
}
|
|
/** Get current state */
|
|
get currentState() {
|
|
return this.nodeState;
|
|
}
|
|
/** Get current term */
|
|
get currentTerm() {
|
|
return this.state.currentTerm;
|
|
}
|
|
/** Get current leader ID */
|
|
get leader() {
|
|
return this.leaderId;
|
|
}
|
|
/** Check if this node is the leader */
|
|
get isLeader() {
|
|
return this.nodeState === types_js_1.NodeState.Leader;
|
|
}
|
|
/** Get commit index */
|
|
get commitIndex() {
|
|
return this.state.commitIndex;
|
|
}
|
|
/** Set transport for RPC communication */
|
|
setTransport(transport) {
|
|
this.transport = transport;
|
|
}
|
|
/** Set state machine for applying commands */
|
|
setStateMachine(stateMachine) {
|
|
this.stateMachine = stateMachine;
|
|
}
|
|
/** Start the Raft node */
|
|
start() {
|
|
if (this.running)
|
|
return;
|
|
this.running = true;
|
|
this.resetElectionTimer();
|
|
}
|
|
/** Stop the Raft node */
|
|
stop() {
|
|
this.running = false;
|
|
this.clearTimers();
|
|
}
|
|
/** Propose a command to be replicated (only works if leader) */
|
|
async propose(command) {
|
|
if (this.nodeState !== types_js_1.NodeState.Leader) {
|
|
throw types_js_1.RaftError.notLeader();
|
|
}
|
|
const entry = await this.state.log.appendCommand(this.state.currentTerm, command);
|
|
this.emit(types_js_1.RaftEvent.LogAppended, entry);
|
|
// Immediately replicate to followers
|
|
await this.replicateToFollowers();
|
|
return entry;
|
|
}
|
|
/** Handle RequestVote RPC from a candidate */
|
|
async handleRequestVote(request) {
|
|
// If request term is higher, update term and become follower
|
|
if (request.term > this.state.currentTerm) {
|
|
await this.state.setTerm(request.term);
|
|
this.transitionTo(types_js_1.NodeState.Follower);
|
|
}
|
|
// Deny vote if request term is less than current term
|
|
if (request.term < this.state.currentTerm) {
|
|
return { term: this.state.currentTerm, voteGranted: false };
|
|
}
|
|
// Check if we can vote for this candidate
|
|
const canVote = (this.state.votedFor === null || this.state.votedFor === request.candidateId) &&
|
|
this.state.log.isUpToDate(request.lastLogTerm, request.lastLogIndex);
|
|
if (canVote) {
|
|
await this.state.vote(request.term, request.candidateId);
|
|
this.resetElectionTimer();
|
|
this.emit(types_js_1.RaftEvent.VoteGranted, { candidateId: request.candidateId, term: request.term });
|
|
return { term: this.state.currentTerm, voteGranted: true };
|
|
}
|
|
return { term: this.state.currentTerm, voteGranted: false };
|
|
}
|
|
/** Handle AppendEntries RPC from leader */
|
|
async handleAppendEntries(request) {
|
|
// If request term is higher, update term
|
|
if (request.term > this.state.currentTerm) {
|
|
await this.state.setTerm(request.term);
|
|
this.transitionTo(types_js_1.NodeState.Follower);
|
|
}
|
|
// Reject if term is less than current term
|
|
if (request.term < this.state.currentTerm) {
|
|
return { term: this.state.currentTerm, success: false };
|
|
}
|
|
// Valid leader - reset election timer
|
|
this.leaderId = request.leaderId;
|
|
this.resetElectionTimer();
|
|
// If not follower, become follower
|
|
if (this.nodeState !== types_js_1.NodeState.Follower) {
|
|
this.transitionTo(types_js_1.NodeState.Follower);
|
|
}
|
|
this.emit(types_js_1.RaftEvent.Heartbeat, { leaderId: request.leaderId, term: request.term });
|
|
// Check if log contains entry at prevLogIndex with prevLogTerm
|
|
if (request.prevLogIndex > 0 && !this.state.log.containsEntry(request.prevLogIndex, request.prevLogTerm)) {
|
|
return { term: this.state.currentTerm, success: false };
|
|
}
|
|
// Append entries
|
|
if (request.entries.length > 0) {
|
|
await this.state.log.append(request.entries);
|
|
}
|
|
// Update commit index
|
|
if (request.leaderCommit > this.state.commitIndex) {
|
|
this.state.setCommitIndex(Math.min(request.leaderCommit, this.state.log.lastIndex));
|
|
await this.applyCommitted();
|
|
}
|
|
return {
|
|
term: this.state.currentTerm,
|
|
success: true,
|
|
matchIndex: this.state.log.lastIndex,
|
|
};
|
|
}
|
|
/** Load persistent state */
|
|
loadState(state) {
|
|
this.state.loadPersistentState(state);
|
|
}
|
|
/** Get current persistent state */
|
|
getState() {
|
|
return this.state.getPersistentState();
|
|
}
|
|
// Private methods
|
|
transitionTo(newState) {
|
|
const previousState = this.nodeState;
|
|
if (previousState === newState)
|
|
return;
|
|
this.nodeState = newState;
|
|
this.clearTimers();
|
|
if (newState === types_js_1.NodeState.Leader) {
|
|
this.state.initLeaderState();
|
|
this.leaderId = this.config.nodeId;
|
|
this.startHeartbeat();
|
|
this.emit(types_js_1.RaftEvent.LeaderElected, {
|
|
leaderId: this.config.nodeId,
|
|
term: this.state.currentTerm,
|
|
});
|
|
}
|
|
else {
|
|
this.state.clearLeaderState();
|
|
if (newState === types_js_1.NodeState.Follower) {
|
|
this.leaderId = null;
|
|
this.resetElectionTimer();
|
|
}
|
|
}
|
|
this.emit(types_js_1.RaftEvent.StateChange, {
|
|
previousState,
|
|
newState,
|
|
term: this.state.currentTerm,
|
|
});
|
|
}
|
|
getRandomElectionTimeout() {
|
|
const [min, max] = this.config.electionTimeout;
|
|
return min + Math.random() * (max - min);
|
|
}
|
|
resetElectionTimer() {
|
|
if (this.electionTimer) {
|
|
clearTimeout(this.electionTimer);
|
|
}
|
|
if (!this.running)
|
|
return;
|
|
this.electionTimer = setTimeout(() => {
|
|
this.startElection();
|
|
}, this.getRandomElectionTimeout());
|
|
}
|
|
clearTimers() {
|
|
if (this.electionTimer) {
|
|
clearTimeout(this.electionTimer);
|
|
this.electionTimer = null;
|
|
}
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
}
|
|
async startElection() {
|
|
if (!this.running)
|
|
return;
|
|
// Increment term and become candidate
|
|
await this.state.setTerm(this.state.currentTerm + 1);
|
|
await this.state.vote(this.state.currentTerm, this.config.nodeId);
|
|
this.transitionTo(types_js_1.NodeState.Candidate);
|
|
this.emit(types_js_1.RaftEvent.VoteRequested, {
|
|
term: this.state.currentTerm,
|
|
candidateId: this.config.nodeId,
|
|
});
|
|
// Start with 1 vote (self)
|
|
let votesReceived = 1;
|
|
const majority = Math.floor((this.config.peers.length + 1) / 2) + 1;
|
|
// Request votes from all peers
|
|
if (!this.transport) {
|
|
this.resetElectionTimer();
|
|
return;
|
|
}
|
|
const votePromises = this.config.peers.map(async (peerId) => {
|
|
try {
|
|
const response = await this.transport.requestVote(peerId, {
|
|
term: this.state.currentTerm,
|
|
candidateId: this.config.nodeId,
|
|
lastLogIndex: this.state.log.lastIndex,
|
|
lastLogTerm: this.state.log.lastTerm,
|
|
});
|
|
// If response term is higher, become follower
|
|
if (response.term > this.state.currentTerm) {
|
|
await this.state.setTerm(response.term);
|
|
this.transitionTo(types_js_1.NodeState.Follower);
|
|
return;
|
|
}
|
|
if (response.voteGranted && this.nodeState === types_js_1.NodeState.Candidate) {
|
|
votesReceived++;
|
|
if (votesReceived >= majority) {
|
|
this.transitionTo(types_js_1.NodeState.Leader);
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
// Peer unavailable, continue
|
|
}
|
|
});
|
|
await Promise.allSettled(votePromises);
|
|
// If still candidate, restart election timer
|
|
if (this.nodeState === types_js_1.NodeState.Candidate) {
|
|
this.resetElectionTimer();
|
|
}
|
|
}
|
|
startHeartbeat() {
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
}
|
|
// Send immediate heartbeat
|
|
this.replicateToFollowers();
|
|
// Start periodic heartbeat
|
|
this.heartbeatTimer = setInterval(() => {
|
|
if (this.nodeState === types_js_1.NodeState.Leader) {
|
|
this.replicateToFollowers();
|
|
}
|
|
}, this.config.heartbeatInterval);
|
|
}
|
|
async replicateToFollowers() {
|
|
if (!this.transport || this.nodeState !== types_js_1.NodeState.Leader)
|
|
return;
|
|
const replicationPromises = this.config.peers.map(async (peerId) => {
|
|
await this.replicateToPeer(peerId);
|
|
});
|
|
await Promise.allSettled(replicationPromises);
|
|
// Update commit index if majority have replicated
|
|
if (this.state.updateCommitIndex()) {
|
|
this.emit(types_js_1.RaftEvent.LogCommitted, {
|
|
index: this.state.commitIndex,
|
|
term: this.state.currentTerm,
|
|
});
|
|
await this.applyCommitted();
|
|
}
|
|
}
|
|
async replicateToPeer(peerId) {
|
|
if (!this.transport || this.nodeState !== types_js_1.NodeState.Leader)
|
|
return;
|
|
const nextIndex = this.state.getNextIndex(peerId);
|
|
const prevLogIndex = nextIndex - 1;
|
|
const prevLogTerm = this.state.log.termAt(prevLogIndex) ?? 0;
|
|
const entries = this.state.log.getFrom(nextIndex, this.config.maxEntriesPerRequest);
|
|
try {
|
|
const response = await this.transport.appendEntries(peerId, {
|
|
term: this.state.currentTerm,
|
|
leaderId: this.config.nodeId,
|
|
prevLogIndex,
|
|
prevLogTerm,
|
|
entries,
|
|
leaderCommit: this.state.commitIndex,
|
|
});
|
|
if (response.term > this.state.currentTerm) {
|
|
await this.state.setTerm(response.term);
|
|
this.transitionTo(types_js_1.NodeState.Follower);
|
|
return;
|
|
}
|
|
if (response.success) {
|
|
if (response.matchIndex !== undefined) {
|
|
this.state.setNextIndex(peerId, response.matchIndex + 1);
|
|
this.state.setMatchIndex(peerId, response.matchIndex);
|
|
}
|
|
else if (entries.length > 0) {
|
|
const lastEntry = entries[entries.length - 1];
|
|
this.state.setNextIndex(peerId, lastEntry.index + 1);
|
|
this.state.setMatchIndex(peerId, lastEntry.index);
|
|
}
|
|
}
|
|
else {
|
|
// Decrement nextIndex and retry
|
|
this.state.setNextIndex(peerId, nextIndex - 1);
|
|
}
|
|
}
|
|
catch {
|
|
// Peer unavailable, will retry on next heartbeat
|
|
}
|
|
}
|
|
async applyCommitted() {
|
|
while (this.state.lastApplied < this.state.commitIndex) {
|
|
const nextIndex = this.state.lastApplied + 1;
|
|
const entry = this.state.log.get(nextIndex);
|
|
if (entry && this.stateMachine) {
|
|
try {
|
|
await this.stateMachine.apply(entry.command);
|
|
this.state.setLastApplied(nextIndex);
|
|
this.emit(types_js_1.RaftEvent.LogApplied, entry);
|
|
}
|
|
catch (error) {
|
|
this.emit(types_js_1.RaftEvent.Error, error);
|
|
break;
|
|
}
|
|
}
|
|
else {
|
|
this.state.setLastApplied(nextIndex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
exports.RaftNode = RaftNode;
|
|
//# sourceMappingURL=node.js.map
|