2025-09-08 14:26:09 -07:00
|
|
|
/**
|
|
|
|
|
* Distributed Coordinator for Brainy 3.0
|
|
|
|
|
* Provides leader election, consensus, and coordination for distributed instances
|
|
|
|
|
*/
|
|
|
|
|
|
feat: add node: protocol to all Node.js built-in imports for bundler compatibility
- Updated all fs, path, crypto, os, url, util, events, http, https, net, child_process, stream, and zlib imports
- Changed both static imports and dynamic imports to use node: protocol
- This makes Brainy more bundler-friendly by explicitly marking Node.js built-ins
- Prevents bundlers from attempting to polyfill or bundle these modules
- Reduces bundle size for web applications using Brainy
- Improves tree-shaking and dead code elimination
Benefits for external bundlers:
- Clear distinction between Node.js built-ins and external dependencies
- No ambiguity about what needs polyfilling
- Smaller bundles for browser builds
- Better compatibility with modern bundlers (Webpack 5, Vite, Rollup, esbuild)
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 14:20:21 -07:00
|
|
|
import { EventEmitter } from 'node:events'
|
2025-09-11 16:23:32 -07:00
|
|
|
import { NetworkTransport, NetworkMessage } from './networkTransport.js'
|
feat: add node: protocol to all Node.js built-in imports for bundler compatibility
- Updated all fs, path, crypto, os, url, util, events, http, https, net, child_process, stream, and zlib imports
- Changed both static imports and dynamic imports to use node: protocol
- This makes Brainy more bundler-friendly by explicitly marking Node.js built-ins
- Prevents bundlers from attempting to polyfill or bundle these modules
- Reduces bundle size for web applications using Brainy
- Improves tree-shaking and dead code elimination
Benefits for external bundlers:
- Clear distinction between Node.js built-ins and external dependencies
- No ambiguity about what needs polyfilling
- Smaller bundles for browser builds
- Better compatibility with modern bundlers (Webpack 5, Vite, Rollup, esbuild)
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 14:20:21 -07:00
|
|
|
import { createHash } from 'node:crypto'
|
2025-09-08 14:26:09 -07:00
|
|
|
|
|
|
|
|
export interface NodeInfo {
|
|
|
|
|
id: string
|
|
|
|
|
address: string
|
|
|
|
|
port: number
|
2025-09-11 16:23:32 -07:00
|
|
|
lastSeen: number
|
|
|
|
|
status: 'active' | 'inactive' | 'suspected'
|
|
|
|
|
state?: 'follower' | 'candidate' | 'leader'
|
|
|
|
|
lastHeartbeat?: number
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CoordinatorConfig {
|
|
|
|
|
nodeId?: string
|
|
|
|
|
address?: string
|
|
|
|
|
port?: number
|
|
|
|
|
heartbeatInterval?: number
|
|
|
|
|
electionTimeout?: number
|
|
|
|
|
nodes?: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ConsensusState {
|
|
|
|
|
term: number
|
|
|
|
|
votedFor: string | null
|
|
|
|
|
leader: string | null
|
|
|
|
|
state: 'follower' | 'candidate' | 'leader'
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
export interface RaftMessage {
|
|
|
|
|
type: 'requestVote' | 'voteResponse' | 'appendEntries' | 'appendResponse'
|
|
|
|
|
term: number
|
|
|
|
|
from: string
|
|
|
|
|
to?: string
|
|
|
|
|
data?: any
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:26:09 -07:00
|
|
|
/**
|
|
|
|
|
* Distributed Coordinator implementing Raft-like consensus
|
|
|
|
|
*/
|
|
|
|
|
export class DistributedCoordinator extends EventEmitter {
|
|
|
|
|
private nodeId: string
|
|
|
|
|
private nodes: Map<string, NodeInfo> = new Map()
|
|
|
|
|
private consensusState: ConsensusState
|
|
|
|
|
private heartbeatInterval: number
|
|
|
|
|
private electionTimeout: number
|
|
|
|
|
private electionTimer?: NodeJS.Timeout
|
|
|
|
|
private heartbeatTimer?: NodeJS.Timeout
|
|
|
|
|
private isRunning: boolean = false
|
2025-09-11 16:23:32 -07:00
|
|
|
private networkTransport?: NetworkTransport
|
|
|
|
|
private votesReceived: Set<string> = new Set()
|
|
|
|
|
private currentTerm: number = 0
|
|
|
|
|
private lastLogIndex: number = -1
|
|
|
|
|
private lastLogTerm: number = 0
|
|
|
|
|
private logEntries: Array<{ term: number; index: number; data: any }> = []
|
|
|
|
|
private transport: any = null // For migration proposals
|
|
|
|
|
private pendingMigrations = new Map<string, any>()
|
|
|
|
|
private committedMigrations = new Set<string>()
|
2025-09-08 14:26:09 -07:00
|
|
|
|
|
|
|
|
constructor(config: CoordinatorConfig = {}) {
|
|
|
|
|
super()
|
|
|
|
|
|
|
|
|
|
// Generate node ID if not provided
|
|
|
|
|
this.nodeId = config.nodeId || this.generateNodeId()
|
|
|
|
|
|
|
|
|
|
// Configuration
|
|
|
|
|
this.heartbeatInterval = config.heartbeatInterval || 1000
|
|
|
|
|
this.electionTimeout = config.electionTimeout || 5000
|
|
|
|
|
|
|
|
|
|
// Initialize consensus state
|
|
|
|
|
this.consensusState = {
|
|
|
|
|
term: 0,
|
|
|
|
|
votedFor: null,
|
|
|
|
|
leader: null,
|
|
|
|
|
state: 'follower'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register this node
|
|
|
|
|
this.nodes.set(this.nodeId, {
|
|
|
|
|
id: this.nodeId,
|
|
|
|
|
address: config.address || 'localhost',
|
|
|
|
|
port: config.port || 3000,
|
2025-09-11 16:23:32 -07:00
|
|
|
lastSeen: Date.now(),
|
|
|
|
|
status: 'active',
|
|
|
|
|
state: 'follower',
|
2025-09-08 14:26:09 -07:00
|
|
|
lastHeartbeat: Date.now()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Register other nodes if provided
|
|
|
|
|
if (config.nodes) {
|
|
|
|
|
this.registerNodes(config.nodes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start the coordinator
|
|
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
async start(networkTransport?: NetworkTransport): Promise<void> {
|
2025-09-08 14:26:09 -07:00
|
|
|
if (this.isRunning) return
|
|
|
|
|
|
|
|
|
|
this.isRunning = true
|
2025-09-11 16:23:32 -07:00
|
|
|
this.networkTransport = networkTransport
|
|
|
|
|
|
|
|
|
|
// Setup network message handlers if transport is provided
|
|
|
|
|
if (this.networkTransport) {
|
|
|
|
|
this.setupNetworkHandlers()
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:26:09 -07:00
|
|
|
this.emit('started', { nodeId: this.nodeId })
|
|
|
|
|
|
|
|
|
|
// Start as follower
|
|
|
|
|
this.becomeFollower()
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
/**
|
|
|
|
|
* Setup network message handlers
|
|
|
|
|
*/
|
|
|
|
|
private setupNetworkHandlers(): void {
|
|
|
|
|
if (!this.networkTransport) return
|
|
|
|
|
|
|
|
|
|
// Register message handlers directly on the messageHandlers map
|
|
|
|
|
const handlers = (this.networkTransport as any).messageHandlers as Map<string, (msg: NetworkMessage) => Promise<any>>
|
|
|
|
|
|
|
|
|
|
// Handle vote requests
|
|
|
|
|
handlers.set('requestVote', async (msg: NetworkMessage) => {
|
|
|
|
|
const response = await this.handleVoteRequest(msg)
|
|
|
|
|
// Send response back
|
|
|
|
|
if (this.networkTransport) {
|
|
|
|
|
await this.networkTransport.sendToNode(msg.from, 'voteResponse', response)
|
|
|
|
|
}
|
|
|
|
|
return response
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Handle vote responses
|
|
|
|
|
handlers.set('voteResponse', async (msg: NetworkMessage) => {
|
|
|
|
|
this.handleVoteResponse(msg)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Handle heartbeats/append entries
|
|
|
|
|
handlers.set('appendEntries', async (msg: NetworkMessage) => {
|
|
|
|
|
const response = await this.handleAppendEntries(msg)
|
|
|
|
|
// Send response back
|
|
|
|
|
if (this.networkTransport) {
|
|
|
|
|
await this.networkTransport.sendToNode(msg.from, 'appendResponse', response)
|
|
|
|
|
}
|
|
|
|
|
return response
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Handle append responses
|
|
|
|
|
handlers.set('appendResponse', async (msg: NetworkMessage) => {
|
|
|
|
|
this.handleAppendResponse(msg)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:26:09 -07:00
|
|
|
/**
|
|
|
|
|
* Stop the coordinator
|
|
|
|
|
*/
|
|
|
|
|
async stop(): Promise<void> {
|
|
|
|
|
if (!this.isRunning) return
|
|
|
|
|
|
|
|
|
|
this.isRunning = false
|
|
|
|
|
|
|
|
|
|
if (this.electionTimer) {
|
|
|
|
|
clearTimeout(this.electionTimer)
|
|
|
|
|
this.electionTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.heartbeatTimer) {
|
|
|
|
|
clearInterval(this.heartbeatTimer)
|
|
|
|
|
this.heartbeatTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
this.emit('stopped')
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-11 16:23:32 -07:00
|
|
|
* Register additional nodes
|
2025-09-08 14:26:09 -07:00
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
registerNodes(nodes: string[]): void {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
const [address, port] = node.split(':')
|
|
|
|
|
const nodeId = this.generateNodeId(node)
|
2025-09-08 14:26:09 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
if (!this.nodes.has(nodeId)) {
|
2025-09-08 14:26:09 -07:00
|
|
|
this.nodes.set(nodeId, {
|
|
|
|
|
id: nodeId,
|
2025-09-11 16:23:32 -07:00
|
|
|
address,
|
|
|
|
|
port: parseInt(port || '3000'),
|
|
|
|
|
lastSeen: Date.now(),
|
|
|
|
|
status: 'active',
|
|
|
|
|
state: 'follower'
|
2025-09-08 14:26:09 -07:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Become a follower
|
|
|
|
|
*/
|
|
|
|
|
private becomeFollower(): void {
|
|
|
|
|
this.consensusState.state = 'follower'
|
|
|
|
|
|
|
|
|
|
const node = this.nodes.get(this.nodeId)
|
|
|
|
|
if (node) {
|
2025-09-11 16:23:32 -07:00
|
|
|
node.state = 'follower'
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.resetElectionTimeout()
|
2025-09-11 16:23:32 -07:00
|
|
|
this.emit('stateChange', 'follower')
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Become a candidate and start election
|
|
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
private async becomeCandidate(): Promise<void> {
|
2025-09-08 14:26:09 -07:00
|
|
|
this.consensusState.state = 'candidate'
|
2025-09-11 16:23:32 -07:00
|
|
|
this.currentTerm++
|
|
|
|
|
this.consensusState.term = this.currentTerm
|
2025-09-08 14:26:09 -07:00
|
|
|
this.consensusState.votedFor = this.nodeId
|
2025-09-11 16:23:32 -07:00
|
|
|
this.votesReceived = new Set([this.nodeId])
|
2025-09-08 14:26:09 -07:00
|
|
|
|
|
|
|
|
const node = this.nodes.get(this.nodeId)
|
|
|
|
|
if (node) {
|
2025-09-11 16:23:32 -07:00
|
|
|
node.state = 'candidate'
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
this.emit('stateChange', 'candidate')
|
|
|
|
|
|
|
|
|
|
// Request votes from all other nodes
|
|
|
|
|
await this.requestVotes()
|
2025-09-08 14:26:09 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Reset election timeout
|
|
|
|
|
this.resetElectionTimeout()
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Become the leader
|
|
|
|
|
*/
|
|
|
|
|
private becomeLeader(): void {
|
|
|
|
|
this.consensusState.state = 'leader'
|
|
|
|
|
this.consensusState.leader = this.nodeId
|
|
|
|
|
|
|
|
|
|
const node = this.nodes.get(this.nodeId)
|
|
|
|
|
if (node) {
|
2025-09-11 16:23:32 -07:00
|
|
|
node.state = 'leader'
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Stop election timer as leader
|
2025-09-08 14:26:09 -07:00
|
|
|
if (this.electionTimer) {
|
|
|
|
|
clearTimeout(this.electionTimer)
|
|
|
|
|
this.electionTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
this.emit('stateChange', 'leader')
|
|
|
|
|
this.emit('leaderElected', this.nodeId)
|
|
|
|
|
|
2025-09-08 14:26:09 -07:00
|
|
|
// Start sending heartbeats
|
|
|
|
|
this.startHeartbeat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-11 16:23:32 -07:00
|
|
|
* Request votes from all nodes
|
2025-09-08 14:26:09 -07:00
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
private async requestVotes(): Promise<void> {
|
|
|
|
|
if (!this.networkTransport) {
|
|
|
|
|
// Simulate vote for testing
|
|
|
|
|
this.checkVoteMajority()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const voteRequest = {
|
|
|
|
|
type: 'requestVote' as const,
|
|
|
|
|
term: this.currentTerm,
|
|
|
|
|
candidateId: this.nodeId,
|
|
|
|
|
lastLogIndex: this.getLastLogIndex(),
|
|
|
|
|
lastLogTerm: this.getLastLogTerm()
|
|
|
|
|
}
|
2025-09-08 14:26:09 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Send vote requests to all other nodes
|
2025-09-08 14:26:09 -07:00
|
|
|
for (const [nodeId] of this.nodes) {
|
|
|
|
|
if (nodeId !== this.nodeId) {
|
2025-09-11 16:23:32 -07:00
|
|
|
try {
|
|
|
|
|
await this.networkTransport.sendToNode(nodeId, 'requestVote', voteRequest)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`Failed to request vote from ${nodeId}:`, err)
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle vote request from another node
|
|
|
|
|
*/
|
|
|
|
|
private async handleVoteRequest(msg: NetworkMessage): Promise<any> {
|
|
|
|
|
const { term, candidateId, lastLogIndex, lastLogTerm } = msg.data
|
|
|
|
|
|
|
|
|
|
// Update term if necessary
|
|
|
|
|
if (term > this.currentTerm) {
|
|
|
|
|
this.currentTerm = term
|
|
|
|
|
this.consensusState.term = term
|
|
|
|
|
this.consensusState.votedFor = null
|
|
|
|
|
this.becomeFollower()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Grant vote if conditions are met
|
|
|
|
|
let voteGranted = false
|
|
|
|
|
if (term >= this.currentTerm &&
|
|
|
|
|
(!this.consensusState.votedFor || this.consensusState.votedFor === candidateId) &&
|
|
|
|
|
this.isLogUpToDate(lastLogIndex, lastLogTerm)) {
|
|
|
|
|
this.consensusState.votedFor = candidateId
|
|
|
|
|
voteGranted = true
|
|
|
|
|
this.resetElectionTimeout()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'voteResponse',
|
|
|
|
|
term: this.currentTerm,
|
|
|
|
|
voteGranted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle vote response
|
|
|
|
|
*/
|
|
|
|
|
private handleVoteResponse(msg: NetworkMessage): void {
|
|
|
|
|
const { term, voteGranted } = msg.data
|
|
|
|
|
|
|
|
|
|
// Ignore old responses
|
|
|
|
|
if (term < this.currentTerm) return
|
|
|
|
|
|
|
|
|
|
// Update term if necessary
|
|
|
|
|
if (term > this.currentTerm) {
|
|
|
|
|
this.currentTerm = term
|
|
|
|
|
this.consensusState.term = term
|
|
|
|
|
this.becomeFollower()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Count vote if granted
|
|
|
|
|
if (voteGranted && this.consensusState.state === 'candidate') {
|
|
|
|
|
this.votesReceived.add(msg.from)
|
|
|
|
|
this.checkVoteMajority()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if we have majority votes
|
|
|
|
|
*/
|
|
|
|
|
private checkVoteMajority(): void {
|
|
|
|
|
const majority = Math.floor(this.nodes.size / 2) + 1
|
|
|
|
|
if (this.votesReceived.size >= majority) {
|
|
|
|
|
this.becomeLeader()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle append entries (heartbeat) from leader
|
|
|
|
|
*/
|
|
|
|
|
private async handleAppendEntries(msg: NetworkMessage): Promise<any> {
|
|
|
|
|
const { term, leaderId, prevLogIndex, prevLogTerm, entries, leaderCommit } = msg.data
|
|
|
|
|
|
|
|
|
|
// Update term if necessary
|
|
|
|
|
if (term > this.currentTerm) {
|
|
|
|
|
this.currentTerm = term
|
|
|
|
|
this.consensusState.term = term
|
|
|
|
|
this.consensusState.votedFor = null
|
|
|
|
|
this.becomeFollower()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset election timeout when receiving valid heartbeat
|
|
|
|
|
if (term >= this.currentTerm) {
|
|
|
|
|
this.consensusState.leader = leaderId
|
|
|
|
|
this.resetElectionTimeout()
|
|
|
|
|
|
|
|
|
|
// Update leader node's last heartbeat
|
|
|
|
|
const leaderNode = this.nodes.get(leaderId)
|
|
|
|
|
if (leaderNode) {
|
|
|
|
|
leaderNode.lastHeartbeat = Date.now()
|
|
|
|
|
leaderNode.lastSeen = Date.now()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check log consistency
|
|
|
|
|
let success = false
|
|
|
|
|
if (term >= this.currentTerm) {
|
|
|
|
|
if (this.checkLogConsistency(prevLogIndex, prevLogTerm)) {
|
|
|
|
|
// Append new entries if any
|
|
|
|
|
if (entries && entries.length > 0) {
|
|
|
|
|
this.appendLogEntries(prevLogIndex, entries)
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
2025-09-11 16:23:32 -07:00
|
|
|
success = true
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
return {
|
|
|
|
|
type: 'appendResponse',
|
|
|
|
|
term: this.currentTerm,
|
|
|
|
|
success
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle append response from follower
|
|
|
|
|
*/
|
|
|
|
|
private handleAppendResponse(msg: NetworkMessage): void {
|
|
|
|
|
const { term, success } = msg.data
|
|
|
|
|
|
|
|
|
|
// Update term if necessary
|
|
|
|
|
if (term > this.currentTerm) {
|
|
|
|
|
this.currentTerm = term
|
|
|
|
|
this.consensusState.term = term
|
|
|
|
|
this.becomeFollower()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process successful append
|
|
|
|
|
if (success && this.consensusState.state === 'leader') {
|
|
|
|
|
// Update follower's match index
|
|
|
|
|
// In a real implementation, this would track replication progress
|
|
|
|
|
}
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-11 16:23:32 -07:00
|
|
|
* Send heartbeat to followers
|
2025-09-08 14:26:09 -07:00
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
private async sendHeartbeat(): Promise<void> {
|
|
|
|
|
if (!this.networkTransport) {
|
|
|
|
|
// Fallback for testing
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const heartbeat = {
|
|
|
|
|
type: 'appendEntries' as const,
|
|
|
|
|
term: this.currentTerm,
|
|
|
|
|
leaderId: this.nodeId,
|
|
|
|
|
prevLogIndex: this.getLastLogIndex(),
|
|
|
|
|
prevLogTerm: this.getLastLogTerm(),
|
|
|
|
|
entries: [],
|
|
|
|
|
leaderCommit: 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send heartbeat to all followers
|
|
|
|
|
for (const [nodeId] of this.nodes) {
|
|
|
|
|
if (nodeId !== this.nodeId) {
|
|
|
|
|
try {
|
|
|
|
|
await this.networkTransport.sendToNode(nodeId, 'appendEntries', heartbeat)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// Node might be down, mark as suspected
|
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
|
if (node) {
|
|
|
|
|
node.status = 'suspected'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-11 16:23:32 -07:00
|
|
|
* Start heartbeat timer as leader
|
2025-09-08 14:26:09 -07:00
|
|
|
*/
|
|
|
|
|
private startHeartbeat(): void {
|
|
|
|
|
if (this.heartbeatTimer) {
|
|
|
|
|
clearInterval(this.heartbeatTimer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.heartbeatTimer = setInterval(() => {
|
|
|
|
|
this.sendHeartbeat()
|
|
|
|
|
}, this.heartbeatInterval)
|
|
|
|
|
|
|
|
|
|
// Send immediate heartbeat
|
|
|
|
|
this.sendHeartbeat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reset election timeout
|
|
|
|
|
*/
|
|
|
|
|
private resetElectionTimeout(): void {
|
|
|
|
|
if (this.electionTimer) {
|
|
|
|
|
clearTimeout(this.electionTimer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Randomize timeout to prevent split votes
|
|
|
|
|
const timeout = this.electionTimeout + Math.random() * this.electionTimeout
|
|
|
|
|
|
|
|
|
|
this.electionTimer = setTimeout(() => {
|
|
|
|
|
if (this.consensusState.state === 'follower') {
|
|
|
|
|
this.becomeCandidate()
|
|
|
|
|
}
|
|
|
|
|
}, timeout)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-11 16:23:32 -07:00
|
|
|
* Check if log is up to date
|
2025-09-08 14:26:09 -07:00
|
|
|
*/
|
2025-09-11 16:23:32 -07:00
|
|
|
private isLogUpToDate(lastLogIndex: number, lastLogTerm: number): boolean {
|
|
|
|
|
const myLastLogTerm = this.getLastLogTerm()
|
|
|
|
|
const myLastLogIndex = this.getLastLogIndex()
|
|
|
|
|
|
|
|
|
|
if (lastLogTerm > myLastLogTerm) return true
|
|
|
|
|
if (lastLogTerm < myLastLogTerm) return false
|
|
|
|
|
return lastLogIndex >= myLastLogIndex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check log consistency
|
|
|
|
|
*/
|
|
|
|
|
private checkLogConsistency(prevLogIndex: number, prevLogTerm: number): boolean {
|
|
|
|
|
if (prevLogIndex === -1) return true
|
|
|
|
|
|
|
|
|
|
if (prevLogIndex >= this.logEntries.length) return false
|
|
|
|
|
|
|
|
|
|
const entry = this.logEntries[prevLogIndex]
|
|
|
|
|
return entry && entry.term === prevLogTerm
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Append log entries
|
|
|
|
|
*/
|
|
|
|
|
private appendLogEntries(prevLogIndex: number, entries: any[]): void {
|
|
|
|
|
// Remove conflicting entries
|
|
|
|
|
this.logEntries = this.logEntries.slice(0, prevLogIndex + 1)
|
|
|
|
|
|
|
|
|
|
// Append new entries
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
this.logEntries.push({
|
|
|
|
|
term: entry.term,
|
|
|
|
|
index: this.logEntries.length,
|
|
|
|
|
data: entry.data
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.lastLogIndex = this.logEntries.length - 1
|
|
|
|
|
if (this.lastLogIndex >= 0) {
|
|
|
|
|
this.lastLogTerm = this.logEntries[this.lastLogIndex].term
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
/**
|
|
|
|
|
* Get last log index
|
|
|
|
|
*/
|
|
|
|
|
private getLastLogIndex(): number {
|
|
|
|
|
return this.lastLogIndex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get last log term
|
|
|
|
|
*/
|
|
|
|
|
private getLastLogTerm(): number {
|
|
|
|
|
return this.lastLogTerm
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:26:09 -07:00
|
|
|
/**
|
|
|
|
|
* Generate a unique node ID
|
|
|
|
|
*/
|
|
|
|
|
private generateNodeId(seed?: string): string {
|
|
|
|
|
const source = seed || `${process.pid}-${Date.now()}-${Math.random()}`
|
|
|
|
|
return createHash('sha256').update(source).digest('hex').substring(0, 16)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current leader
|
|
|
|
|
*/
|
|
|
|
|
getLeader(): string | null {
|
|
|
|
|
return this.consensusState.leader
|
|
|
|
|
}
|
2025-09-11 16:23:32 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Propose a shard migration to the cluster
|
|
|
|
|
*/
|
|
|
|
|
async proposeMigration(migration: {
|
|
|
|
|
shardId: string
|
|
|
|
|
fromNode: string
|
|
|
|
|
toNode: string
|
|
|
|
|
migrationId: string
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
if (!this.isLeader()) {
|
|
|
|
|
throw new Error('Only leader can propose migrations')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Broadcast migration proposal to all nodes
|
|
|
|
|
const message = {
|
|
|
|
|
type: 'migration-proposal',
|
|
|
|
|
migration,
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.transport.broadcast('migration', message)
|
|
|
|
|
|
|
|
|
|
// Store migration as pending
|
|
|
|
|
this.pendingMigrations.set(migration.migrationId, {
|
|
|
|
|
...migration,
|
|
|
|
|
status: 'pending'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get migration status
|
|
|
|
|
*/
|
|
|
|
|
async getMigrationStatus(migrationId: string): Promise<'pending' | 'committed' | 'rejected'> {
|
|
|
|
|
const migration = this.pendingMigrations.get(migrationId)
|
|
|
|
|
|
|
|
|
|
if (!migration) {
|
|
|
|
|
// Check if it was committed
|
|
|
|
|
return this.committedMigrations.has(migrationId) ? 'committed' : 'rejected'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return migration.status || 'pending'
|
|
|
|
|
}
|
2025-09-08 14:26:09 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this node is the leader
|
|
|
|
|
*/
|
|
|
|
|
isLeader(): boolean {
|
|
|
|
|
return this.consensusState.state === 'leader'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all nodes in the cluster
|
|
|
|
|
*/
|
|
|
|
|
getNodes(): NodeInfo[] {
|
|
|
|
|
return Array.from(this.nodes.values())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get cluster health status
|
|
|
|
|
*/
|
|
|
|
|
getHealth(): { healthy: boolean; leader: string | null; nodes: number; activeNodes: number } {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const activeNodes = Array.from(this.nodes.values()).filter(
|
2025-09-11 16:23:32 -07:00
|
|
|
node => now - node.lastSeen < this.electionTimeout
|
2025-09-08 14:26:09 -07:00
|
|
|
).length
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
healthy: this.consensusState.leader !== null && activeNodes > this.nodes.size / 2,
|
|
|
|
|
leader: this.consensusState.leader,
|
|
|
|
|
nodes: this.nodes.size,
|
|
|
|
|
activeNodes
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-11 16:23:32 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Propose a command to the cluster
|
|
|
|
|
*/
|
|
|
|
|
async proposeCommand(command: any): Promise<void> {
|
|
|
|
|
if (this.consensusState.state !== 'leader') {
|
|
|
|
|
throw new Error(`Not the leader. Current leader: ${this.consensusState.leader}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add to log
|
|
|
|
|
const entry = {
|
|
|
|
|
term: this.currentTerm,
|
|
|
|
|
index: this.logEntries.length,
|
|
|
|
|
data: command
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logEntries.push(entry)
|
|
|
|
|
this.lastLogIndex = entry.index
|
|
|
|
|
this.lastLogTerm = entry.term
|
|
|
|
|
|
|
|
|
|
// Replicate to followers
|
|
|
|
|
await this.sendHeartbeat()
|
|
|
|
|
|
|
|
|
|
this.emit('commandProposed', command)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current state
|
|
|
|
|
*/
|
|
|
|
|
getState(): ConsensusState {
|
|
|
|
|
return { ...this.consensusState }
|
|
|
|
|
}
|
2025-09-08 14:26:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a coordinator instance
|
|
|
|
|
*/
|
|
|
|
|
export function createCoordinator(config?: CoordinatorConfig): DistributedCoordinator {
|
|
|
|
|
return new DistributedCoordinator(config)
|
|
|
|
|
}
|