@sparkleideas/swarm 3.0.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +472 -0
- package/README.md +634 -0
- package/__tests__/consensus.test.ts +577 -0
- package/__tests__/coordinator.test.ts +501 -0
- package/__tests__/queen-coordinator.test.ts +1335 -0
- package/__tests__/topology.test.ts +621 -0
- package/package.json +32 -0
- package/src/agent-pool.ts +476 -0
- package/src/application/commands/create-task.command.ts +124 -0
- package/src/application/commands/spawn-agent.command.ts +122 -0
- package/src/application/index.ts +30 -0
- package/src/application/services/swarm-application-service.ts +200 -0
- package/src/attention-coordinator.ts +1000 -0
- package/src/consensus/byzantine.ts +431 -0
- package/src/consensus/gossip.ts +513 -0
- package/src/consensus/index.ts +267 -0
- package/src/consensus/raft.ts +443 -0
- package/src/coordination/agent-registry.ts +544 -0
- package/src/coordination/index.ts +23 -0
- package/src/coordination/swarm-hub.ts +776 -0
- package/src/coordination/task-orchestrator.ts +605 -0
- package/src/domain/entities/agent.d.ts +151 -0
- package/src/domain/entities/agent.d.ts.map +1 -0
- package/src/domain/entities/agent.js +280 -0
- package/src/domain/entities/agent.js.map +1 -0
- package/src/domain/entities/agent.ts +370 -0
- package/src/domain/entities/task.d.ts +133 -0
- package/src/domain/entities/task.d.ts.map +1 -0
- package/src/domain/entities/task.js +261 -0
- package/src/domain/entities/task.js.map +1 -0
- package/src/domain/entities/task.ts +319 -0
- package/src/domain/index.ts +41 -0
- package/src/domain/repositories/agent-repository.interface.d.ts +57 -0
- package/src/domain/repositories/agent-repository.interface.d.ts.map +1 -0
- package/src/domain/repositories/agent-repository.interface.js +9 -0
- package/src/domain/repositories/agent-repository.interface.js.map +1 -0
- package/src/domain/repositories/agent-repository.interface.ts +69 -0
- package/src/domain/repositories/task-repository.interface.d.ts +61 -0
- package/src/domain/repositories/task-repository.interface.d.ts.map +1 -0
- package/src/domain/repositories/task-repository.interface.js +9 -0
- package/src/domain/repositories/task-repository.interface.js.map +1 -0
- package/src/domain/repositories/task-repository.interface.ts +75 -0
- package/src/domain/services/coordination-service.ts +320 -0
- package/src/federation-hub.d.ts +284 -0
- package/src/federation-hub.d.ts.map +1 -0
- package/src/federation-hub.js +692 -0
- package/src/federation-hub.js.map +1 -0
- package/src/federation-hub.ts +979 -0
- package/src/index.ts +348 -0
- package/src/message-bus.ts +607 -0
- package/src/queen-coordinator.ts +2025 -0
- package/src/shared/events.ts +285 -0
- package/src/shared/types.ts +389 -0
- package/src/topology-manager.ts +656 -0
- package/src/types.ts +545 -0
- package/src/unified-coordinator.ts +1844 -0
- package/src/workers/index.ts +65 -0
- package/src/workers/worker-dispatch.d.ts +234 -0
- package/src/workers/worker-dispatch.d.ts.map +1 -0
- package/src/workers/worker-dispatch.js +842 -0
- package/src/workers/worker-dispatch.js.map +1 -0
- package/src/workers/worker-dispatch.ts +1076 -0
- package/tmp.json +0 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V3 Consensus Engine Factory
|
|
3
|
+
* Unified interface for different consensus algorithms
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import {
|
|
8
|
+
ConsensusAlgorithm,
|
|
9
|
+
ConsensusConfig,
|
|
10
|
+
ConsensusProposal,
|
|
11
|
+
ConsensusVote,
|
|
12
|
+
ConsensusResult,
|
|
13
|
+
IConsensusEngine,
|
|
14
|
+
SWARM_CONSTANTS,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
import { RaftConsensus, createRaftConsensus, RaftConfig } from './raft.js';
|
|
17
|
+
import { ByzantineConsensus, createByzantineConsensus, ByzantineConfig } from './byzantine.js';
|
|
18
|
+
import { GossipConsensus, createGossipConsensus, GossipConfig } from './gossip.js';
|
|
19
|
+
|
|
20
|
+
export { RaftConsensus, ByzantineConsensus, GossipConsensus };
|
|
21
|
+
export type { RaftConfig, ByzantineConfig, GossipConfig };
|
|
22
|
+
|
|
23
|
+
type ConsensusImplementation = RaftConsensus | ByzantineConsensus | GossipConsensus;
|
|
24
|
+
|
|
25
|
+
export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
26
|
+
private config: ConsensusConfig;
|
|
27
|
+
private nodeId: string;
|
|
28
|
+
private implementation?: ConsensusImplementation;
|
|
29
|
+
private proposals: Map<string, ConsensusProposal> = new Map();
|
|
30
|
+
|
|
31
|
+
constructor(nodeId: string, config: Partial<ConsensusConfig> = {}) {
|
|
32
|
+
super();
|
|
33
|
+
this.nodeId = nodeId;
|
|
34
|
+
this.config = {
|
|
35
|
+
algorithm: config.algorithm ?? 'raft',
|
|
36
|
+
threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD,
|
|
37
|
+
timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS,
|
|
38
|
+
maxRounds: config.maxRounds ?? 10,
|
|
39
|
+
requireQuorum: config.requireQuorum ?? true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async initialize(config?: ConsensusConfig): Promise<void> {
|
|
44
|
+
if (config) {
|
|
45
|
+
this.config = { ...this.config, ...config };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Create implementation based on algorithm
|
|
49
|
+
switch (this.config.algorithm) {
|
|
50
|
+
case 'raft':
|
|
51
|
+
this.implementation = createRaftConsensus(this.nodeId, {
|
|
52
|
+
threshold: this.config.threshold,
|
|
53
|
+
timeoutMs: this.config.timeoutMs,
|
|
54
|
+
maxRounds: this.config.maxRounds,
|
|
55
|
+
requireQuorum: this.config.requireQuorum,
|
|
56
|
+
});
|
|
57
|
+
break;
|
|
58
|
+
|
|
59
|
+
case 'byzantine':
|
|
60
|
+
this.implementation = createByzantineConsensus(this.nodeId, {
|
|
61
|
+
threshold: this.config.threshold,
|
|
62
|
+
timeoutMs: this.config.timeoutMs,
|
|
63
|
+
maxRounds: this.config.maxRounds,
|
|
64
|
+
requireQuorum: this.config.requireQuorum,
|
|
65
|
+
});
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
case 'gossip':
|
|
69
|
+
this.implementation = createGossipConsensus(this.nodeId, {
|
|
70
|
+
threshold: this.config.threshold,
|
|
71
|
+
timeoutMs: this.config.timeoutMs,
|
|
72
|
+
maxRounds: this.config.maxRounds,
|
|
73
|
+
requireQuorum: this.config.requireQuorum,
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case 'paxos':
|
|
78
|
+
// Fall back to Raft for Paxos (similar guarantees)
|
|
79
|
+
this.implementation = createRaftConsensus(this.nodeId, {
|
|
80
|
+
threshold: this.config.threshold,
|
|
81
|
+
timeoutMs: this.config.timeoutMs,
|
|
82
|
+
maxRounds: this.config.maxRounds,
|
|
83
|
+
requireQuorum: this.config.requireQuorum,
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`Unknown consensus algorithm: ${this.config.algorithm}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await this.implementation.initialize();
|
|
92
|
+
|
|
93
|
+
// Forward events
|
|
94
|
+
this.implementation.on('consensus.achieved', (data) => {
|
|
95
|
+
this.emit('consensus.achieved', data);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.implementation.on('leader.elected', (data) => {
|
|
99
|
+
this.emit('leader.elected', data);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.emit('initialized', {
|
|
103
|
+
nodeId: this.nodeId,
|
|
104
|
+
algorithm: this.config.algorithm
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async shutdown(): Promise<void> {
|
|
109
|
+
if (this.implementation) {
|
|
110
|
+
await this.implementation.shutdown();
|
|
111
|
+
}
|
|
112
|
+
this.emit('shutdown');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
addNode(nodeId: string, options?: { isPrimary?: boolean }): void {
|
|
116
|
+
if (!this.implementation) {
|
|
117
|
+
throw new Error('Consensus engine not initialized');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.implementation instanceof RaftConsensus) {
|
|
121
|
+
this.implementation.addPeer(nodeId);
|
|
122
|
+
} else if (this.implementation instanceof ByzantineConsensus) {
|
|
123
|
+
this.implementation.addNode(nodeId, options?.isPrimary);
|
|
124
|
+
} else if (this.implementation instanceof GossipConsensus) {
|
|
125
|
+
this.implementation.addNode(nodeId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
removeNode(nodeId: string): void {
|
|
130
|
+
if (!this.implementation) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.implementation instanceof RaftConsensus) {
|
|
135
|
+
this.implementation.removePeer(nodeId);
|
|
136
|
+
} else if (this.implementation instanceof ByzantineConsensus) {
|
|
137
|
+
this.implementation.removeNode(nodeId);
|
|
138
|
+
} else if (this.implementation instanceof GossipConsensus) {
|
|
139
|
+
this.implementation.removeNode(nodeId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async propose(value: unknown, proposerId?: string): Promise<ConsensusProposal> {
|
|
144
|
+
if (!this.implementation) {
|
|
145
|
+
throw new Error('Consensus engine not initialized');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const proposal = await this.implementation.propose(value);
|
|
149
|
+
this.proposals.set(proposal.id, proposal);
|
|
150
|
+
return proposal;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async vote(proposalId: string, vote: ConsensusVote): Promise<void> {
|
|
154
|
+
if (!this.implementation) {
|
|
155
|
+
throw new Error('Consensus engine not initialized');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await this.implementation.vote(proposalId, vote);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getProposal(proposalId: string): ConsensusProposal | undefined {
|
|
162
|
+
return this.proposals.get(proposalId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async awaitConsensus(proposalId: string): Promise<ConsensusResult> {
|
|
166
|
+
if (!this.implementation) {
|
|
167
|
+
throw new Error('Consensus engine not initialized');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.implementation.awaitConsensus(proposalId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getActiveProposals(): ConsensusProposal[] {
|
|
174
|
+
return Array.from(this.proposals.values()).filter(
|
|
175
|
+
p => p.status === 'pending'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Algorithm-specific queries
|
|
180
|
+
isLeader(): boolean {
|
|
181
|
+
if (this.implementation instanceof RaftConsensus) {
|
|
182
|
+
return this.implementation.isLeader();
|
|
183
|
+
}
|
|
184
|
+
if (this.implementation instanceof ByzantineConsensus) {
|
|
185
|
+
return this.implementation.isPrimary();
|
|
186
|
+
}
|
|
187
|
+
return false; // Gossip has no leader
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getLeaderId(): string | undefined {
|
|
191
|
+
if (this.implementation instanceof RaftConsensus) {
|
|
192
|
+
return this.implementation.getLeaderId();
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getAlgorithm(): ConsensusAlgorithm {
|
|
198
|
+
return this.config.algorithm;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
getConfig(): ConsensusConfig {
|
|
202
|
+
return { ...this.config };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Metrics
|
|
206
|
+
getStats(): {
|
|
207
|
+
algorithm: ConsensusAlgorithm;
|
|
208
|
+
totalProposals: number;
|
|
209
|
+
pendingProposals: number;
|
|
210
|
+
acceptedProposals: number;
|
|
211
|
+
rejectedProposals: number;
|
|
212
|
+
expiredProposals: number;
|
|
213
|
+
} {
|
|
214
|
+
const proposals = Array.from(this.proposals.values());
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
algorithm: this.config.algorithm,
|
|
218
|
+
totalProposals: proposals.length,
|
|
219
|
+
pendingProposals: proposals.filter(p => p.status === 'pending').length,
|
|
220
|
+
acceptedProposals: proposals.filter(p => p.status === 'accepted').length,
|
|
221
|
+
rejectedProposals: proposals.filter(p => p.status === 'rejected').length,
|
|
222
|
+
expiredProposals: proposals.filter(p => p.status === 'expired').length,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Factory function
|
|
228
|
+
export function createConsensusEngine(
|
|
229
|
+
nodeId: string,
|
|
230
|
+
algorithm: ConsensusAlgorithm = 'raft',
|
|
231
|
+
config?: Partial<ConsensusConfig>
|
|
232
|
+
): ConsensusEngine {
|
|
233
|
+
return new ConsensusEngine(nodeId, { ...config, algorithm });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Helper to select optimal algorithm based on requirements
|
|
237
|
+
export function selectOptimalAlgorithm(requirements: {
|
|
238
|
+
faultTolerance: 'crash' | 'byzantine';
|
|
239
|
+
consistency: 'strong' | 'eventual';
|
|
240
|
+
networkScale: 'small' | 'medium' | 'large';
|
|
241
|
+
latencyPriority: 'low' | 'medium' | 'high';
|
|
242
|
+
}): ConsensusAlgorithm {
|
|
243
|
+
const { faultTolerance, consistency, networkScale, latencyPriority } = requirements;
|
|
244
|
+
|
|
245
|
+
// Byzantine fault tolerance required
|
|
246
|
+
if (faultTolerance === 'byzantine') {
|
|
247
|
+
return 'byzantine';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Eventual consistency acceptable and large scale
|
|
251
|
+
if (consistency === 'eventual' && networkScale === 'large') {
|
|
252
|
+
return 'gossip';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Low latency priority with medium scale
|
|
256
|
+
if (latencyPriority === 'high' && networkScale !== 'large') {
|
|
257
|
+
return 'raft';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Strong consistency with small/medium scale
|
|
261
|
+
if (consistency === 'strong') {
|
|
262
|
+
return 'raft';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Default to Raft
|
|
266
|
+
return 'raft';
|
|
267
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V3 Raft Consensus Implementation
|
|
3
|
+
* Leader election and log replication for distributed coordination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import {
|
|
8
|
+
ConsensusProposal,
|
|
9
|
+
ConsensusVote,
|
|
10
|
+
ConsensusResult,
|
|
11
|
+
ConsensusConfig,
|
|
12
|
+
SWARM_CONSTANTS,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
|
|
15
|
+
export type RaftState = 'follower' | 'candidate' | 'leader';
|
|
16
|
+
|
|
17
|
+
export interface RaftNode {
|
|
18
|
+
id: string;
|
|
19
|
+
state: RaftState;
|
|
20
|
+
currentTerm: number;
|
|
21
|
+
votedFor?: string;
|
|
22
|
+
log: RaftLogEntry[];
|
|
23
|
+
commitIndex: number;
|
|
24
|
+
lastApplied: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RaftLogEntry {
|
|
28
|
+
term: number;
|
|
29
|
+
index: number;
|
|
30
|
+
command: unknown;
|
|
31
|
+
timestamp: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RaftConfig extends Partial<ConsensusConfig> {
|
|
35
|
+
electionTimeoutMinMs?: number;
|
|
36
|
+
electionTimeoutMaxMs?: number;
|
|
37
|
+
heartbeatIntervalMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class RaftConsensus extends EventEmitter {
|
|
41
|
+
private config: RaftConfig;
|
|
42
|
+
private node: RaftNode;
|
|
43
|
+
private peers: Map<string, RaftNode> = new Map();
|
|
44
|
+
private proposals: Map<string, ConsensusProposal> = new Map();
|
|
45
|
+
private electionTimeout?: NodeJS.Timeout;
|
|
46
|
+
private heartbeatInterval?: NodeJS.Timeout;
|
|
47
|
+
private proposalCounter: number = 0;
|
|
48
|
+
|
|
49
|
+
constructor(nodeId: string, config: RaftConfig = {}) {
|
|
50
|
+
super();
|
|
51
|
+
this.config = {
|
|
52
|
+
threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD,
|
|
53
|
+
timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS,
|
|
54
|
+
maxRounds: config.maxRounds ?? 10,
|
|
55
|
+
requireQuorum: config.requireQuorum ?? true,
|
|
56
|
+
electionTimeoutMinMs: config.electionTimeoutMinMs ?? 150,
|
|
57
|
+
electionTimeoutMaxMs: config.electionTimeoutMaxMs ?? 300,
|
|
58
|
+
heartbeatIntervalMs: config.heartbeatIntervalMs ?? 50,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
this.node = {
|
|
62
|
+
id: nodeId,
|
|
63
|
+
state: 'follower',
|
|
64
|
+
currentTerm: 0,
|
|
65
|
+
log: [],
|
|
66
|
+
commitIndex: 0,
|
|
67
|
+
lastApplied: 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async initialize(): Promise<void> {
|
|
72
|
+
this.resetElectionTimeout();
|
|
73
|
+
this.emit('initialized', { nodeId: this.node.id });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async shutdown(): Promise<void> {
|
|
77
|
+
if (this.electionTimeout) {
|
|
78
|
+
clearTimeout(this.electionTimeout);
|
|
79
|
+
}
|
|
80
|
+
if (this.heartbeatInterval) {
|
|
81
|
+
clearInterval(this.heartbeatInterval);
|
|
82
|
+
}
|
|
83
|
+
this.emit('shutdown');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
addPeer(peerId: string): void {
|
|
87
|
+
this.peers.set(peerId, {
|
|
88
|
+
id: peerId,
|
|
89
|
+
state: 'follower',
|
|
90
|
+
currentTerm: 0,
|
|
91
|
+
log: [],
|
|
92
|
+
commitIndex: 0,
|
|
93
|
+
lastApplied: 0,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
removePeer(peerId: string): void {
|
|
98
|
+
this.peers.delete(peerId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async propose(value: unknown): Promise<ConsensusProposal> {
|
|
102
|
+
if (this.node.state !== 'leader') {
|
|
103
|
+
throw new Error('Only leader can propose values');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.proposalCounter++;
|
|
107
|
+
const proposalId = `raft_${this.node.id}_${this.proposalCounter}`;
|
|
108
|
+
|
|
109
|
+
const proposal: ConsensusProposal = {
|
|
110
|
+
id: proposalId,
|
|
111
|
+
proposerId: this.node.id,
|
|
112
|
+
value,
|
|
113
|
+
term: this.node.currentTerm,
|
|
114
|
+
timestamp: new Date(),
|
|
115
|
+
votes: new Map(),
|
|
116
|
+
status: 'pending',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Add to local log
|
|
120
|
+
const logEntry: RaftLogEntry = {
|
|
121
|
+
term: this.node.currentTerm,
|
|
122
|
+
index: this.node.log.length + 1,
|
|
123
|
+
command: { proposalId, value },
|
|
124
|
+
timestamp: new Date(),
|
|
125
|
+
};
|
|
126
|
+
this.node.log.push(logEntry);
|
|
127
|
+
|
|
128
|
+
this.proposals.set(proposalId, proposal);
|
|
129
|
+
|
|
130
|
+
// Leader votes for itself
|
|
131
|
+
proposal.votes.set(this.node.id, {
|
|
132
|
+
voterId: this.node.id,
|
|
133
|
+
approve: true,
|
|
134
|
+
confidence: 1.0,
|
|
135
|
+
timestamp: new Date(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Replicate to followers
|
|
139
|
+
await this.replicateToFollowers(logEntry);
|
|
140
|
+
|
|
141
|
+
return proposal;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async vote(proposalId: string, vote: ConsensusVote): Promise<void> {
|
|
145
|
+
const proposal = this.proposals.get(proposalId);
|
|
146
|
+
if (!proposal) {
|
|
147
|
+
throw new Error(`Proposal ${proposalId} not found`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (proposal.status !== 'pending') {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
proposal.votes.set(vote.voterId, vote);
|
|
155
|
+
|
|
156
|
+
// Check if we have consensus
|
|
157
|
+
await this.checkConsensus(proposalId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async awaitConsensus(proposalId: string): Promise<ConsensusResult> {
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const checkInterval = setInterval(() => {
|
|
165
|
+
const proposal = this.proposals.get(proposalId);
|
|
166
|
+
if (!proposal) {
|
|
167
|
+
clearInterval(checkInterval);
|
|
168
|
+
reject(new Error(`Proposal ${proposalId} not found`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (proposal.status !== 'pending') {
|
|
173
|
+
clearInterval(checkInterval);
|
|
174
|
+
resolve(this.createResult(proposal, Date.now() - startTime));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (Date.now() - startTime > (this.config.timeoutMs ?? 30000)) {
|
|
179
|
+
clearInterval(checkInterval);
|
|
180
|
+
proposal.status = 'expired';
|
|
181
|
+
resolve(this.createResult(proposal, Date.now() - startTime));
|
|
182
|
+
}
|
|
183
|
+
}, 10);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getState(): RaftState {
|
|
188
|
+
return this.node.state;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getTerm(): number {
|
|
192
|
+
return this.node.currentTerm;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
isLeader(): boolean {
|
|
196
|
+
return this.node.state === 'leader';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getLeaderId(): string | undefined {
|
|
200
|
+
if (this.node.state === 'leader') {
|
|
201
|
+
return this.node.id;
|
|
202
|
+
}
|
|
203
|
+
return this.node.votedFor;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ===== PRIVATE METHODS =====
|
|
207
|
+
|
|
208
|
+
private resetElectionTimeout(): void {
|
|
209
|
+
if (this.electionTimeout) {
|
|
210
|
+
clearTimeout(this.electionTimeout);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const timeout = this.randomElectionTimeout();
|
|
214
|
+
this.electionTimeout = setTimeout(() => {
|
|
215
|
+
this.startElection();
|
|
216
|
+
}, timeout);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private randomElectionTimeout(): number {
|
|
220
|
+
const min = this.config.electionTimeoutMinMs ?? 150;
|
|
221
|
+
const max = this.config.electionTimeoutMaxMs ?? 300;
|
|
222
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async startElection(): Promise<void> {
|
|
226
|
+
this.node.state = 'candidate';
|
|
227
|
+
this.node.currentTerm++;
|
|
228
|
+
this.node.votedFor = this.node.id;
|
|
229
|
+
|
|
230
|
+
this.emit('election.started', {
|
|
231
|
+
term: this.node.currentTerm,
|
|
232
|
+
candidateId: this.node.id
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Vote for self
|
|
236
|
+
let votesReceived = 1;
|
|
237
|
+
const votesNeeded = Math.floor((this.peers.size + 1) / 2) + 1;
|
|
238
|
+
|
|
239
|
+
// Request votes from peers
|
|
240
|
+
for (const [peerId, peer] of this.peers) {
|
|
241
|
+
const granted = await this.requestVote(peerId);
|
|
242
|
+
if (granted) {
|
|
243
|
+
votesReceived++;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (votesReceived >= votesNeeded) {
|
|
247
|
+
this.becomeLeader();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Election failed, reset to follower
|
|
253
|
+
this.node.state = 'follower';
|
|
254
|
+
this.resetElectionTimeout();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async requestVote(peerId: string): Promise<boolean> {
|
|
258
|
+
const peer = this.peers.get(peerId);
|
|
259
|
+
if (!peer) return false;
|
|
260
|
+
|
|
261
|
+
// Local vote request - uses in-process peer state
|
|
262
|
+
// Grant vote if candidate's term is higher
|
|
263
|
+
if (this.node.currentTerm > peer.currentTerm) {
|
|
264
|
+
peer.votedFor = this.node.id;
|
|
265
|
+
peer.currentTerm = this.node.currentTerm;
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private becomeLeader(): void {
|
|
273
|
+
this.node.state = 'leader';
|
|
274
|
+
|
|
275
|
+
if (this.electionTimeout) {
|
|
276
|
+
clearTimeout(this.electionTimeout);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Start sending heartbeats
|
|
280
|
+
this.heartbeatInterval = setInterval(() => {
|
|
281
|
+
this.sendHeartbeats();
|
|
282
|
+
}, this.config.heartbeatIntervalMs ?? 50);
|
|
283
|
+
|
|
284
|
+
this.emit('leader.elected', {
|
|
285
|
+
term: this.node.currentTerm,
|
|
286
|
+
leaderId: this.node.id
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async sendHeartbeats(): Promise<void> {
|
|
291
|
+
for (const [peerId, peer] of this.peers) {
|
|
292
|
+
await this.appendEntries(peerId, []);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async appendEntries(peerId: string, entries: RaftLogEntry[]): Promise<boolean> {
|
|
297
|
+
const peer = this.peers.get(peerId);
|
|
298
|
+
if (!peer) return false;
|
|
299
|
+
|
|
300
|
+
// AppendEntries - local peer state update
|
|
301
|
+
if (this.node.currentTerm >= peer.currentTerm) {
|
|
302
|
+
peer.currentTerm = this.node.currentTerm;
|
|
303
|
+
peer.state = 'follower';
|
|
304
|
+
peer.log.push(...entries);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async replicateToFollowers(entry: RaftLogEntry): Promise<void> {
|
|
312
|
+
const replicationPromises = Array.from(this.peers.keys()).map(
|
|
313
|
+
peerId => this.appendEntries(peerId, [entry])
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const results = await Promise.allSettled(replicationPromises);
|
|
317
|
+
const successCount = results.filter(
|
|
318
|
+
r => r.status === 'fulfilled' && r.value
|
|
319
|
+
).length;
|
|
320
|
+
|
|
321
|
+
// Check if majority replicated
|
|
322
|
+
const majority = Math.floor((this.peers.size + 1) / 2) + 1;
|
|
323
|
+
if (successCount + 1 >= majority) {
|
|
324
|
+
this.node.commitIndex = entry.index;
|
|
325
|
+
this.emit('log.committed', { index: entry.index });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async checkConsensus(proposalId: string): Promise<void> {
|
|
330
|
+
const proposal = this.proposals.get(proposalId);
|
|
331
|
+
if (!proposal || proposal.status !== 'pending') {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const totalVoters = this.peers.size + 1;
|
|
336
|
+
const votesReceived = proposal.votes.size;
|
|
337
|
+
const approvingVotes = Array.from(proposal.votes.values()).filter(
|
|
338
|
+
v => v.approve
|
|
339
|
+
).length;
|
|
340
|
+
|
|
341
|
+
const threshold = this.config.threshold ?? 0.66;
|
|
342
|
+
const quorum = Math.floor(totalVoters * threshold);
|
|
343
|
+
|
|
344
|
+
if (approvingVotes >= quorum) {
|
|
345
|
+
proposal.status = 'accepted';
|
|
346
|
+
this.emit('consensus.achieved', { proposalId, approved: true });
|
|
347
|
+
} else if (votesReceived - approvingVotes > totalVoters - quorum) {
|
|
348
|
+
proposal.status = 'rejected';
|
|
349
|
+
this.emit('consensus.achieved', { proposalId, approved: false });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private createResult(proposal: ConsensusProposal, durationMs: number): ConsensusResult {
|
|
354
|
+
const totalVoters = this.peers.size + 1;
|
|
355
|
+
const approvingVotes = Array.from(proposal.votes.values()).filter(
|
|
356
|
+
v => v.approve
|
|
357
|
+
).length;
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
proposalId: proposal.id,
|
|
361
|
+
approved: proposal.status === 'accepted',
|
|
362
|
+
approvalRate: proposal.votes.size > 0
|
|
363
|
+
? approvingVotes / proposal.votes.size
|
|
364
|
+
: 0,
|
|
365
|
+
participationRate: proposal.votes.size / totalVoters,
|
|
366
|
+
finalValue: proposal.value,
|
|
367
|
+
rounds: 1,
|
|
368
|
+
durationMs,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Handle vote request from another candidate
|
|
373
|
+
handleVoteRequest(
|
|
374
|
+
candidateId: string,
|
|
375
|
+
term: number,
|
|
376
|
+
lastLogIndex: number,
|
|
377
|
+
lastLogTerm: number
|
|
378
|
+
): boolean {
|
|
379
|
+
if (term < this.node.currentTerm) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (term > this.node.currentTerm) {
|
|
384
|
+
this.node.currentTerm = term;
|
|
385
|
+
this.node.state = 'follower';
|
|
386
|
+
this.node.votedFor = undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (this.node.votedFor === undefined || this.node.votedFor === candidateId) {
|
|
390
|
+
// Check log is at least as up-to-date
|
|
391
|
+
const lastEntry = this.node.log[this.node.log.length - 1];
|
|
392
|
+
const myLastTerm = lastEntry?.term ?? 0;
|
|
393
|
+
const myLastIndex = lastEntry?.index ?? 0;
|
|
394
|
+
|
|
395
|
+
if (lastLogTerm > myLastTerm ||
|
|
396
|
+
(lastLogTerm === myLastTerm && lastLogIndex >= myLastIndex)) {
|
|
397
|
+
this.node.votedFor = candidateId;
|
|
398
|
+
this.resetElectionTimeout();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Handle append entries from leader
|
|
407
|
+
handleAppendEntries(
|
|
408
|
+
leaderId: string,
|
|
409
|
+
term: number,
|
|
410
|
+
entries: RaftLogEntry[],
|
|
411
|
+
leaderCommit: number
|
|
412
|
+
): boolean {
|
|
413
|
+
if (term < this.node.currentTerm) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.resetElectionTimeout();
|
|
418
|
+
|
|
419
|
+
if (term > this.node.currentTerm) {
|
|
420
|
+
this.node.currentTerm = term;
|
|
421
|
+
this.node.state = 'follower';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.node.votedFor = leaderId;
|
|
425
|
+
|
|
426
|
+
// Append entries
|
|
427
|
+
this.node.log.push(...entries);
|
|
428
|
+
|
|
429
|
+
// Update commit index
|
|
430
|
+
if (leaderCommit > this.node.commitIndex) {
|
|
431
|
+
this.node.commitIndex = Math.min(
|
|
432
|
+
leaderCommit,
|
|
433
|
+
this.node.log.length
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function createRaftConsensus(nodeId: string, config?: RaftConfig): RaftConsensus {
|
|
442
|
+
return new RaftConsensus(nodeId, config);
|
|
443
|
+
}
|