blob: 1b2edfba4cc6de65e865d154433def2dd26920c7 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#
import asyncio
import json
import logging
from typing import Optional
import httpx
import motor.motor_asyncio
import websockets
from pyee import AsyncIOEventEmitter
from pymongo import UpdateOne
from .config import MongoConfig, ResilientDBConfig
from .exceptions import ResilientPythonCacheError
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger(__name__)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
class ResilientPythonCache(AsyncIOEventEmitter):
def __init__(self, mongo_config: MongoConfig, resilient_db_config: ResilientDBConfig):
super().__init__()
self.mongo_config = mongo_config
self.resilient_db_config = resilient_db_config
self.http_endpoint: str
self.ws_endpoint: str
self.reconnect_interval: int
self.fetch_interval: int
self.current_block_number: int = 0
self.fetch_task: Optional[asyncio.Task] = None
self.ws_task: Optional[asyncio.Task] = None
self.is_closing: bool = False
self.reconnect_attempts: int = 0
self.initialize_endpoints()
def initialize_endpoints(self):
config = self.resilient_db_config
self.reconnect_interval = config.reconnect_interval
self.fetch_interval = config.fetch_interval
if not config.base_url.startswith("resilientdb://"):
raise ResilientPythonCacheError("Invalid protocol in base_url. Expected 'resilientdb://'")
url = config.base_url[len("resilientdb://"):]
hostname_port = url.split("/")[0]
hostname, port = (hostname_port.split(":") + [None])[:2]
port = port or ""
http_protocol = "https" if config.http_secure else "http"
ws_protocol = "wss" if config.ws_secure else "ws"
if port:
host_port = f"{hostname}:{port}"
else:
host_port = hostname
self.http_endpoint = config.http_endpoint or f"{http_protocol}://{host_port}/v1/blocks"
self.ws_endpoint = config.ws_endpoint or f"{ws_protocol}://{host_port}/blockupdatelistener"
logger.info(f"HTTP Endpoint: {self.http_endpoint}")
logger.info(f"WebSocket Endpoint: {self.ws_endpoint}")
async def initialize(self):
try:
self.mongo_client = motor.motor_asyncio.AsyncIOMotorClient(self.mongo_config.uri)
self.db = self.mongo_client[self.mongo_config.db_name]
self.collection = self.db[self.mongo_config.collection_name]
await self.mongo_client.admin.command('ping')
logger.info(f"Connected to MongoDB database: {self.mongo_config.db_name}, "
f"collection: {self.mongo_config.collection_name}")
await self.fetch_and_sync_initial_blocks()
self.fetch_task = asyncio.create_task(self.start_periodic_fetch())
self.ws_task = asyncio.create_task(self.connect_websocket())
except Exception as e:
logger.error("Error initializing connections:")
logger.error(e)
raise ResilientPythonCacheError(str(e)) from e
async def fetch_and_sync_initial_blocks(self):
try:
last_block = await self.collection.find_one({}, sort=[("id", -1)])
self.current_block_number = last_block['id'] if last_block and 'id' in last_block else 0
batch_size = 100
concurrency_limit = 5
batch_ranges = []
blocks_fetched = True
async with httpx.AsyncClient() as client:
while blocks_fetched:
min_seq = self.current_block_number + 1
max_seq = min_seq + batch_size - 1
url = f"{self.http_endpoint}/{min_seq}/{max_seq}"
logger.info(f"Checking for blocks from {min_seq} to {max_seq}")
response = await client.get(url)
blocks = response.json()
if isinstance(blocks, list) and blocks:
self.current_block_number = max(block.get('id', 0) for block in blocks)
batch_ranges.append({'min_seq': min_seq, 'max_seq': max_seq})
else:
blocks_fetched = False
if not batch_ranges:
logger.info("No new blocks to sync.")
return
logger.info(f"Total batches to fetch: {len(batch_ranges)}")
semaphore = asyncio.Semaphore(concurrency_limit)
tasks = [self.fetch_and_sync_batch(batch['min_seq'], batch['max_seq'], semaphore)
for batch in batch_ranges]
await asyncio.gather(*tasks)
except Exception as e:
logger.error("Error fetching initial blocks:")
logger.error(e)
raise ResilientPythonCacheError(str(e)) from e
async def fetch_and_sync_batch(self, min_seq: int, max_seq: int, semaphore: asyncio.Semaphore):
async with semaphore:
try:
async with httpx.AsyncClient() as client:
url = f"{self.http_endpoint}/{min_seq}/{max_seq}"
logger.info(f"Fetching blocks from {min_seq} to {max_seq}")
response = await client.get(url)
blocks = response.json()
if isinstance(blocks, list) and blocks:
processed_blocks = self.process_blocks(blocks)
bulk_ops = [
UpdateOne(
{'id': block['id']},
{'$set': block},
upsert=True
) for block in processed_blocks
]
if bulk_ops:
result = await self.collection.bulk_write(bulk_ops)
logger.info(f"Blocks {min_seq} to {max_seq} synced: "
f"Inserted {result.upserted_count}, Modified {result.modified_count}")
self.emit('data', processed_blocks) # Emit 'data' event with new blocks
else:
logger.info(f"No blocks fetched for range {min_seq} to {max_seq}")
except Exception as e:
logger.error(f"Error fetching blocks from {min_seq} to {max_seq}:")
logger.error(e)
def process_blocks(self, blocks: list) -> list:
for block in blocks:
transactions = block.get('transactions', [])
if isinstance(transactions, list):
for transaction in transactions:
if 'value' in transaction and isinstance(transaction['value'], str):
value = transaction['value'].strip()
if (value.startswith('{') and value.endswith('}')) or \
(value.startswith('[') and value.endswith(']')):
try:
transaction['value'] = json.loads(value)
except json.JSONDecodeError:
logger.warning(f"Could not parse transaction value for key {transaction.get('key')}. "
f"Keeping original value.")
return blocks
async def start_periodic_fetch(self):
logger.info(f"Started periodic fetch every {self.fetch_interval / 1000} seconds.")
try:
while not self.is_closing:
await self.fetch_and_sync_new_blocks()
await asyncio.sleep(self.fetch_interval / 1000)
except asyncio.CancelledError:
pass # Allow task to be cancelled gracefully
async def fetch_and_sync_new_blocks(self):
try:
batch_size = 100
concurrency_limit = 5
batch_ranges = []
blocks_fetched = True
async with httpx.AsyncClient() as client:
while blocks_fetched:
min_seq = self.current_block_number + 1
max_seq = min_seq + batch_size - 1
url = f"{self.http_endpoint}/{min_seq}/{max_seq}"
logger.info(f"Checking for new blocks from {min_seq} to {max_seq}")
response = await client.get(url)
blocks = response.json()
if isinstance(blocks, list) and blocks:
self.current_block_number = max(block.get('id', 0) for block in blocks)
batch_ranges.append({'min_seq': min_seq, 'max_seq': max_seq})
else:
blocks_fetched = False
if not batch_ranges:
logger.info("No new blocks to sync.")
return
logger.info(f"Total new batches to fetch: {len(batch_ranges)}")
semaphore = asyncio.Semaphore(concurrency_limit)
tasks = [self.fetch_and_sync_batch(batch['min_seq'], batch['max_seq'], semaphore)
for batch in batch_ranges]
await asyncio.gather(*tasks)
except Exception as e:
logger.error("Error fetching new blocks:")
logger.error(e)
self.emit('error', e)
async def connect_websocket(self):
try:
async with websockets.connect(self.ws_endpoint) as websocket:
logger.info(f"Connected to WebSocket: {self.ws_endpoint}")
self.reconnect_attempts = 0
self.emit('connected')
# Removed the duplicate log statement here
# logger.info("WebSocket connected.")
async for message in websocket:
logger.info(f"Received message: {message}")
try:
parsed_message = json.loads(message)
except json.JSONDecodeError:
parsed_message = {"type": message}
if parsed_message.get("type") == "Update blocks":
await self.fetch_and_sync_new_blocks()
else:
logger.warning(f"Received unrecognized message: {message}")
except (websockets.exceptions.ConnectionClosedError,
websockets.exceptions.InvalidURI,
websockets.exceptions.InvalidHandshake) as e:
logger.warning(f"WebSocket closed or failed to connect: {e}")
if not self.is_closing:
await self.attempt_reconnection()
except asyncio.CancelledError:
pass # Allow task to be cancelled gracefully
except Exception as e:
logger.error("WebSocket error:")
logger.error(e)
if not self.is_closing:
await self.attempt_reconnection()
async def attempt_reconnection(self):
delay = self.reconnect_interval * (2 ** self.reconnect_attempts) / 1000 # Convert ms to seconds
logger.info(f"Attempting to reconnect in {delay} seconds...")
await asyncio.sleep(delay)
self.reconnect_attempts += 1
logger.info(f"Reconnection attempt #{self.reconnect_attempts}")
self.ws_task = asyncio.create_task(self.connect_websocket())
async def close(self):
try:
self.is_closing = True
if self.fetch_task:
self.fetch_task.cancel()
try:
await self.fetch_task
except asyncio.CancelledError:
pass
if self.ws_task:
self.ws_task.cancel()
try:
await self.ws_task
except asyncio.CancelledError:
pass
await self.mongo_client.close()
logger.info("Closed MongoDB and WebSocket connections.")
self.emit('closed')
except Exception as e:
logger.error("Error closing connections:")
logger.error(e)
raise ResilientPythonCacheError(str(e)) from e