raptiye 0.1.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/README.md +171 -0
- package/bench/bench-batch-curve.js +121 -0
- package/bench/bench-event-loop.js +91 -0
- package/bench/bench-failover.js +172 -0
- package/bench/bench-memory.js +107 -0
- package/bench/bench-partition.js +134 -0
- package/bench/bench-pipeline.js +94 -0
- package/bench/bench-recovery.js +134 -0
- package/bench/bench-replication.js +107 -0
- package/bench/bench-single-node.js +77 -0
- package/bench/bench-slow-follower.js +96 -0
- package/bench/harness.js +87 -0
- package/bench/run-all.js +83 -0
- package/bench-results.json +598 -0
- package/index.js +30 -0
- package/package.json +24 -0
- package/src/core/engine.js +907 -0
- package/src/core/invariants.js +146 -0
- package/src/node/raptiye.js +296 -0
- package/src/node/stats.js +82 -0
- package/src/protocol/checksum.js +66 -0
- package/src/protocol/wire.js +551 -0
- package/src/replication/pipeline.js +235 -0
- package/src/sim/cluster.js +316 -0
- package/src/sim/prng.js +52 -0
- package/src/sim/virtual-clock.js +82 -0
- package/src/storage/file-log.js +394 -0
- package/src/storage/interface.js +113 -0
- package/src/storage/memory-log.js +183 -0
- package/src/transport/interface.js +47 -0
- package/src/transport/memory-transport.js +67 -0
- package/src/transport/tcp-transport.js +200 -0
- package/src/types.js +87 -0
- package/test/chaos/chaos.test.js +112 -0
- package/test/integration/cluster.test.js +192 -0
- package/test/integration/node.test.js +78 -0
- package/test/integration/replication-pipeline.test.js +45 -0
- package/test/unit/core.test.js +163 -0
- package/test/unit/protocol.test.js +181 -0
- package/test/unit/storage.test.js +127 -0
- package/test/unit/transport.test.js +90 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: Network Partition and Split-Brain Safety.
|
|
3
|
+
* Tests:
|
|
4
|
+
* 1. Leader isolated (minority partition): verifies isolated leader cannot commit.
|
|
5
|
+
* 2. Majority partition elects new leader and commits new commands.
|
|
6
|
+
* 3. Follower isolated: majority continues committing without pause.
|
|
7
|
+
* 4. Partition heal: old leader demotes itself and reconciles log.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ClusterSimulator } from '../src/sim/cluster.js';
|
|
11
|
+
import { getEnvironmentMetadata, formatTable } from './harness.js';
|
|
12
|
+
import { Role, EventType } from '../src/types.js';
|
|
13
|
+
|
|
14
|
+
export async function runPartitionBenchmark() {
|
|
15
|
+
const scenarios = [];
|
|
16
|
+
|
|
17
|
+
// Scenario 1: Isolated Leader Safety
|
|
18
|
+
{
|
|
19
|
+
const cluster = new ClusterSimulator({ nodeCount: 3, seed: 1234 });
|
|
20
|
+
cluster.start();
|
|
21
|
+
cluster.advance(400);
|
|
22
|
+
|
|
23
|
+
const oldLeader = cluster.getLeader();
|
|
24
|
+
const oldLeaderId = oldLeader.id;
|
|
25
|
+
const followers = [1, 2, 3].filter(id => id !== oldLeaderId);
|
|
26
|
+
|
|
27
|
+
// Initial write committed
|
|
28
|
+
cluster.submit(new Uint8Array([1]));
|
|
29
|
+
cluster.advance(50);
|
|
30
|
+
const initialCommit = oldLeader.engine.commitIndex;
|
|
31
|
+
|
|
32
|
+
// Partition: [OldLeader] | [Follower1, Follower2]
|
|
33
|
+
cluster.partition([oldLeaderId], followers);
|
|
34
|
+
|
|
35
|
+
// Old leader attempts 5 writes while isolated
|
|
36
|
+
for (let i = 0; i < 5; i++) {
|
|
37
|
+
oldLeader.step({ type: EventType.SUBMIT, payload: new Uint8Array([10 + i]) });
|
|
38
|
+
cluster.advance(20);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isolatedCommit = oldLeader.engine.commitIndex;
|
|
42
|
+
const isolatedCannotCommit = (isolatedCommit === initialCommit);
|
|
43
|
+
|
|
44
|
+
// Majority elects new leader
|
|
45
|
+
cluster.advance(400);
|
|
46
|
+
const newLeader = cluster.getLeader();
|
|
47
|
+
const newLeaderElected = (newLeader && newLeader.id !== oldLeaderId);
|
|
48
|
+
|
|
49
|
+
// New leader submits and commits writes
|
|
50
|
+
const newLeaderInitialCommit = newLeader.engine.commitIndex;
|
|
51
|
+
cluster.submit(new Uint8Array([99]));
|
|
52
|
+
cluster.advance(50);
|
|
53
|
+
const majorityCommitted = newLeader.engine.commitIndex > newLeaderInitialCommit;
|
|
54
|
+
|
|
55
|
+
// Heal partition
|
|
56
|
+
cluster.heal();
|
|
57
|
+
cluster.advance(500);
|
|
58
|
+
|
|
59
|
+
const oldLeaderDemoted = oldLeader.engine.role === Role.FOLLOWER;
|
|
60
|
+
const logsReconciled = oldLeader.storage.lastIndex() === newLeader.storage.lastIndex();
|
|
61
|
+
|
|
62
|
+
cluster.verify();
|
|
63
|
+
|
|
64
|
+
scenarios.push({
|
|
65
|
+
scenario: 'Leader Isolated & Heal',
|
|
66
|
+
isolatedCommitted: !isolatedCannotCommit,
|
|
67
|
+
majorityCommitted,
|
|
68
|
+
oldLeaderDemoted,
|
|
69
|
+
splitBrainPrevented: isolatedCannotCommit && majorityCommitted && oldLeaderDemoted,
|
|
70
|
+
passed: isolatedCannotCommit && newLeaderElected && majorityCommitted && oldLeaderDemoted && logsReconciled
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scenario 2: Follower Isolated
|
|
75
|
+
{
|
|
76
|
+
const cluster = new ClusterSimulator({ nodeCount: 3, seed: 5678 });
|
|
77
|
+
cluster.start();
|
|
78
|
+
cluster.advance(400);
|
|
79
|
+
|
|
80
|
+
const leader = cluster.getLeader();
|
|
81
|
+
const followers = [1, 2, 3].filter(id => id !== leader.id);
|
|
82
|
+
const isolatedFollower = followers[0];
|
|
83
|
+
|
|
84
|
+
// Isolate single follower
|
|
85
|
+
cluster.partition([isolatedFollower], [leader.id, followers[1]]);
|
|
86
|
+
|
|
87
|
+
const beforeCommit = leader.engine.commitIndex;
|
|
88
|
+
for (let i = 0; i < 10; i++) {
|
|
89
|
+
cluster.submit(new Uint8Array([i]));
|
|
90
|
+
cluster.advance(20);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const majorityProgress = leader.engine.commitIndex >= beforeCommit + 10n;
|
|
94
|
+
|
|
95
|
+
// Heal and verify catchup
|
|
96
|
+
cluster.heal();
|
|
97
|
+
cluster.advance(500);
|
|
98
|
+
|
|
99
|
+
const followerCaughtUp = cluster.getNode(isolatedFollower).storage.lastIndex() === leader.storage.lastIndex();
|
|
100
|
+
cluster.verify();
|
|
101
|
+
|
|
102
|
+
scenarios.push({
|
|
103
|
+
scenario: 'Single Follower Isolated & Rejoin',
|
|
104
|
+
isolatedCommitted: false,
|
|
105
|
+
majorityCommitted: majorityProgress,
|
|
106
|
+
oldLeaderDemoted: false,
|
|
107
|
+
splitBrainPrevented: true,
|
|
108
|
+
passed: majorityProgress && followerCaughtUp
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
formatTable('Network Partition & Split-Brain Prevention Benchmark',
|
|
113
|
+
['Scenario', 'Isolated Writes Stalled', 'Majority Committed', 'Old Leader Demoted', 'Safety Invariants'],
|
|
114
|
+
scenarios.map(s => [
|
|
115
|
+
s.scenario,
|
|
116
|
+
s.splitBrainPrevented ? 'YES (Safe)' : 'FAIL',
|
|
117
|
+
s.majorityCommitted ? 'YES' : 'FAIL',
|
|
118
|
+
s.oldLeaderDemoted ? 'YES' : 'N/A',
|
|
119
|
+
s.passed ? 'VERIFIED (PASS)' : 'FAIL'
|
|
120
|
+
])
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
benchmark: 'partition_and_split_brain_safety',
|
|
125
|
+
environment: getEnvironmentMetadata(),
|
|
126
|
+
scenarios
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-partition.js')) {
|
|
131
|
+
runPartitionBenchmark().then(res => {
|
|
132
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: Replication Pipeline Depth.
|
|
3
|
+
* Measures throughput and latency across inflight window depths: 1, 2, 4, 8, 16, 32.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Raptiye, MemoryNetwork, MemoryTransport, MemoryLog } from '../index.js';
|
|
7
|
+
import { getEnvironmentMetadata, LatencyHistogram, formatTable } from './harness.js';
|
|
8
|
+
|
|
9
|
+
export async function runPipelineBenchmark() {
|
|
10
|
+
const windowDepths = [1, 2, 4, 8, 16, 32];
|
|
11
|
+
const payload = new Uint8Array(128);
|
|
12
|
+
payload.fill(0x77);
|
|
13
|
+
const totalCommands = 10000;
|
|
14
|
+
|
|
15
|
+
const results = [];
|
|
16
|
+
const tableRows = [];
|
|
17
|
+
|
|
18
|
+
for (const maxInflightBatches of windowDepths) {
|
|
19
|
+
const net = new MemoryNetwork();
|
|
20
|
+
const node1 = new Raptiye({
|
|
21
|
+
id: 1,
|
|
22
|
+
peers: [2, 3],
|
|
23
|
+
storage: new MemoryLog(),
|
|
24
|
+
transport: new MemoryTransport(1, net),
|
|
25
|
+
election: { minTimeout: 50, maxTimeout: 100 },
|
|
26
|
+
heartbeatInterval: 20,
|
|
27
|
+
replication: { maxInflightBatches }
|
|
28
|
+
});
|
|
29
|
+
const node2 = new Raptiye({ id: 2, peers: [1, 3], storage: new MemoryLog(), transport: new MemoryTransport(2, net), election: { minTimeout: 50, maxTimeout: 100 }, heartbeatInterval: 20 });
|
|
30
|
+
const node3 = new Raptiye({ id: 3, peers: [1, 2], storage: new MemoryLog(), transport: new MemoryTransport(3, net), election: { minTimeout: 50, maxTimeout: 100 }, heartbeatInterval: 20 });
|
|
31
|
+
const nodes = [node1, node2, node3];
|
|
32
|
+
|
|
33
|
+
await Promise.all(nodes.map(n => n.start()));
|
|
34
|
+
|
|
35
|
+
let leader = null;
|
|
36
|
+
const startWait = Date.now();
|
|
37
|
+
while (!leader && Date.now() - startWait < 2000) {
|
|
38
|
+
leader = nodes.find(n => n.role() === 'LEADER');
|
|
39
|
+
if (!leader) await new Promise(r => setTimeout(r, 10));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const histogram = new LatencyHistogram();
|
|
43
|
+
const startHr = process.hrtime.bigint();
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < totalCommands; i++) {
|
|
46
|
+
const opStart = process.hrtime.bigint();
|
|
47
|
+
const seq = leader.submit(payload);
|
|
48
|
+
await leader.committed(seq);
|
|
49
|
+
const opEnd = process.hrtime.bigint();
|
|
50
|
+
histogram.record(Number(opEnd - opStart) / 1e6);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const endHr = process.hrtime.bigint();
|
|
54
|
+
const totalSec = Number(endHr - startHr) / 1e9;
|
|
55
|
+
const opsPerSec = Math.round(totalCommands / totalSec);
|
|
56
|
+
const summary = histogram.summary();
|
|
57
|
+
|
|
58
|
+
await Promise.all(nodes.map(n => n.shutdown()));
|
|
59
|
+
|
|
60
|
+
results.push({
|
|
61
|
+
maxInflightBatches,
|
|
62
|
+
opsPerSec,
|
|
63
|
+
p50Ms: Number(summary.p50.toFixed(4)),
|
|
64
|
+
p95Ms: Number(summary.p95.toFixed(4)),
|
|
65
|
+
p99Ms: Number(summary.p99.toFixed(4))
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
tableRows.push([
|
|
69
|
+
maxInflightBatches.toString(),
|
|
70
|
+
totalCommands.toLocaleString(),
|
|
71
|
+
opsPerSec.toLocaleString(),
|
|
72
|
+
`${summary.p50.toFixed(3)} ms`,
|
|
73
|
+
`${summary.p95.toFixed(3)} ms`,
|
|
74
|
+
`${summary.p99.toFixed(3)} ms`
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
formatTable('Replication Pipeline Window Benchmark',
|
|
79
|
+
['Inflight Batches', 'Commands', 'Ops/sec', 'Commit p50', 'Commit p95', 'Commit p99'],
|
|
80
|
+
tableRows
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
benchmark: 'pipeline_depth',
|
|
85
|
+
environment: getEnvironmentMetadata(),
|
|
86
|
+
results
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-pipeline.js')) {
|
|
91
|
+
runPipelineBenchmark().then(res => {
|
|
92
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: Follower Catch-up and Recovery Throughput.
|
|
3
|
+
* Measures recovery when a follower falls behind by 1K and 100K entries.
|
|
4
|
+
* Reports entries/sec, MB/sec, time-to-ready, RSS, and heap.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ConsensusEngine } from '../src/core/engine.js';
|
|
8
|
+
import { MemoryLog } from '../src/storage/memory-log.js';
|
|
9
|
+
import { getEnvironmentMetadata, formatTable } from './harness.js';
|
|
10
|
+
import { Role, EventType, EffectType, MessageType } from '../src/types.js';
|
|
11
|
+
|
|
12
|
+
export async function runRecoveryBenchmark() {
|
|
13
|
+
const behindCounts = [1000, 100000];
|
|
14
|
+
const payloadSize = 64; // 64 bytes per entry
|
|
15
|
+
const payload = new Uint8Array(payloadSize);
|
|
16
|
+
payload.fill(0x33);
|
|
17
|
+
|
|
18
|
+
const results = [];
|
|
19
|
+
const tableRows = [];
|
|
20
|
+
|
|
21
|
+
for (const entriesBehind of behindCounts) {
|
|
22
|
+
const leaderLog = new MemoryLog();
|
|
23
|
+
const followerLog = new MemoryLog();
|
|
24
|
+
|
|
25
|
+
// Populate leader log
|
|
26
|
+
for (let i = 1; i <= entriesBehind; i++) {
|
|
27
|
+
leaderLog.append({
|
|
28
|
+
term: 1n,
|
|
29
|
+
index: BigInt(i),
|
|
30
|
+
payload
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const leader = new ConsensusEngine({
|
|
35
|
+
id: 1,
|
|
36
|
+
peers: [2],
|
|
37
|
+
storage: leaderLog,
|
|
38
|
+
maxBatchEntries: 1024,
|
|
39
|
+
maxBatchBytes: 1024 * 128
|
|
40
|
+
});
|
|
41
|
+
leader.role = Role.LEADER;
|
|
42
|
+
leader.currentTerm = 1n;
|
|
43
|
+
leader._initPeerProgress();
|
|
44
|
+
leader.nextIndex.set(2, 1n); // Follower needs to catch up from index 1
|
|
45
|
+
|
|
46
|
+
// Follower has 0 entries
|
|
47
|
+
const follower = new ConsensusEngine({
|
|
48
|
+
id: 2,
|
|
49
|
+
peers: [1],
|
|
50
|
+
storage: followerLog
|
|
51
|
+
});
|
|
52
|
+
follower.currentTerm = 1n;
|
|
53
|
+
|
|
54
|
+
if (global.gc) global.gc();
|
|
55
|
+
const memBefore = process.memoryUsage();
|
|
56
|
+
const startHr = process.hrtime.bigint();
|
|
57
|
+
|
|
58
|
+
// Catch up loop: Leader streams AppendRequests to Follower until follower.lastIndex == entriesBehind
|
|
59
|
+
let roundtrips = 0;
|
|
60
|
+
let nextAppendMsg = null;
|
|
61
|
+
|
|
62
|
+
// Initial replication request
|
|
63
|
+
const initEffects = [];
|
|
64
|
+
leader._replicateToPeer(2, initEffects, false);
|
|
65
|
+
nextAppendMsg = initEffects.find(e => e.type === EffectType.SEND && e.to === 2)?.message;
|
|
66
|
+
|
|
67
|
+
while (nextAppendMsg && followerLog.lastIndex() < BigInt(entriesBehind)) {
|
|
68
|
+
roundtrips++;
|
|
69
|
+
|
|
70
|
+
// Step follower with AppendRequest
|
|
71
|
+
const followerEffects = follower.step({
|
|
72
|
+
type: EventType.MESSAGE,
|
|
73
|
+
message: nextAppendMsg
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const respMsg = followerEffects.find(e => e.type === EffectType.SEND && e.to === 1)?.message;
|
|
77
|
+
if (!respMsg) break;
|
|
78
|
+
|
|
79
|
+
// Step leader with AppendResponse
|
|
80
|
+
const leaderEffects = leader.step({
|
|
81
|
+
type: EventType.MESSAGE,
|
|
82
|
+
message: respMsg
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
nextAppendMsg = leaderEffects.find(e => e.type === EffectType.SEND && e.to === 2)?.message;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const endHr = process.hrtime.bigint();
|
|
89
|
+
const memAfter = process.memoryUsage();
|
|
90
|
+
|
|
91
|
+
const timeToReadyMs = Number(endHr - startHr) / 1e6;
|
|
92
|
+
const timeToReadySec = timeToReadyMs / 1000;
|
|
93
|
+
const entriesPerSec = Math.round(entriesBehind / timeToReadySec);
|
|
94
|
+
const mbPerSec = Number(((entriesBehind * payloadSize) / (1024 * 1024) / timeToReadySec).toFixed(2));
|
|
95
|
+
|
|
96
|
+
const res = {
|
|
97
|
+
entriesBehind,
|
|
98
|
+
roundtrips,
|
|
99
|
+
timeToReadyMs: Number(timeToReadyMs.toFixed(2)),
|
|
100
|
+
entriesPerSec,
|
|
101
|
+
mbPerSec,
|
|
102
|
+
heapUsedMB: Math.round(memAfter.heapUsed / (1024 * 1024)),
|
|
103
|
+
rssMB: Math.round(memAfter.rss / (1024 * 1024))
|
|
104
|
+
};
|
|
105
|
+
results.push(res);
|
|
106
|
+
|
|
107
|
+
tableRows.push([
|
|
108
|
+
entriesBehind.toLocaleString(),
|
|
109
|
+
roundtrips.toLocaleString(),
|
|
110
|
+
`${timeToReadyMs.toFixed(2)} ms`,
|
|
111
|
+
entriesPerSec.toLocaleString(),
|
|
112
|
+
`${mbPerSec} MB/s`,
|
|
113
|
+
`${res.heapUsedMB} MB`,
|
|
114
|
+
`${res.rssMB} MB`
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
formatTable('Follower Catch-up & Recovery Benchmark',
|
|
119
|
+
['Entries Behind', 'Batches', 'Time to Ready', 'Entries/sec', 'Recovery MB/s', 'Heap (MB)', 'RSS (MB)'],
|
|
120
|
+
tableRows
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
benchmark: 'follower_recovery',
|
|
125
|
+
environment: getEnvironmentMetadata(),
|
|
126
|
+
results
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-recovery.js')) {
|
|
131
|
+
runRecoveryBenchmark().then(res => {
|
|
132
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: 3-Node Quorum Replication Across Payload Sizes.
|
|
3
|
+
* Tests payloads: 16 B, 64 B, 256 B, 1 KB, 4 KB, 64 KB, 1 MB.
|
|
4
|
+
* Measures commands/sec, MB/sec, commit p50, p95, p99.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Raptiye, MemoryNetwork, MemoryTransport, MemoryLog } from '../index.js';
|
|
8
|
+
import { getEnvironmentMetadata, LatencyHistogram, formatTable } from './harness.js';
|
|
9
|
+
|
|
10
|
+
export async function runReplicationBenchmark() {
|
|
11
|
+
const payloadSizes = [
|
|
12
|
+
{ name: '16 B', bytes: 16, iterations: 10000 },
|
|
13
|
+
{ name: '64 B', bytes: 64, iterations: 10000 },
|
|
14
|
+
{ name: '256 B', bytes: 256, iterations: 10000 },
|
|
15
|
+
{ name: '1 KB', bytes: 1024, iterations: 8000 },
|
|
16
|
+
{ name: '4 KB', bytes: 4096, iterations: 5000 },
|
|
17
|
+
{ name: '64 KB', bytes: 64 * 1024, iterations: 1000 },
|
|
18
|
+
{ name: '1 MB', bytes: 1024 * 1024, iterations: 100 }
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const results = [];
|
|
22
|
+
const tableRows = [];
|
|
23
|
+
|
|
24
|
+
for (const { name, bytes, iterations } of payloadSizes) {
|
|
25
|
+
const net = new MemoryNetwork();
|
|
26
|
+
const node1 = new Raptiye({ id: 1, peers: [2, 3], storage: new MemoryLog(), transport: new MemoryTransport(1, net), election: { minTimeout: 50, maxTimeout: 100 }, heartbeatInterval: 20 });
|
|
27
|
+
const node2 = new Raptiye({ id: 2, peers: [1, 3], storage: new MemoryLog(), transport: new MemoryTransport(2, net), election: { minTimeout: 50, maxTimeout: 100 }, heartbeatInterval: 20 });
|
|
28
|
+
const node3 = new Raptiye({ id: 3, peers: [1, 2], storage: new MemoryLog(), transport: new MemoryTransport(3, net), election: { minTimeout: 50, maxTimeout: 100 }, heartbeatInterval: 20 });
|
|
29
|
+
const nodes = [node1, node2, node3];
|
|
30
|
+
|
|
31
|
+
await Promise.all(nodes.map(n => n.start()));
|
|
32
|
+
|
|
33
|
+
// Wait for leader
|
|
34
|
+
let leader = null;
|
|
35
|
+
const startWait = Date.now();
|
|
36
|
+
while (!leader && Date.now() - startWait < 2000) {
|
|
37
|
+
leader = nodes.find(n => n.role() === 'LEADER');
|
|
38
|
+
if (!leader) await new Promise(r => setTimeout(r, 10));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const payload = new Uint8Array(bytes);
|
|
42
|
+
payload.fill(0xAB);
|
|
43
|
+
|
|
44
|
+
const histogram = new LatencyHistogram();
|
|
45
|
+
const startHr = process.hrtime.bigint();
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < iterations; i++) {
|
|
48
|
+
const opStart = process.hrtime.bigint();
|
|
49
|
+
const seq = leader.submit(payload);
|
|
50
|
+
await leader.committed(seq);
|
|
51
|
+
const opEnd = process.hrtime.bigint();
|
|
52
|
+
const latencyMs = Number(opEnd - opStart) / 1e6;
|
|
53
|
+
histogram.record(latencyMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const endHr = process.hrtime.bigint();
|
|
57
|
+
const totalTimeSec = Number(endHr - startHr) / 1e9;
|
|
58
|
+
const opsPerSec = Math.round(iterations / totalTimeSec);
|
|
59
|
+
const mbPerSec = Number(((iterations * bytes) / (1024 * 1024) / totalTimeSec).toFixed(2));
|
|
60
|
+
const summary = histogram.summary();
|
|
61
|
+
|
|
62
|
+
const stats = leader.stats();
|
|
63
|
+
|
|
64
|
+
await Promise.all(nodes.map(n => n.shutdown()));
|
|
65
|
+
|
|
66
|
+
const res = {
|
|
67
|
+
payloadSize: name,
|
|
68
|
+
bytes,
|
|
69
|
+
iterations,
|
|
70
|
+
opsPerSec,
|
|
71
|
+
mbPerSec,
|
|
72
|
+
commitP50Ms: Number(summary.p50.toFixed(4)),
|
|
73
|
+
commitP95Ms: Number(summary.p95.toFixed(4)),
|
|
74
|
+
commitP99Ms: Number(summary.p99.toFixed(4)),
|
|
75
|
+
copiesPerByte: stats.copiesPerReplicatedByte
|
|
76
|
+
};
|
|
77
|
+
results.push(res);
|
|
78
|
+
|
|
79
|
+
tableRows.push([
|
|
80
|
+
name,
|
|
81
|
+
iterations.toLocaleString(),
|
|
82
|
+
opsPerSec.toLocaleString(),
|
|
83
|
+
`${mbPerSec} MB/s`,
|
|
84
|
+
`${summary.p50.toFixed(3)} ms`,
|
|
85
|
+
`${summary.p95.toFixed(3)} ms`,
|
|
86
|
+
`${summary.p99.toFixed(3)} ms`,
|
|
87
|
+
stats.copiesPerReplicatedByte.toFixed(2)
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
formatTable('3-Node Replication Benchmark',
|
|
92
|
+
['Payload', 'Iterations', 'Ops/sec', 'Throughput', 'Commit p50', 'Commit p95', 'Commit p99', 'Copies/Byte'],
|
|
93
|
+
tableRows
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
benchmark: '3_node_replication',
|
|
98
|
+
environment: getEnvironmentMetadata(),
|
|
99
|
+
results
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-replication.js')) {
|
|
104
|
+
runReplicationBenchmark().then(res => {
|
|
105
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: Single-node core overhead without network/storage noise.
|
|
3
|
+
* Measures submit ops/sec, ns/op, allocations/op, and bytes allocated/op.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ConsensusEngine, MemoryLog, Role, EventType } from '../index.js';
|
|
7
|
+
import { getEnvironmentMetadata, formatTable } from './harness.js';
|
|
8
|
+
|
|
9
|
+
export async function runSingleNodeBenchmark() {
|
|
10
|
+
const iterations = 200000;
|
|
11
|
+
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
12
|
+
|
|
13
|
+
const log = new MemoryLog();
|
|
14
|
+
const engine = new ConsensusEngine({
|
|
15
|
+
id: 1,
|
|
16
|
+
peers: [],
|
|
17
|
+
storage: log
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Become leader (single-node election)
|
|
21
|
+
engine.step({ type: EventType.ELECTION_TIMEOUT });
|
|
22
|
+
|
|
23
|
+
// Warmup
|
|
24
|
+
for (let i = 0; i < 10000; i++) {
|
|
25
|
+
engine.step({ type: EventType.SUBMIT, payload });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Force GC if available or record baseline memory
|
|
29
|
+
if (global.gc) global.gc();
|
|
30
|
+
const memBefore = process.memoryUsage();
|
|
31
|
+
const startHr = process.hrtime.bigint();
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < iterations; i++) {
|
|
34
|
+
engine.step({ type: EventType.SUBMIT, payload });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const endHr = process.hrtime.bigint();
|
|
38
|
+
const memAfter = process.memoryUsage();
|
|
39
|
+
|
|
40
|
+
const totalTimeNs = Number(endHr - startHr);
|
|
41
|
+
const totalTimeSec = totalTimeNs / 1e9;
|
|
42
|
+
const opsPerSec = Math.round(iterations / totalTimeSec);
|
|
43
|
+
const nsPerOp = Math.round(totalTimeNs / iterations);
|
|
44
|
+
|
|
45
|
+
const heapDiffBytes = Math.max(0, memAfter.heapUsed - memBefore.heapUsed);
|
|
46
|
+
const bytesPerOp = Math.round(heapDiffBytes / iterations);
|
|
47
|
+
|
|
48
|
+
const result = {
|
|
49
|
+
benchmark: 'single_node_core_overhead',
|
|
50
|
+
environment: getEnvironmentMetadata(),
|
|
51
|
+
iterations,
|
|
52
|
+
totalTimeMs: totalTimeNs / 1e6,
|
|
53
|
+
opsPerSec,
|
|
54
|
+
nsPerOp,
|
|
55
|
+
bytesAllocatedPerOp: bytesPerOp,
|
|
56
|
+
heapUsedMB: Math.round(memAfter.heapUsed / (1024 * 1024))
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
formatTable('Single-Node Core Overhead Benchmark',
|
|
60
|
+
['Iterations', 'Ops/sec', 'ns/op', 'Allocated B/op', 'Heap Used (MB)'],
|
|
61
|
+
[[
|
|
62
|
+
iterations.toLocaleString(),
|
|
63
|
+
opsPerSec.toLocaleString(),
|
|
64
|
+
`${nsPerOp} ns`,
|
|
65
|
+
`${bytesPerOp} B`,
|
|
66
|
+
`${result.heapUsedMB} MB`
|
|
67
|
+
]]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-single-node.js')) {
|
|
74
|
+
runSingleNodeBenchmark().then(res => {
|
|
75
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark: Slow Follower Isolation.
|
|
3
|
+
* Verifies that a slow follower (high RTT) does not drag down cluster quorum throughput.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ClusterSimulator } from '../src/sim/cluster.js';
|
|
7
|
+
import { getEnvironmentMetadata, formatTable } from './harness.js';
|
|
8
|
+
|
|
9
|
+
export async function runSlowFollowerBenchmark() {
|
|
10
|
+
const cluster = new ClusterSimulator({
|
|
11
|
+
nodeCount: 3,
|
|
12
|
+
seed: 42,
|
|
13
|
+
packetDelayMin: 1,
|
|
14
|
+
packetDelayMax: 2
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
cluster.start();
|
|
18
|
+
cluster.advance(500);
|
|
19
|
+
|
|
20
|
+
const leader = cluster.getLeader();
|
|
21
|
+
const leaderId = leader.id;
|
|
22
|
+
const followers = [1, 2, 3].filter(id => id !== leaderId);
|
|
23
|
+
const fastFollowerId = followers[0];
|
|
24
|
+
const slowFollowerId = followers[1];
|
|
25
|
+
|
|
26
|
+
// Intercept delivery to slow follower to simulate 200ms delay
|
|
27
|
+
const originalDeliver = cluster.deliverMessage.bind(cluster);
|
|
28
|
+
cluster.deliverMessage = (from, to, msg) => {
|
|
29
|
+
if ((from === leaderId && to === slowFollowerId) || (from === slowFollowerId && to === leaderId)) {
|
|
30
|
+
cluster.clock.setTimeout(() => {
|
|
31
|
+
const receiver = cluster.getNode(to);
|
|
32
|
+
if (receiver && receiver.alive) {
|
|
33
|
+
receiver.step({ type: 'MESSAGE', message: msg });
|
|
34
|
+
}
|
|
35
|
+
}, 200); // 200ms slow follower RTT
|
|
36
|
+
} else {
|
|
37
|
+
originalDeliver(from, to, msg);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const payload = new Uint8Array([1, 2, 3, 4]);
|
|
42
|
+
const commandsToSubmit = 500;
|
|
43
|
+
const startVirtualTime = cluster.clock.now;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < commandsToSubmit; i++) {
|
|
46
|
+
cluster.submit(payload);
|
|
47
|
+
// Advance virtual clock by 3ms to let fast follower ACK
|
|
48
|
+
cluster.advance(3);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const endVirtualTime = cluster.clock.now;
|
|
52
|
+
const totalVirtualSec = (endVirtualTime - startVirtualTime) / 1000;
|
|
53
|
+
const quorumOpsPerSec = Math.round(commandsToSubmit / totalVirtualSec);
|
|
54
|
+
|
|
55
|
+
const fastFollower = cluster.getNode(fastFollowerId);
|
|
56
|
+
const slowFollower = cluster.getNode(slowFollowerId);
|
|
57
|
+
|
|
58
|
+
const leaderCommit = leader.engine.commitIndex;
|
|
59
|
+
const fastMatch = leader.engine.matchIndex.get(fastFollowerId);
|
|
60
|
+
const slowMatch = leader.engine.matchIndex.get(slowFollowerId);
|
|
61
|
+
|
|
62
|
+
// Fast follower and leader should have committed almost all entries
|
|
63
|
+
const quorumHealthy = fastMatch >= BigInt(commandsToSubmit);
|
|
64
|
+
|
|
65
|
+
formatTable('Slow Follower Quorum Isolation Benchmark',
|
|
66
|
+
['Fast Peer RTT', 'Slow Peer RTT', 'Commands', 'Quorum Ops/sec', 'Leader Commit', 'Fast Peer Match', 'Slow Peer Match'],
|
|
67
|
+
[[
|
|
68
|
+
'2 ms',
|
|
69
|
+
'200 ms',
|
|
70
|
+
commandsToSubmit.toString(),
|
|
71
|
+
quorumOpsPerSec.toLocaleString(),
|
|
72
|
+
leaderCommit.toString(),
|
|
73
|
+
fastMatch.toString(),
|
|
74
|
+
slowMatch.toString()
|
|
75
|
+
]]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
benchmark: 'slow_follower_isolation',
|
|
80
|
+
environment: getEnvironmentMetadata(),
|
|
81
|
+
fastPeerRttMs: 2,
|
|
82
|
+
slowPeerRttMs: 200,
|
|
83
|
+
commandsSubmitted: commandsToSubmit,
|
|
84
|
+
quorumOpsPerSec,
|
|
85
|
+
leaderCommit: Number(leaderCommit),
|
|
86
|
+
fastPeerMatch: Number(fastMatch),
|
|
87
|
+
slowPeerMatch: Number(slowMatch),
|
|
88
|
+
quorumHealthy
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.argv[1] && process.argv[1].endsWith('bench-slow-follower.js')) {
|
|
93
|
+
runSlowFollowerBenchmark().then(res => {
|
|
94
|
+
console.log('\nJSON Output:\n', JSON.stringify(res, null, 2));
|
|
95
|
+
});
|
|
96
|
+
}
|