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}