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,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
+ });