use std::path::Path;
use crate::common::save_on_disk::SaveOnDisk;
use parking_lot::RwLock;
use validator::Validate as _;
use crate::shard::quota::config::QuotaConfig;
use crate::shard::quota::error::{QuotaError, QuotaResult};
pub const QUOTA_CONFIG_FILE: &str = "quota.json";
pub enum Store {
Persisted(SaveOnDisk<QuotaConfig>),
Ephemeral(RwLock<QuotaConfig>),
}
impl Store {
pub fn load_or_init(storage_path: &Path, from_settings: QuotaConfig) -> QuotaResult<Self> {
let config =
SaveOnDisk::load_or_init(storage_path.join(QUOTA_CONFIG_FILE), || from_settings)
.map_err(|err| QuotaError::Io(format!("Failed to read quota config: {err}")))?;
validate(&config.read())?;
Ok(Store::Persisted(config))
}
pub fn ephemeral() -> Self {
Store::Ephemeral(RwLock::new(QuotaConfig::default()))
}
pub fn read(&self) -> QuotaConfig {
match self {
Store::Persisted(config) => *config.read(),
Store::Ephemeral(config) => *config.read(),
}
}
pub fn write(&self, new: QuotaConfig) -> QuotaResult<()> {
validate(&new)?;
match self {
Store::Persisted(config) => config
.write(|current| *current = new)
.map_err(|err| QuotaError::Io(format!("Failed to persist quota config: {err}"))),
Store::Ephemeral(config) => {
*config.write() = new;
Ok(())
}
}
}
}
fn validate(config: &QuotaConfig) -> QuotaResult<()> {
config.validate().map_err(|errs| {
let fields = errs
.field_errors()
.iter()
.map(|(field, errs)| {
let messages = errs
.iter()
.map(|err| {
err.message
.as_deref()
.map_or_else(|| err.code.to_string(), ToString::to_string)
})
.collect::<Vec<_>>()
.join(", ");
format!("{field}: {messages}")
})
.collect::<Vec<_>>()
.join("; ");
QuotaError::InvalidConfig(format!("Invalid quota config: [{fields}]"))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shard::quota::QuotaManager;
#[test]
fn persisted_config_takes_priority_over_settings() {
let dir = tempfile::Builder::new().tempdir().unwrap();
let settings = QuotaConfig {
enabled: true,
max_disk_usage_percent: Some(80),
..Default::default()
};
let manager = QuotaManager::load_or_init(dir.path(), settings).unwrap();
assert_eq!(manager.config(), settings);
let updated = QuotaConfig {
enabled: true,
max_disk_usage_percent: Some(95),
max_resident_memory_percent: Some(90),
..Default::default()
};
manager.set_config(updated).unwrap();
let reloaded = QuotaManager::load_or_init(dir.path(), settings).unwrap();
assert_eq!(reloaded.config(), updated);
}
#[test]
fn out_of_range_limits_are_rejected() {
let dir = tempfile::Builder::new().tempdir().unwrap();
let manager = QuotaManager::load_or_init(dir.path(), QuotaConfig::default()).unwrap();
let impossible = QuotaConfig {
enabled: true,
max_disk_usage_percent: Some(0),
..Default::default()
};
let err = manager.set_config(impossible).unwrap_err();
assert!(
err.to_string().contains("max_disk_usage_percent"),
"the error should name the field that is out of range: {err}",
);
assert_eq!(manager.config(), QuotaConfig::default());
fs_err::write(
dir.path().join(QUOTA_CONFIG_FILE),
serde_json::to_vec(&impossible).unwrap(),
)
.unwrap();
assert!(QuotaManager::load_or_init(dir.path(), QuotaConfig::default()).is_err());
}
#[test]
fn invalid_settings_leave_no_quota_file_behind() {
let dir = tempfile::Builder::new().tempdir().unwrap();
let config_path = dir.path().join(QUOTA_CONFIG_FILE);
let impossible = QuotaConfig {
enabled: true,
max_resident_memory_percent: Some(0),
..Default::default()
};
assert!(QuotaManager::load_or_init(dir.path(), impossible).is_err());
assert!(!config_path.exists());
}
}