Files
aitbc/apps/blockchain-node/src/aitbc_chain/chain_sync.py
aitbc ef43a1eecd
Some checks failed
Integration Tests / test-service-integration (push) Has been cancelled
Python Tests / test-python (push) Has been cancelled
Security Scanning / security-scan (push) Has been cancelled
API Endpoint Tests / test-api-endpoints (push) Successful in 34m15s
Documentation Validation / validate-docs (push) Has been cancelled
Systemd Sync / sync-systemd (push) Failing after 18s
fix: update blockchain monitoring configuration and convert services to use venv python
Blockchain Monitoring Configuration:
 CONFIGURABLE INTERVAL: Added blockchain_monitoring_interval_seconds setting
- apps/blockchain-node/src/aitbc_chain/config.py: New setting with 10s default
- apps/blockchain-node/src/aitbc_chain/chain_sync.py: Import settings with fallback
- chain_sync.py: Replace hardcoded base_delay=2 with config setting
- Reason: Makes monitoring interval configurable instead of hardcoded

 DUMMY ENDPOINTS: Disabled monitoring
2026-03-31 13:31:37 +02:00

282 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Chain Synchronization Service
Keeps blockchain nodes synchronized by sharing blocks via P2P and Redis gossip
"""
import asyncio
import json
import logging
import time
from typing import Dict, Any, Optional, List
logger = logging.getLogger(__name__)
# Import settings for configuration
try:
from .config import settings
except ImportError:
# Fallback if settings not available
class Settings:
blockchain_monitoring_interval_seconds = 10
settings = Settings()
class ChainSyncService:
def __init__(self, redis_url: str, node_id: str, rpc_port: int = 8006, leader_host: str = None,
source_host: str = "127.0.0.1", source_port: int = None,
import_host: str = "127.0.0.1", import_port: int = None):
self.redis_url = redis_url
self.node_id = node_id
self.rpc_port = rpc_port # kept for backward compat (local poll if source_port None)
self.leader_host = leader_host # Host of the leader node (legacy)
self.source_host = source_host
self.source_port = source_port or rpc_port
self.import_host = import_host
self.import_port = import_port or rpc_port
self._stop_event = asyncio.Event()
self._redis = None
self._receiver_ready = asyncio.Event()
async def start(self):
"""Start chain synchronization service"""
logger.info(f"Starting chain sync service for node {self.node_id}")
try:
import redis.asyncio as redis
self._redis = redis.from_url(self.redis_url)
await self._redis.ping()
logger.info("Connected to Redis for chain sync")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
return
# Start block broadcasting task
# Start block receiving task
receive_task = asyncio.create_task(self._receive_blocks())
# Wait until receiver subscribed so we don't drop the initial burst
await self._receiver_ready.wait()
broadcast_task = asyncio.create_task(self._broadcast_blocks())
try:
await self._stop_event.wait()
finally:
broadcast_task.cancel()
receive_task.cancel()
await asyncio.gather(broadcast_task, receive_task, return_exceptions=True)
if self._redis:
await self._redis.close()
async def stop(self):
"""Stop chain synchronization service"""
logger.info("Stopping chain sync service")
self._stop_event.set()
async def _broadcast_blocks(self):
"""Broadcast local blocks to other nodes"""
import aiohttp
last_broadcast_height = 0
retry_count = 0
max_retries = 5
base_delay = settings.blockchain_monitoring_interval_seconds # Use config setting instead of hardcoded value
while not self._stop_event.is_set():
try:
# Get current head from local RPC
async with aiohttp.ClientSession() as session:
async with session.get(f"http://{self.source_host}:{self.source_port}/rpc/head") as resp:
if resp.status == 200:
head_data = await resp.json()
current_height = head_data.get('height', 0)
# Reset retry count on successful connection
retry_count = 0
# Broadcast new blocks
if current_height > last_broadcast_height:
for height in range(last_broadcast_height + 1, current_height + 1):
block_data = await self._get_block_by_height(height, session)
if block_data:
await self._broadcast_block(block_data)
last_broadcast_height = current_height
logger.info(f"Broadcasted blocks up to height {current_height}")
elif resp.status == 429:
raise Exception("rate_limit")
else:
raise Exception(f"RPC returned status {resp.status}")
except Exception as e:
retry_count += 1
# If rate-limited, wait longer before retrying
if str(e) == "rate_limit":
delay = base_delay * 30
logger.warning(f"RPC rate limited, retrying in {delay}s")
await asyncio.sleep(delay)
continue
if retry_count <= max_retries:
delay = base_delay * (2 ** (retry_count - 1)) # Exponential backoff
logger.warning(f"RPC connection failed (attempt {retry_count}/{max_retries}), retrying in {delay}s: {e}")
await asyncio.sleep(delay)
continue
else:
logger.error(f"RPC connection failed after {max_retries} attempts, waiting {base_delay * 10}s: {e}")
await asyncio.sleep(base_delay * 10)
retry_count = 0 # Reset retry count after long wait
await asyncio.sleep(base_delay) # Check every 2 seconds when connected
async def _receive_blocks(self):
"""Receive blocks from other nodes via Redis"""
if not self._redis:
return
pubsub = self._redis.pubsub()
await pubsub.subscribe("blocks")
self._receiver_ready.set()
logger.info("Subscribed to block broadcasts")
async for message in pubsub.listen():
if self._stop_event.is_set():
break
if message['type'] == 'message':
try:
block_data = json.loads(message['data'])
await self._import_block(block_data)
except Exception as e:
logger.error(f"Error processing received block: {e}")
async def _get_block_by_height(self, height: int, session) -> Optional[Dict[str, Any]]:
"""Get block data by height from local RPC"""
try:
async with session.get(f"http://{self.source_host}:{self.source_port}/rpc/blocks-range?start={height}&end={height}") as resp:
if resp.status == 200:
blocks_data = await resp.json()
blocks = blocks_data.get('blocks', [])
block = blocks[0] if blocks else None
return block
except Exception as e:
logger.error(f"Error getting block {height}: {e}")
return None
async def _broadcast_block(self, block_data: Dict[str, Any]):
"""Broadcast block to other nodes via Redis"""
if not self._redis:
return
try:
await self._redis.publish("blocks", json.dumps(block_data))
logger.debug(f"Broadcasted block {block_data.get('height')}")
except Exception as e:
logger.error(f"Error broadcasting block: {e}")
async def _import_block(self, block_data: Dict[str, Any]):
"""Import block from another node"""
import aiohttp
try:
# Don't import our own blocks
if block_data.get('proposer') == self.node_id:
return
# Determine target host - if we're a follower, import to leader, else import locally
target_host = self.import_host
target_port = self.import_port
# Retry logic for import
max_retries = 3
base_delay = 1
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"http://{target_host}:{target_port}/rpc/importBlock",
json=block_data
) as resp:
if resp.status == 200:
result = await resp.json()
if result.get('accepted'):
logger.info(f"Imported block {block_data.get('height')} from {block_data.get('proposer')}")
else:
logger.debug(f"Rejected block {block_data.get('height')}: {result.get('reason')}")
return
else:
try:
body = await resp.text()
except Exception:
body = "<no body>"
raise Exception(f"HTTP {resp.status}: {body}")
except Exception as e:
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logger.warning(f"Import failed (attempt {attempt + 1}/{max_retries}), retrying in {delay}s: {e}")
await asyncio.sleep(delay)
else:
logger.error(f"Failed to import block {block_data.get('height')} after {max_retries} attempts: {e}")
return
except Exception as e:
logger.error(f"Error importing block: {e}")
async def run_chain_sync(
redis_url: str,
node_id: str,
rpc_port: int = 8006,
leader_host: str = None,
source_host: str = "127.0.0.1",
source_port: int = None,
import_host: str = "127.0.0.1",
import_port: int = None,
):
"""Run chain synchronization service"""
service = ChainSyncService(
redis_url=redis_url,
node_id=node_id,
rpc_port=rpc_port,
leader_host=leader_host,
source_host=source_host,
source_port=source_port,
import_host=import_host,
import_port=import_port,
)
await service.start()
def main():
import argparse
parser = argparse.ArgumentParser(description="AITBC Chain Synchronization Service")
parser.add_argument("--redis", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--node-id", required=True, help="Node identifier")
parser.add_argument("--rpc-port", type=int, default=8006, help="RPC port")
parser.add_argument("--leader-host", help="Leader node host (for followers)")
parser.add_argument("--source-host", default="127.0.0.1", help="Host to poll for head/blocks")
parser.add_argument("--source-port", type=int, help="Port to poll for head/blocks")
parser.add_argument("--import-host", default="127.0.0.1", help="Host to import blocks into")
parser.add_argument("--import-port", type=int, help="Port to import blocks into")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
try:
asyncio.run(run_chain_sync(
args.redis,
args.node_id,
args.rpc_port,
args.leader_host,
args.source_host,
args.source_port,
args.import_host,
args.import_port,
))
except KeyboardInterrupt:
logger.info("Chain sync service stopped by user")
if __name__ == "__main__":
main()