use std::collections::BTreeSet;
use std::fmt;
use std::path::Path;
use std::str::FromStr;
use bon::Builder;
use conflaguration::{Settings, Validate, ValidationMessage};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::runtime::RuntimeSelection;
use crate::error::ProximaError;
fn default_cores() -> usize {
0
}
fn default_backend() -> RuntimeBackendSelection {
RuntimeBackendSelection::Auto
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(String);
impl fmt::Display for ParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeBackendSelection {
Auto,
Prime,
Tokio,
}
impl FromStr for RuntimeBackendSelection {
type Err = ParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"auto" => Ok(Self::Auto),
"prime" => Ok(Self::Prime),
"tokio" => Ok(Self::Tokio),
other => Err(ParseError(format!(
"unknown runtime backend `{other}` (expected auto|prime|tokio)"
))),
}
}
}
impl RuntimeBackendSelection {
pub fn resolve(self, cores: usize) -> Result<Option<RuntimeSelection>, ProximaError> {
match self {
Self::Auto => Ok(None),
Self::Prime => resolve_prime_selection(cores).map(Some),
Self::Tokio => resolve_tokio_selection(cores).map(Some),
}
}
}
#[cfg(all(
feature = "runtime-prime-executor",
feature = "runtime-prime-inbox-alloc",
feature = "runtime-prime-reactor",
feature = "runtime-prime-bgpool",
any(target_os = "linux", target_os = "macos", target_os = "windows")
))]
fn resolve_prime_selection(cores: usize) -> Result<RuntimeSelection, ProximaError> {
RuntimeSelection::prime(cores)
}
#[cfg(not(all(
feature = "runtime-prime-executor",
feature = "runtime-prime-inbox-alloc",
feature = "runtime-prime-reactor",
feature = "runtime-prime-bgpool",
any(target_os = "linux", target_os = "macos", target_os = "windows")
)))]
fn resolve_prime_selection(_cores: usize) -> Result<RuntimeSelection, ProximaError> {
Err(ProximaError::Config(
"runtime config selected backend `prime`, but the prime runtime bundle is not linked \
(enable `serve-prime` or the four `runtime-prime-*` features)"
.into(),
))
}
#[cfg(feature = "runtime-tokio")]
fn resolve_tokio_selection(cores: usize) -> Result<RuntimeSelection, ProximaError> {
RuntimeSelection::tokio(cores)
}
#[cfg(not(feature = "runtime-tokio"))]
fn resolve_tokio_selection(_cores: usize) -> Result<RuntimeSelection, ProximaError> {
Err(ProximaError::Config(
"runtime config selected backend `tokio`, but `runtime-tokio` is not linked".into(),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Builder, Deserialize, Serialize, Settings)]
#[settings(prefix = "PROXIMA_RUNTIME")]
#[builder(derive(Clone, Debug))]
pub struct RuntimeConfig {
#[setting(default = 0)]
#[serde(default = "default_cores")]
#[builder(default = default_cores())]
pub cores: usize,
#[setting(default_str = "auto", resolve_with = "parse_backend")]
#[serde(default = "default_backend")]
#[builder(default = default_backend())]
pub backend: RuntimeBackendSelection,
}
fn parse_backend(raw: &str) -> Result<RuntimeBackendSelection, ParseError> {
raw.parse()
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl Validate for RuntimeConfig {
fn validate(&self) -> conflaguration::Result<()> {
Ok(())
}
}
impl RuntimeConfig {
#[must_use]
pub fn resolved_cores(&self) -> usize {
let cores = if self.cores == 0 {
num_cpus::get()
} else {
self.cores
};
cores.max(1)
}
pub fn resolve_selection(&self) -> Result<Option<RuntimeSelection>, ProximaError> {
self.backend.resolve(self.resolved_cores())
}
#[must_use]
pub fn layered() -> RuntimeConfigLayerBuilder {
RuntimeConfigLayerBuilder {
inner: Self::default(),
touched: BTreeSet::new(),
}
}
pub fn resolve_from_env() -> Result<Self, ProximaError> {
conflaguration::builder()
.value(Self::default())
.env()
.build()
.map_err(|error| ProximaError::Config(format!("runtime config: {error}")))
}
}
#[derive(Debug, Clone)]
pub struct RuntimeConfigLayerBuilder {
inner: RuntimeConfig,
touched: BTreeSet<String>,
}
impl RuntimeConfigLayerBuilder {
pub fn from_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, conflaguration::Error> {
let incoming: Value = conflaguration::from_file(path.as_ref())?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Override,
)?;
Ok(self)
}
pub fn underlay_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, conflaguration::Error> {
let incoming: Value = conflaguration::from_file(path.as_ref())?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Underlay,
)?;
Ok(self)
}
pub fn from_env(mut self) -> Result<Self, conflaguration::Error> {
let incoming = runtime_env_partial()?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Override,
)?;
Ok(self)
}
pub fn underlay_env(mut self) -> Result<Self, conflaguration::Error> {
let incoming = runtime_env_partial()?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Underlay,
)?;
Ok(self)
}
#[must_use]
pub fn with_cores(mut self, cores: usize) -> Self {
self.inner.cores = cores;
self.touched.insert("cores".to_string());
self
}
#[must_use]
pub fn with_backend(mut self, backend: RuntimeBackendSelection) -> Self {
self.inner.backend = backend;
self.touched.insert("backend".to_string());
self
}
#[must_use]
pub fn build(self) -> RuntimeConfig {
self.inner
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Builder, Deserialize, Serialize, Settings)]
#[settings(prefix = "PROXIMA_APP")]
#[builder(derive(Clone, Debug))]
pub struct AppConfig {
#[setting(nested, override_prefix = "PROXIMA_RUNTIME")]
#[serde(default)]
#[builder(default)]
pub runtime: RuntimeConfig,
}
impl Validate for AppConfig {
fn validate(&self) -> conflaguration::Result<()> {
self.runtime.validate()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeMode {
Override,
Underlay,
}
fn apply_layer<T>(
inner: &mut T,
touched: &mut BTreeSet<String>,
incoming: Value,
mode: MergeMode,
) -> Result<(), conflaguration::Error>
where
T: Serialize + DeserializeOwned,
{
let Value::Object(incoming_map) = incoming else {
return Ok(());
};
let mut base = to_value(inner)?;
let Value::Object(base_map) = &mut base else {
return Ok(());
};
for (key, value) in incoming_map {
let should_apply = match mode {
MergeMode::Override => true,
MergeMode::Underlay => !touched.contains(&key),
};
if should_apply {
touched.insert(key.clone());
base_map.insert(key, value);
}
}
*inner = from_value(base)?;
Ok(())
}
fn runtime_env_partial() -> Result<Value, conflaguration::Error> {
let resolved = RuntimeConfig::from_env()?;
let mut partial = Map::new();
if std::env::var("PROXIMA_RUNTIME_CORES").is_ok() {
partial.insert("cores".to_string(), to_value(&resolved.cores)?);
}
if std::env::var("PROXIMA_RUNTIME_BACKEND").is_ok() {
partial.insert("backend".to_string(), to_value(&resolved.backend)?);
}
Ok(Value::Object(partial))
}
fn to_value<T: Serialize>(value: &T) -> Result<Value, conflaguration::Error> {
serde_json::to_value(value).map_err(|error| conflaguration::Error::Validation {
errors: vec![ValidationMessage::new(
"layered",
format!("serialize failed: {error}"),
)],
})
}
fn from_value<T: DeserializeOwned>(value: Value) -> Result<T, conflaguration::Error> {
serde_json::from_value(value).map_err(|error| conflaguration::Error::Validation {
errors: vec![ValidationMessage::new(
"layered",
format!("deserialize failed: {error}"),
)],
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_cores_is_auto_sentinel() {
let config = RuntimeConfig::default();
assert_eq!(config.cores, 0);
assert!(config.resolved_cores() >= 1);
}
#[test]
fn explicit_cores_pass_through_resolved_cores() {
let config = RuntimeConfig::builder().cores(4).build();
assert_eq!(config.resolved_cores(), 4);
}
#[test]
fn zero_cores_written_by_hand_clamps_to_at_least_one() {
let config = RuntimeConfig {
cores: 0,
backend: RuntimeBackendSelection::Auto,
};
assert!(config.resolved_cores() >= 1);
}
#[test]
fn env_overrides_default() {
temp_env::with_vars([("PROXIMA_RUNTIME_CORES", Some("3"))], || {
let config = RuntimeConfig::from_env().expect("env config");
assert_eq!(config.cores, 3);
});
}
#[test]
fn malformed_env_value_is_a_loud_error() {
temp_env::with_vars([("PROXIMA_RUNTIME_CORES", Some("not-a-number"))], || {
assert!(RuntimeConfig::from_env().is_err());
});
}
#[test]
fn resolve_from_env_without_var_falls_through_to_default() {
temp_env::with_vars_unset(["PROXIMA_RUNTIME_CORES"], || {
let config = RuntimeConfig::resolve_from_env().expect("resolve");
assert_eq!(config.cores, 0);
});
}
#[test]
fn layered_with_cores_wins_without_env() {
temp_env::with_vars_unset(["PROXIMA_RUNTIME_CORES"], || {
let config = RuntimeConfig::layered().with_cores(7).build();
assert_eq!(config.cores, 7);
});
}
#[test]
fn layered_from_env_overrides_with_cores_set_before_it() {
temp_env::with_vars([("PROXIMA_RUNTIME_CORES", Some("9"))], || {
let config = RuntimeConfig::layered()
.with_cores(2)
.from_env()
.expect("from_env")
.build();
assert_eq!(config.cores, 9, "env applied after with_cores wins");
});
}
#[test]
fn layered_underlay_env_never_clobbers_already_set_field() {
temp_env::with_vars([("PROXIMA_RUNTIME_CORES", Some("9"))], || {
let config = RuntimeConfig::layered()
.with_cores(2)
.underlay_env()
.expect("underlay_env")
.build();
assert_eq!(config.cores, 2, "with_cores already set it; env is dropped");
});
}
#[test]
fn layered_from_path_overrides_default() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("runtime.toml");
std::fs::write(&path, "cores = 5\n").expect("write toml");
let config = RuntimeConfig::layered()
.from_path(&path)
.expect("from_path")
.build();
assert_eq!(config.cores, 5);
}
#[test]
fn app_config_nests_runtime_and_resolves_prefixed_env() {
temp_env::with_vars([("PROXIMA_RUNTIME_CORES", Some("6"))], || {
let config = AppConfig::from_env().expect("app config from env");
assert_eq!(config.runtime.cores, 6);
});
}
#[test]
fn app_config_default_matches_runtime_default() {
let config = AppConfig::default();
assert_eq!(config.runtime, RuntimeConfig::default());
assert!(config.validate().is_ok());
}
#[test]
fn default_backend_is_auto() {
let config = RuntimeConfig::default();
assert_eq!(config.backend, RuntimeBackendSelection::Auto);
}
#[test]
fn auto_backend_resolves_to_none() {
let config = RuntimeConfig::default();
assert!(
config.resolve_selection().expect("resolve").is_none(),
"auto must defer to the caller's own default-resolution fallback"
);
}
#[test]
fn backend_env_override_parses() {
temp_env::with_vars([("PROXIMA_RUNTIME_BACKEND", Some("tokio"))], || {
let config = RuntimeConfig::from_env().expect("env config");
assert_eq!(config.backend, RuntimeBackendSelection::Tokio);
});
}
#[test]
fn malformed_backend_env_value_is_a_loud_error() {
temp_env::with_vars([("PROXIMA_RUNTIME_BACKEND", Some("glommio"))], || {
let error = RuntimeConfig::from_env().expect_err("unknown backend must error");
assert!(format!("{error}").contains("glommio"));
});
}
#[test]
fn layered_with_backend_wins_without_env() {
temp_env::with_vars_unset(["PROXIMA_RUNTIME_BACKEND"], || {
let config = RuntimeConfig::layered()
.with_backend(RuntimeBackendSelection::Prime)
.build();
assert_eq!(config.backend, RuntimeBackendSelection::Prime);
});
}
#[cfg(feature = "runtime-tokio")]
#[test]
fn config_selected_backend_round_trips_to_the_same_selection_as_the_fluent_builder() {
let from_config = RuntimeConfig::builder()
.cores(1)
.backend(RuntimeBackendSelection::Tokio)
.build()
.resolve_selection()
.expect("resolve_selection")
.expect("backend = tokio must resolve to Some");
let from_fluent = RuntimeSelection::tokio(1).expect("RuntimeSelection::tokio");
assert_eq!(from_config.backend, from_fluent.backend);
assert_eq!(
from_config.runtime.num_cores(),
from_fluent.runtime.num_cores()
);
assert_eq!(
from_config.datagram_factory.is_some(),
from_fluent.datagram_factory.is_some(),
"capability shape (which factories are Some) must match backend-for-backend"
);
assert_eq!(
from_config.unix_upstream_factory.is_some(),
from_fluent.unix_upstream_factory.is_some()
);
assert_eq!(
from_config.packet_listener_factory.is_some(),
from_fluent.packet_listener_factory.is_some()
);
}
#[cfg(feature = "runtime-tokio")]
#[test]
fn config_file_selected_backend_resolves_a_runtime_selection() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("runtime.toml");
std::fs::write(&path, "cores = 1\nbackend = \"tokio\"\n").expect("write toml");
let config = RuntimeConfig::layered()
.from_path(&path)
.expect("from_path")
.build();
assert_eq!(config.backend, RuntimeBackendSelection::Tokio);
let selection = config
.resolve_selection()
.expect("resolve_selection")
.expect("backend = tokio must resolve to Some");
assert_eq!(selection.backend, crate::runtime::RuntimeBackend::Tokio);
}
#[cfg(not(feature = "runtime-tokio"))]
#[test]
fn tokio_backend_without_the_feature_is_a_config_error() {
let config = RuntimeConfig::builder()
.backend(RuntimeBackendSelection::Tokio)
.build();
match config.resolve_selection() {
Err(error) => assert!(format!("{error}").contains("tokio")),
Ok(_) => panic!("tokio backend selected without runtime-tokio must error"),
}
}
}