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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract Transport interface for Raptiye.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class Transport {
|
|
6
|
+
/**
|
|
7
|
+
* Send a single buffer to a peer.
|
|
8
|
+
* @param {number} peer
|
|
9
|
+
* @param {Uint8Array} buffer
|
|
10
|
+
*/
|
|
11
|
+
send(peer, buffer) {
|
|
12
|
+
throw new Error('Not implemented');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Send multiple buffers (scatter/gather) to a peer without concatenation.
|
|
17
|
+
* @param {number} peer
|
|
18
|
+
* @param {Uint8Array[]} buffers
|
|
19
|
+
*/
|
|
20
|
+
sendv(peer, buffers) {
|
|
21
|
+
throw new Error('Not implemented');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register incoming message callback.
|
|
26
|
+
* @param {(from: number, message: object) => void} handler
|
|
27
|
+
*/
|
|
28
|
+
onMessage(handler) {
|
|
29
|
+
throw new Error('Not implemented');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Start transport listening and connections.
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async start() {
|
|
37
|
+
throw new Error('Not implemented');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Close transport and all connections.
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
async close() {
|
|
45
|
+
throw new Error('Not implemented');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory zero-copy transport for local clusters and deterministic microbenchmarks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Transport } from './interface.js';
|
|
6
|
+
import { decodeMessage, flattenChunks } from '../protocol/wire.js';
|
|
7
|
+
|
|
8
|
+
export class MemoryNetwork {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.transports = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
register(id, transport) {
|
|
14
|
+
this.transports.set(Number(id), transport);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
unregister(id) {
|
|
18
|
+
this.transports.delete(Number(id));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
deliver(from, to, buffers) {
|
|
22
|
+
const target = this.transports.get(Number(to));
|
|
23
|
+
if (target && target.started && target._messageHandler) {
|
|
24
|
+
// Decode message directly from scatter/gather buffers without extra copy if single buffer
|
|
25
|
+
let flat = null;
|
|
26
|
+
if (buffers.length === 1) {
|
|
27
|
+
flat = buffers[0];
|
|
28
|
+
} else {
|
|
29
|
+
flat = flattenChunks(buffers);
|
|
30
|
+
}
|
|
31
|
+
const msg = decodeMessage(flat);
|
|
32
|
+
target._messageHandler(from, msg);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class MemoryTransport extends Transport {
|
|
38
|
+
constructor(id, network) {
|
|
39
|
+
super();
|
|
40
|
+
this.id = Number(id);
|
|
41
|
+
this.network = network;
|
|
42
|
+
this.started = false;
|
|
43
|
+
this._messageHandler = null;
|
|
44
|
+
this.network.register(this.id, this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
send(peer, buffer) {
|
|
48
|
+
this.network.deliver(this.id, peer, [buffer]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sendv(peer, buffers) {
|
|
52
|
+
this.network.deliver(this.id, peer, buffers);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMessage(handler) {
|
|
56
|
+
this._messageHandler = handler;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async start() {
|
|
60
|
+
this.started = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async close() {
|
|
64
|
+
this.started = false;
|
|
65
|
+
this.network.unregister(this.id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-performance TCP Transport for Raptiye using Node.js net.Socket and writev.
|
|
3
|
+
* Zero external dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { Transport } from './interface.js';
|
|
8
|
+
import { HEADER_SIZE, MAGIC, readHeader, decodeMessage, flattenChunks } from '../protocol/wire.js';
|
|
9
|
+
|
|
10
|
+
export class TCPTransport extends Transport {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} config
|
|
13
|
+
* @param {number} config.id
|
|
14
|
+
* @param {number} config.port
|
|
15
|
+
* @param {string} [config.host='127.0.0.1']
|
|
16
|
+
* @param {Map<number, { host: string, port: number }>} config.peerAddresses
|
|
17
|
+
*/
|
|
18
|
+
constructor({ id, port, host = '127.0.0.1', peerAddresses = new Map() }) {
|
|
19
|
+
super();
|
|
20
|
+
this.id = Number(id);
|
|
21
|
+
this.port = port;
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.peerAddresses = peerAddresses;
|
|
24
|
+
|
|
25
|
+
this.server = null;
|
|
26
|
+
this.started = false;
|
|
27
|
+
this._messageHandler = null;
|
|
28
|
+
|
|
29
|
+
// Outbound peer sockets: peerId -> Socket
|
|
30
|
+
this._outboundSockets = new Map();
|
|
31
|
+
// Inbound sockets: Set<Socket>
|
|
32
|
+
this._inboundSockets = new Set();
|
|
33
|
+
// Connecting promises: peerId -> Promise<Socket>
|
|
34
|
+
this._connecting = new Map();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
registerPeer(peerId, host, port) {
|
|
38
|
+
this.peerAddresses.set(Number(peerId), { host, port });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
onMessage(handler) {
|
|
42
|
+
this._messageHandler = handler;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start() {
|
|
46
|
+
if (this.started) return;
|
|
47
|
+
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
this.server = net.createServer((socket) => {
|
|
50
|
+
this._handleIncomingConnection(socket);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.server.on('error', reject);
|
|
54
|
+
this.server.listen(this.port, this.host, () => {
|
|
55
|
+
this.started = true;
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_handleIncomingConnection(socket) {
|
|
62
|
+
this._inboundSockets.add(socket);
|
|
63
|
+
socket.on('close', () => this._inboundSockets.delete(socket));
|
|
64
|
+
this._attachSocketReader(socket);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_attachSocketReader(socket) {
|
|
68
|
+
socket.setNoDelay(true);
|
|
69
|
+
let buffer = null;
|
|
70
|
+
|
|
71
|
+
socket.on('data', (chunk) => {
|
|
72
|
+
if (!buffer) {
|
|
73
|
+
buffer = chunk;
|
|
74
|
+
} else {
|
|
75
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
while (buffer && buffer.length >= HEADER_SIZE) {
|
|
79
|
+
let header;
|
|
80
|
+
try {
|
|
81
|
+
header = readHeader(buffer, 0);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
socket.destroy(err);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalLength = HEADER_SIZE + header.payloadLength;
|
|
88
|
+
if (buffer.length < totalLength) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rawFrame = buffer.subarray(0, totalLength);
|
|
93
|
+
buffer = buffer.length > totalLength ? buffer.subarray(totalLength) : null;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const frame = new Uint8Array(rawFrame.buffer, rawFrame.byteOffset, rawFrame.byteLength);
|
|
97
|
+
const msg = decodeMessage(frame, 0, true);
|
|
98
|
+
if (msg.sourceNode) {
|
|
99
|
+
this._outboundSockets.set(msg.sourceNode, socket);
|
|
100
|
+
}
|
|
101
|
+
if (this._messageHandler) {
|
|
102
|
+
this._messageHandler(msg.sourceNode, msg);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[TCPTransport ${this.id}] Error decoding frame:`, err.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
socket.on('error', () => {
|
|
111
|
+
socket.destroy();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _getOrCreateSocket(peer) {
|
|
116
|
+
const existing = this._outboundSockets.get(peer);
|
|
117
|
+
if (existing && !existing.destroyed && existing.writable) {
|
|
118
|
+
return existing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this._connecting.has(peer)) {
|
|
122
|
+
return this._connecting.get(peer);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const addr = this.peerAddresses.get(peer);
|
|
126
|
+
if (!addr) {
|
|
127
|
+
throw new Error(`[TCPTransport ${this.id}] No address registered for peer ${peer}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const connectPromise = new Promise((resolve, reject) => {
|
|
131
|
+
const socket = net.createConnection({ host: addr.host, port: addr.port }, () => {
|
|
132
|
+
this._attachSocketReader(socket);
|
|
133
|
+
this._outboundSockets.set(peer, socket);
|
|
134
|
+
this._connecting.delete(peer);
|
|
135
|
+
resolve(socket);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
socket.on('error', (err) => {
|
|
139
|
+
this._outboundSockets.delete(peer);
|
|
140
|
+
this._connecting.delete(peer);
|
|
141
|
+
reject(err);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
socket.on('close', () => {
|
|
145
|
+
this._outboundSockets.delete(peer);
|
|
146
|
+
this._connecting.delete(peer);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this._connecting.set(peer, connectPromise);
|
|
151
|
+
return connectPromise;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
send(peer, buffer) {
|
|
155
|
+
this._getOrCreateSocket(peer)
|
|
156
|
+
.then((socket) => {
|
|
157
|
+
if (!socket.destroyed && socket.writable) {
|
|
158
|
+
socket.write(buffer);
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
.catch(() => {
|
|
162
|
+
// Connection error handled by retry on next message
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
sendv(peer, buffers) {
|
|
167
|
+
this._getOrCreateSocket(peer)
|
|
168
|
+
.then((socket) => {
|
|
169
|
+
if (!socket.destroyed && socket.writable) {
|
|
170
|
+
socket.cork();
|
|
171
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
172
|
+
socket.write(buffers[i]);
|
|
173
|
+
}
|
|
174
|
+
socket.uncork();
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.catch(() => {
|
|
178
|
+
// Retry on next send
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async close() {
|
|
183
|
+
this.started = false;
|
|
184
|
+
for (const socket of this._outboundSockets.values()) {
|
|
185
|
+
socket.destroy();
|
|
186
|
+
}
|
|
187
|
+
this._outboundSockets.clear();
|
|
188
|
+
this._connecting.clear();
|
|
189
|
+
|
|
190
|
+
for (const socket of this._inboundSockets) {
|
|
191
|
+
socket.destroy();
|
|
192
|
+
}
|
|
193
|
+
this._inboundSockets.clear();
|
|
194
|
+
|
|
195
|
+
if (this.server) {
|
|
196
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
197
|
+
this.server = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core protocol and consensus definitions for Raptiye.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const Role = Object.freeze({
|
|
6
|
+
FOLLOWER: 1,
|
|
7
|
+
PRE_CANDIDATE: 2,
|
|
8
|
+
CANDIDATE: 3,
|
|
9
|
+
LEADER: 4
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const RoleName = Object.freeze({
|
|
13
|
+
[Role.FOLLOWER]: 'FOLLOWER',
|
|
14
|
+
[Role.PRE_CANDIDATE]: 'PRE_CANDIDATE',
|
|
15
|
+
[Role.CANDIDATE]: 'CANDIDATE',
|
|
16
|
+
[Role.LEADER]: 'LEADER'
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const MessageType = Object.freeze({
|
|
20
|
+
PRE_VOTE_REQUEST: 1,
|
|
21
|
+
PRE_VOTE_RESPONSE: 2,
|
|
22
|
+
VOTE_REQUEST: 3,
|
|
23
|
+
VOTE_RESPONSE: 4,
|
|
24
|
+
APPEND_REQUEST: 5,
|
|
25
|
+
APPEND_RESPONSE: 6,
|
|
26
|
+
HEARTBEAT: 7,
|
|
27
|
+
SNAPSHOT_BEGIN: 8,
|
|
28
|
+
SNAPSHOT_CHUNK: 9,
|
|
29
|
+
SNAPSHOT_END: 10,
|
|
30
|
+
SNAPSHOT_RESPONSE: 11,
|
|
31
|
+
TIMEOUT_NOW: 12,
|
|
32
|
+
BATCH_DATA: 13,
|
|
33
|
+
BATCH_ACK: 14,
|
|
34
|
+
SESSION_HANDSHAKE: 15,
|
|
35
|
+
SESSION_ACK: 16
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const MessageTypeName = Object.freeze({
|
|
39
|
+
[MessageType.PRE_VOTE_REQUEST]: 'PRE_VOTE_REQUEST',
|
|
40
|
+
[MessageType.PRE_VOTE_RESPONSE]: 'PRE_VOTE_RESPONSE',
|
|
41
|
+
[MessageType.VOTE_REQUEST]: 'VOTE_REQUEST',
|
|
42
|
+
[MessageType.VOTE_RESPONSE]: 'VOTE_RESPONSE',
|
|
43
|
+
[MessageType.APPEND_REQUEST]: 'APPEND_REQUEST',
|
|
44
|
+
[MessageType.APPEND_RESPONSE]: 'APPEND_RESPONSE',
|
|
45
|
+
[MessageType.HEARTBEAT]: 'HEARTBEAT',
|
|
46
|
+
[MessageType.SNAPSHOT_BEGIN]: 'SNAPSHOT_BEGIN',
|
|
47
|
+
[MessageType.SNAPSHOT_CHUNK]: 'SNAPSHOT_CHUNK',
|
|
48
|
+
[MessageType.SNAPSHOT_END]: 'SNAPSHOT_END',
|
|
49
|
+
[MessageType.SNAPSHOT_RESPONSE]: 'SNAPSHOT_RESPONSE',
|
|
50
|
+
[MessageType.TIMEOUT_NOW]: 'TIMEOUT_NOW',
|
|
51
|
+
[MessageType.BATCH_DATA]: 'BATCH_DATA',
|
|
52
|
+
[MessageType.BATCH_ACK]: 'BATCH_ACK',
|
|
53
|
+
[MessageType.SESSION_HANDSHAKE]: 'SESSION_HANDSHAKE',
|
|
54
|
+
[MessageType.SESSION_ACK]: 'SESSION_ACK'
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const EntryType = Object.freeze({
|
|
58
|
+
NORMAL: 1,
|
|
59
|
+
CONFIG: 2,
|
|
60
|
+
NOOP: 3
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const EffectType = Object.freeze({
|
|
64
|
+
SEND: 'SEND',
|
|
65
|
+
PERSIST_HARD_STATE: 'PERSIST_HARD_STATE',
|
|
66
|
+
PERSIST_ENTRIES: 'PERSIST_ENTRIES',
|
|
67
|
+
APPLY: 'APPLY',
|
|
68
|
+
RESET_ELECTION_TIMER: 'RESET_ELECTION_TIMER',
|
|
69
|
+
RESET_HEARTBEAT_TIMER: 'RESET_HEARTBEAT_TIMER',
|
|
70
|
+
BECOME_LEADER: 'BECOME_LEADER',
|
|
71
|
+
BECOME_FOLLOWER: 'BECOME_FOLLOWER',
|
|
72
|
+
NOTIFY_COMMITTED: 'NOTIFY_COMMITTED'
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const EventType = Object.freeze({
|
|
76
|
+
ELECTION_TIMEOUT: 'ELECTION_TIMEOUT',
|
|
77
|
+
HEARTBEAT_TIMEOUT: 'HEARTBEAT_TIMEOUT',
|
|
78
|
+
SUBMIT: 'SUBMIT',
|
|
79
|
+
MESSAGE: 'MESSAGE',
|
|
80
|
+
TRANSFER_LEADERSHIP: 'TRANSFER_LEADERSHIP'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const ProtocolConstants = Object.freeze({
|
|
84
|
+
MAGIC: 0x5250, // 'RP'
|
|
85
|
+
VERSION: 1,
|
|
86
|
+
HEADER_SIZE: 28
|
|
87
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ClusterSimulator } from '../../src/sim/cluster.js';
|
|
4
|
+
|
|
5
|
+
test('Deterministic Chaos Test - 500 randomized faults with continuous invariant checking', () => {
|
|
6
|
+
const seed = 20260919;
|
|
7
|
+
const cluster = new ClusterSimulator({
|
|
8
|
+
nodeCount: 3,
|
|
9
|
+
seed,
|
|
10
|
+
packetDelayMin: 1,
|
|
11
|
+
packetDelayMax: 15,
|
|
12
|
+
packetDropRate: 0.05,
|
|
13
|
+
packetDuplicateRate: 0.02
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
cluster.start();
|
|
17
|
+
cluster.advance(500);
|
|
18
|
+
|
|
19
|
+
let submittedCount = 0;
|
|
20
|
+
|
|
21
|
+
for (let iteration = 0; iteration < 200; iteration++) {
|
|
22
|
+
const action = cluster.prng.nextInt(1, 6);
|
|
23
|
+
|
|
24
|
+
switch (action) {
|
|
25
|
+
case 1: {
|
|
26
|
+
// Submit command
|
|
27
|
+
submittedCount++;
|
|
28
|
+
const payload = new Uint8Array([iteration & 0xFF, (iteration * 3) & 0xFF]);
|
|
29
|
+
cluster.submit(payload);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case 2: {
|
|
34
|
+
// Kill a random node if at least 2 are alive
|
|
35
|
+
const alive = cluster.activeNodes();
|
|
36
|
+
if (alive.length > 2) {
|
|
37
|
+
const victim = cluster.prng.pick(alive);
|
|
38
|
+
cluster.killNode(victim.id);
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case 3: {
|
|
44
|
+
// Restart a dead node
|
|
45
|
+
const dead = Array.from(cluster.nodes.values()).filter(n => !n.alive);
|
|
46
|
+
if (dead.length > 0) {
|
|
47
|
+
const reviver = cluster.prng.pick(dead);
|
|
48
|
+
cluster.restartNode(reviver.id);
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 4: {
|
|
54
|
+
// Create random partition
|
|
55
|
+
if (cluster.partitions.size === 0) {
|
|
56
|
+
cluster.partition([1], [2, 3]);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 5: {
|
|
62
|
+
// Heal partition
|
|
63
|
+
if (cluster.partitions.size > 0) {
|
|
64
|
+
cluster.heal();
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 6: {
|
|
70
|
+
// Advance time by small increment
|
|
71
|
+
const delta = cluster.prng.nextInt(10, 100);
|
|
72
|
+
cluster.advance(delta);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Advance clock to let network events progress
|
|
78
|
+
cluster.advance(cluster.prng.nextInt(10, 50));
|
|
79
|
+
// Continuous invariant assertion!
|
|
80
|
+
cluster.verify();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Heal everything and revive all nodes to verify final consistency
|
|
84
|
+
cluster.heal();
|
|
85
|
+
for (const node of cluster.nodes.values()) {
|
|
86
|
+
if (!node.alive) {
|
|
87
|
+
cluster.restartNode(node.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Allow cluster to reach steady state
|
|
92
|
+
cluster.advance(1500);
|
|
93
|
+
cluster.verify();
|
|
94
|
+
|
|
95
|
+
const leader = cluster.getLeader();
|
|
96
|
+
assert.ok(leader !== null, 'Cluster must reach stable leader after healing faults');
|
|
97
|
+
|
|
98
|
+
// Verify that all caught-up nodes have identical committed logs
|
|
99
|
+
const leaderCommit = leader.engine.commitIndex;
|
|
100
|
+
for (const node of cluster.activeNodes()) {
|
|
101
|
+
if (node.engine.commitIndex === leaderCommit) {
|
|
102
|
+
const leaderEntries = leader.storage.entries(1n, leaderCommit);
|
|
103
|
+
const nodeEntries = node.storage.entries(1n, leaderCommit);
|
|
104
|
+
assert.equal(nodeEntries.length, leaderEntries.length);
|
|
105
|
+
for (let i = 0; i < leaderEntries.length; i++) {
|
|
106
|
+
assert.equal(nodeEntries[i].index, leaderEntries[i].index);
|
|
107
|
+
assert.equal(nodeEntries[i].term, leaderEntries[i].term);
|
|
108
|
+
assert.deepEqual(nodeEntries[i].payload, leaderEntries[i].payload);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ClusterSimulator } from '../../src/sim/cluster.js';
|
|
4
|
+
import { Role } from '../../src/types.js';
|
|
5
|
+
|
|
6
|
+
test('ClusterSimulator - 3-node automatic leader election', () => {
|
|
7
|
+
const cluster = new ClusterSimulator({
|
|
8
|
+
nodeCount: 3,
|
|
9
|
+
seed: 42
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
cluster.start();
|
|
13
|
+
// Initially no leader
|
|
14
|
+
assert.equal(cluster.getLeader(), null);
|
|
15
|
+
|
|
16
|
+
// Advance time past election timeout (e.g. 500ms)
|
|
17
|
+
cluster.advance(500);
|
|
18
|
+
|
|
19
|
+
const leader = cluster.getLeader();
|
|
20
|
+
assert.ok(leader !== null, 'A leader must be elected');
|
|
21
|
+
assert.ok([1, 2, 3].includes(leader.id));
|
|
22
|
+
assert.equal(leader.engine.role, Role.LEADER);
|
|
23
|
+
|
|
24
|
+
// Verify invariants
|
|
25
|
+
cluster.verify();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('ClusterSimulator - 3-node replication and quorum commit', () => {
|
|
29
|
+
const cluster = new ClusterSimulator({
|
|
30
|
+
nodeCount: 3,
|
|
31
|
+
seed: 12345
|
|
32
|
+
});
|
|
33
|
+
cluster.start();
|
|
34
|
+
cluster.advance(500);
|
|
35
|
+
|
|
36
|
+
const leader = cluster.getLeader();
|
|
37
|
+
assert.ok(leader);
|
|
38
|
+
|
|
39
|
+
// Submit 5 commands
|
|
40
|
+
for (let i = 1; i <= 5; i++) {
|
|
41
|
+
const payload = new Uint8Array([i, i * 2, i * 3]);
|
|
42
|
+
const res = cluster.submit(payload);
|
|
43
|
+
assert.equal(res.success, true);
|
|
44
|
+
// Allow replication roundtrips
|
|
45
|
+
cluster.advance(50);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// After replication, all active nodes should have committed entries
|
|
49
|
+
assert.ok(leader.engine.commitIndex >= 5n);
|
|
50
|
+
|
|
51
|
+
for (const node of cluster.activeNodes()) {
|
|
52
|
+
assert.ok(node.engine.commitIndex >= 5n, `Node ${node.id} should have committed index >= 5`);
|
|
53
|
+
assert.equal(node.storage.lastIndex(), leader.storage.lastIndex());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cluster.verify();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('ClusterSimulator - automatic failover when leader dies', () => {
|
|
60
|
+
const cluster = new ClusterSimulator({
|
|
61
|
+
nodeCount: 3,
|
|
62
|
+
seed: 999
|
|
63
|
+
});
|
|
64
|
+
cluster.start();
|
|
65
|
+
cluster.advance(500);
|
|
66
|
+
|
|
67
|
+
const initialLeader = cluster.getLeader();
|
|
68
|
+
assert.ok(initialLeader);
|
|
69
|
+
const initialLeaderId = initialLeader.id;
|
|
70
|
+
|
|
71
|
+
// Submit 2 commands
|
|
72
|
+
cluster.submit(new Uint8Array([10, 20]));
|
|
73
|
+
cluster.advance(50);
|
|
74
|
+
cluster.submit(new Uint8Array([30, 40]));
|
|
75
|
+
cluster.advance(50);
|
|
76
|
+
|
|
77
|
+
const committedBeforeKill = initialLeader.engine.commitIndex;
|
|
78
|
+
|
|
79
|
+
// Kill the leader!
|
|
80
|
+
cluster.killNode(initialLeaderId);
|
|
81
|
+
assert.equal(cluster.getLeader(), null);
|
|
82
|
+
|
|
83
|
+
// Advance time for remaining 2 nodes to detect missing leader, run pre-vote and election
|
|
84
|
+
cluster.advance(600);
|
|
85
|
+
|
|
86
|
+
const newLeader = cluster.getLeader();
|
|
87
|
+
assert.ok(newLeader !== null, 'A new leader must be elected after leader failure');
|
|
88
|
+
assert.notEqual(newLeader.id, initialLeaderId);
|
|
89
|
+
assert.ok(newLeader.engine.currentTerm > initialLeader.engine.currentTerm);
|
|
90
|
+
|
|
91
|
+
// Submit a new command to the new leader!
|
|
92
|
+
const res = cluster.submit(new Uint8Array([99, 100]));
|
|
93
|
+
assert.equal(res.success, true);
|
|
94
|
+
cluster.advance(100);
|
|
95
|
+
|
|
96
|
+
// Newly submitted command must successfully commit!
|
|
97
|
+
assert.ok(newLeader.engine.commitIndex > committedBeforeKill);
|
|
98
|
+
|
|
99
|
+
cluster.verify();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('ClusterSimulator - split-brain prevention during partition', () => {
|
|
103
|
+
const cluster = new ClusterSimulator({
|
|
104
|
+
nodeCount: 3,
|
|
105
|
+
seed: 5555
|
|
106
|
+
});
|
|
107
|
+
cluster.start();
|
|
108
|
+
cluster.advance(500);
|
|
109
|
+
|
|
110
|
+
const leader = cluster.getLeader();
|
|
111
|
+
assert.ok(leader);
|
|
112
|
+
const leaderId = leader.id;
|
|
113
|
+
const followers = [1, 2, 3].filter(id => id !== leaderId);
|
|
114
|
+
|
|
115
|
+
// Partition: Leader is isolated from followers [B, C]
|
|
116
|
+
// Group A: [Leader]
|
|
117
|
+
// Group B: [Follower1, Follower2]
|
|
118
|
+
cluster.partition([leaderId], followers);
|
|
119
|
+
|
|
120
|
+
// Isolated leader tries to submit a command
|
|
121
|
+
const initialCommit = leader.engine.commitIndex;
|
|
122
|
+
leader.step({
|
|
123
|
+
type: 'SUBMIT',
|
|
124
|
+
payload: new Uint8Array([111, 222])
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Advance time
|
|
128
|
+
cluster.advance(600);
|
|
129
|
+
|
|
130
|
+
// The isolated leader MUST NOT be able to commit! (Cannot achieve quorum without majority)
|
|
131
|
+
assert.equal(leader.engine.commitIndex, initialCommit, 'Isolated leader must not advance commitIndex');
|
|
132
|
+
|
|
133
|
+
// Followers in majority group [B, C] must elect a new leader
|
|
134
|
+
const newLeader = cluster.getLeader();
|
|
135
|
+
assert.ok(newLeader !== null);
|
|
136
|
+
assert.notEqual(newLeader.id, leaderId);
|
|
137
|
+
|
|
138
|
+
// Majority can commit new commands
|
|
139
|
+
newLeader.step({
|
|
140
|
+
type: 'SUBMIT',
|
|
141
|
+
payload: new Uint8Array([333, 444])
|
|
142
|
+
});
|
|
143
|
+
cluster.advance(100);
|
|
144
|
+
assert.ok(newLeader.engine.commitIndex > initialCommit);
|
|
145
|
+
|
|
146
|
+
// Heal partition
|
|
147
|
+
cluster.heal();
|
|
148
|
+
cluster.advance(600);
|
|
149
|
+
|
|
150
|
+
// Old leader must step down and accept history of new leader
|
|
151
|
+
assert.equal(leader.engine.role, Role.FOLLOWER);
|
|
152
|
+
assert.equal(leader.engine.currentTerm, newLeader.engine.currentTerm);
|
|
153
|
+
assert.equal(leader.storage.lastIndex(), newLeader.storage.lastIndex());
|
|
154
|
+
|
|
155
|
+
cluster.verify();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('ClusterSimulator - automatic follower catch-up after rejoin', () => {
|
|
159
|
+
const cluster = new ClusterSimulator({
|
|
160
|
+
nodeCount: 3,
|
|
161
|
+
seed: 777
|
|
162
|
+
});
|
|
163
|
+
cluster.start();
|
|
164
|
+
cluster.advance(500);
|
|
165
|
+
|
|
166
|
+
const leader = cluster.getLeader();
|
|
167
|
+
assert.ok(leader);
|
|
168
|
+
|
|
169
|
+
// Kill node 3
|
|
170
|
+
const victim = [1, 2, 3].find(id => id !== leader.id);
|
|
171
|
+
cluster.killNode(victim);
|
|
172
|
+
|
|
173
|
+
// Commit 10 commands with the remaining 2 nodes
|
|
174
|
+
for (let i = 0; i < 10; i++) {
|
|
175
|
+
cluster.submit(new Uint8Array([i]));
|
|
176
|
+
cluster.advance(30);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const leaderCommit = leader.engine.commitIndex;
|
|
180
|
+
|
|
181
|
+
// Restart node 3
|
|
182
|
+
cluster.restartNode(victim);
|
|
183
|
+
|
|
184
|
+
// Advance time to allow leader to detect victim and stream catch-up entries
|
|
185
|
+
cluster.advance(500);
|
|
186
|
+
|
|
187
|
+
const restartedNode = cluster.getNode(victim);
|
|
188
|
+
assert.equal(restartedNode.storage.lastIndex(), leader.storage.lastIndex());
|
|
189
|
+
assert.equal(restartedNode.engine.commitIndex, leaderCommit);
|
|
190
|
+
|
|
191
|
+
cluster.verify();
|
|
192
|
+
});
|