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