volpe/simulations/hyperloglog-tombstone/simulation.ts

1411 lines
No EOL
46 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 peerIds: readonly string[];
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): NodeState<Data> => ({
id,
records: new Map(),
tombstones: new Map(),
peerIds: [],
stats: { messagesReceived: 0, tombstonesGarbageCollected: 0, resurrections: 0 },
});
const addPeerToNode = <Data>(node: NodeState<Data>, peerId: string): NodeState<Data> => {
if (node.peerIds.includes(peerId)) return node;
return { ...node, peerIds: [...node.peerIds, peerId] };
};
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) {
// Keeper step-down logic:
// If incoming tombstone has reached the target count, compare estimates.
// If incoming estimate >= my estimate before merge, step down.
// Use node ID as tie-breaker: higher node ID steps down when estimates are equal.
if (incomingTombstoneEstimate !== null && incomingTombstoneEstimate >= targetCount) {
if (myTombstoneEstimateBeforeMerge < incomingTombstoneEstimate) {
return { shouldGC: true, stepDownAsKeeper: true };
}
// Tie-breaker: if estimates are equal, the lexicographically higher node ID steps down
if (myTombstoneEstimateBeforeMerge === incomingTombstoneEstimate &&
senderNodeId !== null && myNodeId > senderNodeId) {
return { shouldGC: true, stepDownAsKeeper: true };
}
}
return { shouldGC: false, stepDownAsKeeper: false };
}
// Not yet a keeper - will become one if tombstone count reaches target after merge
// (No explicit action needed here, keeper status is inferred from HLL comparison)
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
);
// Always delete the record when we have a tombstone
const newRecords = new Map(node.records);
newRecords.delete(incoming.id);
if (gcStatus.stepDownAsKeeper) {
// Step down: delete both record and tombstone
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 };
};
const createNetwork = <Data>(nodeCount: number, connectivityRatio: number): NetworkState<Data> => {
let nodes = new Map<string, NodeState<Data>>();
for (let i = 0; i < nodeCount; i++) {
nodes.set(`node-${i}`, createNode<Data>(`node-${i}`));
}
const nodeIds = Array.from(nodes.keys());
for (let i = 0; i < nodeIds.length; i++) {
for (let j = i + 1; j < nodeIds.length; j++) {
if (Math.random() < connectivityRatio) {
nodes = new Map(nodes)
.set(nodeIds[i], addPeerToNode(nodes.get(nodeIds[i])!, nodeIds[j]))
.set(nodeIds[j], addPeerToNode(nodes.get(nodeIds[j])!, nodeIds[i]));
}
}
}
for (let i = 0; i < nodeIds.length; i++) {
const nextIdx = (i + 1) % nodeIds.length;
nodes = new Map(nodes)
.set(nodeIds[i], addPeerToNode(nodes.get(nodeIds[i])!, nodeIds[nextIdx]))
.set(nodeIds[nextIdx], addPeerToNode(nodes.get(nodeIds[nextIdx])!, nodeIds[i]));
}
return { nodes };
};
const createBridgedNetwork = <Data>(
clusterSize: number,
intraClusterConnectivity: number
): NetworkState<Data> => {
let nodes = new Map<string, NodeState<Data>>();
for (let i = 0; i < clusterSize; i++) {
nodes.set(`cluster-a-${i}`, createNode<Data>(`cluster-a-${i}`));
nodes.set(`cluster-b-${i}`, createNode<Data>(`cluster-b-${i}`));
}
const clusterA = Array.from(nodes.keys()).filter(id => id.startsWith('cluster-a'));
const clusterB = Array.from(nodes.keys()).filter(id => id.startsWith('cluster-b'));
const connectCluster = (clusterIds: string[]) => {
for (let i = 0; i < clusterIds.length; i++) {
for (let j = i + 1; j < clusterIds.length; j++) {
if (Math.random() < intraClusterConnectivity) {
nodes = new Map(nodes)
.set(clusterIds[i], addPeerToNode(nodes.get(clusterIds[i])!, clusterIds[j]))
.set(clusterIds[j], addPeerToNode(nodes.get(clusterIds[j])!, clusterIds[i]));
}
}
}
for (let i = 0; i < clusterIds.length; i++) {
const nextIdx = (i + 1) % clusterIds.length;
nodes = new Map(nodes)
.set(clusterIds[i], addPeerToNode(nodes.get(clusterIds[i])!, clusterIds[nextIdx]))
.set(clusterIds[nextIdx], addPeerToNode(nodes.get(clusterIds[nextIdx])!, clusterIds[i]));
}
};
connectCluster(clusterA);
connectCluster(clusterB);
const bridgeA = clusterA[0];
const bridgeB = clusterB[0];
nodes = new Map(nodes)
.set(bridgeA, addPeerToNode(nodes.get(bridgeA)!, bridgeB))
.set(bridgeB, addPeerToNode(nodes.get(bridgeB)!, bridgeA));
return { nodes };
};
const forwardTombstoneToAllPeers = <Data>(
network: NetworkState<Data>,
forwardingNodeId: string,
tombstone: Tombstone,
excludePeerId?: string
): NetworkState<Data> => {
const forwardingNode = network.nodes.get(forwardingNodeId);
if (!forwardingNode) return network;
let newNodes = new Map(network.nodes);
for (const peerId of forwardingNode.peerIds) {
if (peerId === excludePeerId) 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 = forwardTombstoneToAllPeers({ 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.peerIds.length === 0) return network;
const record = sender.records.get(recordId);
const tombstone = sender.tombstones.get(recordId);
if (!record && !tombstone) return network;
const peerId = sender.peerIds[Math.floor(Math.random() * sender.peerIds.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 peer stepped down (had tombstone before, doesn't have it now), forward the incoming tombstone
if (peerHadTombstone && !updatedPeer.tombstones.has(recordId)) {
const result = forwardTombstoneToAllPeers({ 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);
// Merge HLLs
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,
};
// Check if sender should step down (peer has higher estimate or wins tie-breaker)
const gcStatus = checkGCStatus(
updatedSenderTombstone,
hllEstimate(peerTombstone.tombstoneHLL),
senderEstimateBeforeMerge,
senderNodeId,
peerId
);
if (gcStatus.stepDownAsKeeper) {
// Sender steps down - remove their tombstone
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 });
// Forward the peer's tombstone to all sender's other peers
const result = forwardTombstoneToAllPeers({ nodes: newNodes }, senderNodeId, peerTombstone, peerId);
newNodes = new Map(result.nodes);
} else {
// Keep tombstone with merged data
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.records.has(recordId) || node.tombstones.has(recordId)) {
state = gossipOnce(state, nodeId, recordId);
}
}
}
return state;
};
interface ClusterStats {
name: string;
nodeCount: number;
recordCount: number;
tombstoneCount: number;
}
interface SimulationResult {
testName: string;
recordsDeleted: boolean;
roundsToDeleteRecords: number;
totalRounds: number;
clusters: ClusterStats[];
}
const getClusterStats = <Data>(
network: NetworkState<Data>,
recordId: string,
clusterPrefix?: string
): ClusterStats => {
let recordCount = 0;
let tombstoneCount = 0;
let nodeCount = 0;
for (const [nodeId, node] of network.nodes) {
if (clusterPrefix && !nodeId.startsWith(clusterPrefix)) continue;
nodeCount++;
if (node.records.has(recordId)) recordCount++;
if (node.tombstones.has(recordId)) tombstoneCount++;
}
return {
name: clusterPrefix ?? 'all',
nodeCount,
recordCount,
tombstoneCount,
};
};
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' : `Cluster ${cluster.name}`;
console.log(` ${clusterLabel} (${cluster.nodeCount} nodes):`);
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;
// Phase 1: Run until records are deleted
while (rounds < maxRounds && !recordsDeleted) {
const stats = getClusterStats(state, recordId);
if (stats.recordCount === 0) {
recordsDeleted = true;
roundsToDeleteRecords = rounds;
}
state = gossipRounds(state, recordId, 10);
rounds += 10;
}
// Phase 2: Continue running to let tombstones converge
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 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, 0.4);
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,
}],
});
};
const testEarlyTombstoneCreation = (): 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 = `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 (${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 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 = 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 (${trials} trials, two clusters)`,
recordsDeleted: deletedCount === trials,
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
totalRounds: Math.round(totalRounds / trials),
clusters: [
{ 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 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 = `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 (${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 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 = 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 (${trials} trials)`,
recordsDeleted: deletedCount === trials,
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
totalRounds: Math.round(totalRounds / trials),
clusters: [
{ 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 = 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>(25, 0.15);
const recordId = `sparse-${trial}`;
network = addRecordToNetwork(network, "node-0", recordId, "Test");
network = gossipRounds(network, recordId, 50);
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: `Sparse Network (${trials} trials, 15% connectivity)`,
recordsDeleted: deletedCount === trials,
roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0,
totalRounds: Math.round(totalRounds / trials),
clusters: [{
name: 'all',
nodeCount: 25 * trials,
recordCount: finalRecords,
tombstoneCount: finalTombstones,
}],
});
};
const runAllTests = (): void => {
console.log("=== HyperLogLog Tombstone Simulation ===");
testSingleNodeDeletion();
testEarlyTombstoneCreation();
testBridgedNetwork();
testConcurrentTombstones();
testNetworkPartitionHeal();
testSparseNetwork();
testDynamicTopology();
testNodeChurn();
testRandomConfigurationChanges();
testNodeDropoutAndReconnect();
console.log("\n=== Simulation Complete ===");
};
runAllTests();