yakmesh 1.1.0 → 1.3.0
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/CHANGELOG.md +65 -75
- package/assets/yakmesh-logo2sm.png +0 -0
- package/assets/ymsm.png +0 -0
- package/discord.md +74 -0
- package/mesh/beacon-broadcast.js +655 -0
- package/mesh/echo-ranging.js +612 -0
- package/mesh/phantom-routing.js +700 -0
- package/mesh/pulse-sync.js +618 -0
- package/mesh/temporal-encoder.js +383 -0
- package/package.json +82 -51
- package/test-novel-systems.mjs +398 -0
- package/test-tme.mjs +383 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAKMESH™ PULSE - Precision Universal Latency Sync Engine
|
|
3
|
+
*
|
|
4
|
+
* The heartbeat of the mesh network that provides:
|
|
5
|
+
* - Distributed liveness detection with temporal proofs
|
|
6
|
+
* - Mesh health monitoring and partition detection
|
|
7
|
+
* - Leader election using cryptographic timing
|
|
8
|
+
* - Consensus-ready heartbeat chains
|
|
9
|
+
*
|
|
10
|
+
* Key Innovation: "Heartbeats that prove themselves"
|
|
11
|
+
* - Each heartbeat contains a temporal hash chain
|
|
12
|
+
* - Nodes can verify liveness through cryptographic proofs
|
|
13
|
+
* - Partition detection through heartbeat gap analysis
|
|
14
|
+
*
|
|
15
|
+
* @module mesh/pulse-sync
|
|
16
|
+
* @license MIT
|
|
17
|
+
* @copyright 2026 YAKMESH Contributors
|
|
18
|
+
* @trademark PULSE™ is a trademark of YAKMESH
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes, createHash } from 'crypto';
|
|
22
|
+
import { sha3_256 } from '@noble/hashes/sha3.js';
|
|
23
|
+
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
24
|
+
|
|
25
|
+
const PULSE_CONFIG = {
|
|
26
|
+
// Heartbeat timing
|
|
27
|
+
heartbeatIntervalMs: 1000, // 1 second between heartbeats
|
|
28
|
+
missedBeatsThreshold: 5, // Node considered dead after 5 missed beats
|
|
29
|
+
suspectThreshold: 2, // Node suspected after 2 missed beats
|
|
30
|
+
|
|
31
|
+
// Health monitoring
|
|
32
|
+
healthWindowSize: 60, // Track last 60 heartbeats
|
|
33
|
+
healthyThreshold: 0.95, // 95% heartbeat success = healthy
|
|
34
|
+
degradedThreshold: 0.8, // 80% = degraded
|
|
35
|
+
|
|
36
|
+
// Partition detection
|
|
37
|
+
partitionDetectionWindow: 10000, // 10s window
|
|
38
|
+
minPartitionNodes: 2, // Minimum nodes to declare partition
|
|
39
|
+
|
|
40
|
+
// Leader election
|
|
41
|
+
leaderElectionTimeout: 5000, // 5s to elect leader
|
|
42
|
+
termDurationMs: 30000, // Leader term duration
|
|
43
|
+
|
|
44
|
+
// Chain settings
|
|
45
|
+
maxChainLength: 1000, // Max heartbeats to keep
|
|
46
|
+
chainPruneInterval: 60000, // Prune chain every minute
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single heartbeat with cryptographic proof
|
|
51
|
+
*/
|
|
52
|
+
class Heartbeat {
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.nodeId = options.nodeId;
|
|
55
|
+
this.sequence = options.sequence || 0;
|
|
56
|
+
this.timestamp = options.timestamp || Date.now();
|
|
57
|
+
this.prevHash = options.prevHash || '0'.repeat(64);
|
|
58
|
+
this.nonce = options.nonce || bytesToHex(randomBytes(8));
|
|
59
|
+
this.meshState = options.meshState || {};
|
|
60
|
+
this.hash = this._computeHash();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_computeHash() {
|
|
64
|
+
const data = [
|
|
65
|
+
this.nodeId,
|
|
66
|
+
this.sequence.toString(),
|
|
67
|
+
this.timestamp.toString(),
|
|
68
|
+
this.prevHash,
|
|
69
|
+
this.nonce,
|
|
70
|
+
JSON.stringify(this.meshState),
|
|
71
|
+
].join(':');
|
|
72
|
+
|
|
73
|
+
return bytesToHex(sha3_256(utf8ToBytes(data)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
verify() {
|
|
77
|
+
return this.hash === this._computeHash();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify this heartbeat chains from previous
|
|
82
|
+
*/
|
|
83
|
+
chainsFrom(prevHeartbeat) {
|
|
84
|
+
if (!prevHeartbeat) return this.sequence === 0;
|
|
85
|
+
return (
|
|
86
|
+
this.prevHash === prevHeartbeat.hash &&
|
|
87
|
+
this.sequence === prevHeartbeat.sequence + 1 &&
|
|
88
|
+
this.timestamp > prevHeartbeat.timestamp
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
serialize() {
|
|
93
|
+
return {
|
|
94
|
+
nodeId: this.nodeId,
|
|
95
|
+
sequence: this.sequence,
|
|
96
|
+
timestamp: this.timestamp,
|
|
97
|
+
prevHash: this.prevHash,
|
|
98
|
+
nonce: this.nonce,
|
|
99
|
+
meshState: this.meshState,
|
|
100
|
+
hash: this.hash,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static deserialize(obj) {
|
|
105
|
+
const hb = new Heartbeat({
|
|
106
|
+
nodeId: obj.nodeId,
|
|
107
|
+
sequence: obj.sequence,
|
|
108
|
+
timestamp: obj.timestamp,
|
|
109
|
+
prevHash: obj.prevHash,
|
|
110
|
+
nonce: obj.nonce,
|
|
111
|
+
meshState: obj.meshState,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (hb.hash !== obj.hash) {
|
|
115
|
+
throw new Error('Heartbeat hash verification failed');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return hb;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Heartbeat chain for a single node
|
|
124
|
+
*/
|
|
125
|
+
class HeartbeatChain {
|
|
126
|
+
constructor(nodeId) {
|
|
127
|
+
this.nodeId = nodeId;
|
|
128
|
+
this.chain = [];
|
|
129
|
+
this.lastReceived = 0;
|
|
130
|
+
this.status = 'unknown'; // unknown, alive, suspect, dead
|
|
131
|
+
this.missedBeats = 0;
|
|
132
|
+
this.metrics = {
|
|
133
|
+
totalReceived: 0,
|
|
134
|
+
totalMissed: 0,
|
|
135
|
+
avgLatency: 0,
|
|
136
|
+
lastLatency: 0,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add heartbeat to chain with validation
|
|
142
|
+
*/
|
|
143
|
+
addHeartbeat(heartbeat, receivedAt = Date.now()) {
|
|
144
|
+
if (heartbeat.nodeId !== this.nodeId) {
|
|
145
|
+
return { success: false, reason: 'Node ID mismatch' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!heartbeat.verify()) {
|
|
149
|
+
return { success: false, reason: 'Hash verification failed' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify chaining
|
|
153
|
+
const lastHb = this.chain[this.chain.length - 1];
|
|
154
|
+
if (lastHb && !heartbeat.chainsFrom(lastHb)) {
|
|
155
|
+
// Could be a gap - check sequence
|
|
156
|
+
if (heartbeat.sequence <= lastHb.sequence) {
|
|
157
|
+
return { success: false, reason: 'Duplicate or old heartbeat' };
|
|
158
|
+
}
|
|
159
|
+
// Gap detected
|
|
160
|
+
const gap = heartbeat.sequence - lastHb.sequence - 1;
|
|
161
|
+
this.metrics.totalMissed += gap;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Calculate latency
|
|
165
|
+
const latency = receivedAt - heartbeat.timestamp;
|
|
166
|
+
this.metrics.lastLatency = latency;
|
|
167
|
+
this.metrics.avgLatency = this.metrics.totalReceived === 0
|
|
168
|
+
? latency
|
|
169
|
+
: (this.metrics.avgLatency * 0.9 + latency * 0.1);
|
|
170
|
+
|
|
171
|
+
this.chain.push(heartbeat);
|
|
172
|
+
this.lastReceived = receivedAt;
|
|
173
|
+
this.missedBeats = 0;
|
|
174
|
+
this.status = 'alive';
|
|
175
|
+
this.metrics.totalReceived++;
|
|
176
|
+
|
|
177
|
+
// Prune old heartbeats
|
|
178
|
+
while (this.chain.length > PULSE_CONFIG.maxChainLength) {
|
|
179
|
+
this.chain.shift();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { success: true, latency, sequence: heartbeat.sequence };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if node has missed heartbeats
|
|
187
|
+
*/
|
|
188
|
+
checkLiveness(currentTime = Date.now()) {
|
|
189
|
+
if (this.lastReceived === 0) {
|
|
190
|
+
return { status: 'unknown', missedBeats: 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const elapsed = currentTime - this.lastReceived;
|
|
194
|
+
const expectedBeats = Math.floor(elapsed / PULSE_CONFIG.heartbeatIntervalMs);
|
|
195
|
+
|
|
196
|
+
if (expectedBeats > this.missedBeats) {
|
|
197
|
+
this.missedBeats = expectedBeats;
|
|
198
|
+
this.metrics.totalMissed += expectedBeats - this.missedBeats;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (this.missedBeats >= PULSE_CONFIG.missedBeatsThreshold) {
|
|
202
|
+
this.status = 'dead';
|
|
203
|
+
} else if (this.missedBeats >= PULSE_CONFIG.suspectThreshold) {
|
|
204
|
+
this.status = 'suspect';
|
|
205
|
+
} else {
|
|
206
|
+
this.status = 'alive';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
status: this.status,
|
|
211
|
+
missedBeats: this.missedBeats,
|
|
212
|
+
lastReceived: this.lastReceived,
|
|
213
|
+
elapsed,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get health score (0-1)
|
|
219
|
+
*/
|
|
220
|
+
getHealthScore() {
|
|
221
|
+
const total = this.metrics.totalReceived + this.metrics.totalMissed;
|
|
222
|
+
if (total === 0) return 0;
|
|
223
|
+
return this.metrics.totalReceived / total;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get the latest N heartbeats as proof of liveness
|
|
228
|
+
*/
|
|
229
|
+
getLivenessProof(count = 5) {
|
|
230
|
+
const recent = this.chain.slice(-count);
|
|
231
|
+
return {
|
|
232
|
+
nodeId: this.nodeId,
|
|
233
|
+
status: this.status,
|
|
234
|
+
heartbeats: recent.map(h => h.serialize()),
|
|
235
|
+
healthScore: this.getHealthScore(),
|
|
236
|
+
metrics: { ...this.metrics },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Mesh health monitor - tracks overall network health
|
|
243
|
+
*/
|
|
244
|
+
class MeshHealthMonitor {
|
|
245
|
+
constructor() {
|
|
246
|
+
this.nodes = new Map(); // nodeId -> HeartbeatChain
|
|
247
|
+
this.partitions = [];
|
|
248
|
+
this.healthHistory = [];
|
|
249
|
+
this.alerts = [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Process incoming heartbeat
|
|
254
|
+
*/
|
|
255
|
+
processHeartbeat(heartbeat, receivedAt = Date.now()) {
|
|
256
|
+
if (!this.nodes.has(heartbeat.nodeId)) {
|
|
257
|
+
this.nodes.set(heartbeat.nodeId, new HeartbeatChain(heartbeat.nodeId));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const chain = this.nodes.get(heartbeat.nodeId);
|
|
261
|
+
return chain.addHeartbeat(heartbeat, receivedAt);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run liveness check on all nodes
|
|
266
|
+
*/
|
|
267
|
+
runLivenessCheck(currentTime = Date.now()) {
|
|
268
|
+
const results = {
|
|
269
|
+
alive: [],
|
|
270
|
+
suspect: [],
|
|
271
|
+
dead: [],
|
|
272
|
+
unknown: [],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
for (const [nodeId, chain] of this.nodes) {
|
|
276
|
+
const check = chain.checkLiveness(currentTime);
|
|
277
|
+
results[check.status].push({
|
|
278
|
+
nodeId,
|
|
279
|
+
...check,
|
|
280
|
+
healthScore: chain.getHealthScore(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check for partition
|
|
285
|
+
this._detectPartition(results, currentTime);
|
|
286
|
+
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Detect network partition
|
|
292
|
+
*/
|
|
293
|
+
_detectPartition(livenessResults, currentTime) {
|
|
294
|
+
const deadCount = livenessResults.dead.length;
|
|
295
|
+
const totalCount = this.nodes.size;
|
|
296
|
+
|
|
297
|
+
if (deadCount >= PULSE_CONFIG.minPartitionNodes &&
|
|
298
|
+
deadCount > totalCount * 0.3) {
|
|
299
|
+
// Possible partition - check if dead nodes have similar last-seen times
|
|
300
|
+
const deadNodes = livenessResults.dead;
|
|
301
|
+
const lastSeenTimes = deadNodes.map(n => n.lastReceived);
|
|
302
|
+
const avgLastSeen = lastSeenTimes.reduce((a, b) => a + b, 0) / deadNodes.length;
|
|
303
|
+
const variance = lastSeenTimes.reduce((s, t) => s + Math.pow(t - avgLastSeen, 2), 0) / deadNodes.length;
|
|
304
|
+
|
|
305
|
+
// Low variance = simultaneous failure = likely partition
|
|
306
|
+
if (Math.sqrt(variance) < PULSE_CONFIG.partitionDetectionWindow) {
|
|
307
|
+
const partition = {
|
|
308
|
+
detectedAt: currentTime,
|
|
309
|
+
affectedNodes: deadNodes.map(n => n.nodeId),
|
|
310
|
+
lastSeenRange: [Math.min(...lastSeenTimes), Math.max(...lastSeenTimes)],
|
|
311
|
+
confidence: 1 - (Math.sqrt(variance) / PULSE_CONFIG.partitionDetectionWindow),
|
|
312
|
+
};
|
|
313
|
+
this.partitions.push(partition);
|
|
314
|
+
this.alerts.push({
|
|
315
|
+
type: 'PARTITION_DETECTED',
|
|
316
|
+
severity: 'critical',
|
|
317
|
+
...partition,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get overall mesh health
|
|
325
|
+
*/
|
|
326
|
+
getMeshHealth() {
|
|
327
|
+
const liveness = this.runLivenessCheck();
|
|
328
|
+
const totalNodes = this.nodes.size;
|
|
329
|
+
|
|
330
|
+
if (totalNodes === 0) {
|
|
331
|
+
return { status: 'unknown', score: 0, details: liveness };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const aliveRatio = liveness.alive.length / totalNodes;
|
|
335
|
+
const avgHealthScore = Array.from(this.nodes.values())
|
|
336
|
+
.reduce((sum, chain) => sum + chain.getHealthScore(), 0) / totalNodes;
|
|
337
|
+
|
|
338
|
+
let status;
|
|
339
|
+
if (aliveRatio >= PULSE_CONFIG.healthyThreshold && avgHealthScore >= PULSE_CONFIG.healthyThreshold) {
|
|
340
|
+
status = 'healthy';
|
|
341
|
+
} else if (aliveRatio >= PULSE_CONFIG.degradedThreshold) {
|
|
342
|
+
status = 'degraded';
|
|
343
|
+
} else {
|
|
344
|
+
status = 'critical';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
status,
|
|
349
|
+
score: Math.round((aliveRatio * 0.5 + avgHealthScore * 0.5) * 100),
|
|
350
|
+
details: liveness,
|
|
351
|
+
partitions: this.partitions,
|
|
352
|
+
alerts: this.alerts.slice(-10),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Leader election using heartbeat timing
|
|
359
|
+
*/
|
|
360
|
+
class PulseLeaderElection {
|
|
361
|
+
constructor(options = {}) {
|
|
362
|
+
this.nodeId = options.nodeId;
|
|
363
|
+
this.currentTerm = 0;
|
|
364
|
+
this.currentLeader = null;
|
|
365
|
+
this.leaderSince = null;
|
|
366
|
+
this.votes = new Map();
|
|
367
|
+
this.state = 'follower'; // follower, candidate, leader
|
|
368
|
+
this.heartbeatChains = new Map();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Start leader election
|
|
373
|
+
*/
|
|
374
|
+
startElection() {
|
|
375
|
+
this.currentTerm++;
|
|
376
|
+
this.state = 'candidate';
|
|
377
|
+
this.votes.clear();
|
|
378
|
+
this.votes.set(this.nodeId, this.currentTerm); // Vote for self
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
type: 'VOTE_REQUEST',
|
|
382
|
+
term: this.currentTerm,
|
|
383
|
+
candidateId: this.nodeId,
|
|
384
|
+
lastHeartbeatSeq: this._getLastHeartbeatSeq(),
|
|
385
|
+
timestamp: Date.now(),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Handle vote request
|
|
391
|
+
*/
|
|
392
|
+
handleVoteRequest(request) {
|
|
393
|
+
// Only vote if candidate has higher term or same term with better heartbeat history
|
|
394
|
+
if (request.term < this.currentTerm) {
|
|
395
|
+
return { voteGranted: false, term: this.currentTerm };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (request.term > this.currentTerm) {
|
|
399
|
+
this.currentTerm = request.term;
|
|
400
|
+
this.state = 'follower';
|
|
401
|
+
this.currentLeader = null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check if we haven't voted this term
|
|
405
|
+
const existingVote = this.votes.get(this.nodeId);
|
|
406
|
+
if (existingVote && existingVote === this.currentTerm) {
|
|
407
|
+
return { voteGranted: false, term: this.currentTerm };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Vote for candidate with better heartbeat history
|
|
411
|
+
const mySeq = this._getLastHeartbeatSeq();
|
|
412
|
+
if (request.lastHeartbeatSeq >= mySeq) {
|
|
413
|
+
this.votes.set(this.nodeId, this.currentTerm);
|
|
414
|
+
return { voteGranted: true, term: this.currentTerm, voterId: this.nodeId };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { voteGranted: false, term: this.currentTerm };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Handle vote response
|
|
422
|
+
*/
|
|
423
|
+
handleVoteResponse(response, totalNodes) {
|
|
424
|
+
if (response.term > this.currentTerm) {
|
|
425
|
+
this.currentTerm = response.term;
|
|
426
|
+
this.state = 'follower';
|
|
427
|
+
return { elected: false };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (response.voteGranted && response.term === this.currentTerm) {
|
|
431
|
+
this.votes.set(response.voterId, response.term);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Check if we have majority
|
|
435
|
+
const voteCount = Array.from(this.votes.values())
|
|
436
|
+
.filter(t => t === this.currentTerm).length;
|
|
437
|
+
|
|
438
|
+
if (voteCount > totalNodes / 2) {
|
|
439
|
+
this.state = 'leader';
|
|
440
|
+
this.currentLeader = this.nodeId;
|
|
441
|
+
this.leaderSince = Date.now();
|
|
442
|
+
return { elected: true, term: this.currentTerm };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { elected: false, votes: voteCount, needed: Math.floor(totalNodes / 2) + 1 };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Receive leader heartbeat (prevents election)
|
|
450
|
+
*/
|
|
451
|
+
acknowledgeLeader(leaderId, term) {
|
|
452
|
+
if (term >= this.currentTerm) {
|
|
453
|
+
this.currentTerm = term;
|
|
454
|
+
this.currentLeader = leaderId;
|
|
455
|
+
this.state = 'follower';
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
_getLastHeartbeatSeq() {
|
|
462
|
+
const myChain = this.heartbeatChains.get(this.nodeId);
|
|
463
|
+
if (!myChain || myChain.chain.length === 0) return 0;
|
|
464
|
+
return myChain.chain[myChain.chain.length - 1].sequence;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getState() {
|
|
468
|
+
return {
|
|
469
|
+
nodeId: this.nodeId,
|
|
470
|
+
state: this.state,
|
|
471
|
+
term: this.currentTerm,
|
|
472
|
+
leader: this.currentLeader,
|
|
473
|
+
leaderSince: this.leaderSince,
|
|
474
|
+
votes: Object.fromEntries(this.votes),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Main PULSE sync engine
|
|
481
|
+
*/
|
|
482
|
+
class PulseSync {
|
|
483
|
+
constructor(options = {}) {
|
|
484
|
+
this.nodeId = options.nodeId || bytesToHex(randomBytes(16));
|
|
485
|
+
this.sequence = 0;
|
|
486
|
+
this.lastHeartbeat = null;
|
|
487
|
+
this.healthMonitor = new MeshHealthMonitor();
|
|
488
|
+
this.election = new PulseLeaderElection({ nodeId: this.nodeId });
|
|
489
|
+
this.election.heartbeatChains = this.healthMonitor.nodes;
|
|
490
|
+
|
|
491
|
+
this.stats = {
|
|
492
|
+
heartbeatsSent: 0,
|
|
493
|
+
heartbeatsReceived: 0,
|
|
494
|
+
electionsStarted: 0,
|
|
495
|
+
termsAsLeader: 0,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Callbacks
|
|
499
|
+
this.onHeartbeat = options.onHeartbeat || (() => {});
|
|
500
|
+
this.onLeaderChange = options.onLeaderChange || (() => {});
|
|
501
|
+
this.onPartitionDetected = options.onPartitionDetected || (() => {});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Generate next heartbeat
|
|
506
|
+
*/
|
|
507
|
+
createHeartbeat(meshState = {}) {
|
|
508
|
+
const prevHash = this.lastHeartbeat ? this.lastHeartbeat.hash : '0'.repeat(64);
|
|
509
|
+
|
|
510
|
+
const heartbeat = new Heartbeat({
|
|
511
|
+
nodeId: this.nodeId,
|
|
512
|
+
sequence: this.sequence++,
|
|
513
|
+
prevHash,
|
|
514
|
+
meshState: {
|
|
515
|
+
...meshState,
|
|
516
|
+
isLeader: this.election.state === 'leader',
|
|
517
|
+
term: this.election.currentTerm,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
this.lastHeartbeat = heartbeat;
|
|
522
|
+
this.stats.heartbeatsSent++;
|
|
523
|
+
|
|
524
|
+
// Also process our own heartbeat for monitoring
|
|
525
|
+
this.healthMonitor.processHeartbeat(heartbeat);
|
|
526
|
+
|
|
527
|
+
return heartbeat.serialize();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Process received heartbeat
|
|
532
|
+
*/
|
|
533
|
+
receiveHeartbeat(heartbeatData, receivedAt = Date.now()) {
|
|
534
|
+
try {
|
|
535
|
+
const heartbeat = Heartbeat.deserialize(heartbeatData);
|
|
536
|
+
const result = this.healthMonitor.processHeartbeat(heartbeat, receivedAt);
|
|
537
|
+
|
|
538
|
+
if (result.success) {
|
|
539
|
+
this.stats.heartbeatsReceived++;
|
|
540
|
+
|
|
541
|
+
// Check if this is from the leader
|
|
542
|
+
if (heartbeat.meshState.isLeader) {
|
|
543
|
+
this.election.acknowledgeLeader(heartbeat.nodeId, heartbeat.meshState.term);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return result;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
return { success: false, reason: err.message };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Start leader election
|
|
555
|
+
*/
|
|
556
|
+
startElection() {
|
|
557
|
+
this.stats.electionsStarted++;
|
|
558
|
+
return this.election.startElection();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Handle vote request
|
|
563
|
+
*/
|
|
564
|
+
handleVoteRequest(request) {
|
|
565
|
+
return this.election.handleVoteRequest(request);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Handle vote response
|
|
570
|
+
*/
|
|
571
|
+
handleVoteResponse(response) {
|
|
572
|
+
const totalNodes = this.healthMonitor.nodes.size;
|
|
573
|
+
const result = this.election.handleVoteResponse(response, totalNodes);
|
|
574
|
+
|
|
575
|
+
if (result.elected) {
|
|
576
|
+
this.stats.termsAsLeader++;
|
|
577
|
+
this.onLeaderChange({ leader: this.nodeId, term: result.term });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get mesh health summary
|
|
585
|
+
*/
|
|
586
|
+
getHealth() {
|
|
587
|
+
return this.healthMonitor.getMeshHealth();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get liveness proof for this node
|
|
592
|
+
*/
|
|
593
|
+
getLivenessProof(count = 5) {
|
|
594
|
+
const chain = this.healthMonitor.nodes.get(this.nodeId);
|
|
595
|
+
if (!chain) return null;
|
|
596
|
+
return chain.getLivenessProof(count);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Get full stats
|
|
601
|
+
*/
|
|
602
|
+
getStats() {
|
|
603
|
+
return {
|
|
604
|
+
...this.stats,
|
|
605
|
+
election: this.election.getState(),
|
|
606
|
+
health: this.healthMonitor.getMeshHealth(),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export {
|
|
612
|
+
PULSE_CONFIG,
|
|
613
|
+
Heartbeat,
|
|
614
|
+
HeartbeatChain,
|
|
615
|
+
MeshHealthMonitor,
|
|
616
|
+
PulseLeaderElection,
|
|
617
|
+
PulseSync,
|
|
618
|
+
};
|