feat: added more test samples to hyper loglog paper

This commit is contained in:
Leyla Becker 2026-02-14 19:19:17 -06:00
parent 87cf9f2c6c
commit 7769007694
2 changed files with 971 additions and 173 deletions

View file

@ -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 ===");
};