use crate::QueryError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Budget {
WorkingSet,
Response,
}
impl Budget {
#[must_use]
pub fn exceeded(self, limit: u64) -> QueryError {
QueryError::ResourceLimit(match self {
Self::WorkingSet => format!(
"query working set would exceed query-memory-max ({limit} bytes); \
raise --query-memory-max or narrow the filter"
),
Self::Response => format!(
"response would exceed the response size limit ({limit} bytes); \
raise --body-limit or request a smaller page"
),
})
}
pub(crate) fn check(self, limit: Option<usize>, bytes: usize) -> Result<(), QueryError> {
match limit {
Some(limit) if bytes > limit => Err(self.exceeded(as_u64(limit))),
_ => Ok(()),
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Cap {
kind: Budget,
limit: usize,
used: usize,
}
impl Cap {
pub(crate) fn new(kind: Budget, limit: usize) -> Self {
Self {
kind,
limit,
used: 0,
}
}
pub(crate) fn for_response(response: Option<usize>, configured: Option<u64>) -> Option<Self> {
match response {
Some(bytes) => Some(Self::new(Budget::Response, bytes)),
None => configured.filter(|bytes| *bytes != 0).map(|bytes| {
Self::new(
Budget::WorkingSet,
usize::try_from(bytes).unwrap_or(usize::MAX),
)
}),
}
}
pub(crate) fn check(self, bytes: usize) -> Result<(), QueryError> {
if self.used.saturating_add(bytes) > self.limit {
return Err(self.kind.exceeded(as_u64(self.limit)));
}
Ok(())
}
pub(crate) fn check_opt(cap: Option<Self>, bytes: usize) -> Result<(), QueryError> {
cap.map_or(Ok(()), |cap| cap.check(bytes))
}
pub(crate) fn after(self, bytes: usize) -> Self {
Self {
used: self.used.saturating_add(bytes),
..self
}
}
pub(crate) fn remaining(self) -> usize {
self.limit.saturating_sub(self.used)
}
pub(crate) fn limit(self) -> usize {
self.limit
}
}
pub(crate) struct WorkingSet {
pub(crate) limit: Option<u64>,
pub(crate) charged: u64,
}
impl WorkingSet {
pub(crate) fn charge(&mut self, bytes: u64, retained: bool) -> Result<(), QueryError> {
if let Some(limit) = self.limit {
if self.charged.saturating_add(bytes) > limit {
return Err(Budget::WorkingSet.exceeded(limit));
}
if retained {
self.charged += bytes;
}
}
Ok(())
}
}
fn as_u64(bytes: usize) -> u64 {
u64::try_from(bytes).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::{Budget, Cap, WorkingSet};
use crate::QueryError;
fn message(error: QueryError) -> String {
match error {
QueryError::ResourceLimit(message) => message,
other => panic!("expected a resource limit, got {other:?}"),
}
}
#[test]
fn each_budget_names_its_own_limit_and_flag() {
let working = message(Budget::WorkingSet.check(Some(10), 11).unwrap_err());
assert!(working.contains("query-memory-max (10 bytes)"), "{working}");
assert!(working.contains("--query-memory-max"), "{working}");
assert!(!working.contains("body-limit"), "{working}");
let response = message(Cap::new(Budget::Response, 10).check(11).unwrap_err());
assert!(
response.contains("response size limit (10 bytes)"),
"{response}"
);
assert!(response.contains("--body-limit"), "{response}");
assert!(!response.contains("query-memory-max"), "{response}");
let mut set = WorkingSet {
limit: Some(8),
charged: 0,
};
set.charge(8, true).unwrap();
let records = message(set.charge(1, false).unwrap_err());
assert_eq!(records, working.replace("(10 bytes)", "(8 bytes)"));
}
#[test]
fn a_cap_spends_from_one_allowance_and_reports_the_whole_limit() {
let cap = Cap::new(Budget::Response, 100).after(60);
assert_eq!(cap.remaining(), 40);
cap.check(40).unwrap();
let error = message(cap.check(41).unwrap_err());
assert!(error.contains("(100 bytes)"), "{error}");
assert!(Cap::for_response(None, Some(0)).is_none());
assert_eq!(Cap::for_response(Some(0), None).unwrap().remaining(), 0);
let fallback = Cap::for_response(None, Some(5)).unwrap();
assert!(message(fallback.check(6).unwrap_err()).contains("query-memory-max"));
}
}