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, v1 fallback, including process membership and ancestor
159/// ceilings rather than only the controller mount root. 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    let (v2, v1) = crate::utils::cgroup_memory::limits();
168    v2.or(v1)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn env_set(key: &str, value: &str) {
176        unsafe {
177            std::env::set_var(key, value);
178        }
179    }
180    fn env_unset(key: &str) {
181        unsafe {
182            std::env::remove_var(key);
183        }
184    }
185
186    #[test]
187    fn from_env_reads_max_db_size() {
188        env_set("RED_MAX_DB_SIZE_BYTES", "1073741824");
189        let limits = ResourceLimits::from_env();
190        env_unset("RED_MAX_DB_SIZE_BYTES");
191        assert_eq!(limits.max_db_size_bytes, Some(1_073_741_824));
192    }
193
194    #[test]
195    fn legacy_reddb_prefix_is_accepted() {
196        env_set("REDDB_MAX_BATCH_SIZE", "10000");
197        let limits = ResourceLimits::from_env();
198        env_unset("REDDB_MAX_BATCH_SIZE");
199        assert_eq!(limits.max_batch_size, Some(10_000));
200    }
201
202    #[test]
203    fn unset_env_yields_no_limit() {
204        env_unset("RED_MAX_QPS");
205        env_unset("REDDB_MAX_QPS");
206        let limits = ResourceLimits::from_env();
207        assert!(limits.max_qps.is_none());
208    }
209
210    #[test]
211    fn db_size_exceeded_respects_zero_as_unbounded() {
212        let limits = ResourceLimits {
213            max_db_size_bytes: Some(0),
214            ..Default::default()
215        };
216        assert!(!limits.db_size_exceeded(u64::MAX));
217    }
218
219    #[test]
220    fn db_size_exceeded_triggers_above_limit() {
221        let limits = ResourceLimits {
222            max_db_size_bytes: Some(1024),
223            ..Default::default()
224        };
225        assert!(!limits.db_size_exceeded(1024));
226        assert!(limits.db_size_exceeded(1025));
227    }
228
229    #[test]
230    fn memory_mb_converts_to_bytes() {
231        env_set("RED_MAX_MEMORY_MB", "256");
232        let limits = ResourceLimits::from_env();
233        env_unset("RED_MAX_MEMORY_MB");
234        assert_eq!(limits.max_memory_bytes, Some(256 * 1024 * 1024));
235    }
236
237    #[test]
238    fn query_duration_parses_to_duration() {
239        env_set("RED_MAX_QUERY_DURATION_MS", "30000");
240        let limits = ResourceLimits::from_env();
241        env_unset("RED_MAX_QUERY_DURATION_MS");
242        assert_eq!(limits.max_query_duration, Some(Duration::from_secs(30)));
243    }
244}