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.
Files changed (41) hide show
  1. package/README.md +171 -0
  2. package/bench/bench-batch-curve.js +121 -0
  3. package/bench/bench-event-loop.js +91 -0
  4. package/bench/bench-failover.js +172 -0
  5. package/bench/bench-memory.js +107 -0
  6. package/bench/bench-partition.js +134 -0
  7. package/bench/bench-pipeline.js +94 -0
  8. package/bench/bench-recovery.js +134 -0
  9. package/bench/bench-replication.js +107 -0
  10. package/bench/bench-single-node.js +77 -0
  11. package/bench/bench-slow-follower.js +96 -0
  12. package/bench/harness.js +87 -0
  13. package/bench/run-all.js +83 -0
  14. package/bench-results.json +598 -0
  15. package/index.js +30 -0
  16. package/package.json +24 -0
  17. package/src/core/engine.js +907 -0
  18. package/src/core/invariants.js +146 -0
  19. package/src/node/raptiye.js +296 -0
  20. package/src/node/stats.js +82 -0
  21. package/src/protocol/checksum.js +66 -0
  22. package/src/protocol/wire.js +551 -0
  23. package/src/replication/pipeline.js +235 -0
  24. package/src/sim/cluster.js +316 -0
  25. package/src/sim/prng.js +52 -0
  26. package/src/sim/virtual-clock.js +82 -0
  27. package/src/storage/file-log.js +394 -0
  28. package/src/storage/interface.js +113 -0
  29. package/src/storage/memory-log.js +183 -0
  30. package/src/transport/interface.js +47 -0
  31. package/src/transport/memory-transport.js +67 -0
  32. package/src/transport/tcp-transport.js +200 -0
  33. package/src/types.js +87 -0
  34. package/test/chaos/chaos.test.js +112 -0
  35. package/test/integration/cluster.test.js +192 -0
  36. package/test/integration/node.test.js +78 -0
  37. package/test/integration/replication-pipeline.test.js +45 -0
  38. package/test/unit/core.test.js +163 -0
  39. package/test/unit/protocol.test.js +181 -0
  40. package/test/unit/storage.test.js +127 -0
  41. 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
+ });