aethermesh 2.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.
- aethermesh/__init__.py +34 -0
- aethermesh/__version__.py +9 -0
- aethermesh/admission/__init__.py +13 -0
- aethermesh/admission/pipeline.py +282 -0
- aethermesh/bft/__init__.py +31 -0
- aethermesh/bft/invariants.py +93 -0
- aethermesh/bft/provenance.py +202 -0
- aethermesh/bft/semantic_matrix.py +154 -0
- aethermesh/cli.py +270 -0
- aethermesh/crdt/__init__.py +17 -0
- aethermesh/crdt/delta_state.py +195 -0
- aethermesh/crdt/semilattice.py +212 -0
- aethermesh/gossip/__init__.py +17 -0
- aethermesh/gossip/peer_sampling.py +178 -0
- aethermesh/gossip/protocol.py +289 -0
- aethermesh/models.py +395 -0
- aethermesh/py.typed +0 -0
- aethermesh/simulation/__init__.py +25 -0
- aethermesh/simulation/network.py +240 -0
- aethermesh/simulation/node.py +242 -0
- aethermesh/simulation/testbed.py +311 -0
- aethermesh-2.0.0.dist-info/METADATA +839 -0
- aethermesh-2.0.0.dist-info/RECORD +28 -0
- aethermesh-2.0.0.dist-info/WHEEL +5 -0
- aethermesh-2.0.0.dist-info/entry_points.txt +2 -0
- aethermesh-2.0.0.dist-info/licenses/AUTHORS.md +60 -0
- aethermesh-2.0.0.dist-info/licenses/LICENSE +26 -0
- aethermesh-2.0.0.dist-info/top_level.txt +1 -0
aethermesh/models.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Core data models.
|
|
3
|
+
|
|
4
|
+
This module defines the fundamental data classes used across the
|
|
5
|
+
AetherMesh reference implementation:
|
|
6
|
+
|
|
7
|
+
- Delta: A signed, content-addressed change record
|
|
8
|
+
- Replica: A local replica state (set of admitted deltas)
|
|
9
|
+
- PeerView: A local view of the peer set
|
|
10
|
+
- AdmissionVerdict: Outcome of the admission pipeline
|
|
11
|
+
- DeltaKind: Operation kind for semantic compatibility
|
|
12
|
+
- Signature: A cryptographic signature
|
|
13
|
+
- Verdict: Admission decision (accept / reject / defer / quarantine)
|
|
14
|
+
|
|
15
|
+
Reference:
|
|
16
|
+
Baladi, S. (2026). AetherMesh: Decentralized Peer-to-Peer Agent
|
|
17
|
+
Synchronization and State CRDTs for Autonomous Multi-Agent Systems.
|
|
18
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
19
|
+
|
|
20
|
+
Definitions 1–4 (Section 4 of the paper).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# Enums
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
class Verdict(str, Enum):
|
|
38
|
+
"""Admission verdict for a remote delta (Section 3.3.4 of the paper)."""
|
|
39
|
+
ACCEPT = "accept"
|
|
40
|
+
REJECT = "reject"
|
|
41
|
+
DEFER = "defer"
|
|
42
|
+
QUARANTINE = "quarantine"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DeltaKind(str, Enum):
|
|
46
|
+
"""
|
|
47
|
+
Operation kind for semantic compatibility matrix (Table 3.1 of the paper).
|
|
48
|
+
"""
|
|
49
|
+
EDIT_BODY = "edit_body"
|
|
50
|
+
RENAME_SYMBOL = "rename_symbol"
|
|
51
|
+
DELETE_SYMBOL = "delete_symbol"
|
|
52
|
+
CHANGE_SIGNATURE = "change_signature"
|
|
53
|
+
EDIT_METADATA = "edit_metadata"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AdmissionStage(str, Enum):
|
|
57
|
+
"""The four admission pipeline stages (Figure 7 of the paper)."""
|
|
58
|
+
SIGNATURE = "signature"
|
|
59
|
+
DAG_INTEGRITY = "dag_integrity"
|
|
60
|
+
SCHEMA = "schema"
|
|
61
|
+
SEMANTICS = "semantics"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# Signature
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Signature:
|
|
70
|
+
"""
|
|
71
|
+
A cryptographic signature over a delta's content hash.
|
|
72
|
+
|
|
73
|
+
In the reference implementation we use Ed25519 (via `cryptography`),
|
|
74
|
+
but the architecture is agnostic to the specific scheme
|
|
75
|
+
(assumption A5).
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
origin: Agent identifier of the signer.
|
|
79
|
+
key_id: Identifier of the public key used.
|
|
80
|
+
value: Raw signature bytes (hex-encoded in serialization).
|
|
81
|
+
"""
|
|
82
|
+
origin: str
|
|
83
|
+
key_id: str
|
|
84
|
+
value: bytes
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
"origin": self.origin,
|
|
89
|
+
"key_id": self.key_id,
|
|
90
|
+
"value": self.value.hex(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Signature":
|
|
95
|
+
return cls(
|
|
96
|
+
origin=d["origin"],
|
|
97
|
+
key_id=d["key_id"],
|
|
98
|
+
value=bytes.fromhex(d["value"]),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# =============================================================================
|
|
103
|
+
# Delta
|
|
104
|
+
# =============================================================================
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Delta:
|
|
108
|
+
"""
|
|
109
|
+
A signed, content-addressed change record.
|
|
110
|
+
|
|
111
|
+
Definition 1 (Section 4.1 of the paper):
|
|
112
|
+
delta_i = (body, parents, timestamp, origin, kind, signature)
|
|
113
|
+
|
|
114
|
+
The delta's identifier is the hash of its canonical content
|
|
115
|
+
(excluding the signature, which is over that hash).
|
|
116
|
+
|
|
117
|
+
Attributes:
|
|
118
|
+
body: The change payload (opaque JSON-serializable dict).
|
|
119
|
+
parents: Identifiers of parent deltas (empty for genesis).
|
|
120
|
+
timestamp: ISO-8601 timestamp of creation.
|
|
121
|
+
origin: Agent identifier of the creator.
|
|
122
|
+
kind: Operation kind (for semantic compatibility).
|
|
123
|
+
signature: Signature by the origin over the content hash.
|
|
124
|
+
"""
|
|
125
|
+
body: Dict[str, Any]
|
|
126
|
+
parents: FrozenSet[str]
|
|
127
|
+
timestamp: str
|
|
128
|
+
origin: str
|
|
129
|
+
kind: DeltaKind
|
|
130
|
+
signature: Optional[Signature] = None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def id(self) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Content-addressed identifier: SHA-256 of the canonical body,
|
|
136
|
+
parents, timestamp, origin, and kind (excluding signature).
|
|
137
|
+
"""
|
|
138
|
+
canonical = {
|
|
139
|
+
"body": self.body,
|
|
140
|
+
"parents": sorted(self.parents),
|
|
141
|
+
"timestamp": self.timestamp,
|
|
142
|
+
"origin": self.origin,
|
|
143
|
+
"kind": self.kind.value,
|
|
144
|
+
}
|
|
145
|
+
raw = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
146
|
+
return hashlib.sha256(raw).hexdigest()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def content_hash(self) -> bytes:
|
|
150
|
+
"""Raw SHA-256 of the canonical content (for signature verification)."""
|
|
151
|
+
return bytes.fromhex(self.id)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
154
|
+
return {
|
|
155
|
+
"body": self.body,
|
|
156
|
+
"parents": sorted(self.parents),
|
|
157
|
+
"timestamp": self.timestamp,
|
|
158
|
+
"origin": self.origin,
|
|
159
|
+
"kind": self.kind.value,
|
|
160
|
+
"signature": self.signature.to_dict() if self.signature else None,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Delta":
|
|
165
|
+
return cls(
|
|
166
|
+
body=d["body"],
|
|
167
|
+
parents=frozenset(d["parents"]),
|
|
168
|
+
timestamp=d["timestamp"],
|
|
169
|
+
origin=d["origin"],
|
|
170
|
+
kind=DeltaKind(d["kind"]),
|
|
171
|
+
signature=Signature.from_dict(d["signature"]) if d.get("signature") else None,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def create(
|
|
176
|
+
cls,
|
|
177
|
+
body: Dict[str, Any],
|
|
178
|
+
origin: str,
|
|
179
|
+
kind: DeltaKind,
|
|
180
|
+
parents: Optional[Set[str]] = None,
|
|
181
|
+
) -> "Delta":
|
|
182
|
+
"""Create a new unsigned delta with current timestamp."""
|
|
183
|
+
return cls(
|
|
184
|
+
body=body,
|
|
185
|
+
parents=frozenset(parents or []),
|
|
186
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
187
|
+
origin=origin,
|
|
188
|
+
kind=kind,
|
|
189
|
+
signature=None,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def with_signature(self, signature: Signature) -> "Delta":
|
|
193
|
+
"""Return a copy of this delta with the given signature attached."""
|
|
194
|
+
return Delta(
|
|
195
|
+
body=self.body,
|
|
196
|
+
parents=self.parents,
|
|
197
|
+
timestamp=self.timestamp,
|
|
198
|
+
origin=self.origin,
|
|
199
|
+
kind=self.kind,
|
|
200
|
+
signature=signature,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# =============================================================================
|
|
205
|
+
# AdmissionVerVerdict
|
|
206
|
+
# =============================================================================
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class AdmissionVerdict:
|
|
210
|
+
"""
|
|
211
|
+
Outcome of the admission pipeline for a single delta.
|
|
212
|
+
|
|
213
|
+
Attributes:
|
|
214
|
+
delta_id: The identifier of the delta that was evaluated.
|
|
215
|
+
verdict: The final verdict (accept / reject / defer / quarantine).
|
|
216
|
+
stage: The stage at which a non-accept verdict was reached.
|
|
217
|
+
reason: Human-readable explanation.
|
|
218
|
+
"""
|
|
219
|
+
delta_id: str
|
|
220
|
+
verdict: Verdict
|
|
221
|
+
stage: Optional[AdmissionStage] = None
|
|
222
|
+
reason: str = ""
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def accepted(self) -> bool:
|
|
226
|
+
return self.verdict == Verdict.ACCEPT
|
|
227
|
+
|
|
228
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
229
|
+
return {
|
|
230
|
+
"delta_id": self.delta_id,
|
|
231
|
+
"verdict": self.verdict.value,
|
|
232
|
+
"stage": self.stage.value if self.stage else None,
|
|
233
|
+
"reason": self.reason,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# =============================================================================
|
|
238
|
+
# Replica
|
|
239
|
+
# =============================================================================
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class Replica:
|
|
243
|
+
"""
|
|
244
|
+
A local replica state — the set of admitted deltas (Definition 1).
|
|
245
|
+
|
|
246
|
+
The join of two replicas is simply set union (the grow-only set CRDT
|
|
247
|
+
lifted to deltas; Section 3.1.1 of the paper).
|
|
248
|
+
|
|
249
|
+
Attributes:
|
|
250
|
+
admitted: Map from delta id -> Delta (admitted deltas).
|
|
251
|
+
origin: Agent identifier of this replica's owner.
|
|
252
|
+
deferred: Deltas awaiting missing parents.
|
|
253
|
+
"""
|
|
254
|
+
admitted: Dict[str, Delta] = field(default_factory=dict)
|
|
255
|
+
origin: str = "anonymous"
|
|
256
|
+
deferred: Dict[str, Delta] = field(default_factory=dict)
|
|
257
|
+
|
|
258
|
+
def join(self, delta: Delta) -> "Replica":
|
|
259
|
+
"""
|
|
260
|
+
Join a single admitted delta into this replica.
|
|
261
|
+
|
|
262
|
+
Returns self (mutating in place) for chaining.
|
|
263
|
+
Idempotent: joining the same delta twice has no additional effect.
|
|
264
|
+
"""
|
|
265
|
+
self.admitted[delta.id] = delta
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
def join_many(self, deltas: List[Delta]) -> "Replica":
|
|
269
|
+
"""Join multiple admitted deltas."""
|
|
270
|
+
for d in deltas:
|
|
271
|
+
self.join(d)
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
def merge(self, other: "Replica") -> "Replica":
|
|
275
|
+
"""
|
|
276
|
+
Merge another replica into this one (set union).
|
|
277
|
+
|
|
278
|
+
The join operation is commutative, associative, and idempotent
|
|
279
|
+
(Section 3.1.2 of the paper).
|
|
280
|
+
"""
|
|
281
|
+
self.admitted.update(other.admitted)
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def __contains__(self, delta_id: str) -> bool:
|
|
285
|
+
return delta_id in self.admitted
|
|
286
|
+
|
|
287
|
+
def __len__(self) -> int:
|
|
288
|
+
return len(self.admitted)
|
|
289
|
+
|
|
290
|
+
def ids(self) -> FrozenSet[str]:
|
|
291
|
+
"""The set of admitted delta identifiers."""
|
|
292
|
+
return frozenset(self.admitted.keys())
|
|
293
|
+
|
|
294
|
+
def has_parents(self, delta: Delta) -> bool:
|
|
295
|
+
"""Check whether all parents of a delta are already admitted."""
|
|
296
|
+
return all(p in self.admitted for p in delta.parents)
|
|
297
|
+
|
|
298
|
+
def materialize(self) -> List[Delta]:
|
|
299
|
+
"""
|
|
300
|
+
Deterministic linearization of the delta set (Section 3.1.1).
|
|
301
|
+
|
|
302
|
+
Topological order of the parent DAG, ties broken by delta id.
|
|
303
|
+
"""
|
|
304
|
+
# Kahn's algorithm with deterministic tie-breaking by id.
|
|
305
|
+
in_degree: Dict[str, int] = {}
|
|
306
|
+
children: Dict[str, List[str]] = {}
|
|
307
|
+
|
|
308
|
+
for did, delta in self.admitted.items():
|
|
309
|
+
in_degree.setdefault(did, 0)
|
|
310
|
+
children.setdefault(did, [])
|
|
311
|
+
for parent in delta.parents:
|
|
312
|
+
if parent in self.admitted:
|
|
313
|
+
in_degree[did] += 1
|
|
314
|
+
children.setdefault(parent, []).append(did)
|
|
315
|
+
|
|
316
|
+
# Start with deltas that have zero in-degree among admitted parents.
|
|
317
|
+
ready = sorted([d for d, deg in in_degree.items() if deg == 0])
|
|
318
|
+
result: List[Delta] = []
|
|
319
|
+
|
|
320
|
+
while ready:
|
|
321
|
+
did = ready.pop(0)
|
|
322
|
+
result.append(self.admitted[did])
|
|
323
|
+
for child in sorted(children.get(did, [])):
|
|
324
|
+
in_degree[child] -= 1
|
|
325
|
+
if in_degree[child] == 0:
|
|
326
|
+
ready.append(child)
|
|
327
|
+
ready.sort()
|
|
328
|
+
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# =============================================================================
|
|
333
|
+
# PeerView
|
|
334
|
+
# =============================================================================
|
|
335
|
+
|
|
336
|
+
@dataclass
|
|
337
|
+
class PeerView:
|
|
338
|
+
"""
|
|
339
|
+
A local, partial view of the peer set (Section 3.2.2).
|
|
340
|
+
|
|
341
|
+
Each node maintains a bounded sample of the total peer population,
|
|
342
|
+
refreshed continuously. Selection for gossip draws from this view.
|
|
343
|
+
|
|
344
|
+
Attributes:
|
|
345
|
+
peers: Current known peer identifiers.
|
|
346
|
+
capacity: Maximum number of peers to retain.
|
|
347
|
+
"""
|
|
348
|
+
peers: Set[str] = field(default_factory=set)
|
|
349
|
+
capacity: int = 32
|
|
350
|
+
|
|
351
|
+
def add(self, peer_id: str) -> None:
|
|
352
|
+
"""Add a peer to the view, evicting if over capacity (FIFO for simplicity)."""
|
|
353
|
+
if peer_id in self.peers:
|
|
354
|
+
return
|
|
355
|
+
if len(self.peers) >= self.capacity:
|
|
356
|
+
# Deterministic eviction: remove the lexicographically smallest.
|
|
357
|
+
# (In production, use random or least-recently-used.)
|
|
358
|
+
self.peers.discard(min(self.peers))
|
|
359
|
+
self.peers.add(peer_id)
|
|
360
|
+
|
|
361
|
+
def remove(self, peer_id: str) -> None:
|
|
362
|
+
self.peers.discard(peer_id)
|
|
363
|
+
|
|
364
|
+
def sample(self, k: int) -> List[str]:
|
|
365
|
+
"""
|
|
366
|
+
Select up to k peers uniformly at random.
|
|
367
|
+
|
|
368
|
+
Uses Python's random.sample for uniform selection over the
|
|
369
|
+
current view (assumption A2).
|
|
370
|
+
"""
|
|
371
|
+
import random
|
|
372
|
+
k = min(k, len(self.peers))
|
|
373
|
+
if k == 0:
|
|
374
|
+
return []
|
|
375
|
+
return random.sample(sorted(self.peers), k)
|
|
376
|
+
|
|
377
|
+
def __len__(self) -> int:
|
|
378
|
+
return len(self.peers)
|
|
379
|
+
|
|
380
|
+
def __contains__(self, peer_id: str) -> bool:
|
|
381
|
+
return peer_id in self.peers
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# =============================================================================
|
|
385
|
+
# Serialization helpers
|
|
386
|
+
# =============================================================================
|
|
387
|
+
|
|
388
|
+
def serialize_delta(delta: Delta) -> str:
|
|
389
|
+
"""Serialize a delta to canonical JSON (for network transmission)."""
|
|
390
|
+
return json.dumps(delta.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def deserialize_delta(s: str) -> Delta:
|
|
394
|
+
"""Deserialize a delta from canonical JSON."""
|
|
395
|
+
return Delta.from_dict(json.loads(s))
|
aethermesh/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""AetherMesh — Simulation module (P2P testbed)."""
|
|
2
|
+
|
|
3
|
+
from aethermesh.simulation.network import (
|
|
4
|
+
Network,
|
|
5
|
+
PartitionEvent,
|
|
6
|
+
PartitionSchedule,
|
|
7
|
+
)
|
|
8
|
+
from aethermesh.simulation.node import (
|
|
9
|
+
Node,
|
|
10
|
+
create_cluster,
|
|
11
|
+
)
|
|
12
|
+
from aethermesh.simulation.testbed import (
|
|
13
|
+
Testbed,
|
|
14
|
+
TestbedConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Network",
|
|
19
|
+
"PartitionEvent",
|
|
20
|
+
"PartitionSchedule",
|
|
21
|
+
"Node",
|
|
22
|
+
"create_cluster",
|
|
23
|
+
"Testbed",
|
|
24
|
+
"TestbedConfig",
|
|
25
|
+
]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Simulated network model (fair-lossy channels).
|
|
3
|
+
|
|
4
|
+
Implements the fair-lossy channel model from Section 4.0 of the paper
|
|
5
|
+
(assumption A1):
|
|
6
|
+
|
|
7
|
+
A message sent infinitely often between two non-faulty nodes is
|
|
8
|
+
delivered infinitely often.
|
|
9
|
+
|
|
10
|
+
In the simulator, the network is a discrete event loop that delivers
|
|
11
|
+
messages between nodes with configurable:
|
|
12
|
+
- latency (uniform in [min_latency, max_latency])
|
|
13
|
+
- drop probability (0.0 = no drops; 1.0 = every message dropped)
|
|
14
|
+
- partition schedule (optional, list of (time, partition_set))
|
|
15
|
+
|
|
16
|
+
Reference:
|
|
17
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Section 4.0, assumption A1.
|
|
18
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import heapq
|
|
24
|
+
import random
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Message
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
@dataclass(order=True)
|
|
35
|
+
class _ScheduledMessage:
|
|
36
|
+
"""Internal: a message scheduled for delivery at a future time."""
|
|
37
|
+
delivery_time: float
|
|
38
|
+
sender: str = field(compare=False)
|
|
39
|
+
recipient: str = field(compare=False)
|
|
40
|
+
payload: Any = field(compare=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# =============================================================================
|
|
44
|
+
# Network
|
|
45
|
+
# =============================================================================
|
|
46
|
+
|
|
47
|
+
class Network:
|
|
48
|
+
"""
|
|
49
|
+
A simulated fair-lossy network.
|
|
50
|
+
|
|
51
|
+
The network maintains a priority queue of scheduled messages,
|
|
52
|
+
ordered by delivery time. It also maintains a partition state:
|
|
53
|
+
a set of node ids that cannot communicate with the rest.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
min_latency: Minimum one-way latency (seconds).
|
|
57
|
+
max_latency: Maximum one-way latency (seconds).
|
|
58
|
+
drop_probability: Probability that any given message is dropped.
|
|
59
|
+
rng: Random source (seeded for reproducibility).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
min_latency: float = 0.01,
|
|
65
|
+
max_latency: float = 0.05,
|
|
66
|
+
drop_probability: float = 0.0,
|
|
67
|
+
seed: Optional[int] = None,
|
|
68
|
+
):
|
|
69
|
+
self.min_latency = min_latency
|
|
70
|
+
self.max_latency = max_latency
|
|
71
|
+
self.drop_probability = drop_probability
|
|
72
|
+
self.rng = random.Random(seed)
|
|
73
|
+
|
|
74
|
+
# Message queue: (delivery_time, _ScheduledMessage)
|
|
75
|
+
self._queue: List[_ScheduledMessage] = []
|
|
76
|
+
|
|
77
|
+
# Partition state: set of nodes currently partitioned from the rest
|
|
78
|
+
self._partitioned: Set[str] = set()
|
|
79
|
+
|
|
80
|
+
# Statistics
|
|
81
|
+
self.messages_sent = 0
|
|
82
|
+
self.messages_delivered = 0
|
|
83
|
+
self.messages_dropped = 0
|
|
84
|
+
self.messages_blocked_by_partition = 0
|
|
85
|
+
|
|
86
|
+
# -------------------------------------------------------------------------
|
|
87
|
+
# Sending
|
|
88
|
+
# -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def send(
|
|
91
|
+
self,
|
|
92
|
+
sender: str,
|
|
93
|
+
recipient: str,
|
|
94
|
+
payload: Any,
|
|
95
|
+
send_time: float,
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Schedule a message for delivery. Returns True if it will be
|
|
99
|
+
delivered (subject to drop probability and partition state).
|
|
100
|
+
|
|
101
|
+
A message is dropped with probability `drop_probability`.
|
|
102
|
+
A message is blocked if sender and recipient are on opposite
|
|
103
|
+
sides of a partition.
|
|
104
|
+
"""
|
|
105
|
+
self.messages_sent += 1
|
|
106
|
+
|
|
107
|
+
# Partition check.
|
|
108
|
+
if self._partition_blocks(sender, recipient):
|
|
109
|
+
self.messages_blocked_by_partition += 1
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Drop check.
|
|
113
|
+
if self.rng.random() < self.drop_probability:
|
|
114
|
+
self.messages_dropped += 1
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Schedule delivery.
|
|
118
|
+
latency = self.rng.uniform(self.min_latency, self.max_latency)
|
|
119
|
+
delivery_time = send_time + latency
|
|
120
|
+
msg = _ScheduledMessage(
|
|
121
|
+
delivery_time=delivery_time,
|
|
122
|
+
sender=sender,
|
|
123
|
+
recipient=recipient,
|
|
124
|
+
payload=payload,
|
|
125
|
+
)
|
|
126
|
+
heapq.heappush(self._queue, msg)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
# Delivery
|
|
131
|
+
# -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def next_delivery_time(self) -> Optional[float]:
|
|
134
|
+
"""The time of the next scheduled message, or None if empty."""
|
|
135
|
+
if not self._queue:
|
|
136
|
+
return None
|
|
137
|
+
return self._queue[0].delivery_time
|
|
138
|
+
|
|
139
|
+
def deliver_until(
|
|
140
|
+
self,
|
|
141
|
+
current_time: float,
|
|
142
|
+
on_delivery: Callable[[str, str, Any], None],
|
|
143
|
+
) -> float:
|
|
144
|
+
"""
|
|
145
|
+
Deliver all messages scheduled at or before `current_time`.
|
|
146
|
+
|
|
147
|
+
Calls `on_delivery(sender, recipient, payload)` for each.
|
|
148
|
+
|
|
149
|
+
Returns the time of the next scheduled message, or `current_time`
|
|
150
|
+
if the queue is empty.
|
|
151
|
+
"""
|
|
152
|
+
while self._queue and self._queue[0].delivery_time <= current_time:
|
|
153
|
+
msg = heapq.heappop(self._queue)
|
|
154
|
+
self.messages_delivered += 1
|
|
155
|
+
on_delivery(msg.sender, msg.recipient, msg.payload)
|
|
156
|
+
return self.next_delivery_time() or current_time
|
|
157
|
+
|
|
158
|
+
# -------------------------------------------------------------------------
|
|
159
|
+
# Partitions
|
|
160
|
+
# -------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def set_partition(self, partitioned_nodes: Set[str]) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Set the current partition: nodes in `partitioned_nodes` cannot
|
|
165
|
+
communicate with nodes outside it.
|
|
166
|
+
"""
|
|
167
|
+
self._partitioned = set(partitioned_nodes)
|
|
168
|
+
|
|
169
|
+
def heal_partition(self) -> None:
|
|
170
|
+
"""Clear the partition state."""
|
|
171
|
+
self._partitioned = set()
|
|
172
|
+
|
|
173
|
+
def _partition_blocks(self, sender: str, recipient: str) -> bool:
|
|
174
|
+
"""Check if a partition blocks communication between two nodes."""
|
|
175
|
+
if not self._partitioned:
|
|
176
|
+
return False
|
|
177
|
+
sender_in = sender in self._partitioned
|
|
178
|
+
recipient_in = recipient in self._partitioned
|
|
179
|
+
return sender_in != recipient_in
|
|
180
|
+
|
|
181
|
+
# -------------------------------------------------------------------------
|
|
182
|
+
# Statistics
|
|
183
|
+
# -------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def stats(self) -> Dict[str, int]:
|
|
186
|
+
return {
|
|
187
|
+
"messages_sent": self.messages_sent,
|
|
188
|
+
"messages_delivered": self.messages_delivered,
|
|
189
|
+
"messages_dropped": self.messages_dropped,
|
|
190
|
+
"messages_blocked_by_partition": self.messages_blocked_by_partition,
|
|
191
|
+
"messages_in_flight": len(self._queue),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def reset_stats(self) -> None:
|
|
195
|
+
self.messages_sent = 0
|
|
196
|
+
self.messages_delivered = 0
|
|
197
|
+
self.messages_dropped = 0
|
|
198
|
+
self.messages_blocked_by_partition = 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# =============================================================================
|
|
202
|
+
# Partition schedules
|
|
203
|
+
# =============================================================================
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class PartitionEvent:
|
|
207
|
+
"""A scheduled partition event: at `time`, set `partitioned`."""
|
|
208
|
+
time: float
|
|
209
|
+
partitioned: Set[str] = field(default_factory=set)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class PartitionSchedule:
|
|
213
|
+
"""
|
|
214
|
+
A schedule of partition events over simulated time.
|
|
215
|
+
|
|
216
|
+
Used by the testbed to model network partitions (Section 3.2.3 of
|
|
217
|
+
the paper: partition healing).
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> schedule = PartitionSchedule([
|
|
221
|
+
... PartitionEvent(time=1.0, partitioned={"n1", "n2"}),
|
|
222
|
+
... PartitionEvent(time=2.0, partitioned=set()), # heal
|
|
223
|
+
... ])
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(self, events: List[PartitionEvent]):
|
|
227
|
+
self.events = sorted(events, key=lambda e: e.time)
|
|
228
|
+
self._applied_index = 0
|
|
229
|
+
|
|
230
|
+
def apply_until(self, current_time: float, network: Network) -> None:
|
|
231
|
+
"""Apply all partition events with time <= current_time."""
|
|
232
|
+
while self._applied_index < len(self.events):
|
|
233
|
+
event = self.events[self._applied_index]
|
|
234
|
+
if event.time > current_time:
|
|
235
|
+
break
|
|
236
|
+
network.set_partition(event.partitioned)
|
|
237
|
+
self._applied_index += 1
|
|
238
|
+
|
|
239
|
+
def reset(self) -> None:
|
|
240
|
+
self._applied_index = 0
|