volpe/simulations/hyperloglog-tombstone/simulation.ts

1198 lines
No EOL
39 KiB
TypeScript

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<Data> {
readonly id: string;
readonly data: Data;
readonly recordHLL: HLL;
}
interface Tombstone {
readonly id: string;
readonly recordHLL: HLL;
readonly tombstoneHLL: HLL;
}
interface NodeState<Data> {
readonly id: string;
readonly records: ReadonlyMap<string, DataRecord<Data>>;
readonly tombstones: ReadonlyMap<string, Tombstone>;
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<Data> {
readonly nodes: ReadonlyMap<string, NodeState<Data>>;
}
const createRecord = <Data>(id: string, data: Data, nodeId: string): DataRecord<Data> => ({
id,
data,
recordHLL: hllAdd(createHLL(), nodeId),
});
const createTombstone = <Data>(record: DataRecord<Data>, nodeId: string): Tombstone => ({
id: record.id,
recordHLL: hllClone(record.recordHLL),
tombstoneHLL: hllAdd(createHLL(), nodeId),
});
const createNode = <Data>(id: string, partitionId: string = "main"): NodeState<Data> => ({
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 = <Data>(
node: NodeState<Data>,
incoming: DataRecord<Data>
): NodeState<Data> => {
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<Data> = 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 = <Data>(
node: NodeState<Data>,
incoming: Tombstone,
senderNodeId: string
): NodeState<Data> => {
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 = <Data>(nodeCount: number, partitionId: string = "main"): NetworkState<Data> => {
const nodes = new Map<string, NodeState<Data>>();
for (let i = 0; i < nodeCount; i++) {
nodes.set(`node-${i}`, createNode<Data>(`node-${i}`, partitionId));
}
return { nodes };
};
// Get all reachable nodes (online and in same partition)
const getReachableNodes = <Data>(
network: NetworkState<Data>,
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 = <Data>(
network: NetworkState<Data>,
forwardingNodeId: string,
tombstone: Tombstone,
excludeNodeId?: string
): NetworkState<Data> => {
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 = <Data>(network: NetworkState<Data>, senderNodeId: string, recordId: string): NetworkState<Data> => {
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 = <Data>(network: NetworkState<Data>, recordId: string, rounds: number): NetworkState<Data> => {
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 = <Data>(
network: NetworkState<Data>,
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<Data> {
network: NetworkState<Data>;
recordsDeleted: boolean;
roundsToDeleteRecords: number;
totalRounds: number;
}
const runToConvergence = <Data>(
network: NetworkState<Data>,
recordId: string,
maxRounds: number,
extraRoundsAfterDeletion: number = 100
): ConvergenceResult<Data> => {
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 = <Data>(network: NetworkState<Data>, nodeId: string, recordId: string, data: Data): NetworkState<Data> => {
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 = <Data>(network: NetworkState<Data>, nodeId: string, recordId: string): NetworkState<Data> => {
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 = <Data>(network: NetworkState<Data>, nodeId: string, isOnline: boolean): NetworkState<Data> => {
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 = <Data>(network: NetworkState<Data>, nodeId: string, partitionId: string): NetworkState<Data> => {
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 = <Data>(
network: NetworkState<Data>,
nodeIds: string[],
partitionId: string
): NetworkState<Data> => {
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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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();