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:

View File

@ -4,12 +4,16 @@ from dataclasses import dataclass
from typing import Dict, List, Optional
import pytest
import httpx
from nacl.signing import SigningKey
from aitbc_crypto.signing import ReceiptSigner
from aitbc_sdk.receipts import (
CoordinatorReceiptClient,
ReceiptFailure,
ReceiptPage,
ReceiptStatus,
ReceiptVerification,
verify_receipt,
verify_receipts,
@ -53,6 +57,8 @@ def test_verify_receipt_success(sample_payload: Dict[str, object]) -> None:
assert isinstance(result, ReceiptVerification)
assert result.miner_signature.valid is True
assert result.verified is True
assert result.failure_reasons() == []
assert result.miner_signature.reason is None
def test_verify_receipt_failure(sample_payload: Dict[str, object]) -> None:
@ -63,6 +69,8 @@ def test_verify_receipt_failure(sample_payload: Dict[str, object]) -> None:
result = verify_receipt(receipt)
assert result.miner_signature.valid is False
assert result.verified is False
assert result.failure_reasons() == [f"miner_signature_invalid:{result.miner_signature.key_id}"]
assert result.miner_signature.reason == "signature mismatch"
def test_verify_receipts_batch(sample_payload: Dict[str, object]) -> None:
@ -84,33 +92,20 @@ class _DummyResponse:
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
class _DummyClient:
def __init__(self, responses: List[_DummyResponse]):
self._responses = responses
def get(self, url: str, *args, **kwargs) -> _DummyResponse:
if not self._responses:
raise AssertionError("no more responses configured")
return self._responses.pop(0)
def __enter__(self) -> "_DummyClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
pass
raise httpx.HTTPStatusError(
f"HTTP {self.status_code}", request=None, response=httpx.Response(self.status_code)
)
def test_coordinator_receipt_client_latest(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt = _sign_receipt(sample_payload, signing_key)
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(200, receipt)])
def _mock_request(self, method, url, params=None, allow_404=False):
assert method == "GET"
return _DummyResponse(200, receipt)
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
fetched = client.fetch_latest("job-123")
@ -121,10 +116,11 @@ def test_coordinator_receipt_client_history(monkeypatch, sample_payload: Dict[st
signing_key = SigningKey.generate()
receipts = [_sign_receipt(sample_payload, signing_key)]
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(200, {"items": receipts})])
def _mock_request(self, method, url, params=None, allow_404=False):
assert method == "GET"
return _DummyResponse(200, {"items": receipts})
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
history = client.fetch_history("job-123")
@ -132,10 +128,153 @@ def test_coordinator_receipt_client_history(monkeypatch, sample_payload: Dict[st
def test_coordinator_receipt_client_latest_404(monkeypatch) -> None:
def _mock_client(self) -> _DummyClient:
return _DummyClient([_DummyResponse(404, {})])
def _mock_request(self, method, url, params=None, allow_404=False):
assert allow_404 is True
return None
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
assert client.fetch_latest("job-missing") is None
def test_fetch_receipts_page_list(monkeypatch, sample_payload: Dict[str, object]) -> None:
items = [_sign_receipt(sample_payload, SigningKey.generate())]
def _mock_request(self, method, url, params=None, allow_404=False):
return _DummyResponse(200, items)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
page = client.fetch_receipts_page(job_id="job-1")
assert isinstance(page, ReceiptPage)
assert page.items == items
assert page.next_cursor is None
def test_fetch_receipts_page_dict_with_cursor(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipts = [_sign_receipt(sample_payload, signing_key)]
responses = [
_DummyResponse(200, {"items": receipts, "next_cursor": "cursor-1"}),
_DummyResponse(200, {"items": receipts, "next": None}),
]
def _mock_request(self, method, url, params=None, allow_404=False):
assert method == "GET"
return responses.pop(0)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
first_page = client.fetch_receipts_page(job_id="job-1")
assert first_page.next_cursor == "cursor-1"
second_page = client.fetch_receipts_page(job_id="job-1", cursor=first_page.next_cursor)
assert second_page.next_cursor is None
def test_iter_receipts_handles_pagination(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipt_a = _sign_receipt(sample_payload, signing_key)
receipt_b = _sign_receipt(sample_payload, signing_key)
responses = [
_DummyResponse(200, {"items": [receipt_a], "next_cursor": "cursor-2"}),
_DummyResponse(200, {"items": [receipt_b]}),
]
def _mock_request(self, method, url, params=None, allow_404=False):
return responses.pop(0)
monkeypatch.setattr(CoordinatorReceiptClient, "_request", _mock_request)
client = CoordinatorReceiptClient("https://coordinator", "api")
collected = list(client.iter_receipts("job-123", page_size=1))
assert collected == [receipt_a, receipt_b]
def test_request_retries_on_transient(monkeypatch, sample_payload: Dict[str, object]) -> None:
responses: List[object] = [
httpx.ReadTimeout("timeout"),
_DummyResponse(429, {}),
_DummyResponse(200, {}),
]
class _RetryClient:
def __init__(self, shared: List[object]):
self._shared = shared
def request(self, method: str, url: str, params=None):
obj = self._shared.pop(0)
if isinstance(obj, Exception):
raise obj
return obj
def __enter__(self) -> "_RetryClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
pass
def _mock_client(self):
return _RetryClient(responses)
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
monkeypatch.setattr("aitbc_sdk.receipts.time.sleep", lambda *_args: None)
client = CoordinatorReceiptClient("https://coordinator", "api", max_retries=3)
response = client._request("GET", "/v1/jobs/job-1/receipts")
assert isinstance(response, _DummyResponse)
assert response.status_code == 200
def test_summarize_receipts_all_verified(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
receipts = [_sign_receipt(sample_payload, signing_key) for _ in range(2)]
def _fake_iter(self, job_id: str, page_size: int = 100):
yield from receipts
monkeypatch.setattr(CoordinatorReceiptClient, "iter_receipts", _fake_iter)
client = CoordinatorReceiptClient("https://coordinator", "api")
status = client.summarize_receipts("job-verified")
assert isinstance(status, ReceiptStatus)
assert status.total == 2
assert status.verified_count == 2
assert status.all_verified is True
assert status.has_failures is False
assert status.failure_reasons == {}
assert status.failures == []
assert isinstance(status.latest_verified, ReceiptVerification)
def test_summarize_receipts_with_failures(monkeypatch, sample_payload: Dict[str, object]) -> None:
signing_key = SigningKey.generate()
good = _sign_receipt(sample_payload, signing_key)
bad = dict(good)
bad["metadata"] = {"job_payload": {"task": "tampered"}}
receipts = [good, bad]
def _fake_iter(self, job_id: str, page_size: int = 100):
yield from receipts
monkeypatch.setattr(CoordinatorReceiptClient, "iter_receipts", _fake_iter)
client = CoordinatorReceiptClient("https://coordinator", "api")
status = client.summarize_receipts("job-mixed")
assert status.total == 2
assert status.verified_count == 1
assert status.all_verified is False
assert status.has_failures is True
assert status.failure_reasons # not empty
assert status.failure_reasons[next(iter(status.failure_reasons))] == 1
assert len(status.failures) == 1
failure = status.failures[0]
assert isinstance(failure, ReceiptFailure)
assert failure.reasons
assert failure.verification.miner_signature.valid is False