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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic point-to-point batch replication pipeline.
|
|
3
|
+
* Manages reliable delivery, in-flight windowing, flow control, and ACKs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MessageType } from '../types.js';
|
|
7
|
+
import { encodeMessage } from '../protocol/wire.js';
|
|
8
|
+
|
|
9
|
+
export class BatchReceipt {
|
|
10
|
+
/**
|
|
11
|
+
* @param {number} generationId
|
|
12
|
+
* @param {Promise<object>} promise
|
|
13
|
+
*/
|
|
14
|
+
constructor(generationId, promise) {
|
|
15
|
+
this.generationId = generationId;
|
|
16
|
+
this._promise = promise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
delivered() {
|
|
20
|
+
return this._promise;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ReplicationPipeline {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} config
|
|
27
|
+
* @param {import('../transport/interface.js').Transport} config.transport
|
|
28
|
+
* @param {number} [config.localNode=1]
|
|
29
|
+
* @param {number} [config.remoteNode=2]
|
|
30
|
+
* @param {number} [config.maxInflightBatches=8]
|
|
31
|
+
* @param {number} [config.maxInflightBytes=32 * 1024 * 1024]
|
|
32
|
+
*/
|
|
33
|
+
constructor({
|
|
34
|
+
transport,
|
|
35
|
+
localNode = 1,
|
|
36
|
+
remoteNode = 2,
|
|
37
|
+
maxInflightBatches = 8,
|
|
38
|
+
maxInflightBytes = 32 * 1024 * 1024
|
|
39
|
+
}) {
|
|
40
|
+
this.transport = transport;
|
|
41
|
+
this.localNode = Number(localNode);
|
|
42
|
+
this.remoteNode = Number(remoteNode);
|
|
43
|
+
this.maxInflightBatches = maxInflightBatches;
|
|
44
|
+
this.maxInflightBytes = maxInflightBytes;
|
|
45
|
+
|
|
46
|
+
this.inflightBatches = 0;
|
|
47
|
+
this.inflightBytes = 0;
|
|
48
|
+
|
|
49
|
+
// generationId -> { resolve, reject, sentAt, bytes }
|
|
50
|
+
this._pendingReceipts = new Map();
|
|
51
|
+
// Capacity waiters
|
|
52
|
+
this._capacityWaiters = [];
|
|
53
|
+
// Receiver callback
|
|
54
|
+
this._batchHandler = null;
|
|
55
|
+
|
|
56
|
+
// Metrics
|
|
57
|
+
this.stats = {
|
|
58
|
+
payloadBytes: 0,
|
|
59
|
+
wireBytes: 0,
|
|
60
|
+
frames: 0,
|
|
61
|
+
tcpWrites: 0,
|
|
62
|
+
tcpDrains: 0,
|
|
63
|
+
inflightBytes: 0,
|
|
64
|
+
inflightBatches: 0,
|
|
65
|
+
rttMs: 0,
|
|
66
|
+
ackLatencyMs: 0,
|
|
67
|
+
reconnects: 0
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Listen to transport messages
|
|
71
|
+
this.transport.onMessage((from, msg) => {
|
|
72
|
+
this._handleMessage(from, msg);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onBatch(handler) {
|
|
77
|
+
this._batchHandler = handler;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
canSubmit(bytes = 0) {
|
|
81
|
+
return (
|
|
82
|
+
this.inflightBatches < this.maxInflightBatches &&
|
|
83
|
+
(this.inflightBytes + bytes) <= this.maxInflightBytes
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async waitForCapacity(bytes = 0) {
|
|
88
|
+
while (!this.canSubmit(bytes)) {
|
|
89
|
+
await new Promise((resolve) => this._capacityWaiters.push(resolve));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_notifyCapacity() {
|
|
94
|
+
while (this._capacityWaiters.length > 0) {
|
|
95
|
+
const waiter = this._capacityWaiters.shift();
|
|
96
|
+
waiter();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Submit a batch to be replicated to the remote peer.
|
|
102
|
+
* @param {object} batch
|
|
103
|
+
* @param {number} batch.generationId
|
|
104
|
+
* @param {number|bigint} batch.firstSequence
|
|
105
|
+
* @param {number|bigint} batch.lastSequence
|
|
106
|
+
* @param {number} batch.entryCount
|
|
107
|
+
* @param {number} [batch.byteLength]
|
|
108
|
+
* @param {Uint8Array[]} batch.buffers
|
|
109
|
+
* @returns {Promise<BatchReceipt>}
|
|
110
|
+
*/
|
|
111
|
+
async submitBatch({
|
|
112
|
+
generationId,
|
|
113
|
+
firstSequence,
|
|
114
|
+
lastSequence,
|
|
115
|
+
entryCount,
|
|
116
|
+
byteLength = 0,
|
|
117
|
+
buffers = []
|
|
118
|
+
}) {
|
|
119
|
+
let totalPayload = byteLength;
|
|
120
|
+
if (totalPayload === 0) {
|
|
121
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
122
|
+
totalPayload += buffers[i].byteLength;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Await inflight capacity (backpressure from slow network / slow receiver)
|
|
127
|
+
await this.waitForCapacity(totalPayload);
|
|
128
|
+
|
|
129
|
+
this.inflightBatches++;
|
|
130
|
+
this.inflightBytes += totalPayload;
|
|
131
|
+
this.stats.inflightBatches = this.inflightBatches;
|
|
132
|
+
this.stats.inflightBytes = this.inflightBytes;
|
|
133
|
+
|
|
134
|
+
const deliveryPromise = new Promise((resolve, reject) => {
|
|
135
|
+
this._pendingReceipts.set(generationId, {
|
|
136
|
+
resolve,
|
|
137
|
+
reject,
|
|
138
|
+
sentAt: Date.now(),
|
|
139
|
+
bytes: totalPayload
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const chunks = encodeMessage({
|
|
144
|
+
type: MessageType.BATCH_DATA,
|
|
145
|
+
sourceNode: this.localNode,
|
|
146
|
+
destNode: this.remoteNode,
|
|
147
|
+
generationId,
|
|
148
|
+
firstSequence: BigInt(firstSequence),
|
|
149
|
+
lastSequence: BigInt(lastSequence),
|
|
150
|
+
entryCount,
|
|
151
|
+
buffers
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.stats.frames++;
|
|
155
|
+
this.stats.tcpWrites++;
|
|
156
|
+
this.stats.payloadBytes += totalPayload;
|
|
157
|
+
|
|
158
|
+
// Approximate wire bytes from chunks
|
|
159
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
160
|
+
this.stats.wireBytes += chunks[i].byteLength;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.transport.sendv(this.remoteNode, chunks);
|
|
164
|
+
|
|
165
|
+
return new BatchReceipt(generationId, deliveryPromise);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async _handleMessage(from, msg) {
|
|
169
|
+
if (msg.type === MessageType.BATCH_DATA) {
|
|
170
|
+
this.stats.frames++;
|
|
171
|
+
if (this._batchHandler) {
|
|
172
|
+
try {
|
|
173
|
+
await this._batchHandler(msg);
|
|
174
|
+
// Send ACK
|
|
175
|
+
const ackChunks = encodeMessage({
|
|
176
|
+
type: MessageType.BATCH_ACK,
|
|
177
|
+
sourceNode: this.localNode,
|
|
178
|
+
destNode: from,
|
|
179
|
+
generationId: msg.generationId,
|
|
180
|
+
sequence: msg.lastSequence,
|
|
181
|
+
bytesReceived: BigInt(msg.buffers ? msg.buffers.reduce((a, b) => a + b.byteLength, 0) : 0),
|
|
182
|
+
status: 0
|
|
183
|
+
});
|
|
184
|
+
this.transport.sendv(from, ackChunks);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const nackChunks = encodeMessage({
|
|
187
|
+
type: MessageType.BATCH_ACK,
|
|
188
|
+
sourceNode: this.localNode,
|
|
189
|
+
destNode: from,
|
|
190
|
+
generationId: msg.generationId,
|
|
191
|
+
sequence: msg.lastSequence,
|
|
192
|
+
bytesReceived: 0n,
|
|
193
|
+
status: 1
|
|
194
|
+
});
|
|
195
|
+
this.transport.sendv(from, nackChunks);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else if (msg.type === MessageType.BATCH_ACK) {
|
|
199
|
+
const pending = this._pendingReceipts.get(msg.generationId);
|
|
200
|
+
if (pending) {
|
|
201
|
+
this._pendingReceipts.delete(msg.generationId);
|
|
202
|
+
|
|
203
|
+
const rtt = Math.max(0, Date.now() - pending.sentAt);
|
|
204
|
+
this.stats.rttMs = rtt;
|
|
205
|
+
this.stats.ackLatencyMs = rtt;
|
|
206
|
+
|
|
207
|
+
this.inflightBatches = Math.max(0, this.inflightBatches - 1);
|
|
208
|
+
this.inflightBytes = Math.max(0, this.inflightBytes - pending.bytes);
|
|
209
|
+
this.stats.inflightBatches = this.inflightBatches;
|
|
210
|
+
this.stats.inflightBytes = this.inflightBytes;
|
|
211
|
+
|
|
212
|
+
if (msg.status === 0) {
|
|
213
|
+
pending.resolve(msg);
|
|
214
|
+
} else {
|
|
215
|
+
pending.reject(new Error(`Remote node rejected batch ${msg.generationId}`));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this._notifyCapacity();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Wait until all submitted batches are delivered.
|
|
225
|
+
*/
|
|
226
|
+
async drain() {
|
|
227
|
+
while (this._pendingReceipts.size > 0) {
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getStats() {
|
|
233
|
+
return { ...this.stats };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Cluster Simulator for Raptiye.
|
|
3
|
+
* Fully reproducible from random seed with comprehensive fault injection.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { PRNG } from './prng.js';
|
|
7
|
+
import { VirtualClock } from './virtual-clock.js';
|
|
8
|
+
import { ConsensusEngine } from '../core/engine.js';
|
|
9
|
+
import { MemoryLog } from '../storage/memory-log.js';
|
|
10
|
+
import { ClusterInvariants } from '../core/invariants.js';
|
|
11
|
+
import { Role, EffectType, EventType } from '../types.js';
|
|
12
|
+
|
|
13
|
+
export class SimulatedNode {
|
|
14
|
+
constructor({ id, peers, storage, clock, prng, cluster, minElection = 150, maxElection = 300, heartbeatInterval = 50 }) {
|
|
15
|
+
this.id = Number(id);
|
|
16
|
+
this.peers = peers;
|
|
17
|
+
this.storage = storage;
|
|
18
|
+
this.clock = clock;
|
|
19
|
+
this.prng = prng;
|
|
20
|
+
this.cluster = cluster;
|
|
21
|
+
|
|
22
|
+
this.minElection = minElection;
|
|
23
|
+
this.maxElection = maxElection;
|
|
24
|
+
this.heartbeatInterval = heartbeatInterval;
|
|
25
|
+
|
|
26
|
+
this.alive = true;
|
|
27
|
+
this.electionTimerId = null;
|
|
28
|
+
this.heartbeatTimerId = null;
|
|
29
|
+
|
|
30
|
+
this.appliedEntries = [];
|
|
31
|
+
this.committedNotifications = [];
|
|
32
|
+
|
|
33
|
+
this.engine = new ConsensusEngine({
|
|
34
|
+
id: this.id,
|
|
35
|
+
peers: this.peers,
|
|
36
|
+
storage: this.storage
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
start() {
|
|
41
|
+
this.resetElectionTimer();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
stop() {
|
|
45
|
+
this.alive = false;
|
|
46
|
+
if (this.electionTimerId) {
|
|
47
|
+
this.clock.clearTimeout(this.electionTimerId);
|
|
48
|
+
this.electionTimerId = null;
|
|
49
|
+
}
|
|
50
|
+
if (this.heartbeatTimerId) {
|
|
51
|
+
this.clock.clearTimeout(this.heartbeatTimerId);
|
|
52
|
+
this.heartbeatTimerId = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
restart() {
|
|
57
|
+
this.alive = true;
|
|
58
|
+
// Recreate engine from stored state
|
|
59
|
+
this.engine = new ConsensusEngine({
|
|
60
|
+
id: this.id,
|
|
61
|
+
peers: this.peers,
|
|
62
|
+
storage: this.storage
|
|
63
|
+
});
|
|
64
|
+
this.start();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
resetElectionTimer() {
|
|
68
|
+
if (!this.alive) return;
|
|
69
|
+
if (this.electionTimerId) {
|
|
70
|
+
this.clock.clearTimeout(this.electionTimerId);
|
|
71
|
+
}
|
|
72
|
+
const timeout = this.prng.nextInt(this.minElection, this.maxElection);
|
|
73
|
+
this.electionTimerId = this.clock.setTimeout(() => {
|
|
74
|
+
this.electionTimerId = null;
|
|
75
|
+
this.step({ type: EventType.ELECTION_TIMEOUT });
|
|
76
|
+
}, timeout);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resetHeartbeatTimer() {
|
|
80
|
+
if (!this.alive) return;
|
|
81
|
+
if (this.heartbeatTimerId) {
|
|
82
|
+
this.clock.clearTimeout(this.heartbeatTimerId);
|
|
83
|
+
}
|
|
84
|
+
this.heartbeatTimerId = this.clock.setTimeout(() => {
|
|
85
|
+
this.heartbeatTimerId = null;
|
|
86
|
+
this.step({ type: EventType.HEARTBEAT_TIMEOUT });
|
|
87
|
+
}, this.heartbeatInterval);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
step(event) {
|
|
91
|
+
if (!this.alive) return [];
|
|
92
|
+
const effects = this.engine.step(event);
|
|
93
|
+
this.processEffects(effects);
|
|
94
|
+
return effects;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
processEffects(effects) {
|
|
98
|
+
for (const effect of effects) {
|
|
99
|
+
switch (effect.type) {
|
|
100
|
+
case EffectType.SEND:
|
|
101
|
+
this.cluster.deliverMessage(this.id, effect.to, effect.message);
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case EffectType.RESET_ELECTION_TIMER:
|
|
105
|
+
this.resetElectionTimer();
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case EffectType.RESET_HEARTBEAT_TIMER:
|
|
109
|
+
this.resetHeartbeatTimer();
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case EffectType.BECOME_LEADER:
|
|
113
|
+
if (this.electionTimerId) {
|
|
114
|
+
this.clock.clearTimeout(this.electionTimerId);
|
|
115
|
+
this.electionTimerId = null;
|
|
116
|
+
}
|
|
117
|
+
this.resetHeartbeatTimer();
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case EffectType.BECOME_FOLLOWER:
|
|
121
|
+
if (this.heartbeatTimerId) {
|
|
122
|
+
this.clock.clearTimeout(this.heartbeatTimerId);
|
|
123
|
+
this.heartbeatTimerId = null;
|
|
124
|
+
}
|
|
125
|
+
this.resetElectionTimer();
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case EffectType.APPLY:
|
|
129
|
+
this.appliedEntries.push(effect.entry);
|
|
130
|
+
break;
|
|
131
|
+
|
|
132
|
+
case EffectType.NOTIFY_COMMITTED:
|
|
133
|
+
this.committedNotifications.push(effect.index);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class ClusterSimulator {
|
|
141
|
+
constructor({
|
|
142
|
+
nodeCount = 3,
|
|
143
|
+
seed = 184822,
|
|
144
|
+
minElection = 150,
|
|
145
|
+
maxElection = 300,
|
|
146
|
+
heartbeatInterval = 50,
|
|
147
|
+
packetDelayMin = 1,
|
|
148
|
+
packetDelayMax = 10,
|
|
149
|
+
packetDropRate = 0,
|
|
150
|
+
packetDuplicateRate = 0
|
|
151
|
+
} = {}) {
|
|
152
|
+
this.seed = seed;
|
|
153
|
+
this.prng = new PRNG(seed);
|
|
154
|
+
this.clock = new VirtualClock();
|
|
155
|
+
this.invariants = new ClusterInvariants();
|
|
156
|
+
|
|
157
|
+
this.packetDelayMin = packetDelayMin;
|
|
158
|
+
this.packetDelayMax = packetDelayMax;
|
|
159
|
+
this.packetDropRate = packetDropRate;
|
|
160
|
+
this.packetDuplicateRate = packetDuplicateRate;
|
|
161
|
+
|
|
162
|
+
this.partitions = new Set(); // Strings of "from:to"
|
|
163
|
+
this.nodes = new Map();
|
|
164
|
+
|
|
165
|
+
const nodeIds = [];
|
|
166
|
+
for (let i = 1; i <= nodeCount; i++) {
|
|
167
|
+
nodeIds.push(i);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const id of nodeIds) {
|
|
171
|
+
const peers = nodeIds.filter(nid => nid !== id);
|
|
172
|
+
const storage = new MemoryLog();
|
|
173
|
+
const node = new SimulatedNode({
|
|
174
|
+
id,
|
|
175
|
+
peers,
|
|
176
|
+
storage,
|
|
177
|
+
clock: this.clock,
|
|
178
|
+
prng: this.prng,
|
|
179
|
+
cluster: this,
|
|
180
|
+
minElection,
|
|
181
|
+
maxElection,
|
|
182
|
+
heartbeatInterval
|
|
183
|
+
});
|
|
184
|
+
this.nodes.set(id, node);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
start() {
|
|
189
|
+
for (const node of this.nodes.values()) {
|
|
190
|
+
node.start();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getNode(id) {
|
|
195
|
+
return this.nodes.get(Number(id));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
activeNodes() {
|
|
199
|
+
return Array.from(this.nodes.values()).filter(n => n.alive);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
getLeader() {
|
|
203
|
+
let bestLeader = null;
|
|
204
|
+
let maxTerm = -1n;
|
|
205
|
+
for (const node of this.activeNodes()) {
|
|
206
|
+
if (node.engine.role === Role.LEADER) {
|
|
207
|
+
if (node.engine.currentTerm > maxTerm) {
|
|
208
|
+
maxTerm = node.engine.currentTerm;
|
|
209
|
+
bestLeader = node;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return bestLeader;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
deliverMessage(from, to, message) {
|
|
217
|
+
const sender = this.getNode(from);
|
|
218
|
+
const receiver = this.getNode(to);
|
|
219
|
+
|
|
220
|
+
if (!sender || !receiver || !sender.alive || !receiver.alive) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check partition
|
|
225
|
+
if (this.isPartitioned(from, to)) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check packet drop
|
|
230
|
+
if (this.packetDropRate > 0 && this.prng.boolean(this.packetDropRate)) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sendPacket = () => {
|
|
235
|
+
const delay = this.prng.nextInt(this.packetDelayMin, this.packetDelayMax);
|
|
236
|
+
this.clock.setTimeout(() => {
|
|
237
|
+
if (receiver.alive && !this.isPartitioned(from, to)) {
|
|
238
|
+
receiver.step({
|
|
239
|
+
type: EventType.MESSAGE,
|
|
240
|
+
message
|
|
241
|
+
});
|
|
242
|
+
this.verify();
|
|
243
|
+
}
|
|
244
|
+
}, delay);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
sendPacket();
|
|
248
|
+
|
|
249
|
+
// Check packet duplication
|
|
250
|
+
if (this.packetDuplicateRate > 0 && this.prng.boolean(this.packetDuplicateRate)) {
|
|
251
|
+
sendPacket();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
isPartitioned(from, to) {
|
|
256
|
+
return this.partitions.has(`${from}:${to}`) || this.partitions.has(`${to}:${from}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
partition(groupA, groupB) {
|
|
260
|
+
for (const a of groupA) {
|
|
261
|
+
for (const b of groupB) {
|
|
262
|
+
this.partitions.add(`${a}:${b}`);
|
|
263
|
+
this.partitions.add(`${b}:${a}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
heal() {
|
|
269
|
+
this.partitions.clear();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
killNode(id) {
|
|
273
|
+
const node = this.getNode(id);
|
|
274
|
+
if (node) {
|
|
275
|
+
node.stop();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
restartNode(id) {
|
|
280
|
+
const node = this.getNode(id);
|
|
281
|
+
if (node) {
|
|
282
|
+
node.restart();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
submit(payload) {
|
|
287
|
+
const leader = this.getLeader();
|
|
288
|
+
if (!leader) {
|
|
289
|
+
return { success: false, error: 'NO_LEADER' };
|
|
290
|
+
}
|
|
291
|
+
leader.step({
|
|
292
|
+
type: EventType.SUBMIT,
|
|
293
|
+
payload
|
|
294
|
+
});
|
|
295
|
+
this.verify();
|
|
296
|
+
return { success: true, leaderId: leader.id };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
advance(ms) {
|
|
300
|
+
this.clock.advance(ms);
|
|
301
|
+
this.verify();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
runUntilQuiet(maxSteps = 1000) {
|
|
305
|
+
let steps = 0;
|
|
306
|
+
while (this.clock.pendingCount() > 0 && steps < maxSteps) {
|
|
307
|
+
this.clock.step();
|
|
308
|
+
this.verify();
|
|
309
|
+
steps++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
verify() {
|
|
314
|
+
this.invariants.check(this.activeNodes());
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/sim/prng.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded pseudo-random number generator for deterministic cluster simulations.
|
|
3
|
+
* Uses 32-bit Mulberry32 algorithm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class PRNG {
|
|
7
|
+
constructor(seed = 184822) {
|
|
8
|
+
this.initialSeed = seed;
|
|
9
|
+
this.s = seed >>> 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates a float in [0, 1).
|
|
14
|
+
* @returns {number}
|
|
15
|
+
*/
|
|
16
|
+
nextFloat() {
|
|
17
|
+
let t = (this.s += 0x6D2B79F5) >>> 0;
|
|
18
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
19
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generates an integer in [min, max] inclusive.
|
|
25
|
+
* @param {number} min
|
|
26
|
+
* @param {number} max
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
nextInt(min, max) {
|
|
30
|
+
return Math.floor(this.nextFloat() * (max - min + 1)) + min;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns true with given probability [0, 1].
|
|
35
|
+
* @param {number} probability
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
boolean(probability = 0.5) {
|
|
39
|
+
return this.nextFloat() < probability;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Picks a random element from an array.
|
|
44
|
+
* @template T
|
|
45
|
+
* @param {T[]} array
|
|
46
|
+
* @returns {T}
|
|
47
|
+
*/
|
|
48
|
+
pick(array) {
|
|
49
|
+
if (array.length === 0) return null;
|
|
50
|
+
return array[this.nextInt(0, array.length - 1)];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual discrete-event clock for deterministic simulation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class VirtualClock {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.now = 0;
|
|
8
|
+
this._timers = []; // Array of { id, dueTime, callback, period }
|
|
9
|
+
this._nextId = 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_insertTimer(timer) {
|
|
13
|
+
let low = 0;
|
|
14
|
+
let high = this._timers.length;
|
|
15
|
+
while (low < high) {
|
|
16
|
+
const mid = (low + high) >>> 1;
|
|
17
|
+
if (this._timers[mid].dueTime <= timer.dueTime) {
|
|
18
|
+
low = mid + 1;
|
|
19
|
+
} else {
|
|
20
|
+
high = mid;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
this._timers.splice(low, 0, timer);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setTimeout(callback, delayMs) {
|
|
27
|
+
const id = this._nextId++;
|
|
28
|
+
const dueTime = this.now + Math.max(0, delayMs);
|
|
29
|
+
this._insertTimer({ id, dueTime, callback, period: null });
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setInterval(callback, periodMs) {
|
|
34
|
+
const id = this._nextId++;
|
|
35
|
+
const dueTime = this.now + Math.max(0, periodMs);
|
|
36
|
+
this._insertTimer({ id, dueTime, callback, period: periodMs });
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clearTimeout(id) {
|
|
41
|
+
const idx = this._timers.findIndex(t => t.id === id);
|
|
42
|
+
if (idx !== -1) {
|
|
43
|
+
this._timers.splice(idx, 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clearInterval(id) {
|
|
48
|
+
this.clearTimeout(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
advance(ms) {
|
|
52
|
+
const target = this.now + ms;
|
|
53
|
+
while (this._timers.length > 0 && this._timers[0].dueTime <= target) {
|
|
54
|
+
const timer = this._timers.shift();
|
|
55
|
+
this.now = timer.dueTime;
|
|
56
|
+
timer.callback();
|
|
57
|
+
|
|
58
|
+
if (timer.period !== null) {
|
|
59
|
+
timer.dueTime = this.now + timer.period;
|
|
60
|
+
this._insertTimer(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
this.now = target;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
step() {
|
|
67
|
+
if (this._timers.length === 0) return false;
|
|
68
|
+
const timer = this._timers.shift();
|
|
69
|
+
this.now = timer.dueTime;
|
|
70
|
+
timer.callback();
|
|
71
|
+
|
|
72
|
+
if (timer.period !== null) {
|
|
73
|
+
timer.dueTime = this.now + timer.period;
|
|
74
|
+
this._insertTimer(timer);
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pendingCount() {
|
|
80
|
+
return this._timers.length;
|
|
81
|
+
}
|
|
82
|
+
}
|