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,78 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Raptiye, MemoryNetwork, MemoryTransport, MemoryLog } from '../../index.js';
|
|
4
|
+
|
|
5
|
+
test('Raptiye Node - 3-node cluster live replication and leadership transfer', async () => {
|
|
6
|
+
const net = new MemoryNetwork();
|
|
7
|
+
|
|
8
|
+
const appliedEntries = new Map([[1, []], [2, []], [3, []]]);
|
|
9
|
+
|
|
10
|
+
const node1 = new Raptiye({
|
|
11
|
+
id: 1,
|
|
12
|
+
peers: [2, 3],
|
|
13
|
+
storage: new MemoryLog(),
|
|
14
|
+
transport: new MemoryTransport(1, net),
|
|
15
|
+
apply: (e) => appliedEntries.get(1).push(e),
|
|
16
|
+
election: { minTimeout: 100, maxTimeout: 200 },
|
|
17
|
+
heartbeatInterval: 30
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const node2 = new Raptiye({
|
|
21
|
+
id: 2,
|
|
22
|
+
peers: [1, 3],
|
|
23
|
+
storage: new MemoryLog(),
|
|
24
|
+
transport: new MemoryTransport(2, net),
|
|
25
|
+
apply: (e) => appliedEntries.get(2).push(e),
|
|
26
|
+
election: { minTimeout: 100, maxTimeout: 200 },
|
|
27
|
+
heartbeatInterval: 30
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const node3 = new Raptiye({
|
|
31
|
+
id: 3,
|
|
32
|
+
peers: [1, 2],
|
|
33
|
+
storage: new MemoryLog(),
|
|
34
|
+
transport: new MemoryTransport(3, net),
|
|
35
|
+
apply: (e) => appliedEntries.get(3).push(e),
|
|
36
|
+
election: { minTimeout: 100, maxTimeout: 200 },
|
|
37
|
+
heartbeatInterval: 30
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const nodes = [node1, node2, node3];
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await Promise.all(nodes.map(n => n.start()));
|
|
44
|
+
|
|
45
|
+
// Wait for a leader to be elected
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
let leader = null;
|
|
48
|
+
while (!leader && Date.now() - start < 3000) {
|
|
49
|
+
leader = nodes.find(n => n.role() === 'LEADER');
|
|
50
|
+
if (!leader) await new Promise(r => setTimeout(r, 25));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
assert.ok(leader, 'A leader must be elected');
|
|
54
|
+
const leaderId = leader.id;
|
|
55
|
+
|
|
56
|
+
// Submit 5 binary payloads
|
|
57
|
+
for (let i = 1; i <= 5; i++) {
|
|
58
|
+
const payload = new Uint8Array([10 * i, 20 * i, 30 * i]);
|
|
59
|
+
const seq = leader.submit(payload);
|
|
60
|
+
await leader.committed(seq);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const stats = leader.stats();
|
|
64
|
+
assert.equal(stats.role, 'LEADER');
|
|
65
|
+
assert.ok(stats.submitted >= 5);
|
|
66
|
+
assert.ok(stats.committed >= 5);
|
|
67
|
+
assert.equal(stats.copiedBytes, 0, 'Zero payload copies in steady state');
|
|
68
|
+
|
|
69
|
+
// Transfer leadership to an eligible peer
|
|
70
|
+
const targetPeer = nodes.find(n => n.id !== leaderId);
|
|
71
|
+
await leader.transferLeadership(targetPeer.id);
|
|
72
|
+
|
|
73
|
+
// Verify target became leader or leader stepped down
|
|
74
|
+
assert.notEqual(leader.role(), 'LEADER');
|
|
75
|
+
} finally {
|
|
76
|
+
await Promise.all(nodes.map(n => n.shutdown()));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { TCPTransport, ReplicationPipeline } from '../../index.js';
|
|
4
|
+
|
|
5
|
+
test('ReplicationPipeline - point-to-point batch delivery over TCP with scatter-gather', async () => {
|
|
6
|
+
const p1Port = 9182;
|
|
7
|
+
const p2Port = 9183;
|
|
8
|
+
const t1 = new TCPTransport({ id: 1, port: p1Port, peerAddresses: new Map([[2, { host: '127.0.0.1', port: p2Port }]]) });
|
|
9
|
+
const t2 = new TCPTransport({ id: 2, port: p2Port, peerAddresses: new Map([[1, { host: '127.0.0.1', port: p1Port }]]) });
|
|
10
|
+
|
|
11
|
+
await t1.start();
|
|
12
|
+
await t2.start();
|
|
13
|
+
|
|
14
|
+
const p1 = new ReplicationPipeline({ transport: t1, localNode: 1, remoteNode: 2 });
|
|
15
|
+
const p2 = new ReplicationPipeline({ transport: t2, localNode: 2, remoteNode: 1 });
|
|
16
|
+
|
|
17
|
+
const receivedBatches = [];
|
|
18
|
+
p2.onBatch(async (batch) => {
|
|
19
|
+
receivedBatches.push(batch);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const metaBuf = new Uint8Array([10, 20, 30]);
|
|
23
|
+
const payloadBuf = new Uint8Array([100, 101, 102, 103, 104]);
|
|
24
|
+
|
|
25
|
+
const receipt = await p1.submitBatch({
|
|
26
|
+
generationId: 101,
|
|
27
|
+
firstSequence: 1n,
|
|
28
|
+
lastSequence: 5n,
|
|
29
|
+
entryCount: 5,
|
|
30
|
+
buffers: [metaBuf, payloadBuf]
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ack = await receipt.delivered();
|
|
34
|
+
assert.strictEqual(ack.generationId, 101);
|
|
35
|
+
assert.strictEqual(ack.status, 0);
|
|
36
|
+
|
|
37
|
+
assert.strictEqual(receivedBatches.length, 1);
|
|
38
|
+
assert.strictEqual(receivedBatches[0].generationId, 101);
|
|
39
|
+
assert.strictEqual(receivedBatches[0].buffers.length, 2);
|
|
40
|
+
assert.deepStrictEqual(Array.from(receivedBatches[0].buffers[0]), [10, 20, 30]);
|
|
41
|
+
assert.deepStrictEqual(Array.from(receivedBatches[0].buffers[1]), [100, 101, 102, 103, 104]);
|
|
42
|
+
|
|
43
|
+
await t1.close();
|
|
44
|
+
await t2.close();
|
|
45
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ConsensusEngine } from '../../src/core/engine.js';
|
|
4
|
+
import { MemoryLog } from '../../src/storage/memory-log.js';
|
|
5
|
+
import { Role, MessageType, EffectType, EventType } from '../../src/types.js';
|
|
6
|
+
|
|
7
|
+
test('ConsensusEngine - PreVote and Election flow', () => {
|
|
8
|
+
const log1 = new MemoryLog();
|
|
9
|
+
const node1 = new ConsensusEngine({
|
|
10
|
+
id: 1,
|
|
11
|
+
peers: [2, 3],
|
|
12
|
+
storage: log1
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
assert.equal(node1.role, Role.FOLLOWER);
|
|
16
|
+
assert.equal(node1.currentTerm, 0n);
|
|
17
|
+
|
|
18
|
+
// 1. Election timeout triggers Pre-Vote
|
|
19
|
+
const effects1 = node1.step({ type: EventType.ELECTION_TIMEOUT });
|
|
20
|
+
assert.equal(node1.role, Role.PRE_CANDIDATE);
|
|
21
|
+
assert.equal(node1.currentTerm, 0n, 'Term must not increment during pre-vote phase');
|
|
22
|
+
|
|
23
|
+
// Should send PRE_VOTE_REQUEST to peers 2 and 3 with candidateTerm = 1n
|
|
24
|
+
const preVoteSends = effects1.filter(e => e.type === EffectType.SEND && e.message.type === MessageType.PRE_VOTE_REQUEST);
|
|
25
|
+
assert.equal(preVoteSends.length, 2);
|
|
26
|
+
assert.equal(preVoteSends[0].message.term, 1n);
|
|
27
|
+
|
|
28
|
+
// 2. Peer 2 grants pre-vote
|
|
29
|
+
const effects2 = node1.step({
|
|
30
|
+
type: EventType.MESSAGE,
|
|
31
|
+
message: {
|
|
32
|
+
type: MessageType.PRE_VOTE_RESPONSE,
|
|
33
|
+
term: 0n,
|
|
34
|
+
sourceNode: 2,
|
|
35
|
+
destNode: 1,
|
|
36
|
+
voteGranted: true
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Now node1 has 2 votes (itself + node 2) -> Quorum reached!
|
|
41
|
+
// Node1 must transition to CANDIDATE, increment term to 1n, and broadcast VOTE_REQUEST
|
|
42
|
+
assert.equal(node1.role, Role.CANDIDATE);
|
|
43
|
+
assert.equal(node1.currentTerm, 1n);
|
|
44
|
+
assert.equal(node1.votedFor, 1);
|
|
45
|
+
|
|
46
|
+
const voteSends = effects2.filter(e => e.type === EffectType.SEND && e.message.type === MessageType.VOTE_REQUEST);
|
|
47
|
+
assert.equal(voteSends.length, 2);
|
|
48
|
+
assert.equal(voteSends[0].message.term, 1n);
|
|
49
|
+
|
|
50
|
+
// 3. Peer 2 grants real vote
|
|
51
|
+
const effects3 = node1.step({
|
|
52
|
+
type: EventType.MESSAGE,
|
|
53
|
+
message: {
|
|
54
|
+
type: MessageType.VOTE_RESPONSE,
|
|
55
|
+
term: 1n,
|
|
56
|
+
sourceNode: 2,
|
|
57
|
+
destNode: 1,
|
|
58
|
+
voteGranted: true
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Quorum reached -> Node1 becomes LEADER!
|
|
63
|
+
assert.equal(node1.role, Role.LEADER);
|
|
64
|
+
assert.equal(node1.leaderId, 1);
|
|
65
|
+
|
|
66
|
+
// Must append NOOP entry and broadcast to followers
|
|
67
|
+
const becomeLeaderEffect = effects3.find(e => e.type === EffectType.BECOME_LEADER);
|
|
68
|
+
assert.ok(becomeLeaderEffect);
|
|
69
|
+
|
|
70
|
+
const appendSends = effects3.filter(e => e.type === EffectType.SEND && e.message.type === MessageType.APPEND_REQUEST);
|
|
71
|
+
assert.equal(appendSends.length, 2);
|
|
72
|
+
assert.equal(log1.lastIndex(), 1n);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ConsensusEngine - Replication, Quorum Commit and Apply', () => {
|
|
76
|
+
const leaderLog = new MemoryLog();
|
|
77
|
+
const leader = new ConsensusEngine({
|
|
78
|
+
id: 1,
|
|
79
|
+
peers: [2, 3],
|
|
80
|
+
storage: leaderLog
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Force node1 to leader for term 1
|
|
84
|
+
leader.currentTerm = 1n;
|
|
85
|
+
leader.role = Role.LEADER;
|
|
86
|
+
leader.leaderId = 1;
|
|
87
|
+
leader._initPeerProgress();
|
|
88
|
+
|
|
89
|
+
const followerLog = new MemoryLog();
|
|
90
|
+
const follower = new ConsensusEngine({
|
|
91
|
+
id: 2,
|
|
92
|
+
peers: [1, 3],
|
|
93
|
+
storage: followerLog
|
|
94
|
+
});
|
|
95
|
+
follower.currentTerm = 1n;
|
|
96
|
+
|
|
97
|
+
// Submit payload on leader
|
|
98
|
+
const payload = new Uint8Array([42, 43, 44]);
|
|
99
|
+
const submitEffects = leader.step({
|
|
100
|
+
type: EventType.SUBMIT,
|
|
101
|
+
payload
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(leaderLog.lastIndex(), 1n);
|
|
105
|
+
assert.equal(leader.commitIndex, 0n, 'Uncommitted before quorum');
|
|
106
|
+
|
|
107
|
+
const appendReq = submitEffects.find(e => e.type === EffectType.SEND && e.to === 2).message;
|
|
108
|
+
assert.equal(appendReq.type, MessageType.APPEND_REQUEST);
|
|
109
|
+
assert.equal(appendReq.entries.length, 1);
|
|
110
|
+
|
|
111
|
+
// Step on follower
|
|
112
|
+
const followerEffects = follower.step({
|
|
113
|
+
type: EventType.MESSAGE,
|
|
114
|
+
message: appendReq
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.equal(followerLog.lastIndex(), 1n);
|
|
118
|
+
const appendResp = followerEffects.find(e => e.type === EffectType.SEND).message;
|
|
119
|
+
assert.equal(appendResp.type, MessageType.APPEND_RESPONSE);
|
|
120
|
+
assert.equal(appendResp.success, true);
|
|
121
|
+
assert.equal(appendResp.matchIndex, 1n);
|
|
122
|
+
|
|
123
|
+
// Pass append response back to leader
|
|
124
|
+
const leaderAckEffects = leader.step({
|
|
125
|
+
type: EventType.MESSAGE,
|
|
126
|
+
message: appendResp
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Quorum (Leader + Follower 2 = 2 of 3) achieved!
|
|
130
|
+
assert.equal(leader.commitIndex, 1n);
|
|
131
|
+
assert.equal(leader.lastApplied, 1n);
|
|
132
|
+
|
|
133
|
+
const commitNotify = leaderAckEffects.find(e => e.type === EffectType.NOTIFY_COMMITTED);
|
|
134
|
+
assert.ok(commitNotify);
|
|
135
|
+
assert.equal(commitNotify.index, 1n);
|
|
136
|
+
|
|
137
|
+
const applyEffect = leaderAckEffects.find(e => e.type === EffectType.APPLY);
|
|
138
|
+
assert.ok(applyEffect);
|
|
139
|
+
assert.deepEqual(applyEffect.entry.payload, payload);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('ConsensusEngine - Leadership Transfer', () => {
|
|
143
|
+
const leaderLog = new MemoryLog();
|
|
144
|
+
leaderLog.append({ term: 1n, index: 1n, payload: new Uint8Array([1]) });
|
|
145
|
+
|
|
146
|
+
const leader = new ConsensusEngine({
|
|
147
|
+
id: 1,
|
|
148
|
+
peers: [2, 3],
|
|
149
|
+
storage: leaderLog
|
|
150
|
+
});
|
|
151
|
+
leader.currentTerm = 1n;
|
|
152
|
+
leader.role = Role.LEADER;
|
|
153
|
+
leader.matchIndex.set(2, 1n);
|
|
154
|
+
|
|
155
|
+
// Trigger leadership transfer to peer 2
|
|
156
|
+
const transferEffects = leader.step({
|
|
157
|
+
type: EventType.TRANSFER_LEADERSHIP,
|
|
158
|
+
targetPeer: 2
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const timeoutNow = transferEffects.find(e => e.type === EffectType.SEND && e.to === 2 && e.message.type === MessageType.TIMEOUT_NOW);
|
|
162
|
+
assert.ok(timeoutNow, 'Should immediately dispatch TIMEOUT_NOW to fully caught-up peer');
|
|
163
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { crc32, crc32Buffers } from '../../src/protocol/checksum.js';
|
|
4
|
+
import {
|
|
5
|
+
encodeMessage,
|
|
6
|
+
decodeMessage,
|
|
7
|
+
flattenChunks,
|
|
8
|
+
readHeader,
|
|
9
|
+
HEADER_SIZE,
|
|
10
|
+
WireMetrics
|
|
11
|
+
} from '../../src/protocol/wire.js';
|
|
12
|
+
import { MessageType, EntryType } from '../../src/types.js';
|
|
13
|
+
|
|
14
|
+
test('CRC32 checksum calculations', () => {
|
|
15
|
+
const data = new TextEncoder().encode('123456789');
|
|
16
|
+
const sum = crc32(data);
|
|
17
|
+
// Standard check value for "123456789" is 0xCBF43926
|
|
18
|
+
assert.equal(sum, 0xCBF43926);
|
|
19
|
+
|
|
20
|
+
// Test multi-buffer scatter/gather CRC
|
|
21
|
+
const b1 = new TextEncoder().encode('1234');
|
|
22
|
+
const b2 = new TextEncoder().encode('56789');
|
|
23
|
+
const multiSum = crc32Buffers([b1, b2]);
|
|
24
|
+
assert.equal(multiSum, 0xCBF43926);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('Binary wire protocol - PreVote and Vote request/response', () => {
|
|
28
|
+
const metrics = new WireMetrics();
|
|
29
|
+
|
|
30
|
+
// Pre-Vote Request
|
|
31
|
+
const preVoteReq = {
|
|
32
|
+
type: MessageType.PRE_VOTE_REQUEST,
|
|
33
|
+
term: 5n,
|
|
34
|
+
sourceNode: 1,
|
|
35
|
+
destNode: 2,
|
|
36
|
+
candidateId: 1,
|
|
37
|
+
lastLogIndex: 100n,
|
|
38
|
+
lastLogTerm: 4n
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const chunks = encodeMessage(preVoteReq, metrics);
|
|
42
|
+
assert.equal(metrics.copiedBytes, 0, 'No payload copies should occur during encoding');
|
|
43
|
+
const flat = flattenChunks(chunks, metrics);
|
|
44
|
+
const decoded = decodeMessage(flat);
|
|
45
|
+
|
|
46
|
+
assert.equal(decoded.type, MessageType.PRE_VOTE_REQUEST);
|
|
47
|
+
assert.equal(decoded.term, 5n);
|
|
48
|
+
assert.equal(decoded.sourceNode, 1);
|
|
49
|
+
assert.equal(decoded.destNode, 2);
|
|
50
|
+
assert.equal(decoded.candidateId, 1);
|
|
51
|
+
assert.equal(decoded.lastLogIndex, 100n);
|
|
52
|
+
assert.equal(decoded.lastLogTerm, 4n);
|
|
53
|
+
|
|
54
|
+
// Vote Response
|
|
55
|
+
const voteResp = {
|
|
56
|
+
type: MessageType.VOTE_RESPONSE,
|
|
57
|
+
term: 5n,
|
|
58
|
+
sourceNode: 2,
|
|
59
|
+
destNode: 1,
|
|
60
|
+
voteGranted: true
|
|
61
|
+
};
|
|
62
|
+
const respFlat = flattenChunks(encodeMessage(voteResp));
|
|
63
|
+
const decodedResp = decodeMessage(respFlat);
|
|
64
|
+
assert.equal(decodedResp.type, MessageType.VOTE_RESPONSE);
|
|
65
|
+
assert.equal(decodedResp.term, 5n);
|
|
66
|
+
assert.equal(decodedResp.voteGranted, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('Binary wire protocol - AppendRequest with multiple entries (zero-copy scatter/gather)', () => {
|
|
70
|
+
const payload1 = new Uint8Array([10, 20, 30, 40]);
|
|
71
|
+
const payload2 = new Uint8Array([100, 101, 102, 103, 104, 105]);
|
|
72
|
+
|
|
73
|
+
const appendReq = {
|
|
74
|
+
type: MessageType.APPEND_REQUEST,
|
|
75
|
+
term: 10n,
|
|
76
|
+
sourceNode: 1,
|
|
77
|
+
destNode: 2,
|
|
78
|
+
leaderId: 1,
|
|
79
|
+
prevLogIndex: 50n,
|
|
80
|
+
prevLogTerm: 9n,
|
|
81
|
+
leaderCommit: 48n,
|
|
82
|
+
entries: [
|
|
83
|
+
{
|
|
84
|
+
term: 10n,
|
|
85
|
+
index: 51n,
|
|
86
|
+
type: EntryType.NORMAL,
|
|
87
|
+
payload: payload1
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
term: 10n,
|
|
91
|
+
index: 52n,
|
|
92
|
+
type: EntryType.NORMAL,
|
|
93
|
+
payload: payload2
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const metrics = new WireMetrics();
|
|
99
|
+
const chunks = encodeMessage(appendReq, metrics);
|
|
100
|
+
assert.equal(metrics.copiedBytes, 0, 'Encoding must not copy payload bytes');
|
|
101
|
+
// Chunks should be: [header, metadata, payload1, payload2]
|
|
102
|
+
assert.equal(chunks.length, 4);
|
|
103
|
+
assert.strictEqual(chunks[2], payload1, 'Scatter/gather must retain payload reference without copying');
|
|
104
|
+
assert.strictEqual(chunks[3], payload2, 'Scatter/gather must retain payload reference without copying');
|
|
105
|
+
|
|
106
|
+
const flat = flattenChunks(chunks);
|
|
107
|
+
const decoded = decodeMessage(flat);
|
|
108
|
+
|
|
109
|
+
assert.equal(decoded.type, MessageType.APPEND_REQUEST);
|
|
110
|
+
assert.equal(decoded.term, 10n);
|
|
111
|
+
assert.equal(decoded.leaderId, 1);
|
|
112
|
+
assert.equal(decoded.prevLogIndex, 50n);
|
|
113
|
+
assert.equal(decoded.prevLogTerm, 9n);
|
|
114
|
+
assert.equal(decoded.leaderCommit, 48n);
|
|
115
|
+
assert.equal(decoded.entries.length, 2);
|
|
116
|
+
|
|
117
|
+
assert.equal(decoded.entries[0].term, 10n);
|
|
118
|
+
assert.equal(decoded.entries[0].index, 51n);
|
|
119
|
+
assert.deepEqual(decoded.entries[0].payload, payload1);
|
|
120
|
+
|
|
121
|
+
assert.equal(decoded.entries[1].term, 10n);
|
|
122
|
+
assert.equal(decoded.entries[1].index, 52n);
|
|
123
|
+
assert.deepEqual(decoded.entries[1].payload, payload2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('Binary wire protocol - Heartbeat, Snapshots, TimeoutNow', () => {
|
|
127
|
+
// Heartbeat
|
|
128
|
+
const hb = {
|
|
129
|
+
type: MessageType.HEARTBEAT,
|
|
130
|
+
term: 3n,
|
|
131
|
+
sourceNode: 1,
|
|
132
|
+
destNode: 2,
|
|
133
|
+
leaderId: 1,
|
|
134
|
+
leaderCommit: 25n
|
|
135
|
+
};
|
|
136
|
+
const decodedHb = decodeMessage(flattenChunks(encodeMessage(hb)));
|
|
137
|
+
assert.equal(decodedHb.type, MessageType.HEARTBEAT);
|
|
138
|
+
assert.equal(decodedHb.leaderCommit, 25n);
|
|
139
|
+
|
|
140
|
+
// Snapshot Begin
|
|
141
|
+
const snapBegin = {
|
|
142
|
+
type: MessageType.SNAPSHOT_BEGIN,
|
|
143
|
+
term: 4n,
|
|
144
|
+
sourceNode: 1,
|
|
145
|
+
destNode: 2,
|
|
146
|
+
leaderId: 1,
|
|
147
|
+
snapshotIndex: 200n,
|
|
148
|
+
snapshotTerm: 3n,
|
|
149
|
+
totalBytes: 5000n
|
|
150
|
+
};
|
|
151
|
+
const decodedSnap = decodeMessage(flattenChunks(encodeMessage(snapBegin)));
|
|
152
|
+
assert.equal(decodedSnap.snapshotIndex, 200n);
|
|
153
|
+
assert.equal(decodedSnap.totalBytes, 5000n);
|
|
154
|
+
|
|
155
|
+
// Snapshot Chunk
|
|
156
|
+
const chunkData = new Uint8Array([1, 2, 3, 4, 5]);
|
|
157
|
+
const snapChunk = {
|
|
158
|
+
type: MessageType.SNAPSHOT_CHUNK,
|
|
159
|
+
term: 4n,
|
|
160
|
+
sourceNode: 1,
|
|
161
|
+
destNode: 2,
|
|
162
|
+
snapshotIndex: 200n,
|
|
163
|
+
offset: 0n,
|
|
164
|
+
chunkData
|
|
165
|
+
};
|
|
166
|
+
const decodedChunk = decodeMessage(flattenChunks(encodeMessage(snapChunk)));
|
|
167
|
+
assert.equal(decodedChunk.snapshotIndex, 200n);
|
|
168
|
+
assert.deepEqual(decodedChunk.chunkData, chunkData);
|
|
169
|
+
|
|
170
|
+
// Timeout Now
|
|
171
|
+
const timeoutNow = {
|
|
172
|
+
type: MessageType.TIMEOUT_NOW,
|
|
173
|
+
term: 4n,
|
|
174
|
+
sourceNode: 1,
|
|
175
|
+
destNode: 2,
|
|
176
|
+
leaderId: 1
|
|
177
|
+
};
|
|
178
|
+
const decodedTimeout = decodeMessage(flattenChunks(encodeMessage(timeoutNow)));
|
|
179
|
+
assert.equal(decodedTimeout.type, MessageType.TIMEOUT_NOW);
|
|
180
|
+
assert.equal(decodedTimeout.leaderId, 1);
|
|
181
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { MemoryLog } from '../../src/storage/memory-log.js';
|
|
4
|
+
|
|
5
|
+
test('MemoryLog - basic appends, lookups, and terms', () => {
|
|
6
|
+
const log = new MemoryLog();
|
|
7
|
+
assert.equal(log.lastIndex(), 0n);
|
|
8
|
+
assert.equal(log.lastTerm(), 0n);
|
|
9
|
+
assert.equal(log.firstIndex(), 1n);
|
|
10
|
+
|
|
11
|
+
log.append({ term: 1n, index: 1n, payload: new Uint8Array([1]) });
|
|
12
|
+
log.append({ term: 1n, index: 2n, payload: new Uint8Array([2]) });
|
|
13
|
+
log.append({ term: 2n, index: 3n, payload: new Uint8Array([3]) });
|
|
14
|
+
|
|
15
|
+
assert.equal(log.lastIndex(), 3n);
|
|
16
|
+
assert.equal(log.lastTerm(), 2n);
|
|
17
|
+
assert.equal(log.term(1n), 1n);
|
|
18
|
+
assert.equal(log.term(2n), 1n);
|
|
19
|
+
assert.equal(log.term(3n), 2n);
|
|
20
|
+
assert.equal(log.term(4n), null);
|
|
21
|
+
|
|
22
|
+
const range = log.entries(1n, 2n);
|
|
23
|
+
assert.equal(range.length, 2);
|
|
24
|
+
assert.equal(range[0].index, 1n);
|
|
25
|
+
assert.equal(range[1].index, 2n);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('MemoryLog - truncation', () => {
|
|
29
|
+
const log = new MemoryLog();
|
|
30
|
+
log.appendMany([
|
|
31
|
+
{ term: 1n, index: 1n, payload: new Uint8Array([1]) },
|
|
32
|
+
{ term: 1n, index: 2n, payload: new Uint8Array([2]) },
|
|
33
|
+
{ term: 2n, index: 3n, payload: new Uint8Array([3]) },
|
|
34
|
+
{ term: 2n, index: 4n, payload: new Uint8Array([4]) }
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
log.truncateFrom(3n);
|
|
38
|
+
assert.equal(log.lastIndex(), 2n);
|
|
39
|
+
assert.equal(log.lastTerm(), 1n);
|
|
40
|
+
assert.equal(log.term(3n), null);
|
|
41
|
+
assert.equal(log.entries(1n, 4n).length, 2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('MemoryLog - compaction and snapshot', () => {
|
|
45
|
+
const log = new MemoryLog();
|
|
46
|
+
for (let i = 1n; i <= 10n; i++) {
|
|
47
|
+
log.append({ term: 1n, index: i, payload: new Uint8Array([Number(i)]) });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
assert.equal(log.lastIndex(), 10n);
|
|
51
|
+
|
|
52
|
+
// Save snapshot at index 5
|
|
53
|
+
log.saveSnapshot({
|
|
54
|
+
index: 5n,
|
|
55
|
+
term: 1n,
|
|
56
|
+
data: new Uint8Array([55])
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.equal(log.firstIndex(), 6n);
|
|
60
|
+
assert.equal(log.lastIndex(), 10n);
|
|
61
|
+
assert.equal(log.term(5n), 1n, 'Term of snapshot index should be available');
|
|
62
|
+
assert.equal(log.term(4n), null, 'Compacted terms should return null');
|
|
63
|
+
|
|
64
|
+
const remaining = log.entries(1n, 10n);
|
|
65
|
+
assert.equal(remaining.length, 5);
|
|
66
|
+
assert.equal(remaining[0].index, 6n);
|
|
67
|
+
assert.equal(remaining[4].index, 10n);
|
|
68
|
+
|
|
69
|
+
const snap = log.getSnapshot();
|
|
70
|
+
assert.equal(snap.index, 5n);
|
|
71
|
+
assert.deepEqual(snap.data, new Uint8Array([55]));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('MemoryLog - hard state persistence', () => {
|
|
75
|
+
const log = new MemoryLog();
|
|
76
|
+
assert.equal(log.getHardState().currentTerm, 0n);
|
|
77
|
+
assert.equal(log.getHardState().votedFor, null);
|
|
78
|
+
|
|
79
|
+
log.setHardState({ currentTerm: 7n, votedFor: 3 });
|
|
80
|
+
assert.equal(log.getHardState().currentTerm, 7n);
|
|
81
|
+
assert.equal(log.getHardState().votedFor, 3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
import { FileLog } from '../../src/storage/file-log.js';
|
|
85
|
+
import os from 'node:os';
|
|
86
|
+
import path from 'node:path';
|
|
87
|
+
import fs from 'node:fs';
|
|
88
|
+
|
|
89
|
+
test('FileLog - crash recovery, persistence across reopen, and corruption truncation', () => {
|
|
90
|
+
const tmpDir = path.join(os.tmpdir(), `raptiye-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
91
|
+
|
|
92
|
+
// 1. Initial write
|
|
93
|
+
let fileLog = new FileLog(tmpDir);
|
|
94
|
+
fileLog.setHardState({ currentTerm: 4n, votedFor: 2 });
|
|
95
|
+
fileLog.append({ term: 4n, index: 1n, payload: new Uint8Array([1, 2, 3]) });
|
|
96
|
+
fileLog.append({ term: 4n, index: 2n, payload: new Uint8Array([4, 5, 6]) });
|
|
97
|
+
fileLog.close();
|
|
98
|
+
|
|
99
|
+
// 2. Reopen and verify state preserved
|
|
100
|
+
fileLog = new FileLog(tmpDir);
|
|
101
|
+
assert.equal(fileLog.getHardState().currentTerm, 4n);
|
|
102
|
+
assert.equal(fileLog.getHardState().votedFor, 2);
|
|
103
|
+
assert.equal(fileLog.lastIndex(), 2n);
|
|
104
|
+
assert.equal(fileLog.term(1n), 4n);
|
|
105
|
+
assert.equal(fileLog.term(2n), 4n);
|
|
106
|
+
assert.deepEqual(fileLog.entries(1n, 1n)[0].payload, new Uint8Array([1, 2, 3]));
|
|
107
|
+
assert.deepEqual(fileLog.entries(2n, 2n)[0].payload, new Uint8Array([4, 5, 6]));
|
|
108
|
+
|
|
109
|
+
// 3. Simulate corrupt partial write at tail of wal.log
|
|
110
|
+
fileLog.close();
|
|
111
|
+
const walPath = path.join(tmpDir, 'wal.log');
|
|
112
|
+
// Append 5 garbage bytes
|
|
113
|
+
fs.appendFileSync(walPath, Buffer.from([0xDE, 0xAD, 0xBE, 0xEF, 0x00]));
|
|
114
|
+
|
|
115
|
+
// 4. Reopen -> should recover valid entries and truncate garbage
|
|
116
|
+
fileLog = new FileLog(tmpDir);
|
|
117
|
+
assert.equal(fileLog.lastIndex(), 2n);
|
|
118
|
+
assert.equal(fileLog.term(2n), 4n);
|
|
119
|
+
|
|
120
|
+
// Can append new entry normally after recovery
|
|
121
|
+
fileLog.append({ term: 4n, index: 3n, payload: new Uint8Array([7, 8, 9]) });
|
|
122
|
+
assert.equal(fileLog.lastIndex(), 3n);
|
|
123
|
+
fileLog.close();
|
|
124
|
+
|
|
125
|
+
// Clean up
|
|
126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
127
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { MemoryNetwork, MemoryTransport } from '../../src/transport/memory-transport.js';
|
|
4
|
+
import { TCPTransport } from '../../src/transport/tcp-transport.js';
|
|
5
|
+
import { encodeMessage } from '../../src/protocol/wire.js';
|
|
6
|
+
import { MessageType } from '../../src/types.js';
|
|
7
|
+
|
|
8
|
+
test('MemoryTransport - send and receive messages', async () => {
|
|
9
|
+
const net = new MemoryNetwork();
|
|
10
|
+
const t1 = new MemoryTransport(1, net);
|
|
11
|
+
const t2 = new MemoryTransport(2, net);
|
|
12
|
+
|
|
13
|
+
await t1.start();
|
|
14
|
+
await t2.start();
|
|
15
|
+
|
|
16
|
+
let received = null;
|
|
17
|
+
t2.onMessage((from, msg) => {
|
|
18
|
+
received = { from, msg };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const msg = {
|
|
22
|
+
type: MessageType.PRE_VOTE_REQUEST,
|
|
23
|
+
term: 1n,
|
|
24
|
+
sourceNode: 1,
|
|
25
|
+
destNode: 2,
|
|
26
|
+
candidateId: 1,
|
|
27
|
+
lastLogIndex: 10n,
|
|
28
|
+
lastLogTerm: 1n
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const chunks = encodeMessage(msg);
|
|
32
|
+
t1.sendv(2, chunks);
|
|
33
|
+
|
|
34
|
+
assert.ok(received);
|
|
35
|
+
assert.equal(received.from, 1);
|
|
36
|
+
assert.equal(received.msg.type, MessageType.PRE_VOTE_REQUEST);
|
|
37
|
+
assert.equal(received.msg.term, 1n);
|
|
38
|
+
|
|
39
|
+
await t1.close();
|
|
40
|
+
await t2.close();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('TCPTransport - peer communication and framing over loopback', async () => {
|
|
44
|
+
const port1 = 19101;
|
|
45
|
+
const port2 = 19102;
|
|
46
|
+
|
|
47
|
+
const peerAddrs1 = new Map([[2, { host: '127.0.0.1', port: port2 }]]);
|
|
48
|
+
const peerAddrs2 = new Map([[1, { host: '127.0.0.1', port: port1 }]]);
|
|
49
|
+
|
|
50
|
+
const tcp1 = new TCPTransport({ id: 1, port: port1, peerAddresses: peerAddrs1 });
|
|
51
|
+
const tcp2 = new TCPTransport({ id: 2, port: port2, peerAddresses: peerAddrs2 });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await tcp1.start();
|
|
55
|
+
await tcp2.start();
|
|
56
|
+
|
|
57
|
+
const receivedPromise = new Promise((resolve) => {
|
|
58
|
+
tcp2.onMessage((from, msg) => {
|
|
59
|
+
resolve({ from, msg });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const msg = {
|
|
64
|
+
type: MessageType.APPEND_REQUEST,
|
|
65
|
+
term: 5n,
|
|
66
|
+
sourceNode: 1,
|
|
67
|
+
destNode: 2,
|
|
68
|
+
leaderId: 1,
|
|
69
|
+
prevLogIndex: 2n,
|
|
70
|
+
prevLogTerm: 1n,
|
|
71
|
+
leaderCommit: 2n,
|
|
72
|
+
entries: [
|
|
73
|
+
{ term: 5n, index: 3n, payload: new Uint8Array([10, 20, 30]) }
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const chunks = encodeMessage(msg);
|
|
78
|
+
tcp1.sendv(2, chunks);
|
|
79
|
+
|
|
80
|
+
const received = await receivedPromise;
|
|
81
|
+
assert.equal(received.from, 1);
|
|
82
|
+
assert.equal(received.msg.type, MessageType.APPEND_REQUEST);
|
|
83
|
+
assert.equal(received.msg.term, 5n);
|
|
84
|
+
assert.equal(received.msg.entries.length, 1);
|
|
85
|
+
assert.deepEqual(received.msg.entries[0].payload, new Uint8Array([10, 20, 30]));
|
|
86
|
+
} finally {
|
|
87
|
+
await tcp1.close();
|
|
88
|
+
await tcp2.close();
|
|
89
|
+
}
|
|
90
|
+
});
|