| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712 |
- from hashlib import sha256
- from random import randint, random, getrandbits
- from collections import Counter
- import time
- import math
- import asyncio
- import logging
- from logging import debug, error, info
- import matplotlib.pyplot as plt
- import networkx as nx
- import numpy as np
- EventId = str
- EventIds = list[EventId]
- def ntp_request() -> float:
- # add random clock drift
- if bool(getrandbits(1)):
- return time.time() + randint(0, 10)
- else:
- return time.time() - randint(0, 10)
- class NetworkPool:
- def __init__(self, nodes):
- self.nodes = nodes
- def request(self, event_id: EventId):
- for n in self.nodes:
- event = n.get_event(event_id)
- if event != None:
- return event
- return None
- class Event:
- def __init__(self, parents: EventIds):
- self.timestamp = ntp_request()
- self.parents = sorted(parents)
- def set_timestamp(self, timestamp):
- self.timestamp = timestamp
- # Hash of timestamp and the parents
- def hash(self) -> str:
- m = sha256()
- m.update(str.encode(str(self.timestamp)))
- for p in self.parents:
- m.update(str.encode(str(p)))
- return m.digest().hex()
- def __str__(self):
- res = f"{self.hash()}"
- for p in self.parents:
- res += f"\n |"
- res += f"\n - {p}"
- res += f"\n"
- return res
- class Graph:
- def __init__(self, max_time_diff):
- self.events = dict()
- self.heads = []
- self.tails = []
- self.max_time_diff = max_time_diff
- def add_event(self, event: Event):
- event_id = event.hash()
- if self.events.get(event_id) != None:
- return
- self.events[event_id] = event
- self.update_heads(event)
- self.update_tails(event)
- def update_tails(self, event):
- event_hash = event.hash()
- if event_hash in self.tails:
- return
- # Remove tails if they are parents of the given event
- for p in event.parents:
- if p in self.tails:
- self.tails.remove(p)
- # Add the event to tails
- self.tails.append(event_hash)
- self.tails = sorted(self.tails)
- def update_heads(self, event):
- event_hash = event.hash()
- if event_hash in self.heads:
- return
- # Remove heads if they are parents of the given event
- for p in event.parents:
- if p in self.heads:
- self.heads.remove(p)
- # Add the event to heads
- self.heads.append(event_hash)
- self.heads = sorted(self.heads)
- # Check if the event is too old from now, by subtracting current timestamp
- # from event timestamp, it must be more than `max_time_diff' to be consider
- # old event
- def is_old_event(self, event: Event):
- # Ignore genesis event
- if event.timestamp == 0.0:
- return False
- current_timestamp = ntp_request()
- diff = current_timestamp - event.timestamp
- if diff > self.max_time_diff:
- return True
- return False
- def prune_old_events(self):
- # Find the old events
- old_events = [eh for eh, ev in self.events.items() if
- self.is_old_event(ev)]
- # Remove the old events
- for eh in old_events:
- self.remove_event(eh)
-
- def remove_event(self, eh: EventId):
- self.events.pop(eh, None)
- # Remove old events from heads
- if eh in self.heads:
- self.heads.remove(eh)
- # Remove old events from tails
- if eh in self.tails:
- self.tails.remove(eh)
- # Check if given events are exist in the graph
- # return a list of missing events
- def check_events(self, events: EventIds) -> EventIds:
- return [e for e in events if self.events.get(e) == None]
- def __str__(self):
- res = ""
- for event in self.events.values():
- res += f"\n {event}"
- return res
- class Node:
- def __init__(self, name: str, queue, max_time_diff):
- self.name = name
- self.orphan_pool = Graph(max_time_diff)
- self.active_pool = Graph(max_time_diff)
- self.queue = queue
- # Pruned events from active pool
- self.pruned_events = []
- # The active pool should always start with one event
- genesis_event = Event([])
- genesis_event.set_timestamp(0.0)
- self.active_pool.add_event(genesis_event)
- # On create new event
- def new_event(self):
- # Pruning old events from active pool
- self.active_pool.prune_old_events()
- return Event(self.active_pool.heads)
- # On receive new event
- def receive_new_event(self, event: Event, peer, np):
- debug(f"{self.name} receive event from {peer}: \n {event}")
- event_hash = event.hash()
- # Reject event with no parents
- if not event.parents:
- return
- # XXX Reject old event
- # no need for this simulation
- # if self.is_old_event(event):
- # return
- # Reject event already exist in active pool
- if not self.active_pool.check_events([event_hash]):
- return
- # Reject event already exist in orphan pool
- if not self.orphan_pool.check_events([event_hash]):
- return
- # Add the new event to the orphan pool
- self.orphan_pool.add_event(event)
- # This function is the core of syncing algorithm
- #
- # Find all the links from the new event to events in orphan pool
- # Bring these events to the active pool then add the new event
- self.relink_orphan(event, np)
- def relink_orphan(self, orphan, np):
- # Check if the parents of the orphan
- # are not missing from active pool
- missing_parents = self.active_pool.check_events(orphan.parents)
- missing_parents = self.check_pruned_events(missing_parents)
- if missing_parents:
- # Check the missing parents from orphan pool and sync with the
- # network for missing ones
- self.check_and_sync(list(missing_parents), np)
- # At this stage all the missing parents must be in the orphan pool
- # The next step is to move them to active pool
- self.add_linked_events_to_active_pool(missing_parents, [])
- # Check again that the parents of the orphan are in the active pool
- missing_parents = self.active_pool.check_events(orphan.parents)
- missing_parents = self.check_pruned_events(missing_parents)
- assert (not missing_parents)
- # Add the event to active pool
- self.activate_event(orphan)
- else:
- self.activate_event(orphan)
- # Last stage, Cleaning up the orphan pool:
- # - Remove orphan if it is too old according to `max_time_diff`
- # - Move orphan to active pool if it doesn't have any missing parents
- self.clean_pools()
-
- def clean_pools(self):
- self.active_pool.prune_old_events()
- for event in self.active_pool.events.values():
- # Check if the event parents are old events
- old_parents = self.active_pool.check_events(event.parents)
- if not old_parents:
- continue
- # Add the event to tails if it has only old events as parents
- self.active_pool.update_tails(event)
- self.orphan_pool.prune_old_events()
- while True:
- active_list = []
- for orphan in self.orphan_pool.events.values():
- # Move the orphan to active pool if it doesn't have missing
- # parents in active pool
- missing_parents = self.active_pool.check_events(orphan.parents)
- if not missing_parents:
- active_list.append(orphan)
-
- if not active_list:
- break
- for ev in active_list:
- self.activate_event(ev)
-
- def check_and_sync(self, missing_events, np):
- debug(f"{self.name} check_and_sync() {missing_events}")
- while True:
- # Check if all missing parents are in orphan pool, otherwise
- # add them to request list
- request_list = []
- self.scan_orphan_pool(request_list, missing_events, [])
- if not request_list:
- break
- missing_events = self.fetch_events_from_network(request_list, np)
- # Check the missing links inside orphan pool
- def scan_orphan_pool(self, request_list, events: EventIds, visited):
- debug(f"{self.name} check_missing_parents() {events}")
- for event_hash in events:
- # Check if the function already visit this event
- if event_hash in visited:
- continue
- visited.append(event_hash)
- # If the event in orphan pool, do recursive call to check its
- # parents as well, otherwise add the event to request_list
- event = self.orphan_pool.events.get(event_hash)
- if event == None:
- # Check first if it's not in the active pool
- if self.active_pool.events.get(event_hash) != None:
- continue
- # Check if it's not in pruned events
- if event_hash in self.pruned_events:
- continue
- request_list.append(event_hash)
- else:
- # Recursive call
- # Climb up for the event parents
- self.scan_orphan_pool(request_list, event.parents, visited)
- def fetch_events_from_network(self, request_list, np):
- debug(f"{self.name} fetch_events() {request_list}")
- # XXX
- # Send the events in request_list to the node who send this event.
- #
- # For simulation purpose the node fetch the missed events from the
- # network pool which contains all the nodes and its events
- result = []
- for p in request_list:
- debug(f"{self.name} request from the network: {p}")
- # Request from the network
- requested_event = np.request(p)
- if requested_event == None:
- if p not in self.pruned_events:
- self.pruned_events.append(p)
- continue
- # Add it to the orphan pool
- self.orphan_pool.add_event(requested_event)
- result.extend(requested_event.parents)
- # Return parents of requested events
- return result
- def add_linked_events_to_active_pool(self, events, visited):
- debug(f"{self.name} add_linked_events_to_active_pool() {events}")
- for event_hash in events:
- # Check if it already visit this event
- if event_hash in visited:
- continue
- visited.append(event_hash)
- if self.active_pool.events.get(event_hash) != None:
- continue
- if event_hash in self.pruned_events:
- continue
- # Get the event from the orphan pool
- event = self.orphan_pool.events.get(event_hash)
- assert (event != None)
- # Add it to the active pool
- self.activate_event(event)
- # Recursive call
- # Climb up for the event parents
- self.add_linked_events_to_active_pool(event.parents, visited)
- def activate_event(self, event):
- # Add the event to active pool
- self.active_pool.add_event(event)
- # Remove event from orphan pool
- self.orphan_pool.remove_event(event.hash())
- # Get an event from orphan pool or active pool
- def get_event(self, event_id: EventId):
- # Check the active_pool
- event = self.active_pool.events.get(event_id)
- # Check the orphan_pool
- if event == None:
- event = self.orphan_pool.events.get(event)
- return event
- # Clean up the given events from pruned events
- def check_pruned_events(self, events):
- return [ev for ev in events if ev not in self.pruned_events]
- def __str__(self):
- return f"""
- \n Name: {self.name}
- \n Active Pool: {self.active_pool}
- \n Orphan Pool: {self.orphan_pool}"""
- # Each node has `nodes_n` of this function running in the background
- # for receiving events from each node separately
- async def recv_loop(podm, node, peer, queue, np):
- while True:
- # Wait new event
- event = await queue.get()
- queue.task_done()
- if event == None:
- break
- if random() < podm:
- debug(f"{node.name} dropped: \n {event}")
- continue
- node.receive_new_event(event, peer, np)
- # Send new event at random intervals
- # Each node has this function running in the background
- async def send_loop(nodes_n, max_delay, broadcast_attempt, node):
- for _ in range(broadcast_attempt):
- await asyncio.sleep(randint(0, max_delay))
- # Create new event with the last heads as parents
- event = node.new_event()
- debug(f"{node.name} broadcast event: \n {event}")
- for _ in range(nodes_n):
- await node.queue.put(event)
- await node.queue.join()
- """
- Run a simulation with the provided params:
- nodes_n: number of nodes
- podm: probability of dropping events (ex: 0.30 -> %30)
- broadcast_attempt: number of events each node should broadcast
- max_time_diff: a max difference in time to detect an old event
- check: check if all nodes have the same graph
- """
- async def run(nodes_n=3, podm=0.30, broadcast_attempt=3, max_time_diff=180.0,
- check=False, max_delay=None):
- debug(f"Running simulation with nodes: {nodes_n}, podm: {podm},\
- broadcast_attempt: {broadcast_attempt}")
- if max_delay == None:
- max_delay = round(math.log(nodes_n))
- broadcast_timeout = nodes_n * broadcast_attempt * max_delay
- nodes = []
- info(f"Run {nodes_n} Nodes")
- try:
- # Initialize `nodes_n` nodes
- for i in range(nodes_n):
- queue = asyncio.Queue()
- node = Node(f"Node{i}", queue, max_time_diff)
- nodes.append(node)
- # Initialize NetworkPool contains all nodes
- np = NetworkPool(nodes)
- # Initialize `nodes_n` * `nodes_n` coroutine tasks for receiving events
- # Each node listen to all queues from the running nodes
- recv_tasks = []
- for node in nodes:
- for n in nodes:
- recv_tasks.append(recv_loop(podm, node, n.name, n.queue, np))
- r_g = asyncio.gather(*recv_tasks)
- # Create coroutine task contains send_loop function for each node
- # Run and wait for send tasks
- s_g = asyncio.gather(
- *[send_loop(nodes_n, max_delay, broadcast_attempt, n) for n in nodes])
- await asyncio.wait_for(s_g, broadcast_timeout)
- # Gracefully stop all receiving tasks
- for n in nodes:
- for _ in range(nodes_n):
- await n.queue.put(None)
- await n.queue.join()
- await r_g
- if check:
- for node in nodes:
- debug(node)
- # Assert if all nodes share the same active pool graph
- assert (all(n.active_pool.events.keys() ==
- nodes[0].active_pool.events.keys() for n in nodes))
- # Assert if all nodes share the same orphan pool graph
- assert (all(n.orphan_pool.events.keys() ==
- nodes[0].orphan_pool.events.keys() for n in nodes))
- return nodes
- except asyncio.exceptions.TimeoutError:
- error("Broadcast TimeoutError")
- async def main(sim_n=6, nodes_increase=False, podm_increase=False,
- time_diff_decrease=False):
- # run the simulation `sim_n` times, while enabling one of these params:
- # - increasing `podm`
- # - increasing `nodes_n`
- # - decreasing `max_time_diff`
- if nodes_increase:
- podm_increase = False
- time_diff_decrease = False
- if podm_increase:
- time_diff_decrease = False
- # number of nodes
- nodes_n = 100
- # probability of dropping events
- podm = 0.0
- # a max difference in time to detect an old event
- max_time_diff = 60 # seconds
- # number of events each node should broadcast
- broadcast_attempt = 10
- # Number of nodes get increase in each simulation
- sim_nodes_inc = int(nodes_n / 5)
- # A value get add to `podm` in each simulation
- sim_podm_inc = podm / 5
- # A value get subtract from `max_time_diff` in each simulation
- sim_diff_time_dec = max_time_diff / 10
- # Contains the nodes for each simulation
- simulations = []
- # Contains the `podm` variables for each simulation
- podm_list = []
- # Contains the `max_time_diff` variables for each simulation
- mtd_list = []
- podm_tmp = podm
- nodes_n_tmp = nodes_n
- time_diff_tmp = max_time_diff
- for _ in range(sim_n):
- nodes = await run(nodes_n_tmp, podm_tmp, broadcast_attempt,
- max_time_diff=time_diff_tmp)
- simulations.append(nodes)
- podm_list.append(podm_tmp)
- mtd_list.append(time_diff_tmp)
- if nodes_increase:
- nodes_n_tmp += sim_nodes_inc
- if podm_increase:
- podm_tmp += sim_podm_inc
- if time_diff_decrease:
- time_diff_tmp -= sim_diff_time_dec
- # Numbers of nodes for each simulation
- nodes_n_list = []
- # Synced events percentage for each simulation
- events_synced_perc = []
- # Number of events in active pool for each simulations
- active_events = []
- # Number of events in pruned events list for each simulations
- pruned_events = []
- for nodes in simulations:
- nodes_n = len(nodes)
- nodes_n_list.append(nodes_n)
- events = Counter()
- p_events = Counter()
- for node in nodes:
- events.update(list(node.active_pool.events.keys()))
- p_events.update(node.pruned_events)
- expect_events_synced = (nodes_n * broadcast_attempt) + 1
- actual_events_synced = 0
- for val in events.values():
- # If the event is fully synced with all nodes
- if val == nodes_n:
- actual_events_synced += 1
- pruned_events_synced = 0
- for val in p_events.values():
- # If the pruned event is fully synced with all nodes
- if val == nodes_n:
- pruned_events_synced += 1
- res = (actual_events_synced * 100) / expect_events_synced
- events_synced_perc.append(res)
- active_events.append(actual_events_synced)
- pruned_events.append(pruned_events_synced)
- info(f"nodes_n: {nodes_n}")
- info(f"actual_events_synced: {actual_events_synced}")
- info(f"expect_events_synced: {expect_events_synced}")
- info(f"pruned_events_synced: {pruned_events_synced}")
- info(f"res: %{res}")
- # Disable logging for matplotlib
- logging.disable()
- if nodes_increase:
- plt.plot(nodes_n_list, events_synced_perc)
- plt.ylim(0, 100)
- plt.title(
- f"Event Graph simulation with %{podm * 100} probability of dropping messages")
- plt.ylabel(
- f"Events sync percentage (each node broadcast {broadcast_attempt} events)")
- plt.xlabel("Number of nodes")
- plt.show()
- return
- if podm_increase:
- plt.plot(podm_list, events_synced_perc)
- plt.ylim(0, 100)
- plt.title(f"Event Graph simulation with {nodes_n} nodes")
- plt.ylabel(
- f"Events sync percentage (each node broadcast {broadcast_attempt} events)")
- plt.xlabel("Probability of dropping messages")
- plt.show()
- return
- if time_diff_decrease:
- x = np.arange(len(mtd_list)) # the label locations
- width = 0.35 # the width of the bars
- fig, ax = plt.subplots()
- rects1 = ax.bar(x - width/2, active_events, width, label='Active')
- rects2 = ax.bar(x + width/2, pruned_events, width, label='Pruned')
- plt.title(
- f"Event Graph simulation with %{podm * 100} probability of dropping messages, and {nodes_n} nodes")
- plt.ylabel(
- f"Number of events broadcasted during the simulation (each node broadcast {broadcast_attempt} events)")
- plt.xlabel("A time duration to detect old events (in seconds)")
- plt.ylim(0, (broadcast_attempt * nodes_n) + 1)
- ax.set_xticks(x, mtd_list)
- ax.legend()
- ax.bar_label(rects1, padding=3)
- ax.bar_label(rects2, padding=3)
- fig.tight_layout()
- plt.show()
- return
- def print_network_graph(node, unpruned=False):
- logging.disable()
- graph = nx.Graph()
- if unpruned:
- for (h, ev) in node.unpruned_active_pool.events.items():
- graph.add_node(h[:5])
- graph.add_edges_from([(h[:5], p[:5]) for p in ev.parents])
- else:
- for (h, ev) in node.active_pool.events.items():
- graph.add_node(h[:5])
- graph.add_edges_from([(h[:5], p[:5]) for p in ev.parents])
- colors = []
- for n in graph.nodes():
- if any(n == t[:5] for t in node.tails):
- colors.append("red")
- elif any(n == h[:5] for h in node.heads):
- colors.append("yellow")
- else:
- colors.append("#697aff")
- nx.draw_networkx(graph, with_labels=True, node_color=colors)
- plt.show()
- if __name__ == "__main__":
- logging.basicConfig(level=logging.DEBUG,
- handlers=[logging.FileHandler("debug.log", mode="w"),
- logging.StreamHandler()])
- #nodes = asyncio.run(run(nodes_n=14, podm=0, broadcast_attempt=4,
- # max_time_diff=30,check=True))
- # print_network_graph(nodes[0])
- # print_network_graph(nodes[0], unpruned=True)
- # asyncio.run(main(time_diff_decrease=True))
|