use std::fmt;
pub const MIB: u64 = 1024 * 1024;
pub const DEFAULT_PORT: u16 = 8760;
pub const DEFAULT_MAX_BODY_MB: u64 = 32;
pub const DEFAULT_MAX_RESULT_MB: u64 = 8;
pub const DEFAULT_MAX_EXPORT_MB: u64 = 32;
pub const DEFAULT_MAX_DOWNLOAD_MB: u64 = rapid_structure_rs::DEFAULT_MAX_DOWNLOAD_MB;
pub const DEFAULT_MAX_QUEUE_TEXT: usize = 4;
pub const DEFAULT_MAX_QUEUE_FORMULA: usize = 2;
pub const DEFAULT_MAX_CONSECUTIVE_TEXT: usize = 4;
pub const DEFAULT_MAX_CONSECUTIVE_FORMULA: usize = 1;
pub const DEFAULT_MAX_RETAINED: usize = 32;
pub const DEFAULT_MAX_RETAINED_MB: u64 = 64;
pub const DEFAULT_MAX_TOMBSTONES: usize = 256;
pub const DEFAULT_JOB_TTL_SECS: u64 = 600;
pub const DEFAULT_MAX_EVAL_CASES: usize = 32;
pub const DEFAULT_ALLOW_DOWNLOAD: bool = false;
pub const DEFAULT_ALLOW_PROVIDER_FALLBACK: bool = false;
#[cfg(feature = "directml-provider")]
pub const DEFAULT_PROVIDER: &str = "directml";
#[cfg(not(feature = "directml-provider"))]
pub const DEFAULT_PROVIDER: &str = "cpu";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServeConfigError {
field: &'static str,
value: String,
reason: String,
}
impl ServeConfigError {
pub(crate) fn new(
field: &'static str,
value: impl fmt::Display,
reason: impl Into<String>,
) -> Self {
Self {
field,
value: value.to_string(),
reason: reason.into(),
}
}
pub fn field(&self) -> &'static str {
self.field
}
pub fn reason(&self) -> &str {
&self.reason
}
}
impl fmt::Display for ServeConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid {}={}: {}",
self.field(),
self.value,
self.reason()
)
}
}
impl std::error::Error for ServeConfigError {}
pub type ServeConfigResult<T> = Result<T, ServeConfigError>;
fn mib_to_bytes(field: &'static str, mib: u64) -> ServeConfigResult<u64> {
if mib == 0 {
return Err(ServeConfigError::new(
field,
mib,
"the limit must be greater than zero (MiB)",
));
}
mib.checked_mul(MIB).ok_or_else(|| {
ServeConfigError::new(
field,
mib,
format!("{mib} MiB overflows the byte limit (u64): {mib} * {MIB} does not fit"),
)
})
}
pub(crate) fn require_positive_usize(
field: &'static str,
value: usize,
) -> ServeConfigResult<usize> {
if value == 0 {
return Err(ServeConfigError::new(
field,
value,
"the value must be at least 1",
));
}
Ok(value)
}
pub(crate) fn require_positive_u64(field: &'static str, value: u64) -> ServeConfigResult<u64> {
if value == 0 {
return Err(ServeConfigError::new(
field,
value,
"the value must be at least 1",
));
}
Ok(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RawServeLimits {
pub max_body_mb: u64,
pub max_result_mb: u64,
pub max_export_mb: u64,
pub max_download_mb: u64,
pub max_queue_text: usize,
pub max_queue_formula: usize,
pub max_consecutive_text: usize,
pub max_consecutive_formula: usize,
pub max_retained: usize,
pub max_retained_mb: u64,
pub max_tombstones: usize,
pub job_ttl_secs: u64,
pub max_eval_cases: usize,
}
impl Default for RawServeLimits {
fn default() -> Self {
Self {
max_body_mb: DEFAULT_MAX_BODY_MB,
max_result_mb: DEFAULT_MAX_RESULT_MB,
max_export_mb: DEFAULT_MAX_EXPORT_MB,
max_download_mb: DEFAULT_MAX_DOWNLOAD_MB,
max_queue_text: DEFAULT_MAX_QUEUE_TEXT,
max_queue_formula: DEFAULT_MAX_QUEUE_FORMULA,
max_consecutive_text: DEFAULT_MAX_CONSECUTIVE_TEXT,
max_consecutive_formula: DEFAULT_MAX_CONSECUTIVE_FORMULA,
max_retained: DEFAULT_MAX_RETAINED,
max_retained_mb: DEFAULT_MAX_RETAINED_MB,
max_tombstones: DEFAULT_MAX_TOMBSTONES,
job_ttl_secs: DEFAULT_JOB_TTL_SECS,
max_eval_cases: DEFAULT_MAX_EVAL_CASES,
}
}
}
impl RawServeLimits {
pub fn validate(self) -> ServeConfigResult<ServeLimits> {
Ok(ServeLimits {
max_body_bytes: mib_to_bytes("--max-body-mb", self.max_body_mb)?,
max_result_bytes: mib_to_bytes("--max-result-mb", self.max_result_mb)?,
max_export_bytes: mib_to_bytes("--max-export-mb", self.max_export_mb)?,
max_download_bytes: mib_to_bytes("--max-download-mb", self.max_download_mb)?,
max_queue_text: require_positive_usize("--max-queue-text", self.max_queue_text)?,
max_queue_formula: require_positive_usize(
"--max-queue-formula",
self.max_queue_formula,
)?,
max_consecutive_text: require_positive_usize(
"--max-consecutive-text",
self.max_consecutive_text,
)?,
max_consecutive_formula: require_positive_usize(
"--max-consecutive-formula",
self.max_consecutive_formula,
)?,
max_retained: require_positive_usize("--max-retained", self.max_retained)?,
max_retained_bytes: mib_to_bytes("--max-retained-mb", self.max_retained_mb)?,
max_tombstones: require_positive_usize("--max-tombstones", self.max_tombstones)?,
max_eval_cases: require_positive_usize("--max-eval-cases", self.max_eval_cases)?,
job_ttl_ms: require_positive_u64("--job-ttl-secs", self.job_ttl_secs)?
.checked_mul(1000)
.ok_or_else(|| {
ServeConfigError::new(
"--job-ttl-secs",
self.job_ttl_secs,
"overflows milliseconds (u64)",
)
})?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServeLimits {
pub max_body_bytes: u64,
pub max_result_bytes: u64,
pub max_export_bytes: u64,
pub max_download_bytes: u64,
pub max_queue_text: usize,
pub max_queue_formula: usize,
pub max_consecutive_text: usize,
pub max_consecutive_formula: usize,
pub max_retained: usize,
pub max_retained_bytes: u64,
pub max_tombstones: usize,
pub job_ttl_ms: u64,
pub max_eval_cases: usize,
}
impl ServeLimits {
pub fn defaults() -> Self {
RawServeLimits::default()
.validate()
.expect("the documented serve defaults must be valid")
}
pub fn download_budget(&self) -> rapid_structure_rs::DownloadBudget {
rapid_structure_rs::DownloadBudget::new(self.max_download_bytes)
}
}
impl Default for ServeLimits {
fn default() -> Self {
Self::defaults()
}
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_JOB_TTL_SECS, DEFAULT_MAX_BODY_MB, DEFAULT_MAX_CONSECUTIVE_FORMULA,
DEFAULT_MAX_CONSECUTIVE_TEXT, DEFAULT_MAX_DOWNLOAD_MB, DEFAULT_MAX_EVAL_CASES,
DEFAULT_MAX_EXPORT_MB, DEFAULT_MAX_QUEUE_FORMULA, DEFAULT_MAX_QUEUE_TEXT,
DEFAULT_MAX_RESULT_MB, DEFAULT_MAX_RETAINED, DEFAULT_MAX_RETAINED_MB,
DEFAULT_MAX_TOMBSTONES, DEFAULT_PORT, MIB, RawServeLimits, ServeLimits,
};
fn default_limits() -> ServeLimits {
ServeLimits::defaults()
}
#[test]
fn documented_defaults_are_exactly_the_documented_values() {
assert_eq!(DEFAULT_PORT, 8760);
assert_eq!(DEFAULT_MAX_BODY_MB, 32);
assert_eq!(DEFAULT_MAX_RESULT_MB, 8);
assert_eq!(DEFAULT_MAX_EXPORT_MB, 32);
assert_eq!(DEFAULT_MAX_DOWNLOAD_MB, 1024);
assert_eq!(DEFAULT_MAX_QUEUE_TEXT, 4);
assert_eq!(DEFAULT_MAX_QUEUE_FORMULA, 2);
assert_eq!(DEFAULT_MAX_CONSECUTIVE_TEXT, 4);
assert_eq!(DEFAULT_MAX_CONSECUTIVE_FORMULA, 1);
assert_eq!(DEFAULT_MAX_RETAINED, 32);
assert_eq!(DEFAULT_MAX_RETAINED_MB, 64);
assert_eq!(DEFAULT_MAX_TOMBSTONES, 256);
assert_eq!(DEFAULT_JOB_TTL_SECS, 600);
assert_eq!(DEFAULT_MAX_EVAL_CASES, 32);
let limits = default_limits();
assert_eq!(limits.max_body_bytes, 32 * MIB);
assert_eq!(limits.max_result_bytes, 8 * MIB);
assert_eq!(limits.max_export_bytes, 32 * MIB);
assert_eq!(limits.max_download_bytes, 1024 * MIB);
assert_eq!(limits.max_retained_bytes, 64 * MIB);
assert_eq!(limits.job_ttl_ms, 600_000);
assert_eq!(limits.max_eval_cases, 32);
assert!(limits.max_download_bytes > 566 * 1_000_000);
}
#[test]
fn zero_mib_limits_are_rejected_with_the_flag_name() {
let cases: [(RawServeLimits, &str); 5] = [
(
RawServeLimits {
max_body_mb: 0,
..Default::default()
},
"--max-body-mb",
),
(
RawServeLimits {
max_result_mb: 0,
..Default::default()
},
"--max-result-mb",
),
(
RawServeLimits {
max_export_mb: 0,
..Default::default()
},
"--max-export-mb",
),
(
RawServeLimits {
max_download_mb: 0,
..Default::default()
},
"--max-download-mb",
),
(
RawServeLimits {
max_retained_mb: 0,
..Default::default()
},
"--max-retained-mb",
),
];
for (raw, expected_field) in cases {
let error = raw
.validate()
.expect_err(&format!("{expected_field}=0 must be rejected"));
assert_eq!(error.field(), expected_field, "error: {error}");
assert!(
error
.to_string()
.starts_with(&format!("invalid {expected_field}=0")),
"error must locate the flag and the value: {error}"
);
}
}
#[test]
fn mib_multiplication_overflow_is_rejected_and_never_wraps() {
let raw = RawServeLimits {
max_body_mb: u64::MAX,
..Default::default()
};
let error = raw.validate().expect_err("u64::MAX MiB must overflow");
assert_eq!(error.field(), "--max-body-mb");
assert!(error.reason().contains("overflows"), "error: {error}");
assert!(
error.reason().contains(&u64::MAX.to_string()),
"the error must name the offending value: {error}"
);
let just_over = RawServeLimits {
max_result_mb: 1 << 44,
..Default::default()
};
assert!(just_over.validate().is_err());
let just_under = RawServeLimits {
max_result_mb: (1 << 44) - 1,
..Default::default()
};
let limits = just_under
.validate()
.expect("(2^44 - 1) MiB must still fit in u64");
assert_eq!(limits.max_result_bytes, ((1 << 44) - 1) * MIB);
}
#[test]
fn zero_queue_and_retention_parameters_are_rejected() {
let cases: [(RawServeLimits, &str); 6] = [
(
RawServeLimits {
max_queue_text: 0,
..Default::default()
},
"--max-queue-text",
),
(
RawServeLimits {
max_queue_formula: 0,
..Default::default()
},
"--max-queue-formula",
),
(
RawServeLimits {
max_consecutive_text: 0,
..Default::default()
},
"--max-consecutive-text",
),
(
RawServeLimits {
max_consecutive_formula: 0,
..Default::default()
},
"--max-consecutive-formula",
),
(
RawServeLimits {
max_retained: 0,
..Default::default()
},
"--max-retained",
),
(
RawServeLimits {
max_tombstones: 0,
..Default::default()
},
"--max-tombstones",
),
];
for (raw, expected_field) in cases {
let error = raw
.validate()
.expect_err(&format!("{expected_field}=0 must be rejected"));
assert_eq!(error.field(), expected_field, "error: {error}");
}
let ttl = RawServeLimits {
job_ttl_secs: 0,
..Default::default()
};
let error = ttl
.validate()
.expect_err("--job-ttl-secs=0 must be rejected");
assert_eq!(error.field(), "--job-ttl-secs");
let eval = RawServeLimits {
max_eval_cases: 0,
..Default::default()
};
let error = eval
.validate()
.expect_err("--max-eval-cases=0 must be rejected");
assert_eq!(error.field(), "--max-eval-cases");
}
#[test]
fn job_ttl_seconds_overflow_is_rejected() {
let raw = RawServeLimits {
job_ttl_secs: u64::MAX,
..Default::default()
};
let error = raw.validate().expect_err("u64::MAX seconds must overflow");
assert_eq!(error.field(), "--job-ttl-secs");
assert!(error.reason().contains("milliseconds"), "error: {error}");
}
#[test]
fn mib_to_bytes_is_exact() {
let one = RawServeLimits {
max_body_mb: 1,
..Default::default()
}
.validate()
.expect("1 MiB is valid");
assert_eq!(one.max_body_bytes, 1_048_576);
let seven = RawServeLimits {
max_body_mb: 7,
..Default::default()
}
.validate()
.expect("7 MiB is valid");
assert_eq!(seven.max_body_bytes, 7 * 1_048_576);
}
#[test]
fn the_download_budget_starts_at_the_single_file_cap_and_decreases_per_file() {
let limits = ServeLimits::defaults();
let mut budget = limits.download_budget();
assert_eq!(budget.total_bytes(), 1024 * MIB);
assert_eq!(budget.remaining_bytes(), 1024 * MIB);
assert_eq!(
budget.per_file_cap(),
1024 * MIB,
"the per-file cap is the whole budget until something is downloaded"
);
budget
.charge(593_915_961)
.expect("the formula model must fit the default budget");
assert_eq!(budget.remaining_bytes(), 1024 * MIB - 593_915_961);
assert_eq!(
budget.per_file_cap(),
budget.remaining_bytes(),
"the next file's cap is the remaining budget"
);
let error = budget
.charge(1024 * MIB)
.expect_err("a file larger than the remaining budget must be refused");
assert_eq!(
budget.remaining_bytes(),
1024 * MIB - 593_915_961,
"a refused charge must not consume the budget: {error}"
);
assert!(error.to_string().contains("byte limit"), "{error}");
}
}