zebra/zebra-state/src/service/finalized_state.rs

347 lines
12 KiB
Rust

//! The primary implementation of the `zebra_state::Service` built upon rocksdb.
//!
//! Zebra's database is implemented in 4 layers:
//! - [`FinalizedState`]: queues, validates, and commits blocks, using...
//! - [`ZebraDb`]: reads and writes [`zebra_chain`] types to the database, using...
//! - [`DiskDb`](disk_db::DiskDb): reads and writes format-specific types
//! to the database, using...
//! - [`disk_format`]: converts types to raw database bytes.
//!
//! These layers allow us to split [`zebra_chain`] types for efficient database storage.
//! They reduce the risk of data corruption bugs, runtime inconsistencies, and panics.
//!
//! # Correctness
//!
//! The [`crate::constants::DATABASE_FORMAT_VERSION`] constant must
//! be incremented each time the database format (column, serialization, etc) changes.
use std::{
collections::HashMap,
io::{stderr, stdout, Write},
path::Path,
};
use zebra_chain::{block, parameters::Network};
use crate::{
service::{check, QueuedFinalized},
BoxError, Config, FinalizedBlock,
};
mod disk_db;
mod disk_format;
mod zebra_db;
#[cfg(any(test, feature = "proptest-impl"))]
mod arbitrary;
#[cfg(test)]
mod tests;
pub use disk_format::{OutputIndex, OutputLocation, TransactionLocation};
pub(super) use zebra_db::ZebraDb;
/// The finalized part of the chain state, stored in the db.
#[derive(Debug)]
pub struct FinalizedState {
/// The underlying database.
db: ZebraDb,
/// Queued blocks that arrived out of order, indexed by their parent block hash.
queued_by_prev_hash: HashMap<block::Hash, QueuedFinalized>,
/// A metric tracking the maximum height that's currently in `queued_by_prev_hash`
///
/// Set to `f64::NAN` if `queued_by_prev_hash` is empty, because grafana shows NaNs
/// as a break in the graph.
max_queued_height: f64,
/// The configured stop height.
///
/// Commit blocks to the finalized state up to this height, then exit Zebra.
debug_stop_at_height: Option<block::Height>,
/// The configured network.
network: Network,
}
impl FinalizedState {
pub fn new(config: &Config, network: Network) -> Self {
let db = ZebraDb::new(config, network);
let new_state = Self {
queued_by_prev_hash: HashMap::new(),
max_queued_height: f64::NAN,
db,
debug_stop_at_height: config.debug_stop_at_height.map(block::Height),
network,
};
// TODO: move debug_stop_at_height into a task in the start command (#3442)
if let Some(tip_height) = new_state.db.finalized_tip_height() {
if new_state.is_at_stop_height(tip_height) {
let debug_stop_at_height = new_state
.debug_stop_at_height
.expect("true from `is_at_stop_height` implies `debug_stop_at_height` is Some");
let tip_hash = new_state.db.finalized_tip_hash();
if tip_height > debug_stop_at_height {
tracing::error!(
?debug_stop_at_height,
?tip_height,
?tip_hash,
"previous state height is greater than the stop height",
);
}
tracing::info!(
?debug_stop_at_height,
?tip_height,
?tip_hash,
"state is already at the configured height"
);
// RocksDB can do a cleanup when column families are opened.
// So we want to drop it before we exit.
std::mem::drop(new_state);
Self::exit_process();
}
}
tracing::info!(tip = ?new_state.db.tip(), "loaded Zebra state cache");
new_state
}
/// Returns the configured network for this database.
pub fn network(&self) -> Network {
self.network
}
/// Returns the `Path` where the files used by this database are located.
pub fn path(&self) -> &Path {
self.db.path()
}
/// Returns a reference to the inner database instance.
pub(crate) fn db(&self) -> &ZebraDb {
&self.db
}
/// Queue a finalized block to be committed to the state.
///
/// After queueing a finalized block, this method checks whether the newly
/// queued block (and any of its descendants) can be committed to the state.
///
/// Returns the highest finalized tip block committed from the queue,
/// or `None` if no blocks were committed in this call.
/// (Use `tip_block` to get the finalized tip, regardless of when it was committed.)
pub fn queue_and_commit_finalized(
&mut self,
queued: QueuedFinalized,
) -> Option<FinalizedBlock> {
let mut highest_queue_commit = None;
let prev_hash = queued.0.block.header.previous_block_hash;
let height = queued.0.height;
self.queued_by_prev_hash.insert(prev_hash, queued);
while let Some(queued_block) = self
.queued_by_prev_hash
.remove(&self.db.finalized_tip_hash())
{
if let Ok(finalized) = self.commit_finalized(queued_block) {
highest_queue_commit = Some(finalized);
} else {
// the last block in the queue failed, so we can't commit the next block
break;
}
}
if self.queued_by_prev_hash.is_empty() {
self.max_queued_height = f64::NAN;
} else if self.max_queued_height.is_nan() || self.max_queued_height < height.0 as f64 {
// if there are still blocks in the queue, then either:
// - the new block was lower than the old maximum, and there was a gap before it,
// so the maximum is still the same (and we skip this code), or
// - the new block is higher than the old maximum, and there is at least one gap
// between the finalized tip and the new maximum
self.max_queued_height = height.0 as f64;
}
metrics::gauge!("state.checkpoint.queued.max.height", self.max_queued_height);
metrics::gauge!(
"state.checkpoint.queued.block.count",
self.queued_by_prev_hash.len() as f64,
);
highest_queue_commit
}
/// Commit a finalized block to the state.
///
/// It's the caller's responsibility to ensure that blocks are committed in
/// order. This function is called by [`Self::queue_and_commit_finalized`],
/// which ensures order. It is intentionally not exposed as part of the
/// public API of the [`FinalizedState`].
fn commit_finalized(&mut self, queued_block: QueuedFinalized) -> Result<FinalizedBlock, ()> {
let (finalized, rsp_tx) = queued_block;
let result = self.commit_finalized_direct(finalized.clone(), "CommitFinalized request");
let block_result = if result.is_ok() {
metrics::counter!("state.checkpoint.finalized.block.count", 1);
metrics::gauge!(
"state.checkpoint.finalized.block.height",
finalized.height.0 as f64,
);
// This height gauge is updated for both fully verified and checkpoint blocks.
// These updates can't conflict, because the state makes sure that blocks
// are committed in order.
metrics::gauge!(
"zcash.chain.verified.block.height",
finalized.height.0 as f64,
);
metrics::counter!("zcash.chain.verified.block.total", 1);
Ok(finalized)
} else {
metrics::counter!("state.checkpoint.error.block.count", 1);
metrics::gauge!(
"state.checkpoint.error.block.height",
finalized.height.0 as f64,
);
Err(())
};
let _ = rsp_tx.send(result.map_err(Into::into));
block_result
}
/// Immediately commit a `finalized` block to the finalized state.
///
/// This can be called either by the non-finalized state (when finalizing
/// a block) or by the checkpoint verifier.
///
/// Use `source` as the source of the block in log messages.
///
/// # Errors
///
/// - Propagates any errors from writing to the DB
/// - Propagates any errors from updating history and note commitment trees
/// - If `hashFinalSaplingRoot` / `hashLightClientRoot` / `hashBlockCommitments`
/// does not match the expected value
#[allow(clippy::unwrap_in_result)]
pub fn commit_finalized_direct(
&mut self,
finalized: FinalizedBlock,
source: &str,
) -> Result<block::Hash, BoxError> {
let committed_tip_hash = self.db.finalized_tip_hash();
let committed_tip_height = self.db.finalized_tip_height();
// Assert that callers (including unit tests) get the chain order correct
if self.db.is_empty() {
assert_eq!(
committed_tip_hash, finalized.block.header.previous_block_hash,
"the first block added to an empty state must be a genesis block, source: {}",
source,
);
assert_eq!(
block::Height(0),
finalized.height,
"cannot commit genesis: invalid height, source: {}",
source,
);
} else {
assert_eq!(
committed_tip_height.expect("state must have a genesis block committed") + 1,
Some(finalized.height),
"committed block height must be 1 more than the finalized tip height, source: {}",
source,
);
assert_eq!(
committed_tip_hash, finalized.block.header.previous_block_hash,
"committed block must be a child of the finalized tip, source: {}",
source,
);
}
// Check the block commitment. For Nu5-onward, the block hash commits only
// to non-authorizing data (see ZIP-244). This checks the authorizing data
// commitment, making sure the entire block contents were committed to.
// The test is done here (and not during semantic validation) because it needs
// the history tree root. While it _is_ checked during contextual validation,
// that is not called by the checkpoint verifier, and keeping a history tree there
// would be harder to implement.
let history_tree = self.db.history_tree();
check::finalized_block_commitment_is_valid_for_chain_history(
&finalized,
self.network,
&history_tree,
)?;
let finalized_height = finalized.height;
let finalized_hash = finalized.hash;
let result = self
.db
.write_block(finalized, history_tree, self.network, source);
// TODO: move the stop height check to the syncer (#3442)
if result.is_ok() && self.is_at_stop_height(finalized_height) {
tracing::info!(
height = ?finalized_height,
hash = ?finalized_hash,
block_source = ?source,
"stopping at configured height, flushing database to disk"
);
// We're just about to do a forced exit, so it's ok to do a forced db shutdown
self.db.shutdown(true);
Self::exit_process();
}
result
}
/// Stop the process if `block_height` is greater than or equal to the
/// configured stop height.
fn is_at_stop_height(&self, block_height: block::Height) -> bool {
let debug_stop_at_height = match self.debug_stop_at_height {
Some(debug_stop_at_height) => debug_stop_at_height,
None => return false,
};
if block_height < debug_stop_at_height {
return false;
}
true
}
/// Exit the host process.
///
/// Designed for debugging and tests.
///
/// TODO: move the stop height check to the syncer (#3442)
fn exit_process() -> ! {
tracing::info!("exiting Zebra");
// Some OSes require a flush to send all output to the terminal.
// Zebra's logging doesn't depend on `tokio`, so we flush the stdlib sync streams.
//
// TODO: if this doesn't work, send an empty line as well.
let _ = stdout().lock().flush();
let _ = stderr().lock().flush();
std::process::exit(0);
}
}