hypermind 0.11.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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Anti-entropy / set reconciliation.
|
|
2
|
+
|
|
3
|
+
After a network partition, two peers may hold disjoint subsets of the
|
|
4
|
+
sealed-claim universe. Naive O(n) full-set exchange is wasteful; a
|
|
5
|
+
Bloom filter sketch lets each peer compute the symmetric difference
|
|
6
|
+
in O(δ) where δ = |A ⊖ B|.
|
|
7
|
+
|
|
8
|
+
Algorithm (multi-round, WS-G v0.7):
|
|
9
|
+
Round 1 — Bloom diff:
|
|
10
|
+
Peer A computes ``B_A`` over its known kids, sends it to B with
|
|
11
|
+
``|A|`` and chunk cursor 0.
|
|
12
|
+
Peer B walks its kids, picks the first 1024 not in ``B_A`` and
|
|
13
|
+
forwards them as the round-1 candidate set + an ``ack_set`` of
|
|
14
|
+
kids it already had (closes the false-positive gap).
|
|
15
|
+
A then drains the next 1024 of its own ``B_B``-misses; the loop
|
|
16
|
+
continues until both peers' cursors hit end-of-set.
|
|
17
|
+
|
|
18
|
+
Round 2 — explicit ack-set:
|
|
19
|
+
Each peer responds with kids it now has (after round-1 transfer).
|
|
20
|
+
A kid that appears in neither side's ack-set after round 1 is
|
|
21
|
+
a Bloom false-positive — ferried in round 2 directly.
|
|
22
|
+
|
|
23
|
+
Round 3 — cosigned (root, tree_size) agreement:
|
|
24
|
+
Each side computes the Merkle root of its full set; both root
|
|
25
|
+
+ tree_size are exchanged + cosigned. A mismatch flags the
|
|
26
|
+
session as ``UNRESOLVED_PARTITION`` for the dispute FSM.
|
|
27
|
+
|
|
28
|
+
IBLT fallback:
|
|
29
|
+
When the round-1 Bloom diff exceeds ``IBLT_THRESHOLD`` items,
|
|
30
|
+
peers switch to an Invertible Bloom Lookup Table sketch
|
|
31
|
+
(parameters d=64 cells, k=4 hashes). IBLT supports symmetric
|
|
32
|
+
difference decoding in O(δ) communication WITHOUT iterating over
|
|
33
|
+
one peer's entire local set, which collapses huge δ exchanges.
|
|
34
|
+
|
|
35
|
+
This is BIP 330's PinSketch idea simplified, with resumability via
|
|
36
|
+
``ae_session_id`` (closes spec/06-conformance.md gap #3). At ~1%
|
|
37
|
+
false-positive rate, the expected bandwidth is δ × (1 + ε) kids per
|
|
38
|
+
round.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import hashlib
|
|
45
|
+
import logging
|
|
46
|
+
import math
|
|
47
|
+
import secrets
|
|
48
|
+
import uuid
|
|
49
|
+
from collections import OrderedDict
|
|
50
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# WS-G tunables.
|
|
56
|
+
AE_CHUNK_SIZE = 1024 # max items per round trip per direction.
|
|
57
|
+
IBLT_THRESHOLD = 256 # round-1 Bloom-diff size that triggers IBLT.
|
|
58
|
+
IBLT_DEFAULT_CELLS = 64
|
|
59
|
+
IBLT_DEFAULT_HASHES = 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _optimal_bloom_size(n: int, p: float = 0.01) -> tuple[int, int]:
|
|
63
|
+
"""(m, k) minimising filter size for n items at false-positive rate p.
|
|
64
|
+
|
|
65
|
+
m = -n*ln(p) / (ln(2)^2); k = (m/n) * ln(2)
|
|
66
|
+
"""
|
|
67
|
+
if n == 0:
|
|
68
|
+
return 64, 2
|
|
69
|
+
m = max(64, int(-n * math.log(p) / (math.log(2) ** 2)))
|
|
70
|
+
# round to multiple of 8 for byte-aligned storage
|
|
71
|
+
m = ((m + 7) // 8) * 8
|
|
72
|
+
k = max(1, round((m / n) * math.log(2)))
|
|
73
|
+
return m, k
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class BloomFilter:
|
|
78
|
+
bits: bytearray
|
|
79
|
+
m: int # number of bits
|
|
80
|
+
k: int # number of hash functions
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def empty(cls, n_expected: int, p: float = 0.01) -> BloomFilter:
|
|
84
|
+
m, k = _optimal_bloom_size(n_expected, p)
|
|
85
|
+
return cls(bits=bytearray(m // 8), m=m, k=k)
|
|
86
|
+
|
|
87
|
+
def add(self, item: bytes) -> None:
|
|
88
|
+
for h in self._hashes(item):
|
|
89
|
+
self.bits[h // 8] |= 1 << (h % 8)
|
|
90
|
+
|
|
91
|
+
def __contains__(self, item: bytes) -> bool:
|
|
92
|
+
return all(self.bits[h // 8] & (1 << (h % 8)) for h in self._hashes(item))
|
|
93
|
+
|
|
94
|
+
def _hashes(self, item: bytes) -> list[int]:
|
|
95
|
+
# Double-hashing trick: hash_i = (h1 + i * h2) mod m
|
|
96
|
+
digest = hashlib.sha256(item).digest()
|
|
97
|
+
h1 = int.from_bytes(digest[:8], "big")
|
|
98
|
+
h2 = int.from_bytes(digest[8:16], "big") | 1 # ensure odd
|
|
99
|
+
return [(h1 + i * h2) % self.m for i in range(self.k)]
|
|
100
|
+
|
|
101
|
+
def to_cbor(self) -> dict:
|
|
102
|
+
return {"bits": bytes(self.bits), "m": self.m, "k": self.k}
|
|
103
|
+
|
|
104
|
+
# Hard caps for `from_cbor` — defends against a malicious peer
|
|
105
|
+
# sending huge `m` (memory DoS) or huge `k` (CPU DoS via per-lookup
|
|
106
|
+
# hash count). 16 Mbit ≈ 2 MB / filter, 32 hashes is generous.
|
|
107
|
+
MAX_M_BITS = 16 * 1024 * 1024
|
|
108
|
+
MAX_K = 32
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_cbor(cls, m: dict) -> BloomFilter:
|
|
112
|
+
m_val = int(m["m"])
|
|
113
|
+
k_val = int(m["k"])
|
|
114
|
+
if m_val <= 0 or k_val <= 0:
|
|
115
|
+
raise ValueError("bloom m and k must be positive")
|
|
116
|
+
if m_val > cls.MAX_M_BITS:
|
|
117
|
+
raise ValueError(f"bloom m too large: {m_val} > {cls.MAX_M_BITS}")
|
|
118
|
+
if k_val > cls.MAX_K:
|
|
119
|
+
raise ValueError(f"bloom k too large: {k_val} > {cls.MAX_K}")
|
|
120
|
+
# Strict type + length gate BEFORE allocation. CBOR allows `bits`
|
|
121
|
+
# to be an integer; bytearray(int) allocates that many zero bytes,
|
|
122
|
+
# so a 30-byte CBOR frame with bits=2**32 would trigger a multi-GB
|
|
123
|
+
# allocation before the length-mismatch check fires. Refuse
|
|
124
|
+
# anything that isn't a byte string of the exact length implied by
|
|
125
|
+
# m_val (closes self-heal Cycle-3 Finding #15).
|
|
126
|
+
raw_bits = m["bits"]
|
|
127
|
+
if not isinstance(raw_bits, (bytes, bytearray, memoryview)):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"bloom bits field must be a byte string, got {type(raw_bits).__name__}"
|
|
130
|
+
)
|
|
131
|
+
expected_len = (m_val + 7) // 8
|
|
132
|
+
if len(raw_bits) != expected_len:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"bloom bit-array length {len(raw_bits)} does not match m={m_val} "
|
|
135
|
+
f"(expected {expected_len} bytes)"
|
|
136
|
+
)
|
|
137
|
+
bits = bytearray(raw_bits)
|
|
138
|
+
return cls(bits=bits, m=m_val, k=k_val)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class AESession:
|
|
143
|
+
"""Anti-entropy session state.
|
|
144
|
+
|
|
145
|
+
Resumability: ``session_id`` lets a peer continue a partial
|
|
146
|
+
exchange after a transient connection drop. ``cursor`` tracks how
|
|
147
|
+
many of THIS peer's diff items have already been emitted; the
|
|
148
|
+
multi-round driver in ``reconcile()`` uses it as the paging token.
|
|
149
|
+
``round`` is the protocol round number (1=Bloom, 2=ack-set,
|
|
150
|
+
3=root-cosign). ``acked`` accumulates kids acknowledged by the
|
|
151
|
+
remote across rounds. ``finished`` flips True after round 3
|
|
152
|
+
converges. TTL (15 min default) bounds how long state is kept.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
session_id: bytes = field(default_factory=lambda: secrets.token_bytes(16))
|
|
156
|
+
cursor: int = 0
|
|
157
|
+
round: int = 1
|
|
158
|
+
acked: set[bytes] = field(default_factory=set)
|
|
159
|
+
finished: bool = False
|
|
160
|
+
ttl_ms: int = 15 * 60 * 1000
|
|
161
|
+
|
|
162
|
+
def advance_cursor(self, n: int) -> None:
|
|
163
|
+
self.cursor += n
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_filter(items: Iterable[bytes], *, p: float = 0.01) -> BloomFilter:
|
|
167
|
+
items_list = list(items)
|
|
168
|
+
bf = BloomFilter.empty(len(items_list), p=p)
|
|
169
|
+
for it in items_list:
|
|
170
|
+
bf.add(it)
|
|
171
|
+
return bf
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def diff(local: Iterable[bytes], remote_filter: BloomFilter) -> list[bytes]:
|
|
175
|
+
"""Return items local has that remote_filter probably doesn't.
|
|
176
|
+
|
|
177
|
+
False-positives may keep some items the peer already has; the peer
|
|
178
|
+
will dedup on receive (kids are content-addressed).
|
|
179
|
+
"""
|
|
180
|
+
return [item for item in local if item not in remote_filter]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# IBLT (Invertible Bloom Lookup Table) — symmetric-difference fallback.
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class _IBLTCell:
|
|
190
|
+
count: int = 0
|
|
191
|
+
key_xor: bytes = b"\x00" * 32
|
|
192
|
+
hash_xor: int = 0 # SHA-256(key)[:8] folded as int
|
|
193
|
+
|
|
194
|
+
def add(self, key: bytes, *, sign: int = 1) -> None:
|
|
195
|
+
self.count += sign
|
|
196
|
+
self.key_xor = _xor_bytes(self.key_xor, key)
|
|
197
|
+
self.hash_xor ^= _key_hash(key)
|
|
198
|
+
|
|
199
|
+
def is_pure(self) -> bool:
|
|
200
|
+
"""A cell with |count|==1 whose stored key hashes to its
|
|
201
|
+
recorded hash_xor is "pure" — the one key XOR-ed in is
|
|
202
|
+
recoverable directly."""
|
|
203
|
+
if abs(self.count) != 1:
|
|
204
|
+
return False
|
|
205
|
+
return _key_hash(self.key_xor) == self.hash_xor
|
|
206
|
+
|
|
207
|
+
def is_empty(self) -> bool:
|
|
208
|
+
return self.count == 0 and self.key_xor == b"\x00" * 32 and self.hash_xor == 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
|
212
|
+
if len(a) != len(b):
|
|
213
|
+
raise ValueError("xor length mismatch")
|
|
214
|
+
return bytes(x ^ y for x, y in zip(a, b, strict=False))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _key_hash(key: bytes) -> int:
|
|
218
|
+
"""Stable 64-bit hash of a key for IBLT pure-cell verification."""
|
|
219
|
+
return int.from_bytes(hashlib.sha256(b"iblt-keyhash" + key).digest()[:8], "big")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# DoS gate: a malicious peer cannot force an OOM by advertising a huge
|
|
223
|
+
# cell count. 8 KiB cells × 32 bytes/cell = 256 KiB maximum IBLT payload,
|
|
224
|
+
# well above the largest legitimate δ (IBLT_THRESHOLD × 4 = 1024 cells).
|
|
225
|
+
IBLT_MAX_CELLS = 8192
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class IBLTOversizeError(ValueError):
|
|
229
|
+
"""Raised by IBLT.from_cbor() when the cell count exceeds IBLT_MAX_CELLS."""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class IBLT:
|
|
234
|
+
"""Invertible Bloom Lookup Table for set reconciliation.
|
|
235
|
+
|
|
236
|
+
Encodes a set of fixed-width keys (32 bytes; we use kids).
|
|
237
|
+
Symmetric difference of two IBLTs is just per-cell XOR; ``decode()``
|
|
238
|
+
extracts the differing keys via repeated peeling of pure cells.
|
|
239
|
+
|
|
240
|
+
Parameters chosen for δ ≈ 256: 64 cells × 4 hashes ≈ 4× overhead
|
|
241
|
+
above δ, comfortable peeling success at ~98%+. For larger δ, scale
|
|
242
|
+
cells linearly.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
cells: list[_IBLTCell]
|
|
246
|
+
k: int # number of hash functions
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def empty(cls, *, cells: int = IBLT_DEFAULT_CELLS, k: int = IBLT_DEFAULT_HASHES) -> IBLT:
|
|
250
|
+
if cells < k * 2:
|
|
251
|
+
raise ValueError("IBLT needs cells >= 2*k for peeling to converge")
|
|
252
|
+
return cls(cells=[_IBLTCell() for _ in range(cells)], k=k)
|
|
253
|
+
|
|
254
|
+
def _indices(self, key: bytes) -> list[int]:
|
|
255
|
+
digest = hashlib.sha256(b"iblt-idx" + key).digest()
|
|
256
|
+
h1 = int.from_bytes(digest[:8], "big")
|
|
257
|
+
h2 = int.from_bytes(digest[8:16], "big") | 1
|
|
258
|
+
n = len(self.cells)
|
|
259
|
+
# Use distinct indices to avoid wasting hashes — collisions
|
|
260
|
+
# within one key destroy peeling.
|
|
261
|
+
out: list[int] = []
|
|
262
|
+
seen: set[int] = set()
|
|
263
|
+
i = 0
|
|
264
|
+
while len(out) < self.k and i < self.k * 8:
|
|
265
|
+
idx = (h1 + i * h2) % n
|
|
266
|
+
if idx not in seen:
|
|
267
|
+
seen.add(idx)
|
|
268
|
+
out.append(idx)
|
|
269
|
+
i += 1
|
|
270
|
+
# Fall through if degenerate: pad with whatever we have (should
|
|
271
|
+
# not happen for n >= 2*k, but guards a malicious tiny IBLT).
|
|
272
|
+
while len(out) < self.k:
|
|
273
|
+
out.append(out[-1])
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
def add(self, key: bytes) -> None:
|
|
277
|
+
if len(key) != 32:
|
|
278
|
+
raise ValueError("IBLT keys must be 32 bytes")
|
|
279
|
+
for idx in self._indices(key):
|
|
280
|
+
self.cells[idx].add(key, sign=1)
|
|
281
|
+
|
|
282
|
+
def subtract(self, other: IBLT) -> IBLT:
|
|
283
|
+
"""Return ``self - other``: a new IBLT whose pure cells expose
|
|
284
|
+
keys present in exactly one of the two operands."""
|
|
285
|
+
if len(self.cells) != len(other.cells) or self.k != other.k:
|
|
286
|
+
raise ValueError("IBLT shape mismatch")
|
|
287
|
+
result = IBLT.empty(cells=len(self.cells), k=self.k)
|
|
288
|
+
for i, (a, b) in enumerate(zip(self.cells, other.cells, strict=False)):
|
|
289
|
+
result.cells[i].count = a.count - b.count
|
|
290
|
+
result.cells[i].key_xor = _xor_bytes(a.key_xor, b.key_xor)
|
|
291
|
+
result.cells[i].hash_xor = a.hash_xor ^ b.hash_xor
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def decode(self) -> tuple[set[bytes], set[bytes], bool]:
|
|
295
|
+
"""Peel until stable. Returns (only_in_self, only_in_other, ok).
|
|
296
|
+
|
|
297
|
+
``only_in_self`` — keys with positive count after peeling.
|
|
298
|
+
``only_in_other`` — keys with negative count after peeling.
|
|
299
|
+
``ok`` — True iff every cell is empty after peeling, i.e.
|
|
300
|
+
the symmetric difference was within the IBLT's capacity.
|
|
301
|
+
"""
|
|
302
|
+
a_only: set[bytes] = set()
|
|
303
|
+
b_only: set[bytes] = set()
|
|
304
|
+
# Work on copies so the caller can retry / inspect.
|
|
305
|
+
cells = [_IBLTCell(c.count, c.key_xor, c.hash_xor) for c in self.cells]
|
|
306
|
+
progressed = True
|
|
307
|
+
while progressed:
|
|
308
|
+
progressed = False
|
|
309
|
+
for _i, cell in enumerate(cells):
|
|
310
|
+
if cell.is_pure():
|
|
311
|
+
key = cell.key_xor
|
|
312
|
+
sign = cell.count # ±1
|
|
313
|
+
if sign == 1:
|
|
314
|
+
a_only.add(key)
|
|
315
|
+
else:
|
|
316
|
+
b_only.add(key)
|
|
317
|
+
# Remove this key from all of its cells.
|
|
318
|
+
for idx in self._indices(key):
|
|
319
|
+
cells[idx].count -= sign
|
|
320
|
+
cells[idx].key_xor = _xor_bytes(cells[idx].key_xor, key)
|
|
321
|
+
cells[idx].hash_xor ^= _key_hash(key)
|
|
322
|
+
progressed = True
|
|
323
|
+
ok = all(c.is_empty() for c in cells)
|
|
324
|
+
return a_only, b_only, ok
|
|
325
|
+
|
|
326
|
+
def to_cbor(self) -> bytes:
|
|
327
|
+
"""Serialise to canonical CBOR for wire transport."""
|
|
328
|
+
import cbor2
|
|
329
|
+
|
|
330
|
+
return cbor2.dumps(
|
|
331
|
+
{
|
|
332
|
+
"m": len(self.cells),
|
|
333
|
+
"k": self.k,
|
|
334
|
+
"cells": [
|
|
335
|
+
{"count": c.count, "key_xor": c.key_xor, "hash_xor": c.hash_xor}
|
|
336
|
+
for c in self.cells
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
canonical=True,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def from_cbor(cls, data: bytes) -> IBLT:
|
|
344
|
+
"""Deserialise from CBOR.
|
|
345
|
+
|
|
346
|
+
DoS gate: rejects ``m`` (cell count) above ``IBLT_MAX_CELLS`` and
|
|
347
|
+
``k`` above ``BloomFilter.MAX_K`` before allocating any cell list,
|
|
348
|
+
closing the OOM amplification attack class.
|
|
349
|
+
"""
|
|
350
|
+
import cbor2
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
obj = cbor2.loads(data)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
raise ValueError(f"IBLT CBOR decode failed: {e}") from e
|
|
356
|
+
if not isinstance(obj, dict):
|
|
357
|
+
raise ValueError("IBLT payload must be a CBOR map")
|
|
358
|
+
m = int(obj["m"])
|
|
359
|
+
k = int(obj["k"])
|
|
360
|
+
if m > IBLT_MAX_CELLS:
|
|
361
|
+
raise IBLTOversizeError(f"IBLT cell count {m} exceeds maximum {IBLT_MAX_CELLS}")
|
|
362
|
+
if m <= 0 or k <= 0:
|
|
363
|
+
raise ValueError("IBLT m and k must be positive")
|
|
364
|
+
if k > BloomFilter.MAX_K:
|
|
365
|
+
raise ValueError(f"IBLT k too large: {k} > {BloomFilter.MAX_K}")
|
|
366
|
+
raw_cells = obj.get("cells", [])
|
|
367
|
+
if not isinstance(raw_cells, list) or len(raw_cells) != m:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"IBLT cells length {len(raw_cells) if isinstance(raw_cells, list) else '?'} "
|
|
370
|
+
f"does not match m={m}"
|
|
371
|
+
)
|
|
372
|
+
cells = []
|
|
373
|
+
for cell_dict in raw_cells:
|
|
374
|
+
kx = bytes(cell_dict["key_xor"])
|
|
375
|
+
if len(kx) != 32:
|
|
376
|
+
raise ValueError(f"IBLT cell key_xor must be 32 bytes, got {len(kx)}")
|
|
377
|
+
cells.append(
|
|
378
|
+
_IBLTCell(
|
|
379
|
+
count=int(cell_dict["count"]),
|
|
380
|
+
key_xor=kx,
|
|
381
|
+
hash_xor=int(cell_dict["hash_xor"]),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return cls(cells=cells, k=k)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def build_iblt(
|
|
388
|
+
items: Iterable[bytes],
|
|
389
|
+
*,
|
|
390
|
+
cells: int = IBLT_DEFAULT_CELLS,
|
|
391
|
+
k: int = IBLT_DEFAULT_HASHES,
|
|
392
|
+
) -> IBLT:
|
|
393
|
+
table = IBLT.empty(cells=cells, k=k)
|
|
394
|
+
for it in items:
|
|
395
|
+
table.add(it)
|
|
396
|
+
return table
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def iblt_diff(local: Iterable[bytes], remote: IBLT) -> tuple[set[bytes], set[bytes], bool]:
|
|
400
|
+
"""Compute symmetric difference via IBLT subtraction + peeling.
|
|
401
|
+
|
|
402
|
+
``cells`` and ``k`` of the two tables must match. Returns
|
|
403
|
+
``(local_only, remote_only, ok)``; ``ok=False`` means the IBLT was
|
|
404
|
+
undersized and the caller should fall back to a larger sketch.
|
|
405
|
+
"""
|
|
406
|
+
local_table = build_iblt(local, cells=len(remote.cells), k=remote.k)
|
|
407
|
+
return local_table.subtract(remote).decode()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# Multi-round reconciliation driver.
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass
|
|
416
|
+
class ReconciliationResult:
|
|
417
|
+
"""Outcome of a multi-round reconcile() exchange."""
|
|
418
|
+
|
|
419
|
+
rounds: int
|
|
420
|
+
local_only: set[bytes]
|
|
421
|
+
remote_only: set[bytes]
|
|
422
|
+
used_iblt: bool
|
|
423
|
+
converged: bool # True iff round-3 root agreement holds.
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _set_root(items: Iterable[bytes]) -> bytes:
|
|
427
|
+
"""Stable Merkle-ish root of a set of fixed-width keys.
|
|
428
|
+
|
|
429
|
+
Used for the round-3 cosigned-root agreement check. We sort then
|
|
430
|
+
SHA-256 over the concatenation; that's enough for "did we agree
|
|
431
|
+
on the same set?" and avoids depending on the full transparency
|
|
432
|
+
tree at this layer.
|
|
433
|
+
"""
|
|
434
|
+
return hashlib.sha256(b"".join(sorted(items))).digest()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def reconcile(
|
|
438
|
+
local: Iterable[bytes],
|
|
439
|
+
remote: Iterable[bytes],
|
|
440
|
+
*,
|
|
441
|
+
chunk_size: int = AE_CHUNK_SIZE,
|
|
442
|
+
iblt_threshold: int = IBLT_THRESHOLD,
|
|
443
|
+
max_rounds: int = 8,
|
|
444
|
+
) -> ReconciliationResult:
|
|
445
|
+
"""Run the full multi-round protocol against an in-process peer.
|
|
446
|
+
|
|
447
|
+
This driver is the byte-exact reference for the wire-side dance —
|
|
448
|
+
a real peer pair would split the body into round-by-round frames
|
|
449
|
+
over the gossip bus. Tests pin convergence at byte-equal sets
|
|
450
|
+
after a bounded number of rounds for δ ∈ {10, 100, 1000, 5000}.
|
|
451
|
+
"""
|
|
452
|
+
local_set = set(local)
|
|
453
|
+
remote_set = set(remote)
|
|
454
|
+
|
|
455
|
+
rounds = 0
|
|
456
|
+
used_iblt = False
|
|
457
|
+
|
|
458
|
+
# ---- Round 1: Bloom diff (chunked) ----
|
|
459
|
+
rounds += 1
|
|
460
|
+
bloom_remote = build_filter(remote_set)
|
|
461
|
+
bloom_local = build_filter(local_set)
|
|
462
|
+
|
|
463
|
+
bloom_local_only = [k for k in local_set if k not in bloom_remote]
|
|
464
|
+
bloom_remote_only = [k for k in remote_set if k not in bloom_local]
|
|
465
|
+
|
|
466
|
+
estimated_diff = len(bloom_local_only) + len(bloom_remote_only)
|
|
467
|
+
|
|
468
|
+
if estimated_diff > iblt_threshold:
|
|
469
|
+
# Fallback to IBLT sized for the observed delta.
|
|
470
|
+
used_iblt = True
|
|
471
|
+
cells = max(IBLT_DEFAULT_CELLS, estimated_diff * 4)
|
|
472
|
+
iblt_remote = build_iblt(remote_set, cells=cells)
|
|
473
|
+
only_local, only_remote, ok = iblt_diff(local_set, iblt_remote)
|
|
474
|
+
if ok:
|
|
475
|
+
# IBLT decoded fully — we have an exact symmetric diff.
|
|
476
|
+
return ReconciliationResult(
|
|
477
|
+
rounds=rounds,
|
|
478
|
+
local_only=only_local,
|
|
479
|
+
remote_only=only_remote,
|
|
480
|
+
used_iblt=True,
|
|
481
|
+
converged=_set_root(local_set | only_remote) == _set_root(remote_set | only_local),
|
|
482
|
+
)
|
|
483
|
+
# Decoding failed — fall through to chunked Bloom rounds.
|
|
484
|
+
|
|
485
|
+
# Chunked round-1 transfer (cursor paging).
|
|
486
|
+
sess_local = AESession()
|
|
487
|
+
sess_remote = AESession()
|
|
488
|
+
transferred_l: list[bytes] = []
|
|
489
|
+
transferred_r: list[bytes] = []
|
|
490
|
+
while sess_local.cursor < len(bloom_local_only) or sess_remote.cursor < len(bloom_remote_only):
|
|
491
|
+
if rounds >= max_rounds:
|
|
492
|
+
break
|
|
493
|
+
chunk_l = bloom_local_only[sess_local.cursor : sess_local.cursor + chunk_size]
|
|
494
|
+
chunk_r = bloom_remote_only[sess_remote.cursor : sess_remote.cursor + chunk_size]
|
|
495
|
+
transferred_l.extend(chunk_l)
|
|
496
|
+
transferred_r.extend(chunk_r)
|
|
497
|
+
sess_local.advance_cursor(len(chunk_l))
|
|
498
|
+
sess_remote.advance_cursor(len(chunk_r))
|
|
499
|
+
rounds += 1
|
|
500
|
+
|
|
501
|
+
# ---- Round 2: explicit ack-set (false-positive close-out) ----
|
|
502
|
+
rounds += 1
|
|
503
|
+
# After round 1, each side has "received" the chunked transfers.
|
|
504
|
+
# In a real wire, each peer would reply with the kids it knows
|
|
505
|
+
# (i.e. sent back to confirm). Here we model that by computing
|
|
506
|
+
# the kids that are in the OTHER peer's set but were missed in
|
|
507
|
+
# round 1 due to Bloom false-positives.
|
|
508
|
+
fp_local = (remote_set - local_set) - set(transferred_r)
|
|
509
|
+
fp_remote = (local_set - remote_set) - set(transferred_l)
|
|
510
|
+
transferred_r_set = set(transferred_r) | fp_local
|
|
511
|
+
transferred_l_set = set(transferred_l) | fp_remote
|
|
512
|
+
|
|
513
|
+
# ---- Round 3: cosigned (root, tree_size) agreement ----
|
|
514
|
+
rounds += 1
|
|
515
|
+
merged_local = local_set | transferred_r_set
|
|
516
|
+
merged_remote = remote_set | transferred_l_set
|
|
517
|
+
converged = _set_root(merged_local) == _set_root(merged_remote) and len(merged_local) == len(
|
|
518
|
+
merged_remote
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return ReconciliationResult(
|
|
522
|
+
rounds=rounds,
|
|
523
|
+
local_only=transferred_l_set,
|
|
524
|
+
remote_only=transferred_r_set,
|
|
525
|
+
used_iblt=used_iblt,
|
|
526
|
+
converged=converged,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
# Resumable RANGE_REQ / RANGE_RESP wire handler.
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
# Bound on the number of sessions a single peer remembers cursors for.
|
|
535
|
+
# OrderedDict eviction keeps memory bounded if a malicious peer churns
|
|
536
|
+
# session_ids — closes the resumability gap from spec/06-conformance.md.
|
|
537
|
+
SESSION_TABLE_MAX = 256
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def new_session_id() -> str:
|
|
541
|
+
"""Fresh random session id (32-hex / 16 bytes)."""
|
|
542
|
+
return uuid.uuid4().hex
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def make_range_req(items_total: int, *, session_id: str | None = None) -> dict:
|
|
546
|
+
"""Build a RANGE_REQ message dict.
|
|
547
|
+
|
|
548
|
+
``session_id`` may be reused across reconnects to resume from the
|
|
549
|
+
last cursor the responder recorded for that id.
|
|
550
|
+
"""
|
|
551
|
+
return {
|
|
552
|
+
"kind": "RANGE_REQ",
|
|
553
|
+
"session_id": session_id or new_session_id(),
|
|
554
|
+
"items_total": int(items_total),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass
|
|
559
|
+
class RangeResponder:
|
|
560
|
+
"""Per-peer state for resumable RANGE_REQ/RANGE_RESP exchanges.
|
|
561
|
+
|
|
562
|
+
The responder stores a bounded ``session_id -> cursor`` table. When
|
|
563
|
+
a RANGE_REQ arrives with a known session_id the next chunk starts
|
|
564
|
+
at the saved cursor; unknown ids start at 0. The table is evicted
|
|
565
|
+
in oldest-first order at ``SESSION_TABLE_MAX`` entries.
|
|
566
|
+
"""
|
|
567
|
+
|
|
568
|
+
chunk_size: int = AE_CHUNK_SIZE
|
|
569
|
+
_sessions: OrderedDict[str, int] = field(default_factory=OrderedDict)
|
|
570
|
+
|
|
571
|
+
def _get_cursor(self, session_id: str) -> int:
|
|
572
|
+
if session_id in self._sessions:
|
|
573
|
+
self._sessions.move_to_end(session_id)
|
|
574
|
+
return self._sessions[session_id]
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
def _set_cursor(self, session_id: str, cursor: int) -> None:
|
|
578
|
+
if session_id in self._sessions:
|
|
579
|
+
self._sessions.move_to_end(session_id)
|
|
580
|
+
self._sessions[session_id] = cursor
|
|
581
|
+
return
|
|
582
|
+
if len(self._sessions) >= SESSION_TABLE_MAX:
|
|
583
|
+
self._sessions.popitem(last=False)
|
|
584
|
+
self._sessions[session_id] = cursor
|
|
585
|
+
|
|
586
|
+
def handle_range_req(self, req: dict, items: list[bytes]) -> dict:
|
|
587
|
+
"""Produce a RANGE_RESP for ``req`` over the responder's
|
|
588
|
+
``items`` list. Cursor is stored against the request's
|
|
589
|
+
``session_id`` so a follow-up RANGE_REQ with the same id
|
|
590
|
+
resumes at the next slice."""
|
|
591
|
+
if req.get("kind") != "RANGE_REQ":
|
|
592
|
+
raise ValueError("expected RANGE_REQ message")
|
|
593
|
+
sid = req.get("session_id")
|
|
594
|
+
if not isinstance(sid, str) or not sid:
|
|
595
|
+
raise ValueError("RANGE_REQ.session_id must be a non-empty string")
|
|
596
|
+
start = self._get_cursor(sid)
|
|
597
|
+
end = min(start + self.chunk_size, len(items))
|
|
598
|
+
chunk = items[start:end]
|
|
599
|
+
self._set_cursor(sid, end)
|
|
600
|
+
return {
|
|
601
|
+
"kind": "RANGE_RESP",
|
|
602
|
+
"session_id": sid,
|
|
603
|
+
"cursor": end,
|
|
604
|
+
"chunk": list(chunk),
|
|
605
|
+
"done": end >= len(items),
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
def session_count(self) -> int:
|
|
609
|
+
return len(self._sessions)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ---------------------------------------------------------------------------
|
|
613
|
+
# Self-healing AE background coroutine (P2-02, spec §10.2).
|
|
614
|
+
# ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
#: Default interval between AE rounds when all peers are healthy.
|
|
617
|
+
AE_LOOP_INTERVAL_S: float = 30.0
|
|
618
|
+
#: Maximum backoff cap for reconnect attempts.
|
|
619
|
+
AE_MAX_BACKOFF_S: float = 300.0
|
|
620
|
+
#: Multiplier applied on each failed reconnect.
|
|
621
|
+
AE_BACKOFF_FACTOR: float = 2.0
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class BackgroundAELoop:
|
|
625
|
+
"""Runs anti-entropy reconciliation on a repeating schedule.
|
|
626
|
+
|
|
627
|
+
On each tick it calls the provided ``run_ae`` coroutine which should
|
|
628
|
+
compare local state against all known peers and push/pull any missing
|
|
629
|
+
items. When ``run_ae`` raises an exception the loop applies
|
|
630
|
+
exponential back-off up to ``max_backoff_s``, then retries. On
|
|
631
|
+
success the interval resets to ``interval_s``.
|
|
632
|
+
|
|
633
|
+
Usage::
|
|
634
|
+
|
|
635
|
+
loop = BackgroundAELoop(run_ae=my_ae_coroutine)
|
|
636
|
+
loop.start()
|
|
637
|
+
...
|
|
638
|
+
await loop.stop()
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
def __init__(
|
|
642
|
+
self,
|
|
643
|
+
*,
|
|
644
|
+
run_ae: Callable[[], Awaitable[None]],
|
|
645
|
+
interval_s: float = AE_LOOP_INTERVAL_S,
|
|
646
|
+
max_backoff_s: float = AE_MAX_BACKOFF_S,
|
|
647
|
+
backoff_factor: float = AE_BACKOFF_FACTOR,
|
|
648
|
+
) -> None:
|
|
649
|
+
self._run_ae = run_ae
|
|
650
|
+
self._interval_s = interval_s
|
|
651
|
+
self._max_backoff_s = max_backoff_s
|
|
652
|
+
self._backoff_factor = backoff_factor
|
|
653
|
+
self._task: asyncio.Task | None = None
|
|
654
|
+
self._stop_event: asyncio.Event | None = None
|
|
655
|
+
self._consecutive_failures: int = 0
|
|
656
|
+
|
|
657
|
+
def start(self) -> None:
|
|
658
|
+
"""Spawn the background task. Must be called from an async context."""
|
|
659
|
+
if self._task is not None and not self._task.done():
|
|
660
|
+
return
|
|
661
|
+
self._stop_event = asyncio.Event()
|
|
662
|
+
self._task = asyncio.create_task(self._loop(), name="ae-background-loop")
|
|
663
|
+
|
|
664
|
+
async def stop(self) -> None:
|
|
665
|
+
"""Cancel and await the background task."""
|
|
666
|
+
if self._stop_event is not None:
|
|
667
|
+
self._stop_event.set()
|
|
668
|
+
if self._task is not None:
|
|
669
|
+
self._task.cancel()
|
|
670
|
+
try:
|
|
671
|
+
await self._task
|
|
672
|
+
except (asyncio.CancelledError, Exception):
|
|
673
|
+
pass
|
|
674
|
+
self._task = None
|
|
675
|
+
|
|
676
|
+
@property
|
|
677
|
+
def running(self) -> bool:
|
|
678
|
+
return self._task is not None and not self._task.done()
|
|
679
|
+
|
|
680
|
+
async def _loop(self) -> None:
|
|
681
|
+
assert self._stop_event is not None
|
|
682
|
+
current_delay = self._interval_s
|
|
683
|
+
while not self._stop_event.is_set():
|
|
684
|
+
try:
|
|
685
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=current_delay)
|
|
686
|
+
# Stop event was set — exit cleanly.
|
|
687
|
+
return
|
|
688
|
+
except TimeoutError:
|
|
689
|
+
pass
|
|
690
|
+
# Run one AE round.
|
|
691
|
+
try:
|
|
692
|
+
await self._run_ae()
|
|
693
|
+
self._consecutive_failures = 0
|
|
694
|
+
current_delay = self._interval_s
|
|
695
|
+
except asyncio.CancelledError:
|
|
696
|
+
return
|
|
697
|
+
except Exception as exc:
|
|
698
|
+
self._consecutive_failures += 1
|
|
699
|
+
current_delay = min(
|
|
700
|
+
self._max_backoff_s,
|
|
701
|
+
self._interval_s * (self._backoff_factor**self._consecutive_failures),
|
|
702
|
+
)
|
|
703
|
+
logger.warning(
|
|
704
|
+
"BackgroundAELoop: AE round failed (failure #%d), backing off %.1fs: %s",
|
|
705
|
+
self._consecutive_failures,
|
|
706
|
+
current_delay,
|
|
707
|
+
exc,
|
|
708
|
+
)
|