Skip to main content

reddb_server/runtime/
resource_limits.rs

1//! Operator-imposed resource limits (PLAN.md Phase 4.1).
2//!
3//! Cloud-agnostic deployments need hard upper bounds enforced
4//! regardless of cgroup or namespace presence — a process that
5//! grows past its allocated capacity slot can starve every other
6//! tenant on the same host. The limits here are read once at boot
7//! from `RED_MAX_*` env vars and held in a single immutable struct
8//! consulted by the various enforcement points (write path, accept
9//! loop, query timer, batch validator).
10//!
11//! `Option<u64>` semantics: `None` means "operator did not pin a
12//! cap at boot — fall through to whatever upstream layer (cgroup,
13//! kernel `RLIMIT_*`, runtime defaults) decides". `Some(0)` is
14//! reserved as "explicitly unbounded" so an operator who sets an
15//! env var to the literal `0` can disable a default without
16//! scripting.
17
18use std::time::Duration;
19
20/// Snapshot of the `RED_MAX_*` env vars read at runtime
21/// construction. Held by `RuntimeInner` and accessible via
22/// `RedDBRuntime::resource_limits()` so observability and
23/// enforcement consult the same values.
24#[derive(Debug, Clone, Default)]
25pub struct ResourceLimits {
26    /// Maximum primary-database file size in bytes. Writes that
27    /// would push the file past this value return
28    /// `RedDBError::QuotaExceeded` with a structured payload
29    /// (`{limit:"max_db_size", current, max}`). Operator-level
30    /// behaviour PLAN.md prescribes: returns HTTP 507 Insufficient
31    /// Storage at the public surface.
32    pub max_db_size_bytes: Option<u64>,
33
34    /// Maximum concurrent client connections. Saturated accept
35    /// loops return HTTP 503 / wire-protocol error so callers back
36    /// off cleanly.
37    pub max_connections: Option<u64>,
38
39    /// Soft memory budget in bytes. Cache eviction fires at this
40    /// threshold; the runtime never panics on OOM. `0` disables
41    /// the soft cap entirely.
42    pub max_memory_bytes: Option<u64>,
43
44    /// Maximum queries-per-second sustained per-instance. Token
45    /// bucket fires HTTP 429 / wire backoff on excess.
46    pub max_qps: Option<u64>,
47
48    /// Cooperative wall-time budget for materialized table/graph/join/path/
49    /// vector/hybrid reads. Work checkpoints return HTTP 504; blocking I/O,
50    /// parsing and operators without checkpoints are not forcibly preempted.
51    pub max_query_duration: Option<Duration>,
52
53    /// Concurrent materialized read executions. Zero leaves admission unbounded.
54    pub max_concurrent_queries: Option<u64>,
55
56    /// Maximum compact-JSON core read payload (query, columns, canonical records).
57    /// HTTP additionally bounds its complete presentation envelope. Larger results fail
58    /// without returning successful partial data. Mutation acknowledgements
59    /// are excluded so a committed write is not reported as a read-limit failure.
60    pub max_result_bytes: Option<u64>,
61
62    /// Maximum rows per bulk insert / update / delete. Caps the
63    /// memory the server allocates for any one batch.
64    pub max_batch_size: Option<u64>,
65}
66
67impl ResourceLimits {
68    pub(crate) fn read_execution_limits_enabled(&self) -> bool {
69        self.max_query_duration
70            .is_some_and(|value| !value.is_zero())
71            || self.max_concurrent_queries.is_some_and(|value| value > 0)
72    }
73    /// Read limits from env vars. Accepts both the cloud-agnostic
74    /// `RED_MAX_*` family (PLAN.md spec) and the legacy `REDDB_MAX_*`
75    /// form for existing dev installs. Missing or unparseable
76    /// values stay `None`. `0` is treated as "explicitly
77    /// unbounded" so operators can disable a deployment-default
78    /// cap without unsetting the env.
79    pub fn from_env() -> Self {
80        let mut out = Self {
81            max_db_size_bytes: Self::read_u64("MAX_DB_SIZE_BYTES"),
82            max_connections: Self::read_u64("MAX_CONNECTIONS"),
83            max_memory_bytes: Self::read_u64("MAX_MEMORY_MB")
84                .map(|mb| mb.saturating_mul(1_048_576)),
85            max_qps: Self::read_u64("MAX_QPS"),
86            max_query_duration: Self::read_u64("MAX_QUERY_DURATION_MS").map(Duration::from_millis),
87            max_concurrent_queries: Self::read_u64("MAX_CONCURRENT_QUERIES"),
88            max_result_bytes: Self::read_u64("MAX_RESULT_BYTES"),
89            max_batch_size: Self::read_u64("MAX_BATCH_SIZE"),
90        };
91
92        // PLAN.md Phase 4.2 — auto-detect container memory cap when
93        // the operator didn't pin one. Cgroup v2 first
94        // (`memory.max`), v1 fallback
95        // (`memory/memory.limit_in_bytes`). Cross-platform: missing
96        // files / non-Linux just leave the field `None`. The
97        // explicit env var still wins so an operator can override
98        // a too-tight cgroup detect without restructuring the
99        // container.
100        if out.max_memory_bytes.is_none() {
101            out.max_memory_bytes = read_cgroup_memory_max();
102        }
103
104        out
105    }
106
107    fn read_u64(suffix: &str) -> Option<u64> {
108        std::env::var(format!("RED_{suffix}"))
109            .or_else(|_| std::env::var(format!("REDDB_{suffix}")))
110            .ok()
111            .and_then(|raw| raw.trim().parse::<u64>().ok())
112    }
113
114    /// Whether `max_db_size_bytes` is set and `current_bytes`
115    /// exceeds it. Cheap branch — caller decides what to do
116    /// (surface-specific error code, refuse new writes, suspend).
117    pub fn db_size_exceeded(&self, current_bytes: u64) -> bool {
118        match self.max_db_size_bytes {
119            Some(limit) if limit > 0 => current_bytes > limit,
120            _ => false,
121        }
122    }
123
124    pub fn batch_size_exceeded(&self, requested: usize) -> bool {
125        match self.max_batch_size {
126            Some(limit) if limit > 0 => (requested as u64) > limit,
127            _ => false,
128        }
129    }
130
131    /// Issue #205 — disk-headroom monitor. When `available_bytes` drops
132    /// below `threshold_bytes`, emit a `DiskSpaceCritical` operator
133    /// event. Returns whether the threshold was breached so callers
134    /// can also fail the path that triggered the check. Cheap to call
135    /// per-write: the threshold check is one branch and the emit only
136    /// runs on breach.
137    pub fn check_disk_headroom(
138        &self,
139        path: &str,
140        available_bytes: u64,
141        threshold_bytes: u64,
142    ) -> bool {
143        if threshold_bytes > 0 && available_bytes < threshold_bytes {
144            crate::telemetry::operator_event::OperatorEvent::DiskSpaceCritical {
145                path: path.to_string(),
146                available_bytes,
147                threshold_bytes,
148            }
149            .emit_global();
150            true
151        } else {
152            false
153        }
154    }
155}
156
157/// Read the active cgroup memory cap, returning bytes when known.
158/// Cgroup v2 first (`/sys/fs/cgroup/memory.max`), v1 fallback
159/// (`/sys/fs/cgroup/memory/memory.limit_in_bytes`). The literal
160/// string `max` (cgroup v2 "no cap") returns `None` so the
161/// resource-limits struct stays at "no cap pinned".
162///
163/// Non-Linux / missing files / unparseable contents → `None`. Never
164/// panics; the caller treats absence as "fall through to whatever
165/// upstream layer decides".
166fn read_cgroup_memory_max() -> Option<u64> {
167    // cgroup v2
168    if let Ok(raw) = std::fs::read_to_string("/sys/fs/cgroup/memory.max") {
169        let trimmed = raw.trim();
170        if trimmed != "max" && !trimmed.is_empty() {
171            if let Ok(bytes) = trimmed.parse::<u64>() {
172                if bytes > 0 && bytes < u64::MAX / 2 {
173                    return Some(bytes);
174                }
175            }
176        }
177    }
178    // cgroup v1
179    if let Ok(raw) = std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes") {
180        if let Ok(bytes) = raw.trim().parse::<u64>() {
181            // Kernels report `9223372036854771712` as "unlimited" in
182            // cgroup v1; treat any value that's effectively
183            // unbounded as `None`.
184            if bytes > 0 && bytes < (u64::MAX / 2) {
185                return Some(bytes);
186            }
187        }
188    }
189    None
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn env_set(key: &str, value: &str) {
197        unsafe {
198            std::env::set_var(key, value);
199        }
200    }
201    fn env_unset(key: &str) {
202        unsafe {
203            std::env::remove_var(key);
204        }
205    }
206
207    #[test]
208    fn from_env_reads_max_db_size() {
209        env_set("RED_MAX_DB_SIZE_BYTES", "1073741824");
210        let limits = ResourceLimits::from_env();
211        env_unset("RED_MAX_DB_SIZE_BYTES");
212        assert_eq!(limits.max_db_size_bytes, Some(1_073_741_824));
213    }
214
215    #[test]
216    fn legacy_reddb_prefix_is_accepted() {
217        env_set("REDDB_MAX_BATCH_SIZE", "10000");
218        let limits = ResourceLimits::from_env();
219        env_unset("REDDB_MAX_BATCH_SIZE");
220        assert_eq!(limits.max_batch_size, Some(10_000));
221    }
222
223    #[test]
224    fn unset_env_yields_no_limit() {
225        env_unset("RED_MAX_QPS");
226        env_unset("REDDB_MAX_QPS");
227        let limits = ResourceLimits::from_env();
228        assert!(limits.max_qps.is_none());
229    }
230
231    #[test]
232    fn db_size_exceeded_respects_zero_as_unbounded() {
233        let limits = ResourceLimits {
234            max_db_size_bytes: Some(0),
235            ..Default::default()
236        };
237        assert!(!limits.db_size_exceeded(u64::MAX));
238    }
239
240    #[test]
241    fn db_size_exceeded_triggers_above_limit() {
242        let limits = ResourceLimits {
243            max_db_size_bytes: Some(1024),
244            ..Default::default()
245        };
246        assert!(!limits.db_size_exceeded(1024));
247        assert!(limits.db_size_exceeded(1025));
248    }
249
250    #[test]
251    fn memory_mb_converts_to_bytes() {
252        env_set("RED_MAX_MEMORY_MB", "256");
253        let limits = ResourceLimits::from_env();
254        env_unset("RED_MAX_MEMORY_MB");
255        assert_eq!(limits.max_memory_bytes, Some(256 * 1024 * 1024));
256    }
257
258    #[test]
259    fn query_duration_parses_to_duration() {
260        env_set("RED_MAX_QUERY_DURATION_MS", "30000");
261        let limits = ResourceLimits::from_env();
262        env_unset("RED_MAX_QUERY_DURATION_MS");
263        assert_eq!(limits.max_query_duration, Some(Duration::from_secs(30)));
264    }
265}