Skip to main content

rushdb_query/
budget.rs

1//! The two byte budgets a read is charged against, and the error that names
2//! the one that tripped.
3//!
4//! A read holds a *working set* while it runs (candidate and match ordinals,
5//! ordering rows, the row being evaluated, aggregate buckets): that is
6//! `query-memory-max`, tightened by the request. What it *returns* is charged
7//! by its real serialized bytes against the *response* budget: the response
8//! size limit (`server.body-limit`), or `query-memory-max` when the caller
9//! gave no response budget.
10
11use crate::QueryError;
12
13/// Which byte budget a read exceeded.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Budget {
16    /// `query-memory-max`: what a query holds while it runs.
17    WorkingSet,
18    /// The response size limit (`server.body-limit`): the serialized rows a
19    /// query returns.
20    Response,
21}
22
23impl Budget {
24    /// The error for a read that would hold or return more than `limit`
25    /// bytes: it names the limit and the flag that raises it.
26    #[must_use]
27    pub fn exceeded(self, limit: u64) -> QueryError {
28        QueryError::ResourceLimit(match self {
29            Self::WorkingSet => format!(
30                "query working set would exceed query-memory-max ({limit} bytes); \
31                 raise --query-memory-max or narrow the filter"
32            ),
33            Self::Response => format!(
34                "response would exceed the response size limit ({limit} bytes); \
35                 raise --body-limit or request a smaller page"
36            ),
37        })
38    }
39
40    /// Fails when `bytes` exceed `limit` (`None`: uncapped).
41    pub(crate) fn check(self, limit: Option<usize>, bytes: usize) -> Result<(), QueryError> {
42        match limit {
43            Some(limit) if bytes > limit => Err(self.exceeded(as_u64(limit))),
44            _ => Ok(()),
45        }
46    }
47}
48
49/// One budget with what is already charged to it, handed down to work that
50/// spends from the same allowance (a page's `$collect` expansion).
51#[derive(Clone, Copy, Debug)]
52pub(crate) struct Cap {
53    kind: Budget,
54    limit: usize,
55    used: usize,
56}
57
58impl Cap {
59    pub(crate) fn new(kind: Budget, limit: usize) -> Self {
60        Self {
61            kind,
62            limit,
63            used: 0,
64        }
65    }
66
67    /// The allowance a read gets: the caller's response budget, else
68    /// `query-memory-max` (`0` = off).
69    ///
70    /// `Some(0)` from a caller is a *hard zero cap* (an exhausted request
71    /// budget), distinct from a configured `query-memory-max` of `0`, which
72    /// disables the cap.
73    pub(crate) fn for_response(response: Option<usize>, configured: Option<u64>) -> Option<Self> {
74        match response {
75            Some(bytes) => Some(Self::new(Budget::Response, bytes)),
76            None => configured.filter(|bytes| *bytes != 0).map(|bytes| {
77                Self::new(
78                    Budget::WorkingSet,
79                    usize::try_from(bytes).unwrap_or(usize::MAX),
80                )
81            }),
82        }
83    }
84
85    /// Fails when `bytes` more than what is already charged exceed the limit.
86    pub(crate) fn check(self, bytes: usize) -> Result<(), QueryError> {
87        if self.used.saturating_add(bytes) > self.limit {
88            return Err(self.kind.exceeded(as_u64(self.limit)));
89        }
90        Ok(())
91    }
92
93    /// [`Self::check`] when there is a cap.
94    pub(crate) fn check_opt(cap: Option<Self>, bytes: usize) -> Result<(), QueryError> {
95        cap.map_or(Ok(()), |cap| cap.check(bytes))
96    }
97
98    /// The same budget with `bytes` more already charged.
99    pub(crate) fn after(self, bytes: usize) -> Self {
100        Self {
101            used: self.used.saturating_add(bytes),
102            ..self
103        }
104    }
105
106    /// Bytes still free.
107    pub(crate) fn remaining(self) -> usize {
108        self.limit.saturating_sub(self.used)
109    }
110
111    pub(crate) fn limit(self) -> usize {
112        self.limit
113    }
114}
115
116/// The bytes a search holds at once, against `query-memory-max`.
117pub(crate) struct WorkingSet {
118    pub(crate) limit: Option<u64>,
119    pub(crate) charged: u64,
120}
121
122impl WorkingSet {
123    /// Admit `bytes`; `retained` bytes stay charged, others (a record that
124    /// only feeds the predicate) only need to fit in the remaining room.
125    pub(crate) fn charge(&mut self, bytes: u64, retained: bool) -> Result<(), QueryError> {
126        if let Some(limit) = self.limit {
127            if self.charged.saturating_add(bytes) > limit {
128                return Err(Budget::WorkingSet.exceeded(limit));
129            }
130            if retained {
131                self.charged += bytes;
132            }
133        }
134        Ok(())
135    }
136}
137
138fn as_u64(bytes: usize) -> u64 {
139    u64::try_from(bytes).unwrap_or(u64::MAX)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{Budget, Cap, WorkingSet};
145    use crate::QueryError;
146
147    fn message(error: QueryError) -> String {
148        match error {
149            QueryError::ResourceLimit(message) => message,
150            other => panic!("expected a resource limit, got {other:?}"),
151        }
152    }
153
154    #[test]
155    fn each_budget_names_its_own_limit_and_flag() {
156        let working = message(Budget::WorkingSet.check(Some(10), 11).unwrap_err());
157        assert!(working.contains("query-memory-max (10 bytes)"), "{working}");
158        assert!(working.contains("--query-memory-max"), "{working}");
159        assert!(!working.contains("body-limit"), "{working}");
160        let response = message(Cap::new(Budget::Response, 10).check(11).unwrap_err());
161        assert!(
162            response.contains("response size limit (10 bytes)"),
163            "{response}"
164        );
165        assert!(response.contains("--body-limit"), "{response}");
166        assert!(!response.contains("query-memory-max"), "{response}");
167        let mut set = WorkingSet {
168            limit: Some(8),
169            charged: 0,
170        };
171        set.charge(8, true).unwrap();
172        let records = message(set.charge(1, false).unwrap_err());
173        assert_eq!(records, working.replace("(10 bytes)", "(8 bytes)"));
174    }
175
176    #[test]
177    fn a_cap_spends_from_one_allowance_and_reports_the_whole_limit() {
178        let cap = Cap::new(Budget::Response, 100).after(60);
179        assert_eq!(cap.remaining(), 40);
180        cap.check(40).unwrap();
181        let error = message(cap.check(41).unwrap_err());
182        assert!(error.contains("(100 bytes)"), "{error}");
183        assert!(Cap::for_response(None, Some(0)).is_none());
184        assert_eq!(Cap::for_response(Some(0), None).unwrap().remaining(), 0);
185        let fallback = Cap::for_response(None, Some(5)).unwrap();
186        assert!(message(fallback.check(6).unwrap_err()).contains("query-memory-max"));
187    }
188}