use std::{
fmt,
fs::{self, canonicalize, remove_dir_all, DirEntry, ReadDir},
io::ErrorKind,
path::{Path, PathBuf},
time::Duration,
};
use semver::Version;
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer, Serialize,
};
use tokio::task::{spawn_blocking, JoinHandle};
use tracing::Span;
use zakura_chain::{common::default_cache_dir, parameters::Network};
use crate::{
constants::{
min_pruning_retention, DATABASE_FORMAT_VERSION_FILE_NAME, MIN_PRUNING_RETENTION,
STATE_DATABASE_KIND,
},
service::finalized_state::restorable_db_versions,
state_database_format_version_in_code, BoxError,
};
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
pub cache_dir: PathBuf,
pub ephemeral: bool,
pub should_backup_non_finalized_state: bool,
pub repair_zakura_header_store_on_startup: bool,
#[serde(skip)]
pub enable_zakura_header_seed_from_committed_blocks: bool,
#[serde(skip)]
pub checkpoint_sync: bool,
#[serde(skip)]
pub vct_fast_sync: bool,
pub delete_old_database: bool,
pub storage_mode: StorageMode,
pub debug_stop_at_height: Option<u32>,
#[serde(with = "humantime_serde")]
pub debug_validity_check_interval: Option<Duration>,
pub debug_skip_non_finalized_state_backup_task: bool,
#[cfg(feature = "elasticsearch")]
pub elasticsearch_url: String,
#[cfg(feature = "elasticsearch")]
pub elasticsearch_username: String,
#[cfg(feature = "elasticsearch")]
pub elasticsearch_password: String,
}
fn gen_temp_path(prefix: &str) -> PathBuf {
tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.expect("temporary directory is created successfully")
.keep()
}
impl Config {
pub fn db_path(
&self,
db_kind: impl AsRef<str>,
major_version: u64,
network: &Network,
) -> PathBuf {
let db_kind = db_kind.as_ref();
let major_version = format!("v{major_version}");
let net_dir = network.lowercase_name();
if self.ephemeral {
gen_temp_path(&format!("zebra-{db_kind}-{major_version}-{net_dir}-"))
} else {
self.cache_dir
.join(db_kind)
.join(major_version)
.join(net_dir)
}
}
pub fn non_finalized_state_backup_dir(&self, network: &Network) -> Option<PathBuf> {
if self.ephemeral || !self.should_backup_non_finalized_state {
return None;
}
let net_dir = network.lowercase_name();
Some(self.cache_dir.join("non_finalized_state").join(net_dir))
}
pub fn version_file_path(
&self,
db_kind: impl AsRef<str>,
major_version: u64,
network: &Network,
) -> PathBuf {
let mut version_path = self.db_path(db_kind, major_version, network);
version_path.push(DATABASE_FORMAT_VERSION_FILE_NAME);
version_path
}
pub fn ephemeral() -> Config {
Config {
ephemeral: true,
..Config::default()
}
}
pub fn pruning_config(&self) -> Option<&PruningConfig> {
match &self.storage_mode {
StorageMode::Archive => None,
StorageMode::Pruned(pruning) => Some(pruning),
}
}
pub fn validate_storage_mode(&self, network: &Network) -> Result<(), BoxError> {
if let Some(pruning) = self.pruning_config() {
let floor = min_pruning_retention(network);
if pruning.tx_retention < floor {
return Err(format!(
"invalid pruning configuration: tx_retention ({}) must be at least {floor} \
on {network} so pruning cannot delete data within the reorg/rollback window",
pruning.tx_retention,
)
.into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageMode {
#[default]
Archive,
Pruned(PruningConfig),
}
impl<'de> Deserialize<'de> for StorageMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
if deserializer.is_human_readable() {
deserializer.deserialize_any(StorageModeVisitor)
} else {
StorageModeSerde::deserialize(deserializer).map(Into::into)
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum StorageModeSerde {
Archive,
Pruned(PruningConfig),
}
impl From<StorageModeSerde> for StorageMode {
fn from(storage_mode: StorageModeSerde) -> Self {
match storage_mode {
StorageModeSerde::Archive => StorageMode::Archive,
StorageModeSerde::Pruned(pruning) => StorageMode::Pruned(pruning),
}
}
}
struct StorageModeVisitor;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EmptyArchiveConfig {}
impl<'de> Visitor<'de> for StorageModeVisitor {
type Value = StorageMode;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(r#"`"archive"`, `"pruned"`, or a storage mode table"#)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match value {
"archive" => Ok(StorageMode::Archive),
"pruned" => Ok(StorageMode::Pruned(PruningConfig::default())),
_ => Err(de::Error::unknown_variant(value, &["archive", "pruned"])),
}
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::invalid_length(0, &self));
};
let storage_mode = match key.as_str() {
"archive" => {
let _ = map.next_value::<EmptyArchiveConfig>()?;
StorageMode::Archive
}
"pruned" => StorageMode::Pruned(map.next_value()?),
_ => return Err(de::Error::unknown_variant(&key, &["archive", "pruned"])),
};
if let Some(key) = map.next_key::<String>()? {
return Err(de::Error::custom(format!(
"multiple storage mode variants configured, including `{key}`"
)));
}
Ok(storage_mode)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct PruningConfig {
pub tx_retention: u32,
}
impl Default for PruningConfig {
fn default() -> Self {
Self {
tx_retention: MIN_PRUNING_RETENTION,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
cache_dir: default_cache_dir(),
ephemeral: false,
should_backup_non_finalized_state: true,
repair_zakura_header_store_on_startup: false,
enable_zakura_header_seed_from_committed_blocks: false,
checkpoint_sync: true,
vct_fast_sync: true,
delete_old_database: true,
storage_mode: StorageMode::default(),
debug_stop_at_height: None,
debug_validity_check_interval: None,
debug_skip_non_finalized_state_backup_task: false,
#[cfg(feature = "elasticsearch")]
elasticsearch_url: "https://localhost:9200".to_string(),
#[cfg(feature = "elasticsearch")]
elasticsearch_username: "elastic".to_string(),
#[cfg(feature = "elasticsearch")]
elasticsearch_password: "".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn storage_mode_deserializes_from_documented_toml() {
assert!(
Config::default().vct_fast_sync,
"VCT fast sync is enabled by default when checkpoint sync and embedded frontiers are available"
);
assert!(
!Config::default().repair_zakura_header_store_on_startup,
"Zakura header-store startup repair is opt-in because it scans the full header frontier"
);
let archive: Config = toml::from_str(r#"storage_mode = "archive""#)
.expect("archive storage mode deserializes from a string");
assert_eq!(archive.storage_mode, StorageMode::Archive);
assert!(
archive.checkpoint_sync && archive.vct_fast_sync,
"serde defaults preserve the internal checkpoint/VCT mirrors until zakurad overwrites them"
);
let archive_table: Config = toml::from_str("[storage_mode.archive]")
.expect("an empty archive storage mode table deserializes");
assert_eq!(archive_table.storage_mode, StorageMode::Archive);
assert!(
toml::from_str::<Config>(
r#"
[storage_mode.archive]
tx_retention = 6000
"#,
)
.is_err(),
"archive mode must not silently ignore pruned-only or misspelled settings"
);
let repair_enabled: Config =
toml::from_str(r#"repair_zakura_header_store_on_startup = true"#)
.expect("startup repair config deserializes from a bool");
assert!(repair_enabled.repair_zakura_header_store_on_startup);
let pruned: Config = toml::from_str(r#"storage_mode = "pruned""#)
.expect("pruned storage mode deserializes from a string");
assert_eq!(
pruned.storage_mode,
StorageMode::Pruned(PruningConfig::default())
);
let pruned_with_retention: Config = toml::from_str(
r#"
[storage_mode.pruned]
tx_retention = 6000
"#,
)
.expect("pruned storage mode deserializes from a table");
assert_eq!(
pruned_with_retention.storage_mode,
StorageMode::Pruned(PruningConfig { tx_retention: 6000 })
);
let serialized = toml::to_string(&Config::default()).expect("state config serializes");
assert!(
!serialized.contains("vct_fast_sync"),
"vct_fast_sync is configured under [consensus], not [state]"
);
}
}
pub fn check_and_delete_old_state_databases(config: &Config, network: &Network) -> JoinHandle<()> {
check_and_delete_old_databases(
config,
STATE_DATABASE_KIND,
state_database_format_version_in_code().major,
network,
)
}
pub fn check_and_delete_old_databases(
config: &Config,
db_kind: impl AsRef<str>,
major_version: u64,
network: &Network,
) -> JoinHandle<()> {
let current_span = Span::current();
let config = config.clone();
let db_kind = db_kind.as_ref().to_string();
let network = network.clone();
spawn_blocking(move || {
current_span.in_scope(|| {
delete_old_databases(config, db_kind, major_version, &network);
info!("finished old database version cleanup task");
})
})
}
fn delete_old_databases(config: Config, db_kind: String, major_version: u64, network: &Network) {
if config.ephemeral || !config.delete_old_database {
return;
}
info!(db_kind, "checking for old database versions");
let restorable_db_versions = restorable_db_versions();
let mut db_path = config.db_path(&db_kind, major_version, network);
assert_eq!(
db_path.file_name(),
Some(network.lowercase_name().as_ref()),
"unexpected database network path structure"
);
assert!(db_path.pop());
assert_eq!(
db_path.file_name(),
Some(format!("v{major_version}").as_ref()),
"unexpected database version path structure"
);
assert!(db_path.pop());
assert_eq!(
db_path.file_name(),
Some(db_kind.as_ref()),
"unexpected database kind path structure"
);
if let Some(db_kind_dir) = read_dir(&db_path) {
for entry in db_kind_dir.flatten() {
let deleted_db =
check_and_delete_database(&config, major_version, &restorable_db_versions, &entry);
if let Some(deleted_db) = deleted_db {
info!(?deleted_db, "deleted outdated {db_kind} database directory");
}
}
}
}
fn read_dir(dir: &Path) -> Option<ReadDir> {
if dir.exists() {
if let Ok(read_dir) = dir.read_dir() {
return Some(read_dir);
}
}
None
}
fn check_and_delete_database(
config: &Config,
major_version: u64,
restorable_db_versions: &[u64],
entry: &DirEntry,
) -> Option<PathBuf> {
let dir_name = parse_dir_name(entry)?;
let dir_major_version = parse_major_version(&dir_name)?;
if dir_major_version >= major_version {
return None;
}
if restorable_db_versions
.iter()
.map(|v| v - 1)
.any(|v| v == dir_major_version)
{
return None;
}
let outdated_path = entry.path();
let cache_path = canonicalize(&config.cache_dir).ok()?;
let outdated_path = canonicalize(outdated_path).ok()?;
if !outdated_path.starts_with(&cache_path) {
info!(
skipped_path = ?outdated_path,
?cache_path,
"skipped cleanup of outdated state directory: state is outside cache directory",
);
return None;
}
remove_dir_all(&outdated_path).ok().map(|()| outdated_path)
}
fn parse_dir_name(entry: &DirEntry) -> Option<String> {
if let Ok(file_type) = entry.file_type() {
if file_type.is_dir() {
if let Ok(dir_name) = entry.file_name().into_string() {
return Some(dir_name);
}
}
}
None
}
fn parse_major_version(dir_name: &str) -> Option<u64> {
dir_name
.strip_prefix('v')
.and_then(|version| version.parse().ok())
}
pub fn state_database_format_version_on_disk(
config: &Config,
network: &Network,
) -> Result<Option<Version>, BoxError> {
database_format_version_on_disk(
config,
STATE_DATABASE_KIND,
state_database_format_version_in_code().major,
network,
)
}
pub fn database_format_version_on_disk(
config: &Config,
db_kind: impl AsRef<str>,
major_version: u64,
network: &Network,
) -> Result<Option<Version>, BoxError> {
let version_path = config.version_file_path(&db_kind, major_version, network);
let db_path = config.db_path(db_kind, major_version, network);
database_format_version_at_path(&version_path, &db_path, major_version)
}
pub(crate) fn database_format_version_at_path(
version_path: &Path,
db_path: &Path,
major_version: u64,
) -> Result<Option<Version>, BoxError> {
let disk_version_file = match fs::read_to_string(version_path) {
Ok(version) => Some(version),
Err(e) if e.kind() == ErrorKind::NotFound => {
None
}
Err(e) => Err(e)?,
};
if let Some(version) = disk_version_file {
return Ok(Some(
version
.parse()
.or_else(|err| {
format!("{major_version}.{version}")
.parse()
.map_err(|err2| format!("failed to parse format version: {err}, {err2}"))
})?,
));
}
match fs::metadata(db_path) {
Ok(_metadata) => Ok(Some(Version::new(major_version, 0, 0))),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(e)?,
}
}
#[allow(unused_imports)]
pub(crate) use hidden::{
write_database_format_version_to_disk, write_state_database_format_version_to_disk,
};
pub(crate) mod hidden {
#![allow(dead_code)]
use zakura_chain::common::atomic_write;
use super::*;
pub fn write_state_database_format_version_to_disk(
config: &Config,
changed_version: &Version,
network: &Network,
) -> Result<(), BoxError> {
write_database_format_version_to_disk(
config,
STATE_DATABASE_KIND,
state_database_format_version_in_code().major,
changed_version,
network,
)
}
pub fn write_database_format_version_to_disk(
config: &Config,
db_kind: impl AsRef<str>,
major_version_in_code: u64,
changed_version: &Version,
network: &Network,
) -> Result<(), BoxError> {
atomic_write(
config.version_file_path(db_kind, major_version_in_code, network),
changed_version.to_string().as_bytes(),
)??;
Ok(())
}
}