49c710e651
* removed explicit storage_tx within MsgModule, TxModule and BlockModule impls * created a NyxdWatcher that does not persist processed block info * removed unused imports
460 lines
14 KiB
Rust
460 lines
14 KiB
Rust
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use crate::PruningOptions;
|
|
use crate::block_processor::types::BlockToProcess;
|
|
use crate::block_processor::{BlockProcessor, BlockProcessorConfig, BlockProcessorPersistence};
|
|
use crate::block_requester::{BlockRequest, BlockRequester};
|
|
use crate::error::ScraperError;
|
|
use crate::modules::{BlockModule, MsgModule, TxModule};
|
|
use crate::rpc_client::RpcClient;
|
|
use crate::storage::NyxdScraperStorage;
|
|
use crate::subscriber::ChainSubscriber;
|
|
use futures::future::join_all;
|
|
use std::marker::PhantomData;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Notify;
|
|
use tokio::sync::mpsc::{
|
|
Receiver, Sender, UnboundedReceiver, UnboundedSender, channel, unbounded_channel,
|
|
};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tokio_util::task::TaskTracker;
|
|
use tracing::{error, info};
|
|
use url::Url;
|
|
|
|
#[derive(Default, Clone, Copy)]
|
|
pub struct StartingBlockOpts {
|
|
pub start_block_height: Option<u32>,
|
|
|
|
/// If the scraper fails to start from the desired height, rather than failing,
|
|
/// attempt to use the next available height
|
|
pub use_best_effort_start_height: bool,
|
|
}
|
|
|
|
pub struct Config {
|
|
/// Url to the websocket endpoint of a validator, for example `wss://rpc.nymtech.net/websocket`
|
|
pub websocket_url: Url,
|
|
|
|
/// Url to the rpc endpoint of a validator, for example `https://rpc.nymtech.net/`
|
|
pub rpc_url: Url,
|
|
|
|
/// Points to either underlying file (sqlite) or connection string (postgres)
|
|
pub database_storage: String,
|
|
|
|
pub pruning_options: PruningOptions,
|
|
|
|
pub store_precommits: bool,
|
|
|
|
pub start_block: StartingBlockOpts,
|
|
|
|
pub run_migrations: bool,
|
|
}
|
|
|
|
pub struct NyxdScraperBuilder<S> {
|
|
_storage: PhantomData<S>,
|
|
config: Config,
|
|
|
|
block_modules: Vec<Box<dyn BlockModule + Send>>,
|
|
tx_modules: Vec<Box<dyn TxModule + Send>>,
|
|
msg_modules: Vec<Box<dyn MsgModule + Send>>,
|
|
}
|
|
|
|
impl<S> NyxdScraperBuilder<S>
|
|
where
|
|
S: NyxdScraperStorage + Send + Sync + 'static,
|
|
S::StorageTransaction: Send + Sync + 'static,
|
|
{
|
|
pub async fn build_and_start(self) -> Result<NyxdScraper<S>, ScraperError> {
|
|
let scraper = NyxdScraper::<S>::new(self.config).await?;
|
|
|
|
let (processing_tx, processing_rx) = unbounded_channel();
|
|
let (req_tx, req_rx) = channel(5);
|
|
|
|
let rpc_client = scraper.rpc_client.clone();
|
|
|
|
// create the tasks
|
|
let block_requester = BlockRequester::new(
|
|
scraper.cancel_token.clone(),
|
|
rpc_client.clone(),
|
|
req_rx,
|
|
processing_tx.clone(),
|
|
);
|
|
|
|
let block_processor_config = BlockProcessorConfig::new(
|
|
scraper.config.pruning_options,
|
|
scraper.config.store_precommits,
|
|
scraper.config.start_block.start_block_height,
|
|
scraper.config.start_block.use_best_effort_start_height,
|
|
);
|
|
|
|
let mut block_processor = BlockProcessor::new(
|
|
scraper.cancel_token.clone(),
|
|
processing_rx,
|
|
req_tx,
|
|
rpc_client,
|
|
)
|
|
.with_persistence(
|
|
BlockProcessorPersistence::new(
|
|
block_processor_config,
|
|
scraper.startup_sync.clone(),
|
|
scraper.storage.clone(),
|
|
)
|
|
.await?,
|
|
)
|
|
.await?;
|
|
block_processor.set_block_modules(self.block_modules);
|
|
block_processor.set_tx_modules(self.tx_modules);
|
|
block_processor.set_msg_modules(self.msg_modules);
|
|
|
|
let chain_subscriber = ChainSubscriber::new(
|
|
&scraper.config.websocket_url,
|
|
scraper.cancel_token.clone(),
|
|
scraper.task_tracker.clone(),
|
|
processing_tx,
|
|
)
|
|
.await?;
|
|
|
|
scraper.start_tasks(block_requester, block_processor, chain_subscriber);
|
|
|
|
Ok(scraper)
|
|
}
|
|
|
|
pub fn new(config: Config) -> Self {
|
|
NyxdScraperBuilder {
|
|
_storage: PhantomData,
|
|
config,
|
|
block_modules: vec![],
|
|
tx_modules: vec![],
|
|
msg_modules: vec![],
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_block_module<M: BlockModule + Send + 'static>(mut self, module: M) -> Self {
|
|
self.block_modules.push(Box::new(module));
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_tx_module<M: TxModule + Send + 'static>(mut self, module: M) -> Self {
|
|
self.tx_modules.push(Box::new(module));
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_msg_module<M: MsgModule + Send + 'static>(mut self, module: M) -> Self {
|
|
self.msg_modules.push(Box::new(module));
|
|
self
|
|
}
|
|
}
|
|
|
|
pub struct NyxdScraper<S> {
|
|
config: Config,
|
|
|
|
task_tracker: TaskTracker,
|
|
cancel_token: CancellationToken,
|
|
startup_sync: Arc<Notify>,
|
|
storage: S,
|
|
rpc_client: RpcClient,
|
|
}
|
|
|
|
impl<S> NyxdScraper<S>
|
|
where
|
|
S: NyxdScraperStorage + Send + Sync + 'static,
|
|
S::StorageTransaction: Send + Sync + 'static,
|
|
{
|
|
pub fn builder(config: Config) -> NyxdScraperBuilder<S> {
|
|
NyxdScraperBuilder::new(config)
|
|
}
|
|
|
|
pub async fn new(config: Config) -> Result<Self, ScraperError> {
|
|
config.pruning_options.validate()?;
|
|
let storage = S::initialise(&config.database_storage, &config.run_migrations).await?;
|
|
let rpc_client = RpcClient::new(&config.rpc_url)?;
|
|
|
|
Ok(NyxdScraper {
|
|
config,
|
|
task_tracker: TaskTracker::new(),
|
|
cancel_token: CancellationToken::new(),
|
|
startup_sync: Arc::new(Default::default()),
|
|
storage,
|
|
rpc_client,
|
|
})
|
|
}
|
|
|
|
pub fn storage(&self) -> &S {
|
|
&self.storage
|
|
}
|
|
|
|
fn start_tasks(
|
|
&self,
|
|
mut block_requester: BlockRequester,
|
|
mut block_processor: BlockProcessor<S>,
|
|
mut chain_subscriber: ChainSubscriber,
|
|
) {
|
|
self.task_tracker
|
|
.spawn(async move { block_requester.run().await });
|
|
self.task_tracker
|
|
.spawn(async move { block_processor.run().await });
|
|
self.task_tracker
|
|
.spawn(async move { chain_subscriber.run().await });
|
|
|
|
self.task_tracker.close();
|
|
}
|
|
|
|
// DO NOT USE UNLESS YOU KNOW EXACTLY WHAT YOU'RE DOING
|
|
// AS THIS WILL NOT USE ANY OF YOUR REGISTERED MODULES
|
|
// YOU WILL BE FIRED IF YOU USE IT : )
|
|
pub async fn unsafe_process_single_block(&self, height: u32) -> Result<(), ScraperError> {
|
|
info!(height = height, "attempting to process a single block");
|
|
if !self.task_tracker.is_empty() {
|
|
return Err(ScraperError::ScraperAlreadyRunning);
|
|
}
|
|
|
|
let (_, processing_rx) = unbounded_channel();
|
|
let (req_tx, _) = channel(5);
|
|
|
|
let mut block_processor = self
|
|
.new_block_processor_with_persistence(
|
|
req_tx.clone(),
|
|
processing_rx,
|
|
PruningOptions::nothing(),
|
|
)
|
|
.await?;
|
|
|
|
let block = self.rpc_client.get_basic_block_details(height).await?;
|
|
|
|
block_processor.process_block(block.into()).await
|
|
}
|
|
|
|
// DO NOT USE UNLESS YOU KNOW EXACTLY WHAT YOU'RE DOING
|
|
// AS THIS WILL NOT USE ANY OF YOUR REGISTERED MODULES
|
|
// YOU WILL BE FIRED IF YOU USE IT : )
|
|
pub async fn unsafe_process_block_range(
|
|
&self,
|
|
starting_height: Option<u32>,
|
|
end_height: Option<u32>,
|
|
) -> Result<(), ScraperError> {
|
|
if !self.task_tracker.is_empty() {
|
|
return Err(ScraperError::ScraperAlreadyRunning);
|
|
}
|
|
|
|
let (_, processing_rx) = unbounded_channel();
|
|
let (req_tx, _) = channel(5);
|
|
|
|
let mut block_processor = self
|
|
.new_block_processor_with_persistence(
|
|
req_tx.clone(),
|
|
processing_rx,
|
|
PruningOptions::nothing(),
|
|
)
|
|
.await?;
|
|
|
|
let mut current_height = self.rpc_client.current_block_height().await? as u32;
|
|
let last_processed = block_processor.last_process_height();
|
|
|
|
let mut starting_height = match starting_height {
|
|
// always attempt to use whatever the user has provided
|
|
Some(explicit) => explicit,
|
|
None => {
|
|
// otherwise, attempt to resume where we last stopped
|
|
// and if we haven't processed anything, start from the current height
|
|
if last_processed != 0 {
|
|
last_processed
|
|
} else {
|
|
current_height
|
|
}
|
|
}
|
|
};
|
|
|
|
let must_catch_up = end_height.is_none();
|
|
let mut end_height = match end_height {
|
|
// always attempt to use whatever the user has provided
|
|
Some(explicit) => explicit,
|
|
None => {
|
|
// otherwise, attempt to either go from the start height to the height right
|
|
// before the final processed block held in the storage (in case there are gaps)
|
|
// or finally, just go to the current block height
|
|
if last_processed > starting_height {
|
|
last_processed - 1
|
|
} else {
|
|
current_height
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut last_processed = starting_height;
|
|
|
|
while last_processed < current_height {
|
|
info!(
|
|
starting_height = starting_height,
|
|
end_height = end_height,
|
|
"attempting to process block range"
|
|
);
|
|
|
|
let range = (starting_height..=end_height).collect::<Vec<_>>();
|
|
|
|
// the most likely bottleneck here are going to be the chain queries,
|
|
// so batch multiple requests
|
|
for batch in range.chunks(4) {
|
|
let batch_result = join_all(
|
|
batch
|
|
.iter()
|
|
.map(|height| self.rpc_client.get_basic_block_details(*height)),
|
|
)
|
|
.await;
|
|
for result in batch_result {
|
|
match result {
|
|
Ok(block) => block_processor.process_block(block.into()).await?,
|
|
Err(err) => {
|
|
error!("failed to retrieve the block: {err}. stopping...");
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// if we don't need to catch up, return early
|
|
if !must_catch_up {
|
|
return Ok(());
|
|
}
|
|
|
|
// check if we have caught up to the current block height
|
|
last_processed = end_height;
|
|
current_height = self.rpc_client.current_block_height().await? as u32;
|
|
|
|
info!(
|
|
last_processed = last_processed,
|
|
current_height = current_height,
|
|
"🏃 still need to catch up..."
|
|
);
|
|
|
|
starting_height = last_processed + 1;
|
|
end_height = current_height;
|
|
}
|
|
|
|
if must_catch_up {
|
|
info!(
|
|
last_processed = last_processed,
|
|
current_height = current_height,
|
|
"✅ block processing has caught up!"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn new_block_requester(
|
|
&self,
|
|
req_rx: Receiver<BlockRequest>,
|
|
processing_tx: UnboundedSender<BlockToProcess>,
|
|
) -> BlockRequester {
|
|
BlockRequester::new(
|
|
self.cancel_token.clone(),
|
|
self.rpc_client.clone(),
|
|
req_rx,
|
|
processing_tx.clone(),
|
|
)
|
|
}
|
|
|
|
fn new_block_processor(
|
|
&self,
|
|
req_tx: Sender<BlockRequest>,
|
|
processing_rx: UnboundedReceiver<BlockToProcess>,
|
|
) -> BlockProcessor<S> {
|
|
BlockProcessor::<S>::new(
|
|
self.cancel_token.clone(),
|
|
processing_rx,
|
|
req_tx,
|
|
self.rpc_client.clone(),
|
|
)
|
|
}
|
|
|
|
async fn new_block_processor_with_persistence(
|
|
&self,
|
|
req_tx: Sender<BlockRequest>,
|
|
processing_rx: UnboundedReceiver<BlockToProcess>,
|
|
pruning_options: impl Into<Option<PruningOptions>>,
|
|
) -> Result<BlockProcessor<S>, ScraperError> {
|
|
let block_processor_config = BlockProcessorConfig::new(
|
|
self.config.pruning_options,
|
|
self.config.store_precommits,
|
|
self.config.start_block.start_block_height,
|
|
self.config.start_block.use_best_effort_start_height,
|
|
);
|
|
|
|
let persistence = match pruning_options.into() {
|
|
Some(options) => BlockProcessorPersistence::new(
|
|
block_processor_config,
|
|
self.startup_sync.clone(),
|
|
self.storage.clone(),
|
|
)
|
|
.await?
|
|
.with_pruning(options),
|
|
None => {
|
|
BlockProcessorPersistence::new(
|
|
block_processor_config,
|
|
self.startup_sync.clone(),
|
|
self.storage.clone(),
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
self.new_block_processor(req_tx, processing_rx)
|
|
.with_persistence(persistence)
|
|
.await
|
|
}
|
|
|
|
async fn new_chain_subscriber(
|
|
&self,
|
|
processing_tx: UnboundedSender<BlockToProcess>,
|
|
) -> Result<ChainSubscriber, ScraperError> {
|
|
ChainSubscriber::new(
|
|
&self.config.websocket_url,
|
|
self.cancel_token.clone(),
|
|
self.task_tracker.clone(),
|
|
processing_tx,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn start(&self) -> Result<(), ScraperError> {
|
|
let (processing_tx, processing_rx) = unbounded_channel();
|
|
let (req_tx, req_rx) = channel(5);
|
|
|
|
// create the tasks
|
|
let block_requester = self.new_block_requester(req_rx, processing_tx.clone());
|
|
let block_processor = self
|
|
.new_block_processor_with_persistence(req_tx, processing_rx, None)
|
|
.await?;
|
|
let chain_subscriber = self.new_chain_subscriber(processing_tx).await?;
|
|
|
|
// spawn them
|
|
self.start_tasks(block_requester, block_processor, chain_subscriber);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn wait_for_startup_sync(&self) {
|
|
info!("awaiting startup chain sync");
|
|
self.startup_sync.notified().await
|
|
}
|
|
|
|
pub async fn stop(self) {
|
|
info!("stopping the chain scraper");
|
|
assert!(self.task_tracker.is_closed());
|
|
|
|
self.cancel_token.cancel();
|
|
self.task_tracker.wait().await
|
|
}
|
|
|
|
pub fn cancel_token(&self) -> CancellationToken {
|
|
self.cancel_token.clone()
|
|
}
|
|
|
|
pub fn is_cancelled(&self) -> bool {
|
|
self.cancel_token.is_cancelled()
|
|
}
|
|
}
|