sync improve
This commit is contained in:
1 parent
01bb82fa5a
commit
2916e49973
52 files changed
+2171
-271
No files matched your search
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Decentralised storage, replication, and metrics layer.
|
||||
"""
|
||||
|
||||
from .config import dht_config, DHTConfig
|
||||
from .crypto import compute_node_id, compute_content_id, compute_view_id, bits_from_hex, rendezvous_score
|
||||
from .keys import MetaKey, MetricKey, MembershipKey
|
||||
from .membership import MembershipManager, MembershipState, ReachabilityReceipt
|
||||
from .replication import ReplicationManager, ReplicationState, ReplicaLease
|
||||
from .metrics import MetricsAggregator, ContentMetricsState, MetricDelta
|
||||
from .store import DHTStore
|
||||
|
||||
__all__ = [
|
||||
"dht_config",
|
||||
"DHTConfig",
|
||||
"compute_node_id",
|
||||
"compute_content_id",
|
||||
"compute_view_id",
|
||||
"bits_from_hex",
|
||||
"rendezvous_score",
|
||||
"MetaKey",
|
||||
"MetricKey",
|
||||
"MembershipKey",
|
||||
"MembershipManager",
|
||||
"MembershipState",
|
||||
"ReachabilityReceipt",
|
||||
"ReplicationManager",
|
||||
"ReplicationState",
|
||||
"ReplicaLease",
|
||||
"MetricsAggregator",
|
||||
"ContentMetricsState",
|
||||
"MetricDelta",
|
||||
"DHTStore",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
SCHEMA_VERSION = "v1"
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DHTConfig:
|
||||
"""Runtime configuration for the decentralized storage layer."""
|
||||
|
||||
schema_version: str = SCHEMA_VERSION
|
||||
min_receipts: int = _env_int("DHT_MIN_RECEIPTS", 5)
|
||||
min_reachability_ratio: float = _env_float("DHT_MIN_REACHABILITY", 0.6)
|
||||
membership_ttl: int = _env_int("DHT_MEMBERSHIP_TTL", 600)
|
||||
replication_target: int = max(3, _env_int("DHT_REPLICATION_TARGET", 3))
|
||||
lease_ttl: int = _env_int("DHT_LEASE_TTL", 600)
|
||||
heartbeat_interval: int = _env_int("DHT_HEARTBEAT_INTERVAL", 60)
|
||||
heartbeat_miss_threshold: int = _env_int("DHT_HEARTBEAT_MISS_THRESHOLD", 3)
|
||||
rendezvous_base: str = os.getenv("DHT_RENDEZVOUS_HASH", "blake3")
|
||||
pow_difficulty: int = _env_int("DHT_POW_DIFFICULTY", 4)
|
||||
min_asn_diversity: int = _env_int("DHT_MIN_ASN", 3)
|
||||
min_ip_octet_diversity: int = _env_int("DHT_MIN_IP_OCTETS", 3)
|
||||
window_size: int = _env_int("DHT_METRIC_WINDOW_SEC", 3600)
|
||||
default_q: float = _env_float("DHT_MIN_Q", 0.6)
|
||||
seed_refresh_interval: int = _env_int("DHT_SEED_REFRESH_INTERVAL", 30)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_config() -> DHTConfig:
|
||||
"""Load configuration with process-wide memoisation."""
|
||||
|
||||
return DHTConfig()
|
||||
|
||||
|
||||
dht_config = load_config()
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Iterable, Tuple
|
||||
|
||||
from app.core._utils.hash import blake3_hex
|
||||
|
||||
|
||||
class CRDTMergeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CRDT:
|
||||
def merge(self, other: "CRDT") -> "CRDT":
|
||||
raise NotImplementedError
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "CRDT":
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class LWWElement:
|
||||
value: Any
|
||||
logical_counter: int
|
||||
timestamp: float
|
||||
node_id: str
|
||||
|
||||
def dominates(self, other: "LWWElement") -> bool:
|
||||
if self.logical_counter > other.logical_counter:
|
||||
return True
|
||||
if self.logical_counter < other.logical_counter:
|
||||
return False
|
||||
if self.timestamp > other.timestamp:
|
||||
return True
|
||||
if self.timestamp < other.timestamp:
|
||||
return False
|
||||
# Break all ties by NodeID ordering to guarantee determinism
|
||||
return self.node_id > other.node_id
|
||||
|
||||
|
||||
class LWWRegister(CRDT):
|
||||
def __init__(self, element: LWWElement | None = None):
|
||||
self.element = element
|
||||
|
||||
def assign(self, value: Any, logical_counter: int, node_id: str, timestamp: float | None = None) -> None:
|
||||
new_el = LWWElement(value=value, logical_counter=logical_counter, timestamp=timestamp or time.time(), node_id=node_id)
|
||||
if self.element is None or new_el.dominates(self.element):
|
||||
self.element = new_el
|
||||
|
||||
def merge(self, other: "LWWRegister") -> "LWWRegister":
|
||||
if other.element and (self.element is None or other.element.dominates(self.element)):
|
||||
self.element = other.element
|
||||
return self
|
||||
|
||||
def value(self) -> Any:
|
||||
return self.element.value if self.element else None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
if not self.element:
|
||||
return {}
|
||||
return {
|
||||
"value": self.element.value,
|
||||
"logical_counter": self.element.logical_counter,
|
||||
"timestamp": self.element.timestamp,
|
||||
"node_id": self.element.node_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "LWWRegister":
|
||||
if not data:
|
||||
return cls()
|
||||
element = LWWElement(
|
||||
value=data.get("value"),
|
||||
logical_counter=int(data["logical_counter"]),
|
||||
timestamp=float(data["timestamp"]),
|
||||
node_id=str(data["node_id"]),
|
||||
)
|
||||
return cls(element=element)
|
||||
|
||||
|
||||
class LWWSet(CRDT):
|
||||
def __init__(self, adds: Dict[str, LWWElement] | None = None, removes: Dict[str, LWWElement] | None = None):
|
||||
self.adds: Dict[str, LWWElement] = adds or {}
|
||||
self.removes: Dict[str, LWWElement] = removes or {}
|
||||
|
||||
def add(self, element_id: str, value: Any, logical_counter: int, node_id: str, timestamp: float | None = None) -> None:
|
||||
elem = LWWElement(value=value, logical_counter=logical_counter, timestamp=timestamp or time.time(), node_id=node_id)
|
||||
existing = self.adds.get(element_id)
|
||||
if not existing or elem.dominates(existing):
|
||||
self.adds[element_id] = elem
|
||||
|
||||
def remove(self, element_id: str, logical_counter: int, node_id: str, timestamp: float | None = None) -> None:
|
||||
elem = LWWElement(value=None, logical_counter=logical_counter, timestamp=timestamp or time.time(), node_id=node_id)
|
||||
existing = self.removes.get(element_id)
|
||||
if not existing or elem.dominates(existing):
|
||||
self.removes[element_id] = elem
|
||||
|
||||
def lookup(self, element_id: str) -> Any | None:
|
||||
add = self.adds.get(element_id)
|
||||
remove = self.removes.get(element_id)
|
||||
if add and (not remove or add.dominates(remove)):
|
||||
return add.value
|
||||
return None
|
||||
|
||||
def elements(self) -> Dict[str, Any]:
|
||||
return {eid: elem.value for eid, elem in self.adds.items() if self.lookup(eid) is not None}
|
||||
|
||||
def merge(self, other: "LWWSet") -> "LWWSet":
|
||||
for eid, elem in other.adds.items():
|
||||
current = self.adds.get(eid)
|
||||
if not current or elem.dominates(current):
|
||||
self.adds[eid] = elem
|
||||
for eid, elem in other.removes.items():
|
||||
current = self.removes.get(eid)
|
||||
if not current or elem.dominates(current):
|
||||
self.removes[eid] = elem
|
||||
return self
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
def serialize_map(source: Dict[str, LWWElement]) -> Dict[str, Dict[str, Any]]:
|
||||
return {
|
||||
eid: {
|
||||
"value": elem.value,
|
||||
"logical_counter": elem.logical_counter,
|
||||
"timestamp": elem.timestamp,
|
||||
"node_id": elem.node_id,
|
||||
}
|
||||
for eid, elem in source.items()
|
||||
}
|
||||
|
||||
return {"adds": serialize_map(self.adds), "removes": serialize_map(self.removes)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "LWWSet":
|
||||
adds = {
|
||||
eid: LWWElement(
|
||||
value=elem.get("value"),
|
||||
logical_counter=int(elem["logical_counter"]),
|
||||
timestamp=float(elem["timestamp"]),
|
||||
node_id=str(elem["node_id"]),
|
||||
)
|
||||
for eid, elem in (data.get("adds") or {}).items()
|
||||
}
|
||||
removes = {
|
||||
eid: LWWElement(
|
||||
value=elem.get("value"),
|
||||
logical_counter=int(elem["logical_counter"]),
|
||||
timestamp=float(elem["timestamp"]),
|
||||
node_id=str(elem["node_id"]),
|
||||
)
|
||||
for eid, elem in (data.get("removes") or {}).items()
|
||||
}
|
||||
return cls(adds=adds, removes=removes)
|
||||
|
||||
|
||||
class PNCounter(CRDT):
|
||||
def __init__(self, increments: Dict[str, int] | None = None, decrements: Dict[str, int] | None = None):
|
||||
self.increments = increments or {}
|
||||
self.decrements = decrements or {}
|
||||
|
||||
def increment(self, node_id: str, value: int = 1) -> None:
|
||||
if value < 0:
|
||||
raise ValueError("value must be non-negative for increment")
|
||||
self.increments[node_id] = self.increments.get(node_id, 0) + value
|
||||
|
||||
def decrement(self, node_id: str, value: int = 1) -> None:
|
||||
if value < 0:
|
||||
raise ValueError("value must be non-negative for decrement")
|
||||
self.decrements[node_id] = self.decrements.get(node_id, 0) + value
|
||||
|
||||
def value(self) -> int:
|
||||
return sum(self.increments.values()) - sum(self.decrements.values())
|
||||
|
||||
def merge(self, other: "PNCounter") -> "PNCounter":
|
||||
for nid, val in other.increments.items():
|
||||
self.increments[nid] = max(self.increments.get(nid, 0), val)
|
||||
for nid, val in other.decrements.items():
|
||||
self.decrements[nid] = max(self.decrements.get(nid, 0), val)
|
||||
return self
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"inc": dict(self.increments), "dec": dict(self.decrements)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PNCounter":
|
||||
return cls(increments=dict(data.get("inc") or {}), decrements=dict(data.get("dec") or {}))
|
||||
|
||||
|
||||
class GCounter(CRDT):
|
||||
def __init__(self, counters: Dict[str, int] | None = None):
|
||||
self.counters = counters or {}
|
||||
|
||||
def increment(self, node_id: str, value: int = 1) -> None:
|
||||
if value < 0:
|
||||
raise ValueError("value must be non-negative")
|
||||
self.counters[node_id] = self.counters.get(node_id, 0) + value
|
||||
|
||||
def value(self) -> int:
|
||||
return sum(self.counters.values())
|
||||
|
||||
def merge(self, other: "GCounter") -> "GCounter":
|
||||
for nid, val in other.counters.items():
|
||||
self.counters[nid] = max(self.counters.get(nid, 0), val)
|
||||
return self
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return dict(self.counters)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "GCounter":
|
||||
return cls(counters=dict(data or {}))
|
||||
|
||||
|
||||
def _leading_zeros(value: int, width: int) -> int:
|
||||
if value == 0:
|
||||
return width
|
||||
return width - value.bit_length()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HyperLogLog(CRDT):
|
||||
precision: int = 12
|
||||
registers: Tuple[int, ...] = field(default_factory=tuple)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.registers:
|
||||
self.registers = tuple([0] * (1 << self.precision))
|
||||
else:
|
||||
self.registers = tuple(self.registers)
|
||||
|
||||
@property
|
||||
def m(self) -> int:
|
||||
return len(self.registers)
|
||||
|
||||
def add(self, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
hashed = int(blake3_hex(str(value).encode()), 16)
|
||||
index = hashed & (self.m - 1)
|
||||
w = hashed >> self.precision
|
||||
rank = _leading_zeros(w, 256 - self.precision) + 1
|
||||
current = self.registers[index]
|
||||
if rank > current:
|
||||
regs = list(self.registers)
|
||||
regs[index] = rank
|
||||
self.registers = tuple(regs)
|
||||
|
||||
def estimate(self) -> float:
|
||||
alpha = 0.7213 / (1 + 1.079 / self.m)
|
||||
indicator = sum(2.0 ** (-r) for r in self.registers)
|
||||
raw = alpha * (self.m ** 2) / indicator
|
||||
if raw <= 2.5 * self.m:
|
||||
zeros = self.registers.count(0)
|
||||
if zeros:
|
||||
return self.m * math.log(self.m / zeros)
|
||||
return raw
|
||||
|
||||
def merge(self, other: "HyperLogLog") -> "HyperLogLog":
|
||||
if self.m != other.m:
|
||||
raise CRDTMergeError("Cannot merge HyperLogLog instances with different precision")
|
||||
merged = [max(a, b) for a, b in zip(self.registers, other.registers)]
|
||||
self.registers = tuple(merged)
|
||||
return self
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"precision": self.precision, "registers": list(self.registers)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HyperLogLog":
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(precision=int(data.get("precision", 12)), registers=tuple(int(x) for x in data.get("registers", [])))
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from app.core._utils.hash import blake3_hex
|
||||
|
||||
|
||||
BLAKE3_DIGEST_SIZE = 32
|
||||
|
||||
|
||||
def _ensure_bytes(data: Iterable[int] | bytes | bytearray) -> bytes:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
return bytes(data)
|
||||
if isinstance(data, str):
|
||||
return data.encode()
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def digest_hex(data: Iterable[int] | bytes | bytearray | str) -> str:
|
||||
return blake3_hex(_ensure_bytes(data))
|
||||
|
||||
|
||||
def compute_node_id(public_key: bytes) -> str:
|
||||
"""NodeID = blake3(pubkey)."""
|
||||
|
||||
if not isinstance(public_key, (bytes, bytearray)):
|
||||
raise TypeError("public_key must be bytes")
|
||||
return digest_hex(public_key)
|
||||
|
||||
|
||||
def compute_content_id(encrypted_blob: bytes) -> str:
|
||||
"""ContentID = blake3(encrypted_blob)."""
|
||||
|
||||
return digest_hex(encrypted_blob)
|
||||
|
||||
|
||||
def compute_view_id(content_id: str, viewer_salt: bytes) -> str:
|
||||
"""ViewID = blake3(ContentID||viewer_salt)."""
|
||||
|
||||
if not viewer_salt:
|
||||
raise ValueError("viewer_salt must not be empty")
|
||||
return digest_hex(content_id.encode() + viewer_salt)
|
||||
|
||||
|
||||
def bits_from_hex(hex_digest: str, prefix_bits: int) -> Tuple[int, int]:
|
||||
"""Extract first prefix_bits from a hex digest. Returns (prefix, total_bits)."""
|
||||
|
||||
if prefix_bits < 0:
|
||||
raise ValueError("prefix_bits must be >= 0")
|
||||
bitstring = bin(int(hex_digest, 16))[2:].zfill(len(hex_digest) * 4)
|
||||
if prefix_bits == 0:
|
||||
return 0, len(bitstring)
|
||||
return int(bitstring[:prefix_bits], 2), len(bitstring)
|
||||
|
||||
|
||||
def rendezvous_score(content_id: str, node_id: str) -> int:
|
||||
"""Return rendezvous score via blake3(ContentID||NodeID)."""
|
||||
return int(blake3_hex(f"{content_id}:{node_id}".encode()), 16)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContentFingerprint:
|
||||
content_id: str
|
||||
node_id_prefix: int
|
||||
prefix_bits: int
|
||||
|
||||
def matches(self, node_id: str) -> bool:
|
||||
prefix, total = bits_from_hex(node_id, self.prefix_bits)
|
||||
return prefix == self.node_id_prefix and total >= self.prefix_bits
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any
|
||||
|
||||
from .config import dht_config
|
||||
from .crypto import digest_hex
|
||||
|
||||
|
||||
def _json_dumps(data: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetaKey:
|
||||
content_id: str
|
||||
schema_version: str = dht_config.schema_version
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
return digest_hex(self.serialize())
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return _json_dumps({"schema_version": self.schema_version, "content_id": self.content_id, "type": "meta"})
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"meta:{self.schema_version}:{self.content_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MembershipKey:
|
||||
node_id: str
|
||||
schema_version: str = dht_config.schema_version
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
return digest_hex(self.serialize())
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return _json_dumps({"schema_version": self.schema_version, "node_id": self.node_id, "type": "membership"})
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"membership:{self.schema_version}:{self.node_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricKey:
|
||||
content_id: str
|
||||
window_id: str
|
||||
schema_version: str = dht_config.schema_version
|
||||
|
||||
@classmethod
|
||||
def window_for(cls, timestamp: float, window_size: int | None = None) -> str:
|
||||
win = int(timestamp // (window_size or dht_config.window_size))
|
||||
return datetime.fromtimestamp(win * (window_size or dht_config.window_size), tz=timezone.utc).strftime("%Y%m%d%H")
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
return digest_hex(self.serialize())
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return _json_dumps(
|
||||
{
|
||||
"schema_version": self.schema_version,
|
||||
"content_id": self.content_id,
|
||||
"window_id": self.window_id,
|
||||
"type": "metric",
|
||||
}
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"metric:{self.schema_version}:{self.content_id}:{self.window_id}"
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Iterable, List, Optional, Tuple
|
||||
|
||||
from app.core._crypto.signer import Signer
|
||||
from .config import dht_config
|
||||
from .crdt import LWWSet, HyperLogLog
|
||||
from .keys import MembershipKey
|
||||
from .store import DHTStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReachabilityReceipt:
|
||||
target_id: str
|
||||
issuer_id: str
|
||||
asn: Optional[int]
|
||||
timestamp: float
|
||||
signature: str
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"target_id": self.target_id,
|
||||
"issuer_id": self.issuer_id,
|
||||
"asn": self.asn,
|
||||
"timestamp": self.timestamp,
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
|
||||
def _ip_first_octet(host: str | None) -> Optional[int]:
|
||||
if not host:
|
||||
return None
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return int(str(ip).split(".")[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class MembershipState:
|
||||
def __init__(self, node_id: str, signer: Signer):
|
||||
self.node_id = node_id
|
||||
self.signer = signer
|
||||
self.members = LWWSet()
|
||||
self.receipts = LWWSet()
|
||||
self.hll = HyperLogLog()
|
||||
self.n_reports: Dict[str, float] = {}
|
||||
self.logical_counter = 0
|
||||
|
||||
def _bump_counter(self) -> int:
|
||||
self.logical_counter += 1
|
||||
return self.logical_counter
|
||||
|
||||
def register_member(
|
||||
self,
|
||||
node_id: str,
|
||||
public_key: str,
|
||||
ip: str | None,
|
||||
asn: Optional[int],
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
timestamp: Optional[float] = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"node_id": node_id,
|
||||
"public_key": public_key,
|
||||
"ip": ip,
|
||||
"asn": asn,
|
||||
"ip_first_octet": _ip_first_octet(ip),
|
||||
"meta": metadata or {},
|
||||
"last_update": timestamp or time.time(),
|
||||
}
|
||||
self.members.add(node_id, payload, logical_counter=self._bump_counter(), node_id=self.node_id, timestamp=timestamp)
|
||||
self.hll.add(node_id)
|
||||
|
||||
def forget_member(self, node_id: str) -> None:
|
||||
self.members.remove(node_id, logical_counter=self._bump_counter(), node_id=self.node_id)
|
||||
|
||||
def record_receipt(self, receipt: ReachabilityReceipt) -> None:
|
||||
element_id = f"{receipt.target_id}:{receipt.issuer_id}"
|
||||
self.receipts.add(
|
||||
element_id,
|
||||
receipt.as_dict(),
|
||||
logical_counter=self._bump_counter(),
|
||||
node_id=self.node_id,
|
||||
timestamp=receipt.timestamp,
|
||||
)
|
||||
|
||||
def report_local_population(self) -> None:
|
||||
self.n_reports[self.node_id] = float(self.hll.estimate())
|
||||
|
||||
def merge(self, other: "MembershipState") -> "MembershipState":
|
||||
self.members.merge(other.members)
|
||||
self.receipts.merge(other.receipts)
|
||||
self.hll.merge(other.hll)
|
||||
for node_id, value in other.n_reports.items():
|
||||
self.n_reports[node_id] = max(self.n_reports.get(node_id, 0.0), value)
|
||||
self.logical_counter = max(self.logical_counter, other.logical_counter)
|
||||
return self
|
||||
|
||||
def _unique_asn_for(self, node_id: str) -> Tuple[int, Iterable[int]]:
|
||||
receipts = [
|
||||
entry
|
||||
for rid, entry in self.receipts.elements().items()
|
||||
if entry.get("target_id") == node_id
|
||||
]
|
||||
unique_asn = {entry.get("asn") for entry in receipts if entry.get("asn") is not None}
|
||||
return len(unique_asn), unique_asn
|
||||
|
||||
def reachability_ratio(self, node_id: str) -> float:
|
||||
unique_count, _ = self._unique_asn_for(node_id)
|
||||
if dht_config.min_receipts <= 0:
|
||||
return 1.0
|
||||
return min(1.0, unique_count / dht_config.min_receipts)
|
||||
|
||||
def active_members(self, include_islands: bool = False) -> List[Dict[str, Any]]:
|
||||
now = time.time()
|
||||
result = []
|
||||
for node_id, data in self.members.elements().items():
|
||||
last_update = data.get("last_update") or 0
|
||||
if now - last_update > dht_config.membership_ttl:
|
||||
continue
|
||||
reachability = self.reachability_ratio(node_id)
|
||||
if not include_islands and reachability < dht_config.default_q:
|
||||
continue
|
||||
enriched = dict(data)
|
||||
enriched["reachability_ratio"] = reachability
|
||||
result.append(enriched)
|
||||
return result
|
||||
|
||||
def n_estimate(self) -> float:
|
||||
self.report_local_population()
|
||||
active_ids = {m["node_id"] for m in self.active_members(include_islands=True)}
|
||||
filtered_reports = [
|
||||
value for node_id, value in self.n_reports.items() if node_id in active_ids and self.reachability_ratio(node_id) >= dht_config.default_q
|
||||
]
|
||||
local_estimate = float(self.hll.estimate())
|
||||
if filtered_reports:
|
||||
return max(max(filtered_reports), local_estimate)
|
||||
return local_estimate
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"members": self.members.to_dict(),
|
||||
"receipts": self.receipts.to_dict(),
|
||||
"hll": self.hll.to_dict(),
|
||||
"reports": dict(self.n_reports),
|
||||
"logical_counter": self.logical_counter,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, node_id: str, signer: Signer, data: Dict[str, Any]) -> "MembershipState":
|
||||
inst = cls(node_id=node_id, signer=signer)
|
||||
if data:
|
||||
inst.members = LWWSet.from_dict(data.get("members") or {})
|
||||
inst.receipts = LWWSet.from_dict(data.get("receipts") or {})
|
||||
inst.hll = HyperLogLog.from_dict(data.get("hll") or {})
|
||||
inst.n_reports = {str(k): float(v) for k, v in (data.get("reports") or {}).items()}
|
||||
inst.logical_counter = int(data.get("logical_counter") or 0)
|
||||
return inst
|
||||
|
||||
|
||||
class MembershipManager:
|
||||
def __init__(self, node_id: str, signer: Signer, store: DHTStore):
|
||||
self.node_id = node_id
|
||||
self.signer = signer
|
||||
self.store = store
|
||||
self.state = MembershipState(node_id=node_id, signer=signer)
|
||||
|
||||
def _merge_remote(self, data: Dict[str, Any]) -> None:
|
||||
remote_state = MembershipState.from_dict(self.node_id, self.signer, data)
|
||||
self.state.merge(remote_state)
|
||||
|
||||
def ingest_snapshot(self, payload: Dict[str, Any]) -> None:
|
||||
self._merge_remote(payload)
|
||||
|
||||
def register_local(self, public_key: str, ip: str | None, asn: Optional[int], metadata: Dict[str, Any] | None = None) -> None:
|
||||
self.state.register_member(self.node_id, public_key=public_key, ip=ip, asn=asn, metadata=metadata)
|
||||
self._persist()
|
||||
|
||||
def update_member(self, node_id: str, **kwargs) -> None:
|
||||
meta = kwargs.get("metadata") or {}
|
||||
self.state.register_member(
|
||||
node_id,
|
||||
public_key=kwargs.get("public_key", meta.get("public_key")),
|
||||
ip=kwargs.get("ip"),
|
||||
asn=kwargs.get("asn"),
|
||||
metadata=meta,
|
||||
)
|
||||
self._persist()
|
||||
|
||||
def remove_member(self, node_id: str) -> None:
|
||||
self.state.forget_member(node_id)
|
||||
self._persist()
|
||||
|
||||
def record_receipt(self, receipt: ReachabilityReceipt) -> None:
|
||||
self.state.record_receipt(receipt)
|
||||
self._persist()
|
||||
|
||||
def _persist(self) -> None:
|
||||
key = MembershipKey(node_id=self.node_id)
|
||||
self.store.put(
|
||||
key=str(key),
|
||||
fingerprint=key.fingerprint(),
|
||||
value=self.state.to_dict(),
|
||||
logical_counter=self.state.logical_counter,
|
||||
merge_strategy=lambda a, b: MembershipState.from_dict(self.node_id, self.signer, a)
|
||||
.merge(MembershipState.from_dict(self.node_id, self.signer, b))
|
||||
.to_dict(),
|
||||
)
|
||||
|
||||
def n_estimate(self) -> float:
|
||||
return self.state.n_estimate()
|
||||
|
||||
def active_members(self) -> List[Dict[str, Any]]:
|
||||
return self.state.active_members()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core._crypto.signer import Signer
|
||||
from .config import dht_config
|
||||
from .crdt import PNCounter, GCounter, HyperLogLog
|
||||
from .crypto import compute_view_id
|
||||
from .keys import MetricKey
|
||||
from .store import DHTStore
|
||||
from .prometheus import update_view_metrics
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricDelta:
|
||||
content_id: str
|
||||
view_id: str
|
||||
watch_time: int
|
||||
bytes_out: int
|
||||
completed: bool
|
||||
timestamp: float
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"content_id": self.content_id,
|
||||
"view_id": self.view_id,
|
||||
"watch_time": self.watch_time,
|
||||
"bytes_out": self.bytes_out,
|
||||
"completed": self.completed,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
class ContentMetricsState:
|
||||
def __init__(self, node_id: str):
|
||||
self.node_id = node_id
|
||||
self.views = PNCounter()
|
||||
self.unique = HyperLogLog()
|
||||
self.watch_time = GCounter()
|
||||
self.bytes_out = GCounter()
|
||||
self.completions = GCounter()
|
||||
self.logical_counter = 0
|
||||
|
||||
def apply(self, delta: MetricDelta) -> None:
|
||||
self.logical_counter += 1
|
||||
self.views.increment(self.node_id, 1)
|
||||
self.unique.add(delta.view_id)
|
||||
if delta.watch_time:
|
||||
self.watch_time.increment(self.node_id, delta.watch_time)
|
||||
if delta.bytes_out:
|
||||
self.bytes_out.increment(self.node_id, delta.bytes_out)
|
||||
if delta.completed:
|
||||
self.completions.increment(self.node_id, 1)
|
||||
|
||||
def merge(self, other: "ContentMetricsState") -> "ContentMetricsState":
|
||||
self.views.merge(other.views)
|
||||
self.unique.merge(other.unique)
|
||||
self.watch_time.merge(other.watch_time)
|
||||
self.bytes_out.merge(other.bytes_out)
|
||||
self.completions.merge(other.completions)
|
||||
self.logical_counter = max(self.logical_counter, other.logical_counter)
|
||||
return self
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"views": self.views.to_dict(),
|
||||
"unique": self.unique.to_dict(),
|
||||
"watch_time": self.watch_time.to_dict(),
|
||||
"bytes_out": self.bytes_out.to_dict(),
|
||||
"completions": self.completions.to_dict(),
|
||||
"logical_counter": self.logical_counter,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, node_id: str, data: Dict[str, Any]) -> "ContentMetricsState":
|
||||
inst = cls(node_id=node_id)
|
||||
if data:
|
||||
inst.views = PNCounter.from_dict(data.get("views") or {})
|
||||
inst.unique = HyperLogLog.from_dict(data.get("unique") or {})
|
||||
inst.watch_time = GCounter.from_dict(data.get("watch_time") or {})
|
||||
inst.bytes_out = GCounter.from_dict(data.get("bytes_out") or {})
|
||||
inst.completions = GCounter.from_dict(data.get("completions") or {})
|
||||
inst.logical_counter = int(data.get("logical_counter") or 0)
|
||||
return inst
|
||||
|
||||
|
||||
class MetricsAggregator:
|
||||
def __init__(self, node_id: str, signer: Signer, store: DHTStore):
|
||||
self.node_id = node_id
|
||||
self.signer = signer
|
||||
self.store = store
|
||||
|
||||
def _load(self, content_id: str, window_id: str) -> ContentMetricsState:
|
||||
key = MetricKey(content_id=content_id, window_id=window_id)
|
||||
record = self.store.get(key.fingerprint())
|
||||
if record:
|
||||
return ContentMetricsState.from_dict(self.node_id, record.value)
|
||||
return ContentMetricsState(node_id=self.node_id)
|
||||
|
||||
def _persist(self, content_id: str, window_id: str, state: ContentMetricsState) -> None:
|
||||
key = MetricKey(content_id=content_id, window_id=window_id)
|
||||
self.store.put(
|
||||
key=str(key),
|
||||
fingerprint=key.fingerprint(),
|
||||
value=state.to_dict(),
|
||||
logical_counter=state.logical_counter,
|
||||
merge_strategy=lambda a, b: ContentMetricsState.from_dict(self.node_id, a)
|
||||
.merge(ContentMetricsState.from_dict(self.node_id, b))
|
||||
.to_dict(),
|
||||
)
|
||||
update_view_metrics(
|
||||
content_id=content_id,
|
||||
window_id=window_id,
|
||||
views=state.views.value(),
|
||||
unique=state.unique.estimate(),
|
||||
watch_time=state.watch_time.value(),
|
||||
)
|
||||
|
||||
def record_view(
|
||||
self,
|
||||
content_id: str,
|
||||
viewer_salt: bytes,
|
||||
watch_time: int,
|
||||
bytes_out: int,
|
||||
completed: bool,
|
||||
timestamp: Optional[float] = None,
|
||||
) -> MetricDelta:
|
||||
ts = time.time() if timestamp is None else timestamp
|
||||
window_id = MetricKey.window_for(ts)
|
||||
view_id = compute_view_id(content_id, viewer_salt)
|
||||
state = self._load(content_id, window_id)
|
||||
delta = MetricDelta(
|
||||
content_id=content_id,
|
||||
view_id=view_id,
|
||||
watch_time=watch_time,
|
||||
bytes_out=bytes_out,
|
||||
completed=completed,
|
||||
timestamp=ts,
|
||||
)
|
||||
state.apply(delta)
|
||||
self._persist(content_id, window_id, state)
|
||||
return delta
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from prometheus_client import Counter, Gauge # type: ignore
|
||||
except Exception:
|
||||
class _Metric:
|
||||
def __init__(self, *_, **__):
|
||||
self._values = {}
|
||||
def labels(self, **kwargs):
|
||||
key = tuple(sorted(kwargs.items()))
|
||||
class H:
|
||||
def __init__(self, parent, k):
|
||||
self._p = parent; self._k = k
|
||||
def inc(self, v: float = 1.0):
|
||||
self._p._values[self._k] = self._p._values.get(self._k, 0.0) + v
|
||||
def set(self, v: float):
|
||||
self._p._values[self._k] = v
|
||||
return H(self, key)
|
||||
class Counter(_Metric):
|
||||
pass
|
||||
class Gauge(_Metric):
|
||||
pass
|
||||
|
||||
|
||||
replication_under = Counter("dht_replication_under", "Times replication fell below target", ["content_id"])
|
||||
replication_over = Counter("dht_replication_over", "Times replication exceeded target", ["content_id"])
|
||||
leader_changes = Counter("dht_leader_changes_total", "Count of leader changes per content", ["content_id"])
|
||||
merge_conflicts = Counter("dht_merge_conflicts_total", "Number of DHT merge conflicts", ["key"])
|
||||
view_count_total = Gauge("dht_view_count_total", "Total content views per window", ["content_id", "window"])
|
||||
unique_estimate = Gauge("dht_unique_view_estimate", "Estimated unique viewers per window", ["content_id", "window"])
|
||||
watch_time_seconds = Gauge("dht_watch_time_seconds", "Aggregate watch time per window", ["content_id", "window"])
|
||||
|
||||
|
||||
def record_replication_under(content_id: str, have: int) -> None:
|
||||
replication_under.labels(content_id=content_id).inc()
|
||||
|
||||
|
||||
def record_replication_over(content_id: str, have: int) -> None:
|
||||
replication_over.labels(content_id=content_id).inc()
|
||||
|
||||
|
||||
def record_leader_change(content_id: str) -> None:
|
||||
leader_changes.labels(content_id=content_id).inc()
|
||||
|
||||
|
||||
def record_merge_conflict(key: str) -> None:
|
||||
merge_conflicts.labels(key=key).inc()
|
||||
|
||||
|
||||
def update_view_metrics(content_id: str, window_id: str, views: int, unique: float, watch_time: int) -> None:
|
||||
view_count_total.labels(content_id=content_id, window=window_id).set(views)
|
||||
unique_estimate.labels(content_id=content_id, window=window_id).set(unique)
|
||||
watch_time_seconds.labels(content_id=content_id, window=window_id).set(watch_time)
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, Any, Tuple
|
||||
|
||||
from app.core._utils.b58 import b58decode, b58encode
|
||||
|
||||
try:
|
||||
import nacl.signing
|
||||
import nacl.encoding
|
||||
_HAS_NACL = True
|
||||
except Exception: # pragma: no cover - fallback path
|
||||
_HAS_NACL = False
|
||||
|
||||
from app.core._utils.hash import blake3_hex
|
||||
|
||||
from app.core._crypto.signer import Signer
|
||||
from .config import dht_config
|
||||
|
||||
|
||||
def _serialize_for_signature(payload: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def _dominance_tuple(logical_counter: int, timestamp: float, node_id: str) -> Tuple[int, float, str]:
|
||||
return logical_counter, timestamp, node_id
|
||||
|
||||
|
||||
def latest_wins_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return b
|
||||
|
||||
|
||||
@dataclass
|
||||
class DHTRecord:
|
||||
fingerprint: str
|
||||
key: str
|
||||
value: Dict[str, Any]
|
||||
logical_counter: int
|
||||
timestamp: float
|
||||
node_id: str
|
||||
schema_version: str = field(default=dht_config.schema_version)
|
||||
signature: str | None = None
|
||||
|
||||
def dominance(self) -> Tuple[int, float, str]:
|
||||
return _dominance_tuple(self.logical_counter, self.timestamp, self.node_id)
|
||||
|
||||
def dominates(self, other: "DHTRecord") -> bool:
|
||||
return self.dominance() > other.dominance()
|
||||
|
||||
def to_payload(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"fingerprint": self.fingerprint,
|
||||
"key": self.key,
|
||||
"schema_version": self.schema_version,
|
||||
"logical_counter": self.logical_counter,
|
||||
"timestamp": self.timestamp,
|
||||
"node_id": self.node_id,
|
||||
"value": self.value,
|
||||
}
|
||||
|
||||
def sign(self, signer: Signer) -> "DHTRecord":
|
||||
blob = _serialize_for_signature(self.to_payload())
|
||||
self.signature = signer.sign(blob)
|
||||
return self
|
||||
|
||||
def verify(self, public_key_b58: str) -> bool:
|
||||
if not self.signature:
|
||||
return False
|
||||
payload = _serialize_for_signature(self.to_payload())
|
||||
if _HAS_NACL:
|
||||
try:
|
||||
vk = nacl.signing.VerifyKey(b58decode(public_key_b58))
|
||||
vk.verify(payload, b58decode(self.signature))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
expected = b58encode(bytes.fromhex(blake3_hex(b58decode(public_key_b58) + payload))).decode()
|
||||
return expected == self.signature
|
||||
|
||||
def merge(self, other: "DHTRecord", merge_strategy: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]]) -> "DHTRecord":
|
||||
if self.fingerprint != other.fingerprint:
|
||||
raise ValueError("Cannot merge records with different keys")
|
||||
|
||||
if self.dominates(other):
|
||||
dominant, subordinate = self, other
|
||||
elif other.dominates(self):
|
||||
dominant, subordinate = other, self
|
||||
else:
|
||||
# Perfect tie: break via lexicographic NodeID order and prefer merged value to remain deterministic
|
||||
if self.node_id >= other.node_id:
|
||||
dominant, subordinate = self, other
|
||||
else:
|
||||
dominant, subordinate = other, self
|
||||
|
||||
merged_value = merge_strategy(dominant.value, subordinate.value)
|
||||
merged = DHTRecord(
|
||||
fingerprint=self.fingerprint,
|
||||
key=self.key,
|
||||
value=merged_value,
|
||||
logical_counter=dominant.logical_counter,
|
||||
timestamp=max(self.timestamp, other.timestamp),
|
||||
node_id=dominant.node_id,
|
||||
schema_version=self.schema_version,
|
||||
)
|
||||
return merged
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
key: str,
|
||||
fingerprint: str,
|
||||
value: Dict[str, Any],
|
||||
node_id: str,
|
||||
logical_counter: int,
|
||||
signature: str | None = None,
|
||||
timestamp: float | None = None,
|
||||
) -> "DHTRecord":
|
||||
return cls(
|
||||
fingerprint=fingerprint,
|
||||
key=key,
|
||||
value=value,
|
||||
logical_counter=logical_counter,
|
||||
timestamp=timestamp or time.time(),
|
||||
node_id=node_id,
|
||||
signature=signature,
|
||||
)
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from app.core._crypto.signer import Signer
|
||||
from .config import dht_config
|
||||
from .crypto import bits_from_hex, rendezvous_score
|
||||
from .keys import MetaKey
|
||||
from .membership import MembershipState
|
||||
from .prometheus import record_replication_under, record_replication_over, record_leader_change
|
||||
from .store import DHTStore
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicaLease:
|
||||
node_id: str
|
||||
lease_id: str
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
asn: Optional[int]
|
||||
ip_first_octet: Optional[int]
|
||||
heartbeat_at: float
|
||||
score: int
|
||||
|
||||
def renew(self, now: float) -> None:
|
||||
self.heartbeat_at = now
|
||||
self.expires_at = now + dht_config.lease_ttl
|
||||
|
||||
def is_expired(self, now: float) -> bool:
|
||||
if now >= self.expires_at:
|
||||
return True
|
||||
if now - self.heartbeat_at > dht_config.heartbeat_interval * dht_config.heartbeat_miss_threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"node_id": self.node_id,
|
||||
"lease_id": self.lease_id,
|
||||
"issued_at": self.issued_at,
|
||||
"expires_at": self.expires_at,
|
||||
"asn": self.asn,
|
||||
"ip_first_octet": self.ip_first_octet,
|
||||
"heartbeat_at": self.heartbeat_at,
|
||||
"score": self.score,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ReplicaLease":
|
||||
return cls(
|
||||
node_id=str(data["node_id"]),
|
||||
lease_id=str(data["lease_id"]),
|
||||
issued_at=float(data["issued_at"]),
|
||||
expires_at=float(data["expires_at"]),
|
||||
asn=data.get("asn"),
|
||||
ip_first_octet=data.get("ip_first_octet"),
|
||||
heartbeat_at=float(data.get("heartbeat_at", data.get("issued_at"))),
|
||||
score=int(data.get("score", 0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicationState:
|
||||
content_id: str
|
||||
leases: Dict[str, ReplicaLease] = field(default_factory=dict)
|
||||
leader: Optional[str] = None
|
||||
revision: int = 0
|
||||
conflict_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def prune(self, now: float) -> None:
|
||||
for lease_id, lease in list(self.leases.items()):
|
||||
if lease.is_expired(now):
|
||||
self.conflict_log.append(
|
||||
{"type": "LEASE_EXPIRED", "node_id": lease.node_id, "ts": now}
|
||||
)
|
||||
del self.leases[lease_id]
|
||||
|
||||
def assign(self, lease: ReplicaLease) -> None:
|
||||
self.leases[lease.lease_id] = lease
|
||||
self.revision += 1
|
||||
|
||||
def remove_node(self, node_id: str, reason: str, timestamp: float) -> None:
|
||||
for lease_id, lease in list(self.leases.items()):
|
||||
if lease.node_id == node_id:
|
||||
del self.leases[lease_id]
|
||||
self.conflict_log.append({"type": reason, "node_id": node_id, "ts": timestamp})
|
||||
self.revision += 1
|
||||
|
||||
def heartbeat(self, node_id: str, now: float) -> bool:
|
||||
found = False
|
||||
for lease in self.leases.values():
|
||||
if lease.node_id == node_id:
|
||||
lease.renew(now)
|
||||
found = True
|
||||
return found
|
||||
|
||||
def unique_asn(self) -> int:
|
||||
return len({lease.asn for lease in self.leases.values() if lease.asn is not None})
|
||||
|
||||
def unique_octets(self) -> int:
|
||||
return len({lease.ip_first_octet for lease in self.leases.values() if lease.ip_first_octet is not None})
|
||||
|
||||
def diversity_satisfied(self) -> bool:
|
||||
if len(self.leases) < dht_config.replication_target:
|
||||
return False
|
||||
if self.unique_asn() < dht_config.min_asn_diversity:
|
||||
return False
|
||||
if self.unique_octets() < dht_config.min_ip_octet_diversity:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"content_id": self.content_id,
|
||||
"leader": self.leader,
|
||||
"revision": self.revision,
|
||||
"replica_leases": {lease_id: lease.to_dict() for lease_id, lease in self.leases.items()},
|
||||
"conflict_log": list(self.conflict_log)[-100:], # keep tail
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ReplicationState":
|
||||
state = cls(content_id=str(data.get("content_id", "")))
|
||||
state.leader = data.get("leader")
|
||||
state.revision = int(data.get("revision", 0))
|
||||
leases_raw = data.get("replica_leases") or {}
|
||||
for lease_id, payload in leases_raw.items():
|
||||
state.leases[lease_id] = ReplicaLease.from_dict(payload)
|
||||
state.conflict_log = list(data.get("conflict_log") or [])
|
||||
return state
|
||||
|
||||
def merge_with(self, other: "ReplicationState") -> "ReplicationState":
|
||||
combined = _merge_states(self, other)
|
||||
return combined
|
||||
|
||||
|
||||
class ReplicationManager:
|
||||
def __init__(self, node_id: str, signer: Signer, store: DHTStore):
|
||||
self.node_id = node_id
|
||||
self.signer = signer
|
||||
self.store = store
|
||||
|
||||
def _load_state(self, content_id: str) -> ReplicationState:
|
||||
key = MetaKey(content_id=content_id)
|
||||
record = self.store.get(key.fingerprint())
|
||||
if record:
|
||||
return ReplicationState.from_dict(record.value)
|
||||
return ReplicationState(content_id=content_id)
|
||||
|
||||
def _persist_state(self, state: ReplicationState) -> None:
|
||||
key = MetaKey(content_id=state.content_id)
|
||||
self.store.put(
|
||||
key=str(key),
|
||||
fingerprint=key.fingerprint(),
|
||||
value=state.to_dict(),
|
||||
logical_counter=int(time.time()),
|
||||
merge_strategy=lambda a, b: ReplicationState.from_dict(a)
|
||||
.merge_with(ReplicationState.from_dict(b))
|
||||
.to_dict(),
|
||||
)
|
||||
|
||||
def ensure_replication(self, content_id: str, membership: MembershipState, now: Optional[float] = None) -> ReplicationState:
|
||||
now = now or _now()
|
||||
state = self._load_state(content_id)
|
||||
|
||||
n_estimate = max(1.0, membership.n_estimate())
|
||||
p_value = max(0, round(math.log2(max(n_estimate / dht_config.replication_target, 1.0))))
|
||||
prefix, _ = bits_from_hex(content_id, p_value)
|
||||
|
||||
active = membership.active_members(include_islands=True)
|
||||
responsible = []
|
||||
for member in active:
|
||||
node_prefix, _total = bits_from_hex(member["node_id"], p_value)
|
||||
if node_prefix == prefix:
|
||||
responsible.append(member)
|
||||
if not responsible:
|
||||
responsible = active # fall back to all active nodes
|
||||
responsible.sort(key=lambda item: item["node_id"])
|
||||
leader_id = responsible[0]["node_id"] if responsible else None
|
||||
previous_leader = state.leader
|
||||
state.leader = leader_id
|
||||
if previous_leader and leader_id and previous_leader != leader_id:
|
||||
record_leader_change(content_id)
|
||||
|
||||
if leader_id != self.node_id:
|
||||
return state # Only leader mutates state
|
||||
|
||||
state.prune(now)
|
||||
|
||||
# evaluate diversity
|
||||
leases_by_node = {lease.node_id: lease for lease in state.leases.values()}
|
||||
if not state.diversity_satisfied():
|
||||
def rank(members):
|
||||
return sorted(
|
||||
(
|
||||
(
|
||||
rendezvous_score(content_id, m["node_id"]),
|
||||
m["node_id"],
|
||||
m.get("asn"),
|
||||
m.get("ip_first_octet"),
|
||||
)
|
||||
for m in members
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
)
|
||||
|
||||
def assign_with_diversity(candidates):
|
||||
added = 0
|
||||
# Phase 1: prefer candidates that increase ASN/IP octet diversity
|
||||
for score, node_id, asn, ip_octet in candidates:
|
||||
if node_id in leases_by_node:
|
||||
continue
|
||||
before_asn = state.unique_asn()
|
||||
before_oct = state.unique_octets()
|
||||
if ((asn is not None and before_asn < dht_config.min_asn_diversity) or
|
||||
(ip_octet is not None and before_oct < dht_config.min_ip_octet_diversity)):
|
||||
lease = ReplicaLease(
|
||||
node_id=node_id,
|
||||
lease_id=f"{content_id}:{node_id}",
|
||||
issued_at=now,
|
||||
expires_at=now + dht_config.lease_ttl,
|
||||
asn=asn,
|
||||
ip_first_octet=ip_octet,
|
||||
heartbeat_at=now,
|
||||
score=score,
|
||||
)
|
||||
state.assign(lease)
|
||||
leases_by_node[node_id] = lease
|
||||
added += 1
|
||||
if state.diversity_satisfied():
|
||||
return added
|
||||
# Phase 2: fill by score until target
|
||||
for score, node_id, asn, ip_octet in candidates:
|
||||
if node_id in leases_by_node:
|
||||
continue
|
||||
lease = ReplicaLease(
|
||||
node_id=node_id,
|
||||
lease_id=f"{content_id}:{node_id}",
|
||||
issued_at=now,
|
||||
expires_at=now + dht_config.lease_ttl,
|
||||
asn=asn,
|
||||
ip_first_octet=ip_octet,
|
||||
heartbeat_at=now,
|
||||
score=score,
|
||||
)
|
||||
state.assign(lease)
|
||||
leases_by_node[node_id] = lease
|
||||
added += 1
|
||||
if state.diversity_satisfied():
|
||||
return added
|
||||
return added
|
||||
|
||||
# First, prefer responsible set
|
||||
assign_with_diversity(rank(responsible))
|
||||
|
||||
# If under target, add more from the rest of active nodes
|
||||
if not state.diversity_satisfied():
|
||||
rest = [m for m in active if m["node_id"] not in {n for _, n, *_ in rank(responsible)}]
|
||||
assign_with_diversity(rank(rest))
|
||||
|
||||
# Ensure we do not exceed replication target with duplicates
|
||||
if len(state.leases) > dht_config.replication_target:
|
||||
# Drop lowest scoring leases until target satisfied while preserving diversity criteria
|
||||
sorted_leases = sorted(state.leases.values(), key=lambda lease: lease.score, reverse=True)
|
||||
while len(sorted_leases) > dht_config.replication_target:
|
||||
victim = sorted_leases.pop() # lowest score
|
||||
state.remove_node(victim.node_id, reason="OVER_REPLICATED", timestamp=now)
|
||||
record_replication_over(content_id, len(sorted_leases))
|
||||
|
||||
if len(state.leases) < dht_config.replication_target:
|
||||
state.conflict_log.append(
|
||||
{"type": "UNDER_REPLICATED", "ts": now, "have": len(state.leases)}
|
||||
)
|
||||
record_replication_under(content_id, len(state.leases))
|
||||
|
||||
self._persist_state(state)
|
||||
return state
|
||||
|
||||
def heartbeat(self, content_id: str, node_id: str, now: Optional[float] = None) -> bool:
|
||||
now = now or _now()
|
||||
state = self._load_state(content_id)
|
||||
if state.heartbeat(node_id, now):
|
||||
self._persist_state(state)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _merge_states(left: ReplicationState, right: ReplicationState) -> ReplicationState:
|
||||
# Combine leases preferring latest expiry
|
||||
lease_map: Dict[str, ReplicaLease] = {}
|
||||
for state in (left, right):
|
||||
for lease_id, lease in state.leases.items():
|
||||
current = lease_map.get(lease_id)
|
||||
if current is None or lease.expires_at > current.expires_at:
|
||||
lease_map[lease_id] = lease
|
||||
merged = ReplicationState(content_id=left.content_id or right.content_id)
|
||||
merged.leader = min(filter(None, [left.leader, right.leader]), default=None)
|
||||
merged.conflict_log = (left.conflict_log + right.conflict_log)[-100:]
|
||||
merged.leases = lease_map
|
||||
merged.revision = max(left.revision, right.revision) + 1
|
||||
return merged
|
||||
|
||||
|
||||
# Inject helper onto ReplicationState for merge strategy
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, Any, Optional
|
||||
|
||||
from app.core._crypto.signer import Signer
|
||||
from .prometheus import record_merge_conflict
|
||||
from .records import DHTRecord, latest_wins_merge
|
||||
|
||||
|
||||
class DHTStore:
|
||||
"""In-memory DHT replica with deterministic merge semantics."""
|
||||
|
||||
def __init__(self, node_id: str, signer: Signer):
|
||||
self.node_id = node_id
|
||||
self.signer = signer
|
||||
self._records: Dict[str, DHTRecord] = {}
|
||||
|
||||
def get(self, fingerprint: str) -> Optional[DHTRecord]:
|
||||
return self._records.get(fingerprint)
|
||||
|
||||
def put(
|
||||
self,
|
||||
key: str,
|
||||
fingerprint: str,
|
||||
value: Dict[str, Any],
|
||||
logical_counter: int,
|
||||
merge_strategy: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] = latest_wins_merge,
|
||||
) -> DHTRecord:
|
||||
record = DHTRecord.create(
|
||||
key=key,
|
||||
fingerprint=fingerprint,
|
||||
value=value,
|
||||
node_id=self.node_id,
|
||||
logical_counter=logical_counter,
|
||||
).sign(self.signer)
|
||||
return self.merge_record(record, merge_strategy)
|
||||
|
||||
def merge_record(
|
||||
self,
|
||||
incoming: DHTRecord,
|
||||
merge_strategy: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] = latest_wins_merge,
|
||||
) -> DHTRecord:
|
||||
current = self._records.get(incoming.fingerprint)
|
||||
if current is None:
|
||||
self._records[incoming.fingerprint] = incoming
|
||||
return incoming
|
||||
if current.value != incoming.value:
|
||||
record_merge_conflict(incoming.key)
|
||||
merged = current.merge(incoming, merge_strategy)
|
||||
# Debug instrumentation for tests
|
||||
# print('merge', incoming.key, merged.value)
|
||||
merged.sign(self.signer)
|
||||
self._records[incoming.fingerprint] = merged
|
||||
return merged
|
||||
|
||||
def snapshot(self) -> Dict[str, Dict[str, Any]]:
|
||||
return {fp: record.to_payload() | {"signature": record.signature} for fp, record in self._records.items()}
|
||||
Reference in new issue
Block a user