feat: added more test samples to hyper loglog paper
This commit is contained in:
parent
87cf9f2c6c
commit
7769007694
2 changed files with 971 additions and 173 deletions
|
|
@ -617,123 +617,738 @@ const testSingleNodeDeletion = (): void => {
|
|||
};
|
||||
|
||||
const testEarlyTombstoneCreation = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const recordId = "early-tombstone";
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
// Only propagate record for 3 rounds before creating tombstone
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test");
|
||||
network = gossipRounds(network, recordId, 3);
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const recordId = `early-tombstone-${trial}`;
|
||||
|
||||
// Only propagate record for 3 rounds before creating tombstone
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test");
|
||||
network = gossipRounds(network, recordId, 3);
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
|
||||
if (result.recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += result.roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += result.totalRounds;
|
||||
|
||||
const stats = getClusterStats(result.network, recordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: "Early Tombstone (record partially propagated)",
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
roundsToDeleteRecords: result.roundsToDeleteRecords,
|
||||
totalRounds: result.totalRounds,
|
||||
clusters: [getClusterStats(result.network, recordId)],
|
||||
testName: `Early Tombstone (${trials} trials, record partially propagated)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 20 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const testBridgedNetwork = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
const clusterSize = 15;
|
||||
let network = createBridgedNetwork<string>(clusterSize, 0.5);
|
||||
const recordId = "bridged-record";
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecordsA = 0;
|
||||
let finalTombstonesA = 0;
|
||||
let finalRecordsB = 0;
|
||||
let finalTombstonesB = 0;
|
||||
|
||||
network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 20);
|
||||
network = addTombstoneToNetwork(network, "cluster-a-0", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createBridgedNetwork<string>(clusterSize, 0.5);
|
||||
const recordId = `bridged-record-${trial}`;
|
||||
|
||||
network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 20);
|
||||
network = addTombstoneToNetwork(network, "cluster-a-0", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
|
||||
if (result.recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += result.roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += result.totalRounds;
|
||||
|
||||
const statsA = getClusterStats(result.network, recordId, "cluster-a");
|
||||
const statsB = getClusterStats(result.network, recordId, "cluster-b");
|
||||
finalRecordsA += statsA.recordCount;
|
||||
finalTombstonesA += statsA.tombstoneCount;
|
||||
finalRecordsB += statsB.recordCount;
|
||||
finalTombstonesB += statsB.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: "Bridged Network (two clusters with single connection)",
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
roundsToDeleteRecords: result.roundsToDeleteRecords,
|
||||
totalRounds: result.totalRounds,
|
||||
testName: `Bridged Network (${trials} trials, two clusters)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [
|
||||
getClusterStats(result.network, recordId, "cluster-a"),
|
||||
getClusterStats(result.network, recordId, "cluster-b"),
|
||||
{ name: 'cluster-a', nodeCount: clusterSize * trials, recordCount: finalRecordsA, tombstoneCount: finalTombstonesA },
|
||||
{ name: 'cluster-b', nodeCount: clusterSize * trials, recordCount: finalRecordsB, tombstoneCount: finalTombstonesB },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const testConcurrentTombstones = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const recordId = "concurrent-delete";
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 30);
|
||||
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
network = addTombstoneToNetwork(network, "node-5", recordId);
|
||||
network = addTombstoneToNetwork(network, "node-10", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const recordId = `concurrent-delete-${trial}`;
|
||||
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 30);
|
||||
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
network = addTombstoneToNetwork(network, "node-5", recordId);
|
||||
network = addTombstoneToNetwork(network, "node-10", recordId);
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
|
||||
if (result.recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += result.roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += result.totalRounds;
|
||||
|
||||
const stats = getClusterStats(result.network, recordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: "Concurrent Tombstones (3 nodes delete same record)",
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
roundsToDeleteRecords: result.roundsToDeleteRecords,
|
||||
totalRounds: result.totalRounds,
|
||||
clusters: [getClusterStats(result.network, recordId)],
|
||||
testName: `Concurrent Tombstones (${trials} trials, 3 nodes delete)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 20 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const testNetworkPartitionHeal = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
const clusterSize = 10;
|
||||
let network = createBridgedNetwork<string>(clusterSize, 0.5);
|
||||
const recordId = "partition-test";
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecordsA = 0;
|
||||
let finalTombstonesA = 0;
|
||||
let finalRecordsB = 0;
|
||||
let finalTombstonesB = 0;
|
||||
|
||||
network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 30);
|
||||
|
||||
// Partition the network
|
||||
const bridgeA = network.nodes.get("cluster-a-0")!;
|
||||
const bridgeB = network.nodes.get("cluster-b-0")!;
|
||||
const newBridgeAPeers = bridgeA.peerIds.filter(p => p !== "cluster-b-0");
|
||||
const newBridgeBPeers = bridgeB.peerIds.filter(p => p !== "cluster-a-0");
|
||||
|
||||
let partitionedNodes = new Map(network.nodes);
|
||||
partitionedNodes.set("cluster-a-0", { ...bridgeA, peerIds: newBridgeAPeers });
|
||||
partitionedNodes.set("cluster-b-0", { ...bridgeB, peerIds: newBridgeBPeers });
|
||||
network = { nodes: partitionedNodes };
|
||||
|
||||
network = addTombstoneToNetwork(network, "cluster-a-0", recordId);
|
||||
|
||||
// Run during partition
|
||||
const partitionResult = runToConvergence(network, recordId, 500);
|
||||
network = partitionResult.network;
|
||||
|
||||
// Heal the network
|
||||
const healedBridgeA = network.nodes.get("cluster-a-0")!;
|
||||
const healedBridgeB = network.nodes.get("cluster-b-0")!;
|
||||
let healedNodes = new Map(network.nodes);
|
||||
healedNodes.set("cluster-a-0", addPeerToNode(healedBridgeA, "cluster-b-0"));
|
||||
healedNodes.set("cluster-b-0", addPeerToNode(healedBridgeB, "cluster-a-0"));
|
||||
network = { nodes: healedNodes };
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createBridgedNetwork<string>(clusterSize, 0.5);
|
||||
const recordId = `partition-test-${trial}`;
|
||||
|
||||
network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 30);
|
||||
|
||||
// Partition the network
|
||||
const bridgeA = network.nodes.get("cluster-a-0")!;
|
||||
const bridgeB = network.nodes.get("cluster-b-0")!;
|
||||
const newBridgeAPeers = bridgeA.peerIds.filter(p => p !== "cluster-b-0");
|
||||
const newBridgeBPeers = bridgeB.peerIds.filter(p => p !== "cluster-a-0");
|
||||
|
||||
let partitionedNodes = new Map(network.nodes);
|
||||
partitionedNodes.set("cluster-a-0", { ...bridgeA, peerIds: newBridgeAPeers });
|
||||
partitionedNodes.set("cluster-b-0", { ...bridgeB, peerIds: newBridgeBPeers });
|
||||
network = { nodes: partitionedNodes };
|
||||
|
||||
network = addTombstoneToNetwork(network, "cluster-a-0", recordId);
|
||||
|
||||
// Run during partition
|
||||
const partitionResult = runToConvergence(network, recordId, 500);
|
||||
network = partitionResult.network;
|
||||
|
||||
// Heal the network
|
||||
const healedBridgeA = network.nodes.get("cluster-a-0")!;
|
||||
const healedBridgeB = network.nodes.get("cluster-b-0")!;
|
||||
let healedNodes = new Map(network.nodes);
|
||||
healedNodes.set("cluster-a-0", addPeerToNode(healedBridgeA, "cluster-b-0"));
|
||||
healedNodes.set("cluster-b-0", addPeerToNode(healedBridgeB, "cluster-a-0"));
|
||||
network = { nodes: healedNodes };
|
||||
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
|
||||
if (result.recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += partitionResult.roundsToDeleteRecords + result.roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += partitionResult.totalRounds + result.totalRounds;
|
||||
|
||||
const statsA = getClusterStats(result.network, recordId, "cluster-a");
|
||||
const statsB = getClusterStats(result.network, recordId, "cluster-b");
|
||||
finalRecordsA += statsA.recordCount;
|
||||
finalTombstonesA += statsA.tombstoneCount;
|
||||
finalRecordsB += statsB.recordCount;
|
||||
finalTombstonesB += statsB.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: "Network Partition and Heal",
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
roundsToDeleteRecords: partitionResult.roundsToDeleteRecords + result.roundsToDeleteRecords,
|
||||
totalRounds: partitionResult.totalRounds + result.totalRounds,
|
||||
testName: `Network Partition and Heal (${trials} trials)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [
|
||||
getClusterStats(result.network, recordId, "cluster-a"),
|
||||
getClusterStats(result.network, recordId, "cluster-b"),
|
||||
{ name: 'cluster-a', nodeCount: clusterSize * trials, recordCount: finalRecordsA, tombstoneCount: finalTombstonesA },
|
||||
{ name: 'cluster-b', nodeCount: clusterSize * trials, recordCount: finalRecordsB, tombstoneCount: finalTombstonesB },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const applyDynamicTopologyChanges = <Data>(network: NetworkState<Data>): NetworkState<Data> => {
|
||||
const nodeIds = Array.from(network.nodes.keys());
|
||||
const changeCount = Math.floor(Math.random() * 5) + 1;
|
||||
let result = network;
|
||||
|
||||
for (let c = 0; c < changeCount; c++) {
|
||||
const nodeA = nodeIds[Math.floor(Math.random() * nodeIds.length)];
|
||||
const nodeB = nodeIds[Math.floor(Math.random() * nodeIds.length)];
|
||||
if (nodeA === nodeB) continue;
|
||||
|
||||
const nodeAState = result.nodes.get(nodeA)!;
|
||||
const nodeBState = result.nodes.get(nodeB)!;
|
||||
|
||||
// 50% chance to add connection, 50% to remove
|
||||
if (Math.random() < 0.5) {
|
||||
// Add connection if not already connected
|
||||
if (!nodeAState.peerIds.includes(nodeB)) {
|
||||
const newNodes = new Map(result.nodes);
|
||||
newNodes.set(nodeA, addPeerToNode(nodeAState, nodeB));
|
||||
newNodes.set(nodeB, addPeerToNode(nodeBState, nodeA));
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
} else {
|
||||
// Remove connection if connected and both have more than 1 peer
|
||||
if (nodeAState.peerIds.includes(nodeB) &&
|
||||
nodeAState.peerIds.length > 1 &&
|
||||
nodeBState.peerIds.length > 1) {
|
||||
const newNodes = new Map(result.nodes);
|
||||
newNodes.set(nodeA, {
|
||||
...nodeAState,
|
||||
peerIds: nodeAState.peerIds.filter(p => p !== nodeB),
|
||||
});
|
||||
newNodes.set(nodeB, {
|
||||
...nodeBState,
|
||||
peerIds: nodeBState.peerIds.filter(p => p !== nodeA),
|
||||
});
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const testDynamicTopology = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(20, 0.3);
|
||||
const recordId = `dynamic-${trial}`;
|
||||
|
||||
// Create and propagate record
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 10);
|
||||
|
||||
// Create tombstone
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
|
||||
// Simulate dynamic topology changes during gossip
|
||||
let rounds = 0;
|
||||
let recordsDeleted = false;
|
||||
let roundsToDeleteRecords = 0;
|
||||
|
||||
while (rounds < maxRounds && !recordsDeleted) {
|
||||
// Random topology changes every 5 rounds
|
||||
if (rounds % 5 === 0) {
|
||||
network = applyDynamicTopologyChanges(network);
|
||||
}
|
||||
|
||||
const stats = getClusterStats(network, recordId);
|
||||
if (stats.recordCount === 0) {
|
||||
recordsDeleted = true;
|
||||
roundsToDeleteRecords = rounds;
|
||||
}
|
||||
network = gossipRounds(network, recordId, 5);
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
// Continue for convergence with dynamic topology still active
|
||||
let extraRounds = 0;
|
||||
while (extraRounds < 100) {
|
||||
if (extraRounds % 5 === 0) {
|
||||
network = applyDynamicTopologyChanges(network);
|
||||
}
|
||||
network = gossipRounds(network, recordId, 5);
|
||||
extraRounds += 5;
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
if (recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += rounds;
|
||||
|
||||
const stats = getClusterStats(network, recordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: `Dynamic Topology (${trials} trials, connections changing)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 20 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const applyNodeChurn = <Data>(
|
||||
network: NetworkState<Data>,
|
||||
nodeCounter: { value: number }
|
||||
): NetworkState<Data> => {
|
||||
let result = network;
|
||||
const nodeIds = Array.from(result.nodes.keys());
|
||||
|
||||
// Remove 1-2 random nodes (not node-0 which has the tombstone)
|
||||
const removeCount = Math.floor(Math.random() * 2) + 1;
|
||||
for (let r = 0; r < removeCount; r++) {
|
||||
const candidateNodes = nodeIds.filter(id => id !== "node-0" && result.nodes.has(id));
|
||||
if (candidateNodes.length <= 5) break; // Keep minimum network size
|
||||
|
||||
const nodeToRemove = candidateNodes[Math.floor(Math.random() * candidateNodes.length)];
|
||||
const nodeState = result.nodes.get(nodeToRemove);
|
||||
if (!nodeState) continue;
|
||||
|
||||
// Remove node and all its peer connections
|
||||
const newNodes = new Map(result.nodes);
|
||||
newNodes.delete(nodeToRemove);
|
||||
|
||||
for (const peerId of nodeState.peerIds) {
|
||||
const peer = newNodes.get(peerId);
|
||||
if (peer) {
|
||||
newNodes.set(peerId, {
|
||||
...peer,
|
||||
peerIds: peer.peerIds.filter(p => p !== nodeToRemove),
|
||||
});
|
||||
}
|
||||
}
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
|
||||
// Add 1-2 new nodes
|
||||
const addCount = Math.floor(Math.random() * 2) + 1;
|
||||
for (let a = 0; a < addCount; a++) {
|
||||
const newNodeId = `node-${nodeCounter.value++}`;
|
||||
const newNode = createNode<Data>(newNodeId);
|
||||
|
||||
// Connect to 2-4 random existing nodes
|
||||
const existingNodes = Array.from(result.nodes.keys());
|
||||
const connectionCount = Math.min(existingNodes.length, Math.floor(Math.random() * 3) + 2);
|
||||
const shuffled = existingNodes.sort(() => Math.random() - 0.5);
|
||||
const peersToConnect = shuffled.slice(0, connectionCount);
|
||||
|
||||
let newNodes = new Map(result.nodes);
|
||||
let updatedNewNode = newNode;
|
||||
|
||||
for (const peerId of peersToConnect) {
|
||||
const peer = newNodes.get(peerId)!;
|
||||
updatedNewNode = addPeerToNode(updatedNewNode, peerId);
|
||||
newNodes.set(peerId, addPeerToNode(peer, newNodeId));
|
||||
}
|
||||
|
||||
newNodes.set(newNodeId, updatedNewNode);
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const testNodeChurn = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const recordId = `churn-${trial}`;
|
||||
const nodeCounter = { value: 20 };
|
||||
|
||||
// Create and propagate record
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 15);
|
||||
|
||||
// Create tombstone
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
|
||||
// Simulate node churn during gossip
|
||||
let rounds = 0;
|
||||
let recordsDeleted = false;
|
||||
let roundsToDeleteRecords = 0;
|
||||
|
||||
while (rounds < maxRounds && !recordsDeleted) {
|
||||
// Node churn every 10 rounds
|
||||
if (rounds % 10 === 0 && rounds > 0) {
|
||||
network = applyNodeChurn(network, nodeCounter);
|
||||
}
|
||||
|
||||
const stats = getClusterStats(network, recordId);
|
||||
if (stats.recordCount === 0) {
|
||||
recordsDeleted = true;
|
||||
roundsToDeleteRecords = rounds;
|
||||
}
|
||||
network = gossipRounds(network, recordId, 5);
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
// Continue for convergence with node churn still active
|
||||
let extraRounds = 0;
|
||||
while (extraRounds < 100) {
|
||||
if (extraRounds % 10 === 0) {
|
||||
network = applyNodeChurn(network, nodeCounter);
|
||||
}
|
||||
network = gossipRounds(network, recordId, 5);
|
||||
extraRounds += 5;
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
if (recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += rounds;
|
||||
|
||||
const stats = getClusterStats(network, recordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: `Node Churn (${trials} trials, nodes joining/leaving)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 20 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const applyRandomConfigChanges = <Data>(
|
||||
network: NetworkState<Data>,
|
||||
trial: number,
|
||||
recordCounter: { value: number }
|
||||
): NetworkState<Data> => {
|
||||
let result = network;
|
||||
const nodeIds = Array.from(result.nodes.keys());
|
||||
const changeCount = Math.floor(Math.random() * 4) + 1;
|
||||
|
||||
for (let c = 0; c < changeCount; c++) {
|
||||
const nodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)];
|
||||
const action = Math.random();
|
||||
|
||||
if (action < 0.3) {
|
||||
// Add a new unrelated record to this node (simulating config change)
|
||||
const newRecordId = `config-extra-${trial}-${recordCounter.value++}`;
|
||||
result = addRecordToNetwork(result, nodeId, newRecordId, "Extra Data" as Data);
|
||||
} else if (action < 0.6) {
|
||||
// Modify peer list randomly (add a new peer)
|
||||
const otherNodes = nodeIds.filter(id => {
|
||||
const node = result.nodes.get(nodeId);
|
||||
return id !== nodeId && node && !node.peerIds.includes(id);
|
||||
});
|
||||
if (otherNodes.length > 0) {
|
||||
const newPeer = otherNodes[Math.floor(Math.random() * otherNodes.length)];
|
||||
const nodeState = result.nodes.get(nodeId)!;
|
||||
const peerState = result.nodes.get(newPeer)!;
|
||||
const newNodes = new Map(result.nodes);
|
||||
newNodes.set(nodeId, addPeerToNode(nodeState, newPeer));
|
||||
newNodes.set(newPeer, addPeerToNode(peerState, nodeId));
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
} else {
|
||||
// Remove a random peer (if we have more than 1)
|
||||
const nodeState = result.nodes.get(nodeId)!;
|
||||
if (nodeState.peerIds.length > 1) {
|
||||
const peerToRemove = nodeState.peerIds[Math.floor(Math.random() * nodeState.peerIds.length)];
|
||||
const peerState = result.nodes.get(peerToRemove)!;
|
||||
|
||||
// Only remove if peer also has more than 1 connection
|
||||
if (peerState.peerIds.length > 1) {
|
||||
const newNodes = new Map(result.nodes);
|
||||
newNodes.set(nodeId, {
|
||||
...nodeState,
|
||||
peerIds: nodeState.peerIds.filter(p => p !== peerToRemove),
|
||||
});
|
||||
newNodes.set(peerToRemove, {
|
||||
...peerState,
|
||||
peerIds: peerState.peerIds.filter(p => p !== nodeId),
|
||||
});
|
||||
result = { nodes: newNodes };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const testRandomConfigurationChanges = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(20, 0.4);
|
||||
const primaryRecordId = `config-primary-${trial}`;
|
||||
const recordCounter = { value: 0 };
|
||||
|
||||
// Create and propagate primary record
|
||||
network = addRecordToNetwork(network, "node-0", primaryRecordId, "Primary Data");
|
||||
network = gossipRounds(network, primaryRecordId, 15);
|
||||
|
||||
// Create tombstone for primary record
|
||||
network = addTombstoneToNetwork(network, "node-0", primaryRecordId);
|
||||
|
||||
// Simulate random configuration changes during gossip
|
||||
let rounds = 0;
|
||||
let recordsDeleted = false;
|
||||
let roundsToDeleteRecords = 0;
|
||||
|
||||
while (rounds < maxRounds && !recordsDeleted) {
|
||||
// Random configuration changes every 8 rounds
|
||||
if (rounds % 8 === 0 && rounds > 0) {
|
||||
network = applyRandomConfigChanges(network, trial, recordCounter);
|
||||
}
|
||||
|
||||
const stats = getClusterStats(network, primaryRecordId);
|
||||
if (stats.recordCount === 0) {
|
||||
recordsDeleted = true;
|
||||
roundsToDeleteRecords = rounds;
|
||||
}
|
||||
network = gossipRounds(network, primaryRecordId, 5);
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
// Continue for convergence with config changes still active
|
||||
let extraRounds = 0;
|
||||
while (extraRounds < 100) {
|
||||
if (extraRounds % 8 === 0) {
|
||||
network = applyRandomConfigChanges(network, trial, recordCounter);
|
||||
}
|
||||
network = gossipRounds(network, primaryRecordId, 5);
|
||||
extraRounds += 5;
|
||||
rounds += 5;
|
||||
}
|
||||
|
||||
if (recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += roundsToDeleteRecords;
|
||||
}
|
||||
totalRounds += rounds;
|
||||
|
||||
const stats = getClusterStats(network, primaryRecordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: `Random Config Changes (${trials} trials, mixed changes)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 20 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const disconnectNode = <Data>(
|
||||
network: NetworkState<Data>,
|
||||
nodeId: string
|
||||
): { network: NetworkState<Data>; savedPeers: readonly string[] } => {
|
||||
const node = network.nodes.get(nodeId);
|
||||
if (!node) return { network, savedPeers: [] };
|
||||
|
||||
const savedPeers = node.peerIds;
|
||||
let newNodes = new Map(network.nodes);
|
||||
|
||||
// Remove this node from all its peers' peer lists
|
||||
for (const peerId of savedPeers) {
|
||||
const peer = newNodes.get(peerId);
|
||||
if (peer) {
|
||||
newNodes.set(peerId, {
|
||||
...peer,
|
||||
peerIds: peer.peerIds.filter(p => p !== nodeId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear this node's peer list
|
||||
newNodes.set(nodeId, { ...node, peerIds: [] });
|
||||
|
||||
return { network: { nodes: newNodes }, savedPeers };
|
||||
};
|
||||
|
||||
const reconnectNode = <Data>(
|
||||
network: NetworkState<Data>,
|
||||
nodeId: string,
|
||||
peers: readonly string[]
|
||||
): NetworkState<Data> => {
|
||||
const node = network.nodes.get(nodeId);
|
||||
if (!node) return network;
|
||||
|
||||
let newNodes = new Map(network.nodes);
|
||||
|
||||
// Restore this node's peer list (only peers that still exist)
|
||||
const validPeers = peers.filter(p => newNodes.has(p));
|
||||
newNodes.set(nodeId, { ...node, peerIds: validPeers });
|
||||
|
||||
// Add this node back to each peer's peer list
|
||||
for (const peerId of validPeers) {
|
||||
const peer = newNodes.get(peerId);
|
||||
if (peer && !peer.peerIds.includes(nodeId)) {
|
||||
newNodes.set(peerId, {
|
||||
...peer,
|
||||
peerIds: [...peer.peerIds, nodeId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: newNodes };
|
||||
};
|
||||
|
||||
const testNodeDropoutAndReconnect = (): void => {
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
const dropoutRounds = 100;
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
let totalRounds = 0;
|
||||
let finalRecords = 0;
|
||||
let finalTombstones = 0;
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
let network = createNetwork<string>(15, 0.4);
|
||||
const recordId = `dropout-${trial}`;
|
||||
const dropoutNodeId = "node-5"; // Node that will drop out
|
||||
|
||||
// Create and propagate record to all nodes including the dropout node
|
||||
network = addRecordToNetwork(network, "node-0", recordId, "Test Data");
|
||||
network = gossipRounds(network, recordId, 20);
|
||||
|
||||
// Verify the dropout node has received the record
|
||||
const dropoutNode = network.nodes.get(dropoutNodeId)!;
|
||||
if (!dropoutNode.records.has(recordId)) {
|
||||
// Force propagation to ensure it has the record
|
||||
network = gossipRounds(network, recordId, 10);
|
||||
}
|
||||
|
||||
// Create tombstone at origin node
|
||||
network = addTombstoneToNetwork(network, "node-0", recordId);
|
||||
|
||||
// Disconnect the dropout node (simulating it going offline)
|
||||
const { network: disconnectedNetwork, savedPeers } = disconnectNode(network, dropoutNodeId);
|
||||
network = disconnectedNetwork;
|
||||
|
||||
// Run gossip for 100 rounds while the node is disconnected
|
||||
// The tombstone should propagate to all other nodes
|
||||
for (let r = 0; r < dropoutRounds; r += 10) {
|
||||
network = gossipRounds(network, recordId, 10);
|
||||
}
|
||||
|
||||
// Reconnect the dropout node
|
||||
network = reconnectNode(network, dropoutNodeId, savedPeers);
|
||||
|
||||
// Continue running to see if the system converges properly
|
||||
const result = runToConvergence(network, recordId, maxRounds);
|
||||
|
||||
if (result.recordsDeleted) {
|
||||
deletedCount++;
|
||||
totalDeletionRounds += result.roundsToDeleteRecords + dropoutRounds;
|
||||
}
|
||||
totalRounds += result.totalRounds + dropoutRounds;
|
||||
|
||||
const stats = getClusterStats(result.network, recordId);
|
||||
finalRecords += stats.recordCount;
|
||||
finalTombstones += stats.tombstoneCount;
|
||||
}
|
||||
|
||||
printSimulationResult({
|
||||
testName: `Node Dropout & Reconnect (${trials} trials, ${dropoutRounds} rounds offline)`,
|
||||
recordsDeleted: deletedCount === trials,
|
||||
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
|
||||
totalRounds: Math.round(totalRounds / trials),
|
||||
clusters: [{
|
||||
name: 'all',
|
||||
nodeCount: 15 * trials,
|
||||
recordCount: finalRecords,
|
||||
tombstoneCount: finalTombstones,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const testSparseNetwork = (): void => {
|
||||
const trials = 20;
|
||||
const trials = 50;
|
||||
const maxRounds = 99999;
|
||||
let deletedCount = 0;
|
||||
let totalDeletionRounds = 0;
|
||||
|
|
@ -785,6 +1400,10 @@ const runAllTests = (): void => {
|
|||
testConcurrentTombstones();
|
||||
testNetworkPartitionHeal();
|
||||
testSparseNetwork();
|
||||
testDynamicTopology();
|
||||
testNodeChurn();
|
||||
testRandomConfigurationChanges();
|
||||
testNodeDropoutAndReconnect();
|
||||
|
||||
console.log("\n=== Simulation Complete ===");
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue