feat: add foreign key constraints and metrics for blockchain node
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user