koi-net 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of koi-net might be problematic. Click here for more details.
- koi_net/__init__.py +1 -0
- koi_net/config.py +101 -0
- koi_net/core.py +126 -0
- koi_net/identity.py +42 -0
- koi_net/network/__init__.py +1 -0
- koi_net/network/graph.py +127 -0
- koi_net/network/interface.py +276 -0
- koi_net/network/request_handler.py +149 -0
- koi_net/network/response_handler.py +59 -0
- koi_net/processor/__init__.py +1 -0
- koi_net/processor/default_handlers.py +220 -0
- koi_net/processor/handler.py +59 -0
- koi_net/processor/interface.py +300 -0
- koi_net/processor/knowledge_object.py +123 -0
- koi_net/protocol/__init__.py +0 -0
- koi_net/protocol/api_models.py +47 -0
- koi_net/protocol/consts.py +7 -0
- koi_net/protocol/edge.py +20 -0
- koi_net/protocol/event.py +54 -0
- koi_net/protocol/helpers.py +25 -0
- koi_net/protocol/node.py +17 -0
- koi_net-1.0.0.dist-info/METADATA +756 -0
- koi_net-1.0.0.dist-info/RECORD +25 -0
- koi_net-1.0.0.dist-info/WHEEL +4 -0
- koi_net-1.0.0.dist-info/licenses/LICENSE +21 -0
koi_net/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .core import NodeInterface
|
koi_net/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
from ruamel.yaml import YAML
|
|
4
|
+
from koi_net.protocol.node import NodeProfile
|
|
5
|
+
from rid_lib.types import KoiNetNode
|
|
6
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServerConfig(BaseModel):
|
|
11
|
+
host: str | None = "127.0.0.1"
|
|
12
|
+
port: int | None = 8000
|
|
13
|
+
path: str | None = "/koi-net"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def url(self) -> str:
|
|
17
|
+
return f"http://{self.host}:{self.port}{self.path or ''}"
|
|
18
|
+
|
|
19
|
+
class KoiNetConfig(BaseModel):
|
|
20
|
+
node_name: str
|
|
21
|
+
node_rid: KoiNetNode | None = None
|
|
22
|
+
node_profile: NodeProfile
|
|
23
|
+
|
|
24
|
+
cache_directory_path: str | None = ".rid_cache"
|
|
25
|
+
event_queues_path: str | None = "event_queues.json"
|
|
26
|
+
|
|
27
|
+
first_contact: str | None = None
|
|
28
|
+
|
|
29
|
+
class EnvConfig(BaseModel):
|
|
30
|
+
def __init__(self, **kwargs):
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
load_dotenv()
|
|
33
|
+
|
|
34
|
+
def __getattribute__(self, name):
|
|
35
|
+
value = super().__getattribute__(name)
|
|
36
|
+
if name in type(self).model_fields:
|
|
37
|
+
env_val = os.getenv(value)
|
|
38
|
+
if env_val is None:
|
|
39
|
+
raise ValueError(f"Required environment variable {value} not set")
|
|
40
|
+
return env_val
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
class NodeConfig(BaseModel):
|
|
44
|
+
server: ServerConfig | None = Field(default_factory=ServerConfig)
|
|
45
|
+
koi_net: KoiNetConfig
|
|
46
|
+
_file_path: str = PrivateAttr(default="config.yaml")
|
|
47
|
+
_file_content: str | None = PrivateAttr(default=None)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load_from_yaml(
|
|
51
|
+
cls,
|
|
52
|
+
file_path: str = "config.yaml",
|
|
53
|
+
generate_missing: bool = True
|
|
54
|
+
):
|
|
55
|
+
yaml = YAML()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(file_path, "r") as f:
|
|
59
|
+
file_content = f.read()
|
|
60
|
+
config_data = yaml.load(file_content)
|
|
61
|
+
config = cls.model_validate(config_data)
|
|
62
|
+
config._file_content = file_content
|
|
63
|
+
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
# empty_fields = {}
|
|
66
|
+
# for name, field in cls.model_fields.items():
|
|
67
|
+
|
|
68
|
+
# if field.default is None or field.default_factory is None:
|
|
69
|
+
# print(empty_fields)
|
|
70
|
+
config = cls()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
config._file_path = file_path
|
|
74
|
+
|
|
75
|
+
if generate_missing:
|
|
76
|
+
config.koi_net.node_rid = (
|
|
77
|
+
config.koi_net.node_rid or KoiNetNode.generate(config.koi_net.node_name)
|
|
78
|
+
)
|
|
79
|
+
config.koi_net.node_profile.base_url = (
|
|
80
|
+
config.koi_net.node_profile.base_url or config.server.url
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
config.save_to_yaml()
|
|
84
|
+
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
def save_to_yaml(self):
|
|
88
|
+
yaml = YAML()
|
|
89
|
+
|
|
90
|
+
with open(self._file_path, "w") as f:
|
|
91
|
+
try:
|
|
92
|
+
config_data = self.model_dump(mode="json")
|
|
93
|
+
yaml.dump(config_data, f)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
if self._file_content:
|
|
96
|
+
f.seek(0)
|
|
97
|
+
f.truncate()
|
|
98
|
+
f.write(self._file_content)
|
|
99
|
+
raise e
|
|
100
|
+
|
|
101
|
+
ConfigType = TypeVar("ConfigType", bound=NodeConfig)
|
koi_net/core.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Generic
|
|
3
|
+
import httpx
|
|
4
|
+
from rid_lib.ext import Cache, Bundle
|
|
5
|
+
from .network import NetworkInterface
|
|
6
|
+
from .processor import ProcessorInterface
|
|
7
|
+
from .processor import default_handlers
|
|
8
|
+
from .processor.handler import KnowledgeHandler
|
|
9
|
+
from .identity import NodeIdentity
|
|
10
|
+
from .protocol.event import Event, EventType
|
|
11
|
+
from .config import ConfigType
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NodeInterface(Generic[ConfigType]):
|
|
18
|
+
config: ConfigType
|
|
19
|
+
cache: Cache
|
|
20
|
+
identity: NodeIdentity
|
|
21
|
+
network: NetworkInterface
|
|
22
|
+
processor: ProcessorInterface
|
|
23
|
+
|
|
24
|
+
use_kobj_processor_thread: bool
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config: ConfigType,
|
|
29
|
+
use_kobj_processor_thread: bool = False,
|
|
30
|
+
|
|
31
|
+
handlers: list[KnowledgeHandler] | None = None,
|
|
32
|
+
|
|
33
|
+
cache: Cache | None = None,
|
|
34
|
+
network: NetworkInterface | None = None,
|
|
35
|
+
processor: ProcessorInterface | None = None
|
|
36
|
+
):
|
|
37
|
+
self.config: ConfigType = config
|
|
38
|
+
self.cache = cache or Cache(
|
|
39
|
+
self.config.koi_net.cache_directory_path)
|
|
40
|
+
|
|
41
|
+
self.identity = NodeIdentity(
|
|
42
|
+
config=self.config,
|
|
43
|
+
cache=self.cache)
|
|
44
|
+
|
|
45
|
+
self.network = network or NetworkInterface(
|
|
46
|
+
config=self.config,
|
|
47
|
+
cache=self.cache,
|
|
48
|
+
identity=self.identity
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# pull all handlers defined in default_handlers module
|
|
52
|
+
if handlers is None:
|
|
53
|
+
handlers = [
|
|
54
|
+
obj for obj in vars(default_handlers).values()
|
|
55
|
+
if isinstance(obj, KnowledgeHandler)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
self.use_kobj_processor_thread = use_kobj_processor_thread
|
|
59
|
+
self.processor = processor or ProcessorInterface(
|
|
60
|
+
config=self.config,
|
|
61
|
+
cache=self.cache,
|
|
62
|
+
network=self.network,
|
|
63
|
+
identity=self.identity,
|
|
64
|
+
use_kobj_processor_thread=self.use_kobj_processor_thread,
|
|
65
|
+
default_handlers=handlers
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def start(self) -> None:
|
|
69
|
+
"""Starts a node, call this method first.
|
|
70
|
+
|
|
71
|
+
Starts the processor thread (if enabled). Loads event queues into memory. Generates network graph from nodes and edges in cache. Processes any state changes of node bundle. Initiates handshake with first contact (if provided) if node doesn't have any neighbors.
|
|
72
|
+
"""
|
|
73
|
+
if self.use_kobj_processor_thread:
|
|
74
|
+
logger.info("Starting processor worker thread")
|
|
75
|
+
self.processor.worker_thread.start()
|
|
76
|
+
|
|
77
|
+
self.network._load_event_queues()
|
|
78
|
+
self.network.graph.generate()
|
|
79
|
+
|
|
80
|
+
self.processor.handle(
|
|
81
|
+
bundle=Bundle.generate(
|
|
82
|
+
rid=self.identity.rid,
|
|
83
|
+
contents=self.identity.profile.model_dump()
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
logger.debug("Waiting for kobj queue to empty")
|
|
88
|
+
if self.use_kobj_processor_thread:
|
|
89
|
+
self.processor.kobj_queue.join()
|
|
90
|
+
else:
|
|
91
|
+
self.processor.flush_kobj_queue()
|
|
92
|
+
logger.debug("Done")
|
|
93
|
+
|
|
94
|
+
if not self.network.graph.get_neighbors() and self.config.koi_net.first_contact:
|
|
95
|
+
logger.debug(f"I don't have any neighbors, reaching out to first contact {self.config.koi_net.first_contact}")
|
|
96
|
+
|
|
97
|
+
events = [
|
|
98
|
+
Event.from_rid(EventType.FORGET, self.identity.rid),
|
|
99
|
+
Event.from_bundle(EventType.NEW, self.identity.bundle)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
self.network.request_handler.broadcast_events(
|
|
104
|
+
url=self.config.koi_net.first_contact,
|
|
105
|
+
events=events
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
except httpx.ConnectError:
|
|
109
|
+
logger.warning("Failed to reach first contact")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def stop(self):
|
|
114
|
+
"""Stops a node, call this method last.
|
|
115
|
+
|
|
116
|
+
Finishes processing knowledge object queue. Saves event queues to storage.
|
|
117
|
+
"""
|
|
118
|
+
logger.info("Stopping node...")
|
|
119
|
+
|
|
120
|
+
if self.use_kobj_processor_thread:
|
|
121
|
+
logger.info(f"Waiting for kobj queue to empty ({self.processor.kobj_queue.unfinished_tasks} tasks remaining)")
|
|
122
|
+
self.processor.kobj_queue.join()
|
|
123
|
+
else:
|
|
124
|
+
self.processor.flush_kobj_queue()
|
|
125
|
+
|
|
126
|
+
self.network._save_event_queues()
|
koi_net/identity.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rid_lib.ext.bundle import Bundle
|
|
3
|
+
from rid_lib.ext.cache import Cache
|
|
4
|
+
from rid_lib.types.koi_net_node import KoiNetNode
|
|
5
|
+
|
|
6
|
+
from .config import NodeConfig
|
|
7
|
+
from .protocol.node import NodeProfile
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeIdentity:
|
|
13
|
+
"""Represents a node's identity (RID, profile, bundle)."""
|
|
14
|
+
|
|
15
|
+
config: NodeConfig
|
|
16
|
+
cache: Cache
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
config: NodeConfig,
|
|
21
|
+
cache: Cache
|
|
22
|
+
):
|
|
23
|
+
"""Initializes node identity from a name and profile.
|
|
24
|
+
|
|
25
|
+
Attempts to read identity from storage. If it doesn't already exist, a new RID is generated from the provided name, and that RID and profile are written to storage. Changes to the name or profile will update the stored identity.
|
|
26
|
+
|
|
27
|
+
WARNING: If the name is changed, the RID will be overwritten which will have consequences for the rest of the network.
|
|
28
|
+
"""
|
|
29
|
+
self.config = config
|
|
30
|
+
self.cache = cache
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def rid(self) -> KoiNetNode:
|
|
34
|
+
return self.config.koi_net.node_rid
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def profile(self) -> NodeProfile:
|
|
38
|
+
return self.config.koi_net.node_profile
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def bundle(self) -> Bundle:
|
|
42
|
+
return self.cache.read(self.rid)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .interface import NetworkInterface
|
koi_net/network/graph.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Literal
|
|
3
|
+
import networkx as nx
|
|
4
|
+
from rid_lib import RIDType
|
|
5
|
+
from rid_lib.ext import Cache
|
|
6
|
+
from rid_lib.types import KoiNetEdge, KoiNetNode
|
|
7
|
+
from ..identity import NodeIdentity
|
|
8
|
+
from ..protocol.edge import EdgeProfile, EdgeStatus
|
|
9
|
+
from ..protocol.node import NodeProfile
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NetworkGraph:
|
|
15
|
+
"""Graph functions for this node's view of its network."""
|
|
16
|
+
|
|
17
|
+
cache: Cache
|
|
18
|
+
identity: NodeIdentity
|
|
19
|
+
dg: nx.DiGraph
|
|
20
|
+
|
|
21
|
+
def __init__(self, cache: Cache, identity: NodeIdentity):
|
|
22
|
+
self.cache = cache
|
|
23
|
+
self.dg = nx.DiGraph()
|
|
24
|
+
self.identity = identity
|
|
25
|
+
|
|
26
|
+
def generate(self):
|
|
27
|
+
"""Generates directed graph from cached KOI nodes and edges."""
|
|
28
|
+
logger.debug("Generating network graph")
|
|
29
|
+
self.dg.clear()
|
|
30
|
+
for rid in self.cache.list_rids():
|
|
31
|
+
if type(rid) == KoiNetNode:
|
|
32
|
+
self.dg.add_node(rid)
|
|
33
|
+
logger.debug(f"Added node {rid}")
|
|
34
|
+
|
|
35
|
+
elif type(rid) == KoiNetEdge:
|
|
36
|
+
edge_profile = self.get_edge_profile(rid)
|
|
37
|
+
if not edge_profile:
|
|
38
|
+
logger.warning(f"Failed to load {rid!r}")
|
|
39
|
+
continue
|
|
40
|
+
self.dg.add_edge(edge_profile.source, edge_profile.target, rid=rid)
|
|
41
|
+
logger.debug(f"Added edge {rid} ({edge_profile.source} -> {edge_profile.target})")
|
|
42
|
+
logger.debug("Done")
|
|
43
|
+
|
|
44
|
+
def get_node_profile(self, rid: KoiNetNode) -> NodeProfile | None:
|
|
45
|
+
"""Returns node profile given its RID."""
|
|
46
|
+
bundle = self.cache.read(rid)
|
|
47
|
+
if bundle:
|
|
48
|
+
return bundle.validate_contents(NodeProfile)
|
|
49
|
+
|
|
50
|
+
def get_edge_profile(
|
|
51
|
+
self,
|
|
52
|
+
rid: KoiNetEdge | None = None,
|
|
53
|
+
source: KoiNetNode | None = None,
|
|
54
|
+
target: KoiNetNode | None = None,
|
|
55
|
+
) -> EdgeProfile | None:
|
|
56
|
+
"""Returns edge profile given its RID, or source and target node RIDs."""
|
|
57
|
+
if source and target:
|
|
58
|
+
if (source, target) not in self.dg.edges: return
|
|
59
|
+
edge_data = self.dg.get_edge_data(source, target)
|
|
60
|
+
if not edge_data: return
|
|
61
|
+
rid = edge_data.get("rid")
|
|
62
|
+
if not rid: return
|
|
63
|
+
elif not rid:
|
|
64
|
+
raise ValueError("Either 'rid' or 'source' and 'target' must be provided")
|
|
65
|
+
|
|
66
|
+
bundle = self.cache.read(rid)
|
|
67
|
+
if bundle:
|
|
68
|
+
return bundle.validate_contents(EdgeProfile)
|
|
69
|
+
|
|
70
|
+
def get_edges(
|
|
71
|
+
self,
|
|
72
|
+
direction: Literal["in", "out"] | None = None,
|
|
73
|
+
) -> list[KoiNetEdge]:
|
|
74
|
+
"""Returns edges this node belongs to.
|
|
75
|
+
|
|
76
|
+
All edges returned by default, specify `direction` to restrict to incoming or outgoing edges only."""
|
|
77
|
+
|
|
78
|
+
edges = []
|
|
79
|
+
if direction != "in" and self.dg.out_edges:
|
|
80
|
+
out_edges = self.dg.out_edges(self.identity.rid)
|
|
81
|
+
edges.extend([e for e in out_edges])
|
|
82
|
+
|
|
83
|
+
if direction != "out" and self.dg.in_edges:
|
|
84
|
+
in_edges = self.dg.in_edges(self.identity.rid)
|
|
85
|
+
edges.extend([e for e in in_edges])
|
|
86
|
+
|
|
87
|
+
edge_rids = []
|
|
88
|
+
for edge in edges:
|
|
89
|
+
edge_data = self.dg.get_edge_data(*edge)
|
|
90
|
+
if not edge_data: continue
|
|
91
|
+
edge_rid = edge_data.get("rid")
|
|
92
|
+
if not edge_rid: continue
|
|
93
|
+
edge_rids.append(edge_rid)
|
|
94
|
+
|
|
95
|
+
return edge_rids
|
|
96
|
+
|
|
97
|
+
def get_neighbors(
|
|
98
|
+
self,
|
|
99
|
+
direction: Literal["in", "out"] | None = None,
|
|
100
|
+
status: EdgeStatus | None = None,
|
|
101
|
+
allowed_type: RIDType | None = None
|
|
102
|
+
) -> list[KoiNetNode]:
|
|
103
|
+
"""Returns neighboring nodes this node shares an edge with.
|
|
104
|
+
|
|
105
|
+
All neighboring nodes returned by default, specify `direction` to restrict to neighbors connected by incoming or outgoing edges only."""
|
|
106
|
+
|
|
107
|
+
neighbors = []
|
|
108
|
+
for edge_rid in self.get_edges(direction):
|
|
109
|
+
edge_profile = self.get_edge_profile(edge_rid)
|
|
110
|
+
|
|
111
|
+
if not edge_profile:
|
|
112
|
+
logger.warning(f"Failed to find edge {edge_rid!r} in cache")
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if status and edge_profile.status != status:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if allowed_type and allowed_type not in edge_profile.rid_types:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if edge_profile.target == self.identity.rid:
|
|
122
|
+
neighbors.append(edge_profile.source)
|
|
123
|
+
elif edge_profile.source == self.identity.rid:
|
|
124
|
+
neighbors.append(edge_profile.target)
|
|
125
|
+
|
|
126
|
+
return list(neighbors)
|
|
127
|
+
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from queue import Queue
|
|
3
|
+
from typing import Generic
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from rid_lib import RID
|
|
7
|
+
from rid_lib.core import RIDType
|
|
8
|
+
from rid_lib.ext import Cache
|
|
9
|
+
from rid_lib.types import KoiNetNode
|
|
10
|
+
|
|
11
|
+
from .graph import NetworkGraph
|
|
12
|
+
from .request_handler import RequestHandler
|
|
13
|
+
from .response_handler import ResponseHandler
|
|
14
|
+
from ..protocol.node import NodeType
|
|
15
|
+
from ..protocol.edge import EdgeType
|
|
16
|
+
from ..protocol.event import Event
|
|
17
|
+
from ..identity import NodeIdentity
|
|
18
|
+
from ..config import ConfigType
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventQueueModel(BaseModel):
|
|
24
|
+
webhook: dict[KoiNetNode, list[Event]]
|
|
25
|
+
poll: dict[KoiNetNode, list[Event]]
|
|
26
|
+
|
|
27
|
+
type EventQueue = dict[RID, Queue[Event]]
|
|
28
|
+
|
|
29
|
+
class NetworkInterface(Generic[ConfigType]):
|
|
30
|
+
"""A collection of functions and classes to interact with the KOI network."""
|
|
31
|
+
|
|
32
|
+
config: ConfigType
|
|
33
|
+
identity: NodeIdentity
|
|
34
|
+
cache: Cache
|
|
35
|
+
graph: NetworkGraph
|
|
36
|
+
request_handler: RequestHandler
|
|
37
|
+
response_handler: ResponseHandler
|
|
38
|
+
poll_event_queue: EventQueue
|
|
39
|
+
webhook_event_queue: EventQueue
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: ConfigType,
|
|
44
|
+
cache: Cache,
|
|
45
|
+
identity: NodeIdentity
|
|
46
|
+
):
|
|
47
|
+
self.config = config
|
|
48
|
+
self.identity = identity
|
|
49
|
+
self.cache = cache
|
|
50
|
+
self.graph = NetworkGraph(cache, identity)
|
|
51
|
+
self.request_handler = RequestHandler(cache, self.graph)
|
|
52
|
+
self.response_handler = ResponseHandler(cache)
|
|
53
|
+
|
|
54
|
+
self.poll_event_queue = dict()
|
|
55
|
+
self.webhook_event_queue = dict()
|
|
56
|
+
self._load_event_queues()
|
|
57
|
+
|
|
58
|
+
def _load_event_queues(self):
|
|
59
|
+
"""Loads event queues from storage."""
|
|
60
|
+
try:
|
|
61
|
+
with open(self.config.koi_net.event_queues_path, "r") as f:
|
|
62
|
+
queues = EventQueueModel.model_validate_json(f.read())
|
|
63
|
+
|
|
64
|
+
for node in queues.poll.keys():
|
|
65
|
+
for event in queues.poll[node]:
|
|
66
|
+
queue = self.poll_event_queue.setdefault(node, Queue())
|
|
67
|
+
queue.put(event)
|
|
68
|
+
|
|
69
|
+
for node in queues.webhook.keys():
|
|
70
|
+
for event in queues.webhook[node]:
|
|
71
|
+
queue = self.webhook_event_queue.setdefault(node, Queue())
|
|
72
|
+
queue.put(event)
|
|
73
|
+
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
def _save_event_queues(self):
|
|
78
|
+
"""Writes event queues to storage."""
|
|
79
|
+
events_model = EventQueueModel(
|
|
80
|
+
poll={
|
|
81
|
+
node: list(queue.queue)
|
|
82
|
+
for node, queue in self.poll_event_queue.items()
|
|
83
|
+
if not queue.empty()
|
|
84
|
+
},
|
|
85
|
+
webhook={
|
|
86
|
+
node: list(queue.queue)
|
|
87
|
+
for node, queue in self.webhook_event_queue.items()
|
|
88
|
+
if not queue.empty()
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if len(events_model.poll) == 0 and len(events_model.webhook) == 0:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
with open(self.config.koi_net.event_queues_path, "w") as f:
|
|
96
|
+
f.write(events_model.model_dump_json(indent=2))
|
|
97
|
+
|
|
98
|
+
def push_event_to(self, event: Event, node: KoiNetNode, flush=False):
|
|
99
|
+
"""Pushes event to queue of specified node.
|
|
100
|
+
|
|
101
|
+
Event will be sent to webhook or poll queue depending on the node type and edge type of the specified node. If `flush` is set to `True`, the webhook queued will be flushed after pushing the event.
|
|
102
|
+
"""
|
|
103
|
+
logger.debug(f"Pushing event {event.event_type} {event.rid} to {node}")
|
|
104
|
+
|
|
105
|
+
node_profile = self.graph.get_node_profile(node)
|
|
106
|
+
if not node_profile:
|
|
107
|
+
logger.warning(f"Node {node!r} unknown to me")
|
|
108
|
+
|
|
109
|
+
# if there's an edge from me to the target node, override broadcast type
|
|
110
|
+
edge_profile = self.graph.get_edge_profile(
|
|
111
|
+
source=self.identity.rid,
|
|
112
|
+
target=node
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if edge_profile:
|
|
116
|
+
if edge_profile.edge_type == EdgeType.WEBHOOK:
|
|
117
|
+
event_queue = self.webhook_event_queue
|
|
118
|
+
elif edge_profile.edge_type == EdgeType.POLL:
|
|
119
|
+
event_queue = self.poll_event_queue
|
|
120
|
+
else:
|
|
121
|
+
if node_profile.node_type == NodeType.FULL:
|
|
122
|
+
event_queue = self.webhook_event_queue
|
|
123
|
+
elif node_profile.node_type == NodeType.PARTIAL:
|
|
124
|
+
event_queue = self.poll_event_queue
|
|
125
|
+
|
|
126
|
+
queue = event_queue.setdefault(node, Queue())
|
|
127
|
+
queue.put(event)
|
|
128
|
+
|
|
129
|
+
if flush and event_queue is self.webhook_event_queue:
|
|
130
|
+
self.flush_webhook_queue(node)
|
|
131
|
+
|
|
132
|
+
def _flush_queue(self, event_queue: EventQueue, node: KoiNetNode) -> list[Event]:
|
|
133
|
+
"""Flushes a node's queue, returning list of events."""
|
|
134
|
+
queue = event_queue.get(node)
|
|
135
|
+
events = list()
|
|
136
|
+
if queue:
|
|
137
|
+
while not queue.empty():
|
|
138
|
+
event = queue.get()
|
|
139
|
+
logger.debug(f"Dequeued {event.event_type} '{event.rid}'")
|
|
140
|
+
events.append(event)
|
|
141
|
+
|
|
142
|
+
return events
|
|
143
|
+
|
|
144
|
+
def flush_poll_queue(self, node: KoiNetNode) -> list[Event]:
|
|
145
|
+
"""Flushes a node's poll queue, returning list of events."""
|
|
146
|
+
logger.debug(f"Flushing poll queue for {node}")
|
|
147
|
+
return self._flush_queue(self.poll_event_queue, node)
|
|
148
|
+
|
|
149
|
+
def flush_webhook_queue(self, node: KoiNetNode):
|
|
150
|
+
"""Flushes a node's webhook queue, and broadcasts events.
|
|
151
|
+
|
|
152
|
+
If node profile is unknown, or node type is not `FULL`, this operation will fail silently. If the remote node cannot be reached, all events will be requeued.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
logger.debug(f"Flushing webhook queue for {node}")
|
|
156
|
+
|
|
157
|
+
node_profile = self.graph.get_node_profile(node)
|
|
158
|
+
|
|
159
|
+
if not node_profile:
|
|
160
|
+
logger.warning(f"{node!r} not found")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if node_profile.node_type != NodeType.FULL:
|
|
164
|
+
logger.warning(f"{node!r} is a partial node!")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
events = self._flush_queue(self.webhook_event_queue, node)
|
|
168
|
+
if not events: return
|
|
169
|
+
|
|
170
|
+
logger.debug(f"Broadcasting {len(events)} events")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
self.request_handler.broadcast_events(node, events=events)
|
|
174
|
+
return True
|
|
175
|
+
except httpx.ConnectError:
|
|
176
|
+
logger.warning("Broadcast failed")
|
|
177
|
+
for event in events:
|
|
178
|
+
self.push_event_to(event, node)
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def get_state_providers(self, rid_type: RIDType) -> list[KoiNetNode]:
|
|
182
|
+
"""Returns list of node RIDs which provide state for the specified RID type."""
|
|
183
|
+
|
|
184
|
+
logger.debug(f"Looking for state providers of '{rid_type}'")
|
|
185
|
+
provider_nodes = []
|
|
186
|
+
for node_rid in self.cache.list_rids(rid_types=[KoiNetNode]):
|
|
187
|
+
node = self.graph.get_node_profile(node_rid)
|
|
188
|
+
|
|
189
|
+
if node.node_type == NodeType.FULL and rid_type in node.provides.state:
|
|
190
|
+
logger.debug(f"Found provider '{node_rid}'")
|
|
191
|
+
provider_nodes.append(node_rid)
|
|
192
|
+
|
|
193
|
+
if not provider_nodes:
|
|
194
|
+
logger.debug("Failed to find providers")
|
|
195
|
+
return provider_nodes
|
|
196
|
+
|
|
197
|
+
def fetch_remote_bundle(self, rid: RID):
|
|
198
|
+
"""Attempts to fetch a bundle by RID from known peer nodes."""
|
|
199
|
+
|
|
200
|
+
logger.debug(f"Fetching remote bundle '{rid}'")
|
|
201
|
+
remote_bundle = None
|
|
202
|
+
for node_rid in self.get_state_providers(type(rid)):
|
|
203
|
+
payload = self.request_handler.fetch_bundles(
|
|
204
|
+
node=node_rid, rids=[rid])
|
|
205
|
+
|
|
206
|
+
if payload.bundles:
|
|
207
|
+
remote_bundle = payload.bundles[0]
|
|
208
|
+
logger.debug(f"Got bundle from '{node_rid}'")
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
if not remote_bundle:
|
|
212
|
+
logger.warning("Failed to fetch remote bundle")
|
|
213
|
+
|
|
214
|
+
return remote_bundle
|
|
215
|
+
|
|
216
|
+
def fetch_remote_manifest(self, rid: RID):
|
|
217
|
+
"""Attempts to fetch a manifest by RID from known peer nodes."""
|
|
218
|
+
|
|
219
|
+
logger.debug(f"Fetching remote manifest '{rid}'")
|
|
220
|
+
remote_manifest = None
|
|
221
|
+
for node_rid in self.get_state_providers(type(rid)):
|
|
222
|
+
payload = self.request_handler.fetch_manifests(
|
|
223
|
+
node=node_rid, rids=[rid])
|
|
224
|
+
|
|
225
|
+
if payload.manifests:
|
|
226
|
+
remote_manifest = payload.manifests[0]
|
|
227
|
+
logger.debug(f"Got bundle from '{node_rid}'")
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
if not remote_manifest:
|
|
231
|
+
logger.warning("Failed to fetch remote bundle")
|
|
232
|
+
|
|
233
|
+
return remote_manifest
|
|
234
|
+
|
|
235
|
+
def poll_neighbors(self) -> list[Event]:
|
|
236
|
+
"""Polls all neighboring nodes and returns compiled list of events.
|
|
237
|
+
|
|
238
|
+
If this node has no neighbors, it will instead attempt to poll the provided first contact URL.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
neighbors = self.graph.get_neighbors()
|
|
242
|
+
|
|
243
|
+
if not neighbors and self.config.koi_net.first_contact:
|
|
244
|
+
logger.debug("No neighbors found, polling first contact")
|
|
245
|
+
try:
|
|
246
|
+
payload = self.request_handler.poll_events(
|
|
247
|
+
url=self.config.koi_net.first_contact,
|
|
248
|
+
rid=self.identity.rid
|
|
249
|
+
)
|
|
250
|
+
if payload.events:
|
|
251
|
+
logger.debug(f"Received {len(payload.events)} events from '{self.config.koi_net.first_contact}'")
|
|
252
|
+
return payload.events
|
|
253
|
+
except httpx.ConnectError:
|
|
254
|
+
logger.debug(f"Failed to reach first contact '{self.config.koi_net.first_contact}'")
|
|
255
|
+
|
|
256
|
+
events = []
|
|
257
|
+
for node_rid in neighbors:
|
|
258
|
+
node = self.graph.get_node_profile(node_rid)
|
|
259
|
+
if not node: continue
|
|
260
|
+
if node.node_type != NodeType.FULL: continue
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
payload = self.request_handler.poll_events(
|
|
264
|
+
node=node_rid,
|
|
265
|
+
rid=self.identity.rid
|
|
266
|
+
)
|
|
267
|
+
if payload.events:
|
|
268
|
+
logger.debug(f"Received {len(payload.events)} events from {node_rid!r}")
|
|
269
|
+
events.extend(payload.events)
|
|
270
|
+
except httpx.ConnectError:
|
|
271
|
+
logger.debug(f"Failed to reach node '{node_rid}'")
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
return events
|
|
275
|
+
|
|
276
|
+
|