use crate::infrastructure::config::simulation_v2::{
DEFAULT_MAX_SNAPSHOT_CONTRACTS, max_snapshot_contracts,
};
use crate::utils::ChainError;
use std::env;
use std::time::Duration;
use tracing::info;
pub const DEFAULT_SNAPSHOT_PERSISTENCE_ENABLED: bool = false;
pub const DEFAULT_SNAPSHOT_BATCH_ROWS: usize = DEFAULT_MAX_SNAPSHOT_CONTRACTS;
pub const DEFAULT_SNAPSHOT_MAX_READ_ROWS: usize = 1_000_000;
pub const DEFAULT_SNAPSHOT_INSERT_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_SNAPSHOT_RETENTION_DAYS: u32 = 90;
const MAX_BATCH_ROWS: usize = 5_000_000;
const MAX_READ_ROWS_CEILING: usize = 10_000_000;
const MAX_INSERT_TIMEOUT_SECS: u64 = 600;
const MAX_RETENTION_DAYS: u32 = 3_650;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SnapshotPersistenceConfig {
pub enabled: bool,
pub batch_rows: usize,
pub max_read_rows: usize,
pub insert_timeout: Duration,
pub retention_days: u32,
}
impl Default for SnapshotPersistenceConfig {
fn default() -> Self {
Self {
enabled: DEFAULT_SNAPSHOT_PERSISTENCE_ENABLED,
batch_rows: DEFAULT_SNAPSHOT_BATCH_ROWS,
max_read_rows: DEFAULT_SNAPSHOT_MAX_READ_ROWS,
insert_timeout: Duration::from_secs(DEFAULT_SNAPSHOT_INSERT_TIMEOUT_SECS),
retention_days: DEFAULT_SNAPSHOT_RETENTION_DAYS,
}
}
}
impl SnapshotPersistenceConfig {
pub fn from_env() -> Result<Self, ChainError> {
let config = Self {
enabled: parse_bool(
"OCS_SNAPSHOT_PERSISTENCE_ENABLED",
read("OCS_SNAPSHOT_PERSISTENCE_ENABLED").as_deref(),
DEFAULT_SNAPSHOT_PERSISTENCE_ENABLED,
)?,
batch_rows: parse_bounded_usize(
"OCS_SNAPSHOT_BATCH_ROWS",
read("OCS_SNAPSHOT_BATCH_ROWS").as_deref(),
DEFAULT_SNAPSHOT_BATCH_ROWS,
MAX_BATCH_ROWS,
)?,
max_read_rows: parse_bounded_usize(
"OCS_SNAPSHOT_MAX_READ_ROWS",
read("OCS_SNAPSHOT_MAX_READ_ROWS").as_deref(),
DEFAULT_SNAPSHOT_MAX_READ_ROWS,
MAX_READ_ROWS_CEILING,
)?,
insert_timeout: Duration::from_secs(parse_bounded_u64(
"OCS_SNAPSHOT_INSERT_TIMEOUT_SECS",
read("OCS_SNAPSHOT_INSERT_TIMEOUT_SECS").as_deref(),
DEFAULT_SNAPSHOT_INSERT_TIMEOUT_SECS,
MAX_INSERT_TIMEOUT_SECS,
)?),
retention_days: parse_bounded_u32(
"OCS_SNAPSHOT_RETENTION_DAYS",
read("OCS_SNAPSHOT_RETENTION_DAYS").as_deref(),
DEFAULT_SNAPSHOT_RETENTION_DAYS,
MAX_RETENTION_DAYS,
)?,
};
let accepted = max_snapshot_contracts();
if config.enabled && config.batch_rows < accepted {
return Err(ChainError::Validation {
field: "OCS_SNAPSHOT_BATCH_ROWS".to_string(),
reason: format!(
"is {} but OCS_MAX_SNAPSHOT_CONTRACTS accepts snapshots of up to \
{accepted} rows, so every persist of a snapshot above {} would fail; \
raise this or lower that",
config.batch_rows, config.batch_rows
),
});
}
info!(
enabled = config.enabled,
batch_rows = config.batch_rows,
max_read_rows = config.max_read_rows,
insert_timeout_secs = config.insert_timeout.as_secs(),
retention_days = config.retention_days,
"Loaded the v2 snapshot persistence configuration"
);
Ok(config)
}
}
fn parse_bool(variable: &str, raw: Option<&str>, default: bool) -> Result<bool, ChainError> {
let Some(raw) = raw else {
return Ok(default);
};
match raw.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ChainError::Validation {
field: variable.to_string(),
reason: format!("must be a boolean (true/false), got {raw:?}"),
}),
}
}
fn parse_bounded_usize(
variable: &str,
raw: Option<&str>,
default: usize,
max: usize,
) -> Result<usize, ChainError> {
let Some(raw) = raw else {
return Ok(default);
};
let value = raw.parse::<usize>().map_err(|_| invalid(variable, raw))?;
if value == 0 {
return Err(zero(variable));
}
if value > max {
return Err(too_large(variable, &value.to_string(), &max.to_string()));
}
Ok(value)
}
fn parse_bounded_u64(
variable: &str,
raw: Option<&str>,
default: u64,
max: u64,
) -> Result<u64, ChainError> {
let Some(raw) = raw else {
return Ok(default);
};
let value = raw.parse::<u64>().map_err(|_| invalid(variable, raw))?;
if value == 0 {
return Err(zero(variable));
}
if value > max {
return Err(too_large(variable, &value.to_string(), &max.to_string()));
}
Ok(value)
}
fn parse_bounded_u32(
variable: &str,
raw: Option<&str>,
default: u32,
max: u32,
) -> Result<u32, ChainError> {
let Some(raw) = raw else {
return Ok(default);
};
let value = raw.parse::<u32>().map_err(|_| invalid(variable, raw))?;
if value == 0 {
return Err(zero(variable));
}
if value > max {
return Err(too_large(variable, &value.to_string(), &max.to_string()));
}
Ok(value)
}
#[cold]
fn zero(variable: &str) -> ChainError {
ChainError::Validation {
field: variable.to_string(),
reason: "must be at least 1".to_string(),
}
}
#[cold]
fn too_large(variable: &str, value: &str, max: &str) -> ChainError {
ChainError::Validation {
field: variable.to_string(),
reason: format!("must not exceed {max}, got {value}"),
}
}
fn read(variable: &str) -> Option<String> {
let raw = env::var(variable).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cold]
fn invalid(variable: &str, raw: &str) -> ChainError {
ChainError::Validation {
field: variable.to_string(),
reason: format!("must be a whole number, got {raw:?}"),
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_the_default_storage_bound_covers_what_creation_accepts() {
const {
assert!(
DEFAULT_SNAPSHOT_BATCH_ROWS >= DEFAULT_MAX_SNAPSHOT_CONTRACTS,
"a storage bound below the accepted snapshot size fails every persist"
);
}
}
use super::*;
#[test]
fn test_the_defaults_are_the_documented_values() {
let config = SnapshotPersistenceConfig::default();
assert_eq!(config.enabled, DEFAULT_SNAPSHOT_PERSISTENCE_ENABLED);
assert_eq!(config.batch_rows, DEFAULT_SNAPSHOT_BATCH_ROWS);
assert_eq!(config.max_read_rows, DEFAULT_SNAPSHOT_MAX_READ_ROWS);
assert_eq!(
config.insert_timeout.as_secs(),
DEFAULT_SNAPSHOT_INSERT_TIMEOUT_SECS
);
assert_eq!(config.retention_days, DEFAULT_SNAPSHOT_RETENTION_DAYS);
}
#[test]
fn test_persistence_is_off_by_default() {
assert!(!SnapshotPersistenceConfig::default().enabled);
}
#[test]
fn test_boolean_spellings_are_accepted() {
for raw in ["1", "true", "TRUE", "Yes", "on"] {
match parse_bool("OCS_SNAPSHOT_PERSISTENCE_ENABLED", Some(raw), false) {
Ok(value) => assert!(value, "{raw} must enable persistence"),
Err(error) => panic!("{raw} must parse: {error}"),
}
}
for raw in ["0", "false", "FALSE", "No", "off"] {
match parse_bool("OCS_SNAPSHOT_PERSISTENCE_ENABLED", Some(raw), true) {
Ok(value) => assert!(!value, "{raw} must disable persistence"),
Err(error) => panic!("{raw} must parse: {error}"),
}
}
}
#[test]
fn test_an_unparseable_boolean_fails_by_name() {
match parse_bool("OCS_SNAPSHOT_PERSISTENCE_ENABLED", Some("treu"), false) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "OCS_SNAPSHOT_PERSISTENCE_ENABLED");
assert!(reason.contains("boolean"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_an_unset_boolean_takes_its_default() {
match parse_bool("OCS_SNAPSHOT_PERSISTENCE_ENABLED", None, true) {
Ok(value) => assert!(value),
Err(error) => panic!("an unset knob must take its default: {error}"),
}
}
#[test]
fn test_counts_take_their_default_and_accept_valid_values() {
match parse_bounded_usize("OCS_SNAPSHOT_BATCH_ROWS", None, 64, 128) {
Ok(value) => assert_eq!(value, 64),
Err(error) => panic!("an unset knob must take its default: {error}"),
}
match parse_bounded_usize("OCS_SNAPSHOT_BATCH_ROWS", Some("100"), 64, 128) {
Ok(value) => assert_eq!(value, 100),
Err(error) => panic!("a valid count must be accepted: {error}"),
}
}
#[test]
fn test_a_zero_count_is_rejected() {
match parse_bounded_usize("OCS_SNAPSHOT_BATCH_ROWS", Some("0"), 64, 128) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "OCS_SNAPSHOT_BATCH_ROWS");
assert!(reason.contains("at least 1"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_count_beyond_its_ceiling_is_rejected() {
match parse_bounded_usize("OCS_SNAPSHOT_MAX_READ_ROWS", Some("999999999"), 64, 128) {
Err(ChainError::Validation { reason, .. }) => {
assert!(reason.contains("must not exceed"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_an_unparseable_count_fails_by_name() {
match parse_bounded_usize("OCS_SNAPSHOT_BATCH_ROWS", Some("lots"), 64, 128) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "OCS_SNAPSHOT_BATCH_ROWS");
assert!(reason.contains("whole number"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_the_timeout_is_bounded() {
match parse_bounded_u64("OCS_SNAPSHOT_INSERT_TIMEOUT_SECS", Some("45"), 30, 600) {
Ok(value) => assert_eq!(value, 45),
Err(error) => panic!("a valid timeout must be accepted: {error}"),
}
match parse_bounded_u64("OCS_SNAPSHOT_INSERT_TIMEOUT_SECS", Some("6000"), 30, 600) {
Err(ChainError::Validation { field, .. }) => {
assert_eq!(field, "OCS_SNAPSHOT_INSERT_TIMEOUT_SECS");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_the_retention_is_bounded() {
match parse_bounded_u32(
"OCS_SNAPSHOT_RETENTION_DAYS",
Some("30"),
90,
MAX_RETENTION_DAYS,
) {
Ok(value) => assert_eq!(value, 30),
Err(error) => panic!("a valid retention must be accepted: {error}"),
}
match parse_bounded_u32(
"OCS_SNAPSHOT_RETENTION_DAYS",
Some("40000"),
90,
MAX_RETENTION_DAYS,
) {
Err(ChainError::Validation { reason, .. }) => {
assert!(reason.contains("must not exceed"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_it_loads_from_the_environment() {
match SnapshotPersistenceConfig::from_env() {
Ok(config) => {
assert!(config.batch_rows >= 1);
assert!(config.max_read_rows >= 1);
assert!(config.retention_days >= 1);
assert!(config.insert_timeout.as_secs() >= 1);
}
Err(error) => panic!("the ambient environment must load: {error}"),
}
}
}