docs: update README with comprehensive test results, CLI documentation, and enhanced feature descriptions

- Update key capabilities to include GPU marketplace, payments, billing, and governance
- Expand CLI section from basic examples to 12 command groups with 90+ subcommands
- Add detailed test results table showing 208 passing tests across 6 test suites
- Update documentation links to reference new CLI reference and coordinator API docs
- Revise test commands to reflect actual test structure (
This commit is contained in:
oib
2026-02-12 20:58:21 +01:00
parent 5120861e17
commit 65b63de56f
47 changed files with 5622 additions and 1148 deletions

View File

@@ -1,5 +1,5 @@
from __future__ import annotations
from .poa import PoAProposer, ProposerConfig
from .poa import PoAProposer, ProposerConfig, CircuitBreaker
__all__ = ["PoAProposer", "ProposerConfig"]
__all__ = ["PoAProposer", "ProposerConfig", "CircuitBreaker"]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import hashlib
import time
from dataclasses import dataclass
from datetime import datetime
import re
@@ -11,6 +12,9 @@ from sqlmodel import Session, select
from ..logger import get_logger
from ..metrics import metrics_registry
from ..models import Block, Transaction
from ..gossip import gossip_broker
from ..mempool import get_mempool
_METRIC_KEY_SANITIZE = re.compile(r"[^0-9a-zA-Z]+")
@@ -19,8 +23,6 @@ _METRIC_KEY_SANITIZE = re.compile(r"[^0-9a-zA-Z]+")
def _sanitize_metric_suffix(value: str) -> str:
sanitized = _METRIC_KEY_SANITIZE.sub("_", value).strip("_")
return sanitized or "unknown"
from ..models import Block
from ..gossip import gossip_broker
@dataclass
@@ -28,6 +30,47 @@ class ProposerConfig:
chain_id: str
proposer_id: str
interval_seconds: int
max_block_size_bytes: int = 1_000_000
max_txs_per_block: int = 500
class CircuitBreaker:
"""Circuit breaker for graceful degradation on repeated failures."""
def __init__(self, threshold: int = 5, timeout: int = 30) -> None:
self._threshold = threshold
self._timeout = timeout
self._failure_count = 0
self._last_failure_time: float = 0
self._state = "closed" # closed, open, half-open
@property
def state(self) -> str:
if self._state == "open":
if time.time() - self._last_failure_time >= self._timeout:
self._state = "half-open"
return self._state
def record_success(self) -> None:
self._failure_count = 0
self._state = "closed"
metrics_registry.set_gauge("circuit_breaker_state", 0.0)
def record_failure(self) -> None:
self._failure_count += 1
self._last_failure_time = time.time()
if self._failure_count >= self._threshold:
self._state = "open"
metrics_registry.set_gauge("circuit_breaker_state", 1.0)
metrics_registry.increment("circuit_breaker_trips_total")
def allow_request(self) -> bool:
state = self.state
if state == "closed":
return True
if state == "half-open":
return True
return False
class PoAProposer:
@@ -36,6 +79,7 @@ class PoAProposer:
*,
config: ProposerConfig,
session_factory: Callable[[], ContextManager[Session]],
circuit_breaker: Optional[CircuitBreaker] = None,
) -> None:
self._config = config
self._session_factory = session_factory
@@ -43,6 +87,7 @@ class PoAProposer:
self._stop_event = asyncio.Event()
self._task: Optional[asyncio.Task[None]] = None
self._last_proposer_id: Optional[str] = None
self._circuit_breaker = circuit_breaker or CircuitBreaker()
async def start(self) -> None:
if self._task is not None:
@@ -60,15 +105,31 @@ class PoAProposer:
await self._task
self._task = None
@property
def is_healthy(self) -> bool:
return self._circuit_breaker.state != "open"
async def _run_loop(self) -> None:
while not self._stop_event.is_set():
await self._wait_until_next_slot()
if self._stop_event.is_set():
break
try:
self._propose_block()
except Exception as exc: # pragma: no cover - defensive logging
self._logger.exception("Failed to propose block", extra={"error": str(exc)})
metrics_registry.set_gauge("poa_proposer_running", 1.0)
try:
while not self._stop_event.is_set():
await self._wait_until_next_slot()
if self._stop_event.is_set():
break
if not self._circuit_breaker.allow_request():
self._logger.warning("Circuit breaker open, skipping block proposal")
metrics_registry.increment("blocks_skipped_circuit_breaker_total")
continue
try:
self._propose_block()
self._circuit_breaker.record_success()
except Exception as exc:
self._circuit_breaker.record_failure()
self._logger.exception("Failed to propose block", extra={"error": str(exc)})
metrics_registry.increment("poa_propose_errors_total")
finally:
metrics_registry.set_gauge("poa_proposer_running", 0.0)
self._logger.info("PoA proposer loop exited")
async def _wait_until_next_slot(self) -> None:
head = self._fetch_chain_head()
@@ -85,6 +146,7 @@ class PoAProposer:
return
def _propose_block(self) -> None:
start_time = time.perf_counter()
with self._session_factory() as session:
head = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
next_height = 0
@@ -95,6 +157,13 @@ class PoAProposer:
parent_hash = head.hash
interval_seconds = (datetime.utcnow() - head.timestamp).total_seconds()
# Drain transactions from mempool
mempool = get_mempool()
pending_txs = mempool.drain(
max_count=self._config.max_txs_per_block,
max_bytes=self._config.max_block_size_bytes,
)
timestamp = datetime.utcnow()
block_hash = self._compute_block_hash(next_height, parent_hash, timestamp)
@@ -104,14 +173,33 @@ class PoAProposer:
parent_hash=parent_hash,
proposer=self._config.proposer_id,
timestamp=timestamp,
tx_count=0,
tx_count=len(pending_txs),
state_root=None,
)
session.add(block)
# Batch-insert transactions into the block
total_fees = 0
for ptx in pending_txs:
tx = Transaction(
tx_hash=ptx.tx_hash,
block_height=next_height,
sender=ptx.content.get("sender", ""),
recipient=ptx.content.get("recipient", ptx.content.get("payload", {}).get("recipient", "")),
payload=ptx.content,
)
session.add(tx)
total_fees += ptx.fee
session.commit()
# Metrics
build_duration = time.perf_counter() - start_time
metrics_registry.increment("blocks_proposed_total")
metrics_registry.set_gauge("chain_head_height", float(next_height))
metrics_registry.set_gauge("last_block_tx_count", float(len(pending_txs)))
metrics_registry.set_gauge("last_block_total_fees", float(total_fees))
metrics_registry.observe("block_build_duration_seconds", build_duration)
if interval_seconds is not None and interval_seconds >= 0:
metrics_registry.observe("block_interval_seconds", interval_seconds)
metrics_registry.set_gauge("poa_last_block_interval_seconds", float(interval_seconds))
@@ -142,6 +230,9 @@ class PoAProposer:
"hash": block_hash,
"parent_hash": parent_hash,
"timestamp": timestamp.isoformat(),
"tx_count": len(pending_txs),
"total_fees": total_fees,
"build_ms": round(build_duration * 1000, 2),
},
)
@@ -180,8 +271,16 @@ class PoAProposer:
self._logger.info("Created genesis block", extra={"hash": genesis_hash})
def _fetch_chain_head(self) -> Optional[Block]:
with self._session_factory() as session:
return session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
for attempt in range(3):
try:
with self._session_factory() as session:
return session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
except Exception as exc:
if attempt == 2:
self._logger.error("Failed to fetch chain head after 3 attempts", extra={"error": str(exc)})
metrics_registry.increment("poa_db_errors_total")
return None
time.sleep(0.1 * (attempt + 1))
def _compute_block_hash(self, height: int, parent_hash: str, timestamp: datetime) -> str:
payload = f"{self._config.chain_id}|{height}|{parent_hash}|{timestamp.isoformat()}".encode()