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,146 @@
1
+ /**
2
+ * Safety invariant assertions for Raptiye clusters.
3
+ * Enforces the 8 core Raft safety properties across the cluster state.
4
+ */
5
+
6
+ export class ClusterInvariants {
7
+ constructor() {
8
+ // History of committed entries by index: index -> { term, checksum }
9
+ this.committedEntries = new Map();
10
+ }
11
+
12
+ /**
13
+ * Run all invariant checks across all nodes in the cluster.
14
+ * @param {Array<{ id: number, engine: object, storage: object }>} nodes
15
+ */
16
+ check(nodes) {
17
+ this.checkSingleLeaderPerTerm(nodes);
18
+ this.checkCommitIndexMonotonicity(nodes);
19
+ this.checkLastAppliedLeqCommitIndex(nodes);
20
+ this.checkLogMatching(nodes);
21
+ this.checkCommittedEntriesImmutability(nodes);
22
+ }
23
+
24
+ /**
25
+ * Invariant 1: Election Safety
26
+ * At most one leader can be elected in a given term.
27
+ */
28
+ checkSingleLeaderPerTerm(nodes) {
29
+ const termLeaders = new Map();
30
+ for (const node of nodes) {
31
+ if (node.engine.role === 4 /* LEADER */) {
32
+ const term = node.engine.currentTerm;
33
+ if (termLeaders.has(term)) {
34
+ const existing = termLeaders.get(term);
35
+ if (existing !== node.id) {
36
+ throw new Error(
37
+ `Invariant Violation [Election Safety]: Multiple leaders in term ${term}: node ${existing} and node ${node.id}`
38
+ );
39
+ }
40
+ } else {
41
+ termLeaders.set(term, node.id);
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Invariant 4: CommitIndex Monotonicity
49
+ * A node's commitIndex never decreases.
50
+ */
51
+ checkCommitIndexMonotonicity(nodes) {
52
+ for (const node of nodes) {
53
+ if (node.engine.commitIndex < node.engine._prevCheckedCommitIndex) {
54
+ throw new Error(
55
+ `Invariant Violation [Commit Index]: Node ${node.id} commitIndex decreased: ${node.engine._prevCheckedCommitIndex} -> ${node.engine.commitIndex}`
56
+ );
57
+ }
58
+ node.engine._prevCheckedCommitIndex = node.engine.commitIndex;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Invariant 5: LastApplied <= CommitIndex
64
+ */
65
+ checkLastAppliedLeqCommitIndex(nodes) {
66
+ for (const node of nodes) {
67
+ if (node.engine.lastApplied > node.engine.commitIndex) {
68
+ throw new Error(
69
+ `Invariant Violation [Applied Leq Commit]: Node ${node.id} lastApplied (${node.engine.lastApplied}) > commitIndex (${node.engine.commitIndex})`
70
+ );
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Invariant 7: Log Matching Property
77
+ * If two logs contain an entry with the same index and term,
78
+ * then the logs are identical in all entries up through the given index.
79
+ */
80
+ checkLogMatching(nodes) {
81
+ for (let i = 0; i < nodes.length; i++) {
82
+ for (let j = i + 1; j < nodes.length; j++) {
83
+ const n1 = nodes[i];
84
+ const n2 = nodes[j];
85
+ const minLast = n1.storage.lastIndex() < n2.storage.lastIndex()
86
+ ? n1.storage.lastIndex()
87
+ : n2.storage.lastIndex();
88
+
89
+ const firstCommon = n1.storage.firstIndex() > n2.storage.firstIndex()
90
+ ? n1.storage.firstIndex()
91
+ : n2.storage.firstIndex();
92
+
93
+ let matchedAtHigher = false;
94
+ // Scan backwards from minLast
95
+ for (let idx = minLast; idx >= firstCommon; idx--) {
96
+ const t1 = n1.storage.term(idx);
97
+ const t2 = n2.storage.term(idx);
98
+ if (t1 !== null && t2 !== null) {
99
+ if (t1 === t2) {
100
+ matchedAtHigher = true;
101
+ } else if (matchedAtHigher) {
102
+ // Violated: matched at a higher index, but differed at prior index!
103
+ throw new Error(
104
+ `Invariant Violation [Log Matching]: Node ${n1.id} and Node ${n2.id} matched at higher index but differ at prior index ${idx} (${t1} vs ${t2})`
105
+ );
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Invariants 2 & 3: Committed Entries Durability and State Machine Safety
115
+ * Once an entry is committed by any leader, it must never disappear
116
+ * and no node may commit a different entry for that index.
117
+ */
118
+ checkCommittedEntriesImmutability(nodes) {
119
+ for (const node of nodes) {
120
+ const commitIndex = node.engine.commitIndex;
121
+ if (commitIndex === 0n) continue;
122
+
123
+ const first = node.storage.firstIndex();
124
+ const checkFrom = first > 1n ? first : 1n;
125
+
126
+ if (commitIndex >= checkFrom) {
127
+ const entries = node.storage.entries(checkFrom, commitIndex);
128
+ for (const entry of entries) {
129
+ const existing = this.committedEntries.get(entry.index);
130
+ if (existing) {
131
+ if (existing.term !== entry.term || existing.checksum !== entry.checksum) {
132
+ throw new Error(
133
+ `Invariant Violation [State Machine Safety]: Committed index ${entry.index} has conflicting entries! Existing: term ${existing.term}, crc ${existing.checksum}; Node ${node.id}: term ${entry.term}, crc ${entry.checksum}`
134
+ );
135
+ }
136
+ } else {
137
+ this.committedEntries.set(entry.index, {
138
+ term: entry.term,
139
+ checksum: entry.checksum
140
+ });
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * High-level Raptiye Node coordinating consensus engine, storage, transport, and public API.
3
+ */
4
+
5
+ import { ConsensusEngine } from '../core/engine.js';
6
+ import { Role, RoleName, EffectType, EventType, MessageType } from '../types.js';
7
+ import { encodeMessage } from '../protocol/wire.js';
8
+ import { NodeStats } from './stats.js';
9
+
10
+ export class Raptiye {
11
+ /**
12
+ * @param {object} config
13
+ * @param {number} config.id
14
+ * @param {number[]} [config.peers=[]]
15
+ * @param {import('../storage/interface.js').LogStorage} config.storage
16
+ * @param {import('../transport/interface.js').Transport} config.transport
17
+ * @param {(entry: { term: bigint, index: bigint, payload: Uint8Array }) => void} [config.apply]
18
+ * @param {object} [config.election]
19
+ * @param {number} [config.election.minTimeout=150]
20
+ * @param {number} [config.election.maxTimeout=300]
21
+ * @param {number} [config.heartbeatInterval=50]
22
+ * @param {object} [config.replication]
23
+ * @param {number} [config.replication.maxBatchBytes=64*1024]
24
+ * @param {number} [config.replication.maxBatchEntries=256]
25
+ * @param {number} [config.replication.maxInflightBatches=16]
26
+ * @param {number} [config.replication.maxInflightBytes=4*1024*1024]
27
+ */
28
+ constructor({
29
+ id,
30
+ peers = [],
31
+ storage,
32
+ transport,
33
+ apply = null,
34
+ election = {},
35
+ heartbeatInterval = 50,
36
+ replication = {}
37
+ }) {
38
+ this.id = Number(id);
39
+ this.peers = peers.map(Number).filter(p => p !== this.id);
40
+ this.storage = storage;
41
+ this.transport = transport;
42
+ this.applyCallback = apply;
43
+
44
+ this.minElectionTimeout = election.minTimeout || 150;
45
+ this.maxElectionTimeout = election.maxTimeout || 300;
46
+ this.heartbeatInterval = heartbeatInterval;
47
+
48
+ this.engine = new ConsensusEngine({
49
+ id: this.id,
50
+ peers: this.peers,
51
+ storage: this.storage,
52
+ maxBatchBytes: replication.maxBatchBytes,
53
+ maxBatchEntries: replication.maxBatchEntries,
54
+ maxInflightBatches: replication.maxInflightBatches,
55
+ maxInflightBytes: replication.maxInflightBytes
56
+ });
57
+
58
+ this.statsCollector = new NodeStats();
59
+ this.started = false;
60
+ this._electionTimer = null;
61
+ this._heartbeatTimer = null;
62
+
63
+ // Fast sequence -> promise resolvers for committed entries
64
+ this._pendingCommits = new Map();
65
+
66
+ // Wire up transport incoming message handler
67
+ this.transport.onMessage((from, msg) => {
68
+ if (!this.started) return;
69
+ this._step({
70
+ type: EventType.MESSAGE,
71
+ message: msg
72
+ });
73
+ });
74
+ }
75
+
76
+ async start() {
77
+ if (this.started) return;
78
+ this.started = true;
79
+ await this.transport.start();
80
+ this._resetElectionTimer();
81
+ }
82
+
83
+ role() {
84
+ return RoleName[this.engine.role];
85
+ }
86
+
87
+ leader() {
88
+ return this.engine.leaderId;
89
+ }
90
+
91
+ stats() {
92
+ return this.statsCollector.snapshot(this);
93
+ }
94
+
95
+ /**
96
+ * Submit an opaque binary payload for consensus replication.
97
+ * @param {Uint8Array} payload
98
+ * @returns {bigint} Log index assigned to this command
99
+ */
100
+ submit(payload) {
101
+ if (this.engine.role !== Role.LEADER) {
102
+ throw new Error(`Not leader (current leader is ${this.engine.leaderId})`);
103
+ }
104
+
105
+ this.statsCollector.recordSubmit(payload.byteLength);
106
+
107
+ const targetIndex = this.storage.lastIndex() + 1n;
108
+
109
+ this._step({
110
+ type: EventType.SUBMIT,
111
+ payload
112
+ });
113
+
114
+ return targetIndex;
115
+ }
116
+
117
+ /**
118
+ * Wait for a submitted sequence/log index to be committed by consensus quorum.
119
+ * @param {bigint} index
120
+ * @returns {Promise<void>}
121
+ */
122
+ committed(index) {
123
+ const idx = BigInt(index);
124
+ if (this.engine.commitIndex >= idx) {
125
+ return Promise.resolve();
126
+ }
127
+
128
+ return new Promise((resolve, reject) => {
129
+ let bucket = this._pendingCommits.get(idx);
130
+ if (!bucket) {
131
+ bucket = [];
132
+ this._pendingCommits.set(idx, bucket);
133
+ }
134
+ bucket.push({ resolve, reject });
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Transfer leadership to a specific peer.
140
+ * @param {number} targetPeer
141
+ * @returns {Promise<void>}
142
+ */
143
+ async transferLeadership(targetPeer) {
144
+ if (this.engine.role !== Role.LEADER) {
145
+ throw new Error(`Not leader`);
146
+ }
147
+
148
+ this._step({
149
+ type: EventType.TRANSFER_LEADERSHIP,
150
+ targetPeer: Number(targetPeer)
151
+ });
152
+
153
+ // Wait until leadership leaves this node or times out
154
+ const start = Date.now();
155
+ while (this.engine.role === Role.LEADER && Date.now() - start < 3000) {
156
+ await new Promise(resolve => setTimeout(resolve, 20));
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Graceful shutdown with optional leadership transfer.
162
+ * @param {object} [options]
163
+ * @param {boolean} [options.transferLeadership=false]
164
+ */
165
+ async shutdown({ transferLeadership = false } = {}) {
166
+ if (transferLeadership && this.engine.role === Role.LEADER && this.peers.length > 0) {
167
+ const candidate = this.peers[0];
168
+ try {
169
+ await this.transferLeadership(candidate);
170
+ } catch {
171
+ // Continue shutdown
172
+ }
173
+ }
174
+
175
+ this.started = false;
176
+ this._clearTimers();
177
+
178
+ // Reject any pending uncommitted submissions
179
+ for (const bucket of this._pendingCommits.values()) {
180
+ for (const p of bucket) {
181
+ p.reject(new Error('Node shutting down'));
182
+ }
183
+ }
184
+ this._pendingCommits.clear();
185
+
186
+ await this.transport.close();
187
+ this.storage.close();
188
+ }
189
+
190
+ _step(event) {
191
+ const prevRole = this.engine.role;
192
+ const effects = this.engine.step(event);
193
+ this._processEffects(effects);
194
+
195
+ if (this.engine.role !== prevRole) {
196
+ this.statsCollector.recordLeaderChange();
197
+ if (this.engine.role === Role.LEADER) {
198
+ this.statsCollector.recordElection();
199
+ }
200
+ }
201
+ }
202
+
203
+ _processEffects(effects) {
204
+ for (let i = 0; i < effects.length; i++) {
205
+ const effect = effects[i];
206
+ switch (effect.type) {
207
+ case EffectType.SEND: {
208
+ const chunks = encodeMessage(effect.message);
209
+ this.transport.sendv(effect.to, chunks);
210
+ break;
211
+ }
212
+
213
+ case EffectType.RESET_ELECTION_TIMER:
214
+ this._resetElectionTimer();
215
+ break;
216
+
217
+ case EffectType.RESET_HEARTBEAT_TIMER:
218
+ this._resetHeartbeatTimer();
219
+ break;
220
+
221
+ case EffectType.BECOME_LEADER:
222
+ this._clearElectionTimer();
223
+ this._resetHeartbeatTimer();
224
+ break;
225
+
226
+ case EffectType.BECOME_FOLLOWER:
227
+ this._clearHeartbeatTimer();
228
+ this._resetElectionTimer();
229
+ break;
230
+
231
+ case EffectType.APPLY:
232
+ this.statsCollector.recordApply();
233
+ if (this.applyCallback) {
234
+ this.applyCallback(effect.entry);
235
+ }
236
+ break;
237
+
238
+ case EffectType.NOTIFY_COMMITTED: {
239
+ this.statsCollector.recordCommit();
240
+ const idx = effect.index;
241
+ const bucket = this._pendingCommits.get(idx);
242
+ if (bucket) {
243
+ for (let j = 0; j < bucket.length; j++) {
244
+ bucket[j].resolve();
245
+ }
246
+ this._pendingCommits.delete(idx);
247
+ }
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ _resetElectionTimer() {
255
+ if (!this.started || this.engine.role === Role.LEADER) return;
256
+ this._clearElectionTimer();
257
+
258
+ // Jittered random election timeout
259
+ const range = this.maxElectionTimeout - this.minElectionTimeout;
260
+ const timeout = this.minElectionTimeout + Math.floor(Math.random() * (range + 1));
261
+
262
+ this._electionTimer = setTimeout(() => {
263
+ this._electionTimer = null;
264
+ this._step({ type: EventType.ELECTION_TIMEOUT });
265
+ }, timeout);
266
+ }
267
+
268
+ _clearElectionTimer() {
269
+ if (this._electionTimer) {
270
+ clearTimeout(this._electionTimer);
271
+ this._electionTimer = null;
272
+ }
273
+ }
274
+
275
+ _resetHeartbeatTimer() {
276
+ if (!this.started || this.engine.role !== Role.LEADER) return;
277
+ this._clearHeartbeatTimer();
278
+
279
+ this._heartbeatTimer = setTimeout(() => {
280
+ this._heartbeatTimer = null;
281
+ this._step({ type: EventType.HEARTBEAT_TIMEOUT });
282
+ }, this.heartbeatInterval);
283
+ }
284
+
285
+ _clearHeartbeatTimer() {
286
+ if (this._heartbeatTimer) {
287
+ clearTimeout(this._heartbeatTimer);
288
+ this._heartbeatTimer = null;
289
+ }
290
+ }
291
+
292
+ _clearTimers() {
293
+ this._clearElectionTimer();
294
+ this._clearHeartbeatTimer();
295
+ }
296
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Zero-allocation aggregated counters and telemetry for Raptiye.
3
+ */
4
+
5
+ export class NodeStats {
6
+ constructor() {
7
+ this.submitted = 0;
8
+ this.replicated = 0;
9
+ this.committed = 0;
10
+ this.applied = 0;
11
+
12
+ this.wireBytes = 0;
13
+ this.payloadBytes = 0;
14
+ this.protocolOverheadBytes = 0;
15
+ this.copiedBytes = 0;
16
+ this.allocatedBytes = 0;
17
+
18
+ this.elections = 0;
19
+ this.failedElections = 0;
20
+ this.leaderChanges = 0;
21
+
22
+ this.snapshotsSent = 0;
23
+ this.snapshotsReceived = 0;
24
+ this.catchupEntries = 0;
25
+ }
26
+
27
+ recordSubmit(byteLength) {
28
+ this.submitted++;
29
+ this.payloadBytes += byteLength;
30
+ }
31
+
32
+ recordCommit() {
33
+ this.committed++;
34
+ }
35
+
36
+ recordApply() {
37
+ this.applied++;
38
+ }
39
+
40
+ recordElection() {
41
+ this.elections++;
42
+ }
43
+
44
+ recordLeaderChange() {
45
+ this.leaderChanges++;
46
+ }
47
+
48
+ recordWireBytes(wire, payload, overhead, copied, allocated) {
49
+ this.wireBytes += wire;
50
+ this.protocolOverheadBytes += overhead;
51
+ this.copiedBytes += copied;
52
+ this.allocatedBytes += allocated;
53
+ }
54
+
55
+ snapshot(node) {
56
+ const engine = node ? node.engine : null;
57
+ return {
58
+ role: node ? node.role() : null,
59
+ term: engine ? engine.currentTerm : 0n,
60
+ leaderId: engine ? engine.leaderId : null,
61
+ commitIndex: engine ? engine.commitIndex : 0n,
62
+ lastApplied: engine ? engine.lastApplied : 0n,
63
+ lastLogIndex: node && node.storage ? node.storage.lastIndex() : 0n,
64
+
65
+ submitted: this.submitted,
66
+ committed: this.committed,
67
+ applied: this.applied,
68
+
69
+ wireBytes: this.wireBytes,
70
+ payloadBytes: this.payloadBytes,
71
+ protocolOverheadBytes: this.protocolOverheadBytes,
72
+ copiedBytes: this.copiedBytes,
73
+ allocatedBytes: this.allocatedBytes,
74
+ copiesPerReplicatedByte: this.payloadBytes > 0 ? (this.copiedBytes / this.payloadBytes) : 0,
75
+
76
+ elections: this.elections,
77
+ leaderChanges: this.leaderChanges,
78
+ snapshotsSent: this.snapshotsSent,
79
+ snapshotsReceived: this.snapshotsReceived
80
+ };
81
+ }
82
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * High-performance, zero-dependency CRC32 implementation using Uint32Array lookup table.
3
+ */
4
+
5
+ const CRC_TABLE = new Uint32Array(256);
6
+
7
+ // Precompute CRC-32 lookup table (polynomial 0xEDB88320)
8
+ for (let i = 0; i < 256; i++) {
9
+ let c = i;
10
+ for (let j = 0; j < 8; j++) {
11
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
12
+ }
13
+ CRC_TABLE[i] = c >>> 0;
14
+ }
15
+
16
+ /**
17
+ * Calculate CRC32 of a Uint8Array buffer or slice.
18
+ * @param {Uint8Array} buf
19
+ * @param {number} [offset=0]
20
+ * @param {number} [length=buf.length - offset]
21
+ * @param {number} [prevCrc=0]
22
+ * @returns {number} 32-bit unsigned integer
23
+ */
24
+ export function crc32(buf, offset = 0, length = buf.length - offset, prevCrc = 0) {
25
+ let crc = (prevCrc ^ -1) >>> 0;
26
+ const end = offset + length;
27
+
28
+ // Unroll loop for speed
29
+ let i = offset;
30
+ const unrolledEnd = offset + ((length >>> 3) << 3);
31
+
32
+ while (i < unrolledEnd) {
33
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xFF];
34
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 1]) & 0xFF];
35
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 2]) & 0xFF];
36
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 3]) & 0xFF];
37
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 4]) & 0xFF];
38
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 5]) & 0xFF];
39
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 6]) & 0xFF];
40
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i + 7]) & 0xFF];
41
+ i += 8;
42
+ }
43
+
44
+ while (i < end) {
45
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xFF];
46
+ i++;
47
+ }
48
+
49
+ return (crc ^ -1) >>> 0;
50
+ }
51
+
52
+ /**
53
+ * Update CRC32 across multiple buffers (scatter/gather).
54
+ * @param {Uint8Array[]} buffers
55
+ * @returns {number}
56
+ */
57
+ export function crc32Buffers(buffers) {
58
+ let crc = 0;
59
+ for (let i = 0; i < buffers.length; i++) {
60
+ const buf = buffers[i];
61
+ if (buf && buf.byteLength > 0) {
62
+ crc = crc32(buf, 0, buf.byteLength, crc);
63
+ }
64
+ }
65
+ return crc;
66
+ }