1411 lines
No EOL
46 KiB
TypeScript
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(); |