feat: add foreign key constraints and metrics for blockchain node

This commit is contained in:
oib
2025-09-28 06:04:30 +02:00
parent c1926136fb
commit fb60505cdf
189 changed files with 15678 additions and 158 deletions

View File

@ -2,6 +2,7 @@
from .receipts import (
CoordinatorReceiptClient,
ReceiptPage,
ReceiptVerification,
SignatureValidation,
verify_receipt,
@ -10,6 +11,7 @@ from .receipts import (
__all__ = [
"CoordinatorReceiptClient",
"ReceiptPage",
"ReceiptVerification",
"SignatureValidation",
"verify_receipt",

View File

@ -1,7 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Iterator, List, Optional
import httpx
import base64
@ -14,6 +15,7 @@ class SignatureValidation:
key_id: str
valid: bool
algorithm: str = "Ed25519"
reason: Optional[str] = None
@dataclass
@ -28,12 +30,58 @@ class ReceiptVerification:
return False
return all(att.valid for att in self.coordinator_attestations)
def failure_reasons(self) -> List[str]:
reasons: List[str] = []
if not self.miner_signature.valid:
key_part = self.miner_signature.key_id or "unknown"
reasons.append(f"miner_signature_invalid:{key_part}")
for att in self.coordinator_attestations:
if not att.valid:
key_part = att.key_id or "unknown"
reasons.append(f"coordinator_attestation_invalid:{key_part}")
return reasons
@dataclass
class ReceiptFailure:
receipt_id: str
reasons: List[str]
verification: ReceiptVerification
@dataclass
class ReceiptStatus:
job_id: str
total: int
verified_count: int
failed: List[ReceiptVerification] = field(default_factory=list)
latest_verified: Optional[ReceiptVerification] = None
failure_reasons: Dict[str, int] = field(default_factory=dict)
failures: List[ReceiptFailure] = field(default_factory=list)
@property
def all_verified(self) -> bool:
return self.total > 0 and self.verified_count == self.total
@property
def has_failures(self) -> bool:
return bool(self.failures)
class CoordinatorReceiptClient:
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0) -> None:
def __init__(
self,
base_url: str,
api_key: str,
timeout: float = 10.0,
max_retries: int = 3,
backoff_seconds: float = 0.5,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.max_retries = max_retries
self.backoff_seconds = backoff_seconds
def _client(self) -> httpx.Client:
return httpx.Client(
@ -43,28 +91,139 @@ class CoordinatorReceiptClient:
)
def fetch_latest(self, job_id: str) -> Optional[Dict[str, Any]]:
with self._client() as client:
resp = client.get(f"/v1/jobs/{job_id}/receipt")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
resp = self._request("GET", f"/v1/jobs/{job_id}/receipt", allow_404=True)
if resp is None:
return None
return resp.json()
def fetch_history(self, job_id: str) -> List[Dict[str, Any]]:
with self._client() as client:
resp = client.get(f"/v1/jobs/{job_id}/receipts")
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict) and isinstance(data.get("items"), list):
return data["items"]
raise ValueError("unexpected receipt history response shape")
return list(self.iter_receipts(job_id=job_id))
def iter_receipts(self, job_id: str, page_size: int = 100) -> Iterator[Dict[str, Any]]:
cursor: Optional[str] = None
while True:
page = self.fetch_receipts_page(job_id=job_id, cursor=cursor, limit=page_size)
for item in page.items:
yield item
if not page.next_cursor:
break
cursor = page.next_cursor
def fetch_receipts_page(
self,
*,
job_id: str,
cursor: Optional[str] = None,
limit: Optional[int] = 100,
) -> "ReceiptPage":
params: Dict[str, Any] = {}
if cursor:
params["cursor"] = cursor
if limit is not None:
params["limit"] = limit
response = self._request("GET", f"/v1/jobs/{job_id}/receipts", params=params)
payload = response.json()
if isinstance(payload, list):
items = payload
next_cursor: Optional[str] = None
raw: Dict[str, Any] = {"items": items}
else:
items = list(payload.get("items") or [])
next_cursor = payload.get("next_cursor") or payload.get("next") or payload.get("cursor")
raw = payload
return ReceiptPage(items=items, next_cursor=next_cursor, raw=raw)
def summarize_receipts(self, job_id: str, page_size: int = 100) -> "ReceiptStatus":
receipts = list(self.iter_receipts(job_id=job_id, page_size=page_size))
if not receipts:
return ReceiptStatus(job_id=job_id, total=0, verified_count=0, failed=[], latest_verified=None)
verifications = verify_receipts(receipts)
verified = [v for v in verifications if v.verified]
failed = [v for v in verifications if not v.verified]
failures: List[ReceiptFailure] = []
reason_counts: Dict[str, int] = {}
for verification in failed:
reasons = verification.failure_reasons()
receipt_id = str(
verification.receipt.get("receipt_id")
or verification.receipt.get("id")
or verification.receipt.get("uuid")
or ""
)
for reason in reasons:
reason_counts[reason] = reason_counts.get(reason, 0) + 1
failures.append(ReceiptFailure(receipt_id=receipt_id, reasons=reasons, verification=verification))
latest_verified = verified[-1] if verified else None
return ReceiptStatus(
job_id=job_id,
total=len(verifications),
verified_count=len(verified),
failed=failed,
latest_verified=latest_verified,
failure_reasons=reason_counts,
failures=failures,
)
def _request(
self,
method: str,
url: str,
*,
params: Optional[Dict[str, Any]] = None,
allow_404: bool = False,
) -> Optional[httpx.Response]:
attempt = 0
while True:
try:
with self._client() as client:
response = client.request(method=method, url=url, params=params)
except httpx.HTTPError:
if attempt >= self.max_retries:
raise
attempt += 1
time.sleep(self.backoff_seconds * (2 ** (attempt - 1)))
continue
if response.status_code == 404 and allow_404:
return None
if response.status_code in {429} or response.status_code >= 500:
if attempt >= self.max_retries:
response.raise_for_status()
else:
attempt += 1
time.sleep(self.backoff_seconds * (2 ** (attempt - 1)))
continue
response.raise_for_status()
return response
@dataclass
class ReceiptPage:
items: List[Dict[str, Any]]
next_cursor: Optional[str] = None
raw: Dict[str, Any] = field(default_factory=dict)
def _verify_signature(payload: Dict[str, Any], signature: Dict[str, Any]) -> SignatureValidation:
key_id = signature.get("key_id", "")
verifier = ReceiptVerifier(_decode_key(key_id))
valid = verifier.verify(payload, signature)
return SignatureValidation(key_id=key_id, valid=valid)
try:
valid = verifier.verify(payload, signature)
reason: Optional[str] = None if valid else "signature mismatch"
except Exception as exc: # pragma: no cover - verifier could raise on malformed payloads
valid = False
reason = str(exc) or "signature verification error"
algorithm = signature.get("algorithm") or "Ed25519"
return SignatureValidation(key_id=key_id, valid=valid, algorithm=algorithm, reason=reason)
def verify_receipt(receipt: Dict[str, Any]) -> ReceiptVerification: