use crate::utils::ChainError;
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
use tracing::info;
pub const DEFAULT_RETENTION_SECS: u64 = 3_600;
pub const DEFAULT_CLEANUP_INTERVAL_SECS: u64 = 60;
pub const DEFAULT_MAX_CACHED_TAPES: usize = 64;
pub const DEFAULT_MAX_CACHED_SNAPSHOTS: usize = 256;
pub const DEFAULT_MAX_SNAPSHOT_CONTRACTS: usize = 200_000;
pub const DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS: usize = 4_000_000;
const MAX_RETENTION_SECS: u64 = 30 * 24 * 3_600;
const MAX_CLEANUP_INTERVAL_SECS: u64 = 3_600;
const MAX_CACHE_CAPACITY: usize = 1_000_000;
const MAX_SNAPSHOT_CONTRACTS_CEILING: usize = 10_000_000;
const MAX_CACHE_CONTRACTS: usize = 100_000_000;
pub const DEFAULT_MAX_EXPORT_ROWS: usize = 100_000;
const MAX_EXPORT_ROWS_CEILING: usize = 10_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SimulationV2Config {
pub retention: Duration,
pub cleanup_interval: Duration,
pub max_cached_tapes: usize,
pub max_cached_snapshots: usize,
pub max_snapshot_contracts: usize,
pub max_cached_snapshot_contracts: usize,
pub max_export_rows: usize,
}
impl Default for SimulationV2Config {
fn default() -> Self {
Self {
retention: Duration::from_secs(DEFAULT_RETENTION_SECS),
cleanup_interval: Duration::from_secs(DEFAULT_CLEANUP_INTERVAL_SECS),
max_cached_tapes: DEFAULT_MAX_CACHED_TAPES,
max_cached_snapshots: DEFAULT_MAX_CACHED_SNAPSHOTS,
max_snapshot_contracts: DEFAULT_MAX_SNAPSHOT_CONTRACTS,
max_cached_snapshot_contracts: DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS,
max_export_rows: DEFAULT_MAX_EXPORT_ROWS,
}
}
}
impl SimulationV2Config {
pub fn from_env() -> Result<Self, ChainError> {
let config = Self {
retention: Duration::from_secs(parse_secs(
"OCS_V2_RETENTION_SECS",
read("OCS_V2_RETENTION_SECS").as_deref(),
DEFAULT_RETENTION_SECS,
MAX_RETENTION_SECS,
)?),
cleanup_interval: Duration::from_secs(parse_secs(
"OCS_V2_CLEANUP_INTERVAL_SECS",
read("OCS_V2_CLEANUP_INTERVAL_SECS").as_deref(),
DEFAULT_CLEANUP_INTERVAL_SECS,
MAX_CLEANUP_INTERVAL_SECS,
)?),
max_cached_tapes: parse_capacity(
"OCS_MAX_CACHED_TAPES",
read("OCS_MAX_CACHED_TAPES").as_deref(),
DEFAULT_MAX_CACHED_TAPES,
)?,
max_cached_snapshots: parse_capacity(
"OCS_MAX_CACHED_SNAPSHOTS",
read("OCS_MAX_CACHED_SNAPSHOTS").as_deref(),
DEFAULT_MAX_CACHED_SNAPSHOTS,
)?,
max_snapshot_contracts: parse_bounded(
"OCS_MAX_SNAPSHOT_CONTRACTS",
read("OCS_MAX_SNAPSHOT_CONTRACTS").as_deref(),
DEFAULT_MAX_SNAPSHOT_CONTRACTS,
MAX_SNAPSHOT_CONTRACTS_CEILING,
)?,
max_cached_snapshot_contracts: parse_bounded(
"OCS_MAX_CACHED_SNAPSHOT_CONTRACTS",
read("OCS_MAX_CACHED_SNAPSHOT_CONTRACTS").as_deref(),
DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS,
MAX_CACHE_CONTRACTS,
)?,
max_export_rows: parse_bounded(
"OCS_MAX_EXPORT_ROWS",
read("OCS_MAX_EXPORT_ROWS").as_deref(),
DEFAULT_MAX_EXPORT_ROWS,
MAX_EXPORT_ROWS_CEILING,
)?,
};
info!(
retention_secs = config.retention.as_secs(),
cleanup_interval_secs = config.cleanup_interval.as_secs(),
max_cached_tapes = config.max_cached_tapes,
max_cached_snapshots = config.max_cached_snapshots,
max_snapshot_contracts = config.max_snapshot_contracts,
max_cached_snapshot_contracts = config.max_cached_snapshot_contracts,
max_export_rows = config.max_export_rows,
"Loaded the v2 simulation configuration"
);
let _ = SNAPSHOT_CONTRACT_CAP.set(config.max_snapshot_contracts);
Ok(config)
}
#[must_use]
pub fn retention_secs(&self) -> u64 {
self.retention.as_secs()
}
}
fn parse_secs(
variable: &str,
raw: Option<&str>,
default: u64,
max: u64,
) -> Result<u64, ChainError> {
let Some(raw) = raw else {
return Ok(default);
};
let seconds = raw.parse::<u64>().map_err(|_| invalid(variable, raw))?;
if seconds == 0 {
return Err(ChainError::Validation {
field: variable.to_string(),
reason: "must be at least 1 second".to_string(),
});
}
if seconds > max {
return Err(ChainError::Validation {
field: variable.to_string(),
reason: format!("must not exceed {max} seconds, got {seconds}"),
});
}
Ok(seconds)
}
fn parse_capacity(variable: &str, raw: Option<&str>, default: usize) -> Result<usize, ChainError> {
parse_bounded(variable, raw, default, MAX_CACHE_CAPACITY)
}
fn parse_bounded(
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(ChainError::Validation {
field: variable.to_string(),
reason: "must be at least 1".to_string(),
});
}
if value > max {
return Err(ChainError::Validation {
field: variable.to_string(),
reason: format!("must not exceed {max}, got {value}"),
});
}
Ok(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:?}"),
}
}
static SNAPSHOT_CONTRACT_CAP: OnceLock<usize> = OnceLock::new();
#[must_use]
pub fn max_snapshot_contracts() -> usize {
*SNAPSHOT_CONTRACT_CAP
.get()
.unwrap_or(&DEFAULT_MAX_SNAPSHOT_CONTRACTS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_the_defaults_are_the_documented_values() {
let config = SimulationV2Config::default();
assert_eq!(config.retention.as_secs(), DEFAULT_RETENTION_SECS);
assert_eq!(
config.cleanup_interval.as_secs(),
DEFAULT_CLEANUP_INTERVAL_SECS
);
assert_eq!(config.max_cached_tapes, DEFAULT_MAX_CACHED_TAPES);
assert_eq!(config.max_cached_snapshots, DEFAULT_MAX_CACHED_SNAPSHOTS);
assert_eq!(config.retention_secs(), DEFAULT_RETENTION_SECS);
}
#[test]
fn test_the_v2_retention_outlasts_the_v1_default() {
let v1_default_secs: u64 = 1_800;
assert!(
SimulationV2Config::default().retention_secs() > v1_default_secs,
"a v2 simulation is walked one request at a time over a long horizon"
);
}
#[test]
fn test_an_unset_duration_takes_its_default() {
match parse_secs("OCS_V2_RETENTION_SECS", None, 900, 3_600) {
Ok(seconds) => assert_eq!(seconds, 900),
Err(error) => panic!("an unset knob must take its default: {error}"),
}
}
#[test]
fn test_a_valid_duration_is_accepted() {
match parse_secs("OCS_V2_RETENTION_SECS", Some("120"), 900, 3_600) {
Ok(seconds) => assert_eq!(seconds, 120),
Err(error) => panic!("a valid duration must be accepted: {error}"),
}
}
#[test]
fn test_an_unparseable_duration_fails_by_name() {
match parse_secs("OCS_V2_RETENTION_SECS", Some("an hour"), 900, 3_600) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "OCS_V2_RETENTION_SECS");
assert!(reason.contains("whole number"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_zero_duration_is_rejected() {
match parse_secs("OCS_V2_RETENTION_SECS", Some("0"), 900, 3_600) {
Err(ChainError::Validation { reason, .. }) => {
assert!(reason.contains("at least 1 second"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_duration_beyond_its_ceiling_is_rejected() {
match parse_secs("OCS_V2_RETENTION_SECS", Some("999999999"), 900, 3_600) {
Err(ChainError::Validation { reason, .. }) => {
assert!(reason.contains("must not exceed"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_capacity_parsing_accepts_valid_values() {
match parse_capacity("OCS_MAX_CACHED_TAPES", None, 64) {
Ok(capacity) => assert_eq!(capacity, 64),
Err(error) => panic!("an unset knob must take its default: {error}"),
}
match parse_capacity("OCS_MAX_CACHED_TAPES", Some("8"), 64) {
Ok(capacity) => assert_eq!(capacity, 8),
Err(error) => panic!("a valid capacity must be accepted: {error}"),
}
}
#[test]
fn test_a_zero_capacity_is_rejected() {
match parse_capacity("OCS_MAX_CACHED_SNAPSHOTS", Some("0"), 256) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "OCS_MAX_CACHED_SNAPSHOTS");
assert!(reason.contains("at least 1"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_capacity_beyond_its_ceiling_is_rejected() {
match parse_capacity("OCS_MAX_CACHED_SNAPSHOTS", Some("99999999"), 256) {
Err(ChainError::Validation { reason, .. }) => {
assert!(reason.contains("must not exceed"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_an_unparseable_capacity_fails_by_name() {
match parse_capacity("OCS_MAX_CACHED_TAPES", Some("lots"), 64) {
Err(ChainError::Validation { field, .. }) => {
assert_eq!(field, "OCS_MAX_CACHED_TAPES");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_both_stores_default_to_the_configured_retention() {
use crate::session::{DEFAULT_V2_RETENTION_SECS, InMemorySimulationStore};
assert_eq!(DEFAULT_V2_RETENTION_SECS, DEFAULT_RETENTION_SECS);
assert_eq!(
InMemorySimulationStore::new().idle_retention(),
SimulationV2Config::default().retention,
"the in-memory store must apply the configured window"
);
}
#[test]
fn test_it_loads_from_the_environment() {
match SimulationV2Config::from_env() {
Ok(config) => {
assert!(config.retention.as_secs() >= 1);
assert!(config.max_cached_tapes >= 1);
assert!(config.max_cached_snapshots >= 1);
}
Err(error) => panic!("the ambient environment must load: {error}"),
}
}
}