type HLLRegisters = Uint8Array; interface HLL { registers: HLLRegisters; m: number; alphaMM: number; } const createHLL = (precision: number = 10): HLL => { const m = 1 << precision; const alphaMM = m === 16 ? 0.673 * m * m : m === 32 ? 0.697 * m * m : m === 64 ? 0.709 * m * m : (0.7213 / (1 + 1.079 / m)) * m * m; return { registers: new Uint8Array(m), m, alphaMM }; }; const hashString = (value: string): number => { let hash = 0; for (let i = 0; i < value.length; i++) { hash = ((hash << 5) - hash) + value.charCodeAt(i); hash = hash & hash; } hash ^= hash >>> 16; hash = Math.imul(hash, 0x85ebca6b); hash ^= hash >>> 13; hash = Math.imul(hash, 0xc2b2ae35); hash ^= hash >>> 16; return hash >>> 0; }; const rho = (value: number): number => { if (value === 0) return 32; let count = 1; while ((value & 0x80000000) === 0) { count++; value <<= 1; } return count; }; const hllAdd = (hll: HLL, value: string): HLL => { const hash = hashString(value); const index = hash >>> (32 - Math.log2(hll.m)); const w = hash << Math.log2(hll.m); const rank = rho(w); const newRegisters = new Uint8Array(hll.registers); newRegisters[index] = Math.max(newRegisters[index], rank); return { ...hll, registers: newRegisters }; }; const hllEstimate = (hll: HLL): number => { let sum = 0; let zeros = 0; for (let i = 0; i < hll.m; i++) { sum += Math.pow(2, -hll.registers[i]); if (hll.registers[i] === 0) zeros++; } let estimate = hll.alphaMM / sum; if (estimate <= 2.5 * hll.m && zeros > 0) { estimate = hll.m * Math.log(hll.m / zeros); } return Math.round(estimate); }; const hllMerge = (a: HLL, b: HLL): HLL => { const newRegisters = new Uint8Array(a.m); for (let i = 0; i < a.m; i++) { newRegisters[i] = Math.max(a.registers[i], b.registers[i]); } return { ...a, registers: newRegisters }; }; const hllClone = (hll: HLL): HLL => ({ ...hll, registers: new Uint8Array(hll.registers), }); interface DataRecord { readonly id: string; readonly data: Data; readonly recordHLL: HLL; } interface Tombstone { readonly id: string; readonly recordHLL: HLL; readonly tombstoneHLL: HLL; } interface NodeState { readonly id: string; readonly records: ReadonlyMap>; readonly tombstones: ReadonlyMap; readonly isOnline: boolean; readonly partitionId: string; // Nodes in the same partition can communicate readonly stats: { readonly messagesReceived: number; readonly tombstonesGarbageCollected: number; readonly resurrections: number; }; } interface NetworkState { readonly nodes: ReadonlyMap>; } const createRecord = (id: string, data: Data, nodeId: string): DataRecord => ({ id, data, recordHLL: hllAdd(createHLL(), nodeId), }); const createTombstone = (record: DataRecord, nodeId: string): Tombstone => ({ id: record.id, recordHLL: hllClone(record.recordHLL), tombstoneHLL: hllAdd(createHLL(), nodeId), }); const createNode = (id: string, partitionId: string = "main"): NodeState => ({ id, records: new Map(), tombstones: new Map(), isOnline: true, partitionId, stats: { messagesReceived: 0, tombstonesGarbageCollected: 0, resurrections: 0 }, }); const checkGCStatus = ( tombstone: Tombstone, incomingTombstoneEstimate: number | null, myTombstoneEstimateBeforeMerge: number, myNodeId: string, senderNodeId: string | null ): { shouldGC: boolean; stepDownAsKeeper: boolean } => { const targetCount = hllEstimate(tombstone.recordHLL); const isKeeper = myTombstoneEstimateBeforeMerge >= targetCount; if (isKeeper) { if (incomingTombstoneEstimate !== null && incomingTombstoneEstimate >= targetCount) { if (myTombstoneEstimateBeforeMerge < incomingTombstoneEstimate) { return { shouldGC: true, stepDownAsKeeper: true }; } if (myTombstoneEstimateBeforeMerge === incomingTombstoneEstimate && senderNodeId !== null && myNodeId > senderNodeId) { return { shouldGC: true, stepDownAsKeeper: true }; } } return { shouldGC: false, stepDownAsKeeper: false }; } return { shouldGC: false, stepDownAsKeeper: false }; }; const receiveRecord = ( node: NodeState, incoming: DataRecord ): NodeState => { const newStats = { ...node.stats, messagesReceived: node.stats.messagesReceived + 1 }; if (node.tombstones.has(incoming.id)) { return { ...node, stats: { ...newStats, resurrections: newStats.resurrections + 1 } }; } const existing = node.records.get(incoming.id); const updatedRecord: DataRecord = existing ? { ...existing, recordHLL: hllAdd(hllMerge(existing.recordHLL, incoming.recordHLL), node.id) } : { ...incoming, recordHLL: hllAdd(hllClone(incoming.recordHLL), node.id) }; const newRecords = new Map(node.records); newRecords.set(incoming.id, updatedRecord); return { ...node, records: newRecords, stats: newStats }; }; const receiveTombstone = ( node: NodeState, incoming: Tombstone, senderNodeId: string ): NodeState => { let newStats = { ...node.stats, messagesReceived: node.stats.messagesReceived + 1 }; const record = node.records.get(incoming.id); if (!record) { return { ...node, stats: newStats }; } const existing = node.tombstones.get(incoming.id); const mergedTombstoneHLL = existing ? hllAdd(hllMerge(existing.tombstoneHLL, incoming.tombstoneHLL), node.id) : hllAdd(hllClone(incoming.tombstoneHLL), node.id); let bestFrozenHLL = incoming.recordHLL; if (existing?.recordHLL) { bestFrozenHLL = hllEstimate(existing.recordHLL) > hllEstimate(bestFrozenHLL) ? existing.recordHLL : bestFrozenHLL; } if (hllEstimate(record.recordHLL) > hllEstimate(bestFrozenHLL)) { bestFrozenHLL = hllClone(record.recordHLL); } const updatedTombstone: Tombstone = { id: incoming.id, tombstoneHLL: mergedTombstoneHLL, recordHLL: bestFrozenHLL, }; const myEstimateBeforeMerge = existing ? hllEstimate(existing.tombstoneHLL) : 0; const gcStatus = checkGCStatus( updatedTombstone, hllEstimate(incoming.tombstoneHLL), myEstimateBeforeMerge, node.id, senderNodeId ); const newRecords = new Map(node.records); newRecords.delete(incoming.id); if (gcStatus.stepDownAsKeeper) { const newTombstones = new Map(node.tombstones); newTombstones.delete(incoming.id); newStats = { ...newStats, tombstonesGarbageCollected: newStats.tombstonesGarbageCollected + 1 }; return { ...node, records: newRecords, tombstones: newTombstones, stats: newStats }; } const newTombstones = new Map(node.tombstones); newTombstones.set(incoming.id, updatedTombstone); return { ...node, records: newRecords, tombstones: newTombstones, stats: newStats }; }; // Create a fully connected network (all nodes can talk to all other online nodes in same partition) const createNetwork = (nodeCount: number, partitionId: string = "main"): NetworkState => { const nodes = new Map>(); for (let i = 0; i < nodeCount; i++) { nodes.set(`node-${i}`, createNode(`node-${i}`, partitionId)); } return { nodes }; }; // Get all reachable nodes (online and in same partition) const getReachableNodes = ( network: NetworkState, fromNodeId: string ): string[] => { const fromNode = network.nodes.get(fromNodeId); if (!fromNode || !fromNode.isOnline) return []; const reachable: string[] = []; for (const [nodeId, node] of network.nodes) { if (nodeId !== fromNodeId && node.isOnline && node.partitionId === fromNode.partitionId) { reachable.push(nodeId); } } return reachable; }; const forwardTombstoneToAllReachable = ( network: NetworkState, forwardingNodeId: string, tombstone: Tombstone, excludeNodeId?: string ): NetworkState => { const forwardingNode = network.nodes.get(forwardingNodeId); if (!forwardingNode || !forwardingNode.isOnline) return network; let newNodes = new Map(network.nodes); const reachable = getReachableNodes({ nodes: newNodes }, forwardingNodeId); for (const peerId of reachable) { if (peerId === excludeNodeId) continue; const peer = newNodes.get(peerId); if (!peer || !peer.records.has(tombstone.id)) continue; const updatedPeer = receiveTombstone(peer, tombstone, forwardingNodeId); newNodes.set(peerId, updatedPeer); // If this peer also stepped down, recursively forward if (!updatedPeer.tombstones.has(tombstone.id) && peer.tombstones.has(tombstone.id)) { const result = forwardTombstoneToAllReachable({ nodes: newNodes }, peerId, tombstone, forwardingNodeId); newNodes = new Map(result.nodes); } } return { nodes: newNodes }; }; const gossipOnce = (network: NetworkState, senderNodeId: string, recordId: string): NetworkState => { const sender = network.nodes.get(senderNodeId); if (!sender || !sender.isOnline) return network; const record = sender.records.get(recordId); const tombstone = sender.tombstones.get(recordId); if (!record && !tombstone) return network; const reachable = getReachableNodes(network, senderNodeId); if (reachable.length === 0) return network; const peerId = reachable[Math.floor(Math.random() * reachable.length)]; const peer = network.nodes.get(peerId); if (!peer) return network; let newNodes = new Map(network.nodes); if (record && !tombstone) { const updatedPeer = receiveRecord(peer, record); newNodes.set(peerId, updatedPeer); } if (tombstone) { if (record && !peer.records.has(recordId)) { const peerWithRecord = receiveRecord(peer, record); newNodes.set(peerId, peerWithRecord); } const currentPeer = newNodes.get(peerId)!; const peerHadTombstone = currentPeer.tombstones.has(recordId); const updatedPeer = receiveTombstone(currentPeer, tombstone, senderNodeId); newNodes.set(peerId, updatedPeer); if (peerHadTombstone && !updatedPeer.tombstones.has(recordId)) { const result = forwardTombstoneToAllReachable({ nodes: newNodes }, peerId, tombstone, senderNodeId); newNodes = new Map(result.nodes); } if (updatedPeer.tombstones.has(recordId)) { const peerTombstone = updatedPeer.tombstones.get(recordId)!; const senderEstimateBeforeMerge = hllEstimate(tombstone.tombstoneHLL); const mergedTombstoneHLL = hllMerge(tombstone.tombstoneHLL, peerTombstone.tombstoneHLL); const bestFrozenHLL = hllEstimate(peerTombstone.recordHLL) > hllEstimate(tombstone.recordHLL) ? peerTombstone.recordHLL : tombstone.recordHLL; const updatedSenderTombstone: Tombstone = { ...tombstone, tombstoneHLL: mergedTombstoneHLL, recordHLL: bestFrozenHLL, }; const gcStatus = checkGCStatus( updatedSenderTombstone, hllEstimate(peerTombstone.tombstoneHLL), senderEstimateBeforeMerge, senderNodeId, peerId ); if (gcStatus.stepDownAsKeeper) { const currentSender = newNodes.get(senderNodeId)!; const newSenderTombstones = new Map(currentSender.tombstones); newSenderTombstones.delete(recordId); const newSenderStats = { ...currentSender.stats, tombstonesGarbageCollected: currentSender.stats.tombstonesGarbageCollected + 1 }; newNodes.set(senderNodeId, { ...currentSender, tombstones: newSenderTombstones, stats: newSenderStats }); const result = forwardTombstoneToAllReachable({ nodes: newNodes }, senderNodeId, peerTombstone, peerId); newNodes = new Map(result.nodes); } else { const currentSender = newNodes.get(senderNodeId)!; const newSenderTombstones = new Map(currentSender.tombstones); newSenderTombstones.set(recordId, updatedSenderTombstone); newNodes.set(senderNodeId, { ...currentSender, tombstones: newSenderTombstones }); } } } return { nodes: newNodes }; }; const gossipRounds = (network: NetworkState, recordId: string, rounds: number): NetworkState => { let state = network; for (let round = 0; round < rounds; round++) { for (const [nodeId, node] of state.nodes) { if (node.isOnline && (node.records.has(recordId) || node.tombstones.has(recordId))) { state = gossipOnce(state, nodeId, recordId); } } } return state; }; interface ClusterStats { name: string; nodeCount: number; recordCount: number; tombstoneCount: number; onlineCount: number; } interface SimulationResult { testName: string; recordsDeleted: boolean; roundsToDeleteRecords: number; totalRounds: number; clusters: ClusterStats[]; } const getClusterStats = ( network: NetworkState, recordId: string, partitionFilter?: string ): ClusterStats => { let recordCount = 0; let tombstoneCount = 0; let nodeCount = 0; let onlineCount = 0; for (const [, node] of network.nodes) { if (partitionFilter && node.partitionId !== partitionFilter) continue; nodeCount++; if (node.isOnline) onlineCount++; if (node.records.has(recordId)) recordCount++; if (node.tombstones.has(recordId)) tombstoneCount++; } return { name: partitionFilter ?? 'all', nodeCount, recordCount, tombstoneCount, onlineCount, }; }; const printSimulationResult = (result: SimulationResult): void => { console.log(`\n== ${result.testName} ==`); if (result.recordsDeleted) { console.log(` Records deleted: YES (${result.roundsToDeleteRecords} rounds)`); } else { console.log(` Records deleted: NO`); } console.log(` Total rounds run: ${result.totalRounds}`); console.log(` Final State:`); for (const cluster of result.clusters) { const clusterLabel = cluster.name === 'all' ? 'Network' : `Partition ${cluster.name}`; console.log(` ${clusterLabel} (${cluster.nodeCount} nodes, ${cluster.onlineCount} online):`); console.log(` Records: ${cluster.recordCount}`); console.log(` Tombstones: ${cluster.tombstoneCount}`); } }; interface ConvergenceResult { network: NetworkState; recordsDeleted: boolean; roundsToDeleteRecords: number; totalRounds: number; } const runToConvergence = ( network: NetworkState, recordId: string, maxRounds: number, extraRoundsAfterDeletion: number = 100 ): ConvergenceResult => { let rounds = 0; let state = network; let recordsDeleted = false; let roundsToDeleteRecords = 0; while (rounds < maxRounds && !recordsDeleted) { const stats = getClusterStats(state, recordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDeleteRecords = rounds; } state = gossipRounds(state, recordId, 10); rounds += 10; } let extraRounds = 0; while (extraRounds < extraRoundsAfterDeletion) { state = gossipRounds(state, recordId, 10); extraRounds += 10; rounds += 10; } return { network: state, recordsDeleted, roundsToDeleteRecords, totalRounds: rounds, }; }; const addRecordToNetwork = (network: NetworkState, nodeId: string, recordId: string, data: Data): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const newRecords = new Map(node.records); newRecords.set(recordId, createRecord(recordId, data, nodeId)); const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, records: newRecords }); return { nodes: newNodes }; }; const addTombstoneToNetwork = (network: NetworkState, nodeId: string, recordId: string): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const record = node.records.get(recordId); if (!record) return network; const newTombstones = new Map(node.tombstones); newTombstones.set(recordId, createTombstone(record, nodeId)); const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, tombstones: newTombstones }); return { nodes: newNodes }; }; const setNodeOnline = (network: NetworkState, nodeId: string, isOnline: boolean): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, isOnline }); return { nodes: newNodes }; }; const setNodePartition = (network: NetworkState, nodeId: string, partitionId: string): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, partitionId }); return { nodes: newNodes }; }; const setMultipleNodesPartition = ( network: NetworkState, nodeIds: string[], partitionId: string ): NetworkState => { let result = network; for (const nodeId of nodeIds) { result = setNodePartition(result, nodeId, partitionId); } return result; }; // === Test Scenarios === const testSingleNodeDeletion = (): 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(15); const recordId = `test-${trial}`; network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); 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: `Single Node Deletion (${trials} trials)`, 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, onlineCount: 15 * trials, }], }); }; const testNodeOfflineDuringTombstone = (): 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(15); const recordId = `offline-${trial}`; const offlineNodeId = "node-5"; // Propagate record to all nodes network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); // Take node-5 offline network = setNodeOnline(network, offlineNodeId, false); // Create tombstone while node-5 is offline network = addTombstoneToNetwork(network, "node-0", recordId); // Run gossip while node-5 is offline (tombstone propagates to online nodes) network = gossipRounds(network, recordId, 50); // Bring node-5 back online network = setNodeOnline(network, offlineNodeId, true); // Continue - the stale record on node-5 should be deleted when it receives tombstone const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + 50; } totalRounds += result.totalRounds + 50; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Node Offline During Tombstone (${trials} trials)`, 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, onlineCount: 15 * trials, }], }); }; const testMultipleNodesOffline = (): 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(20); const recordId = `multi-offline-${trial}`; const offlineNodes = ["node-3", "node-7", "node-12", "node-15"]; // Propagate record to all nodes network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 25); // Take multiple nodes offline for (const nodeId of offlineNodes) { network = setNodeOnline(network, nodeId, false); } // Create tombstone network = addTombstoneToNetwork(network, "node-0", recordId); // Run gossip while nodes are offline network = gossipRounds(network, recordId, 60); // Bring nodes back online one by one with some rounds in between for (const nodeId of offlineNodes) { network = setNodeOnline(network, nodeId, true); network = gossipRounds(network, recordId, 15); } const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + 60 + offlineNodes.length * 15; } totalRounds += result.totalRounds + 60 + offlineNodes.length * 15; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Multiple Nodes Offline (${trials} trials, 4 nodes offline)`, 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, onlineCount: 20 * trials, }], }); }; const testNetworkPartition = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecordsA = 0; let finalTombstonesA = 0; let finalRecordsB = 0; let finalTombstonesB = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20); const recordId = `partition-${trial}`; // Propagate record to all nodes (all in same partition initially) network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 25); // Split network into two partitions const partitionA = ["node-0", "node-1", "node-2", "node-3", "node-4", "node-5", "node-6", "node-7", "node-8", "node-9"]; const partitionB = ["node-10", "node-11", "node-12", "node-13", "node-14", "node-15", "node-16", "node-17", "node-18", "node-19"]; network = setMultipleNodesPartition(network, partitionA, "partition-a"); network = setMultipleNodesPartition(network, partitionB, "partition-b"); // Create tombstone in partition A network = addTombstoneToNetwork(network, "node-0", recordId); // Run gossip while partitioned (tombstone only propagates in partition A) network = gossipRounds(network, recordId, 80); // Check state while partitioned const statsADuringPartition = getClusterStats(network, recordId, "partition-a"); const statsBDuringPartition = getClusterStats(network, recordId, "partition-b"); // Heal partition (move all nodes back to main) network = setMultipleNodesPartition(network, [...partitionA, ...partitionB], "main"); // Continue after heal const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + 80; } totalRounds += result.totalRounds + 80; // Get final stats per original partition membership let recordsA = 0, tombstonesA = 0; let recordsB = 0, tombstonesB = 0; for (const nodeId of partitionA) { const node = result.network.nodes.get(nodeId)!; if (node.records.has(recordId)) recordsA++; if (node.tombstones.has(recordId)) tombstonesA++; } for (const nodeId of partitionB) { const node = result.network.nodes.get(nodeId)!; if (node.records.has(recordId)) recordsB++; if (node.tombstones.has(recordId)) tombstonesB++; } finalRecordsA += recordsA; finalTombstonesA += tombstonesA; finalRecordsB += recordsB; finalTombstonesB += tombstonesB; } printSimulationResult({ testName: `Network Partition & Heal (${trials} trials)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [ { name: 'partition-a (origin)', nodeCount: 10 * trials, recordCount: finalRecordsA, tombstoneCount: finalTombstonesA, onlineCount: 10 * trials }, { name: 'partition-b (stale)', nodeCount: 10 * trials, recordCount: finalRecordsB, tombstoneCount: finalTombstonesB, onlineCount: 10 * trials }, ], }); }; const testClusterSeparation = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecordsMain = 0; let finalTombstonesMain = 0; let finalRecordsIsolated = 0; let finalTombstonesIsolated = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(25); const recordId = `cluster-sep-${trial}`; // Propagate record to all nodes network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 30); // Isolate a cluster of 5 nodes (simulating a data center going offline together) const isolatedCluster = ["node-10", "node-11", "node-12", "node-13", "node-14"]; network = setMultipleNodesPartition(network, isolatedCluster, "isolated"); // Create tombstone in main partition network = addTombstoneToNetwork(network, "node-0", recordId); // Run for extended period while cluster is isolated network = gossipRounds(network, recordId, 150); // Rejoin the isolated cluster network = setMultipleNodesPartition(network, isolatedCluster, "main"); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + 150; } totalRounds += result.totalRounds + 150; // Get final stats let recordsMain = 0, tombstonesMain = 0; let recordsIsolated = 0, tombstonesIsolated = 0; for (const [nodeId, node] of result.network.nodes) { const isIsolated = isolatedCluster.includes(nodeId); if (node.records.has(recordId)) { if (isIsolated) recordsIsolated++; else recordsMain++; } if (node.tombstones.has(recordId)) { if (isIsolated) tombstonesIsolated++; else tombstonesMain++; } } finalRecordsMain += recordsMain; finalTombstonesMain += tombstonesMain; finalRecordsIsolated += recordsIsolated; finalTombstonesIsolated += tombstonesIsolated; } printSimulationResult({ testName: `Cluster Separation (${trials} trials, 5-node cluster isolated)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [ { name: 'main (20 nodes)', nodeCount: 20 * trials, recordCount: finalRecordsMain, tombstoneCount: finalTombstonesMain, onlineCount: 20 * trials }, { name: 'isolated (5 nodes)', nodeCount: 5 * trials, recordCount: finalRecordsIsolated, tombstoneCount: finalTombstonesIsolated, onlineCount: 5 * trials }, ], }); }; const testConcurrentTombstones = (): 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(20); const recordId = `concurrent-delete-${trial}`; network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 30); // Multiple nodes create tombstones simultaneously 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 (${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, onlineCount: 20 * trials, }], }); }; const testStaggeredNodeRecovery = (): 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(20); const recordId = `staggered-${trial}`; const offlineNodes = ["node-4", "node-8", "node-12", "node-16"]; // Propagate record to all nodes network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 25); // All nodes go offline for (const nodeId of offlineNodes) { network = setNodeOnline(network, nodeId, false); } // Create tombstone network = addTombstoneToNetwork(network, "node-0", recordId); // Run while offline network = gossipRounds(network, recordId, 40); // Bring nodes back online at staggered intervals let roundsSinceStart = 40; for (let i = 0; i < offlineNodes.length; i++) { // Run some rounds network = gossipRounds(network, recordId, 20); roundsSinceStart += 20; // Bring next node online network = setNodeOnline(network, offlineNodes[i], true); } const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + roundsSinceStart; } totalRounds += result.totalRounds + roundsSinceStart; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Staggered Node Recovery (${trials} trials)`, 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, onlineCount: 20 * trials, }], }); }; const testOriginNodeGoesOffline = (): 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(15); const recordId = `origin-offline-${trial}`; // Node-0 creates and propagates record network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); // Node-0 creates tombstone then immediately goes offline network = addTombstoneToNetwork(network, "node-0", recordId); network = setNodeOnline(network, "node-0", false); // The tombstone should still propagate via other nodes const result = runToConvergence(network, recordId, maxRounds); // Bring node-0 back online for final check network = setNodeOnline(result.network, "node-0", true); const finalResult = runToConvergence(network, recordId, maxRounds, 50); if (finalResult.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds + finalResult.totalRounds; const stats = getClusterStats(finalResult.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Origin Node Goes Offline (${trials} trials)`, 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, onlineCount: 15 * trials, }], }); }; const testFlappingNode = (): 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(15); const recordId = `flapping-${trial}`; const flappingNode = "node-7"; // Propagate record network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); // Create tombstone network = addTombstoneToNetwork(network, "node-0", recordId); // Simulate a flapping node (repeatedly going offline/online) let rounds = 0; let recordsDeleted = false; let roundsToDelete = 0; while (rounds < maxRounds && !recordsDeleted) { // Toggle node state every 5 rounds if (rounds % 10 < 5) { network = setNodeOnline(network, flappingNode, true); } else { network = setNodeOnline(network, flappingNode, false); } const stats = getClusterStats(network, recordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDelete = rounds; } network = gossipRounds(network, recordId, 5); rounds += 5; } // Stabilize the node and run to convergence network = setNodeOnline(network, flappingNode, true); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted || recordsDeleted) { deletedCount++; totalDeletionRounds += recordsDeleted ? roundsToDelete : (result.roundsToDeleteRecords + rounds); } totalRounds += result.totalRounds + rounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Flapping Node (${trials} trials, node toggles online/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, onlineCount: 15 * trials, }], }); }; const testPartitionDuringKeeperElection = (): 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(20); const recordId = `partition-during-gc-${trial}`; // Propagate record network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 25); // Create tombstone and let it propagate briefly network = addTombstoneToNetwork(network, "node-0", recordId); network = gossipRounds(network, recordId, 15); // Now partition the network during keeper election phase const partitionA = Array.from({ length: 10 }, (_, i) => `node-${i}`); const partitionB = Array.from({ length: 10 }, (_, i) => `node-${i + 10}`); network = setMultipleNodesPartition(network, partitionA, "partition-a"); network = setMultipleNodesPartition(network, partitionB, "partition-b"); // Run while partitioned (keeper election happens independently) network = gossipRounds(network, recordId, 100); // Heal partition network = setMultipleNodesPartition(network, [...partitionA, ...partitionB], "main"); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + 15 + 100; } totalRounds += result.totalRounds + 15 + 100; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Partition During Keeper Election (${trials} trials)`, 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, onlineCount: 20 * trials, }], }); }; const runAllTests = (): void => { console.log("=== HyperLogLog Tombstone Simulation ==="); console.log("Model: Fully connected network with offline nodes and partitions\n"); testSingleNodeDeletion(); testNodeOfflineDuringTombstone(); testMultipleNodesOffline(); testNetworkPartition(); testClusterSeparation(); testConcurrentTombstones(); testStaggeredNodeRecovery(); testOriginNodeGoesOffline(); testFlappingNode(); testPartitionDuringKeeperElection(); console.log("\n=== Simulation Complete ==="); }; runAllTests();