saluki_app/
accounting.rs

1//! Resource accounting and telemetry.
2
3use std::{collections::VecDeque, env, fs, time::Duration};
4
5use bytesize::ByteSize;
6use metrics::{counter, gauge, Counter, Gauge, Level};
7use saluki_api::{DynamicRoute, EndpointType};
8use saluki_common::resource_tracking::{ResourceGroupRegistry, ResourceStats, ResourceStatsSnapshot};
9use saluki_common::{collections::FastHashMap, sync::shutdown::ShutdownHandle};
10use saluki_core::accounting::{
11    ComponentBounds, ComponentRegistry, ComponentRegistryHandle, MemoryGrant, MemoryLimiter,
12};
13use saluki_core::{
14    diagnostic::DiagnosticsEmitter,
15    runtime::{state::DataspaceRegistry, InitializationError, Supervisable, SupervisorFuture},
16    support::SubsystemIdentifier,
17};
18use saluki_error::{generic_error, ErrorContext as _, GenericError};
19use tokio::{select, time::sleep};
20use tonic::async_trait;
21use tracing::{error, info, warn};
22
23/// Bounds validation and global memory limiter behavior.
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub enum MemoryMode {
26    /// Bounds validation is skipped, and no memory limiting is applied.
27    #[default]
28    Disabled,
29
30    /// Treat bounds validation failures as non-fatal.
31    ///
32    /// Global memory limiter will be enabled and active if a memory limit is configured.
33    Permissive,
34
35    /// Treat bounds validation failures as fatal.
36    ///
37    /// Global memory limiter will be enabled and active if a memory limit is configured.
38    Strict,
39}
40
41/// Configuration for memory bounds.
42pub struct MemoryBoundsConfiguration {
43    /// Process memory limit. `None` enables cgroup detection.
44    pub memory_limit: Option<ByteSize>,
45
46    /// The slop factor to apply to the given memory limit.
47    ///
48    /// Memory bounds are inherently fuzzy, as components are required to manually define their bounds, and as such, can
49    /// only account for memory usage that they know about. The slop factor is applied as a reduction to the overall
50    /// memory limit, such that we account for the "known unknowns" -- memory that hasn't yet been accounted for -- by
51    /// simply ensuring that we can fit within a portion of the overall limit.
52    ///
53    /// Values between 0 to 1 are allowed, and represent the percentage of `memory_limit` that is held back. This means
54    /// that a slop factor of 0.25, for example, will cause 25% of `memory_limit` to be withheld. If `memory_limit` was
55    /// 100 MB, we would then verify that the memory bounds can fit within 75 MB (100 MB * (1 - 0.25) => 75 MB).
56    pub memory_slop_factor: f64,
57
58    /// Whether or not to enable the global memory limiter.
59    ///
60    /// When set to `false`, the global memory limiter will operate in a no-op mode. All calls to use it will never
61    /// exert backpressure, and only the inherent memory bounds of the running components will influence memory usage.
62    pub enable_global_limiter: bool,
63
64    /// The memory mode to use when reconciling the calculated memory bounds against the configured memory limit.
65    ///
66    /// See [`MemoryMode`] for the available modes and their behavior.
67    pub memory_mode: MemoryMode,
68}
69
70/// Initializes the memory bounds system and verifies any configured bounds based on the configured memory mode.
71///
72/// When no memory limit is configured and `DOCKER_DD_AGENT` is set to a non-empty value, the limit is read from the
73/// process cgroup instead.
74///
75/// See [`MemoryMode`] for details on the behavior of each mode.
76///
77/// # Errors
78///
79/// If the bounds could not be validated under [`MemoryMode::Strict`], or if the configured grant is invalid, an error
80/// is returned.
81pub fn initialize_memory_bounds(
82    configuration: MemoryBoundsConfiguration, component_registry: ComponentRegistryHandle,
83) -> Result<MemoryLimiter, GenericError> {
84    let memory_limit = configuration.memory_limit.or_else(detect_cgroup_memory_limit);
85
86    let configured_grant = memory_limit
87        .map(|limit| MemoryGrant::with_slop_factor(limit.as_u64() as usize, configuration.memory_slop_factor))
88        .transpose()
89        .error_context("Given memory limit and/or slop factor invalid.")?;
90
91    let limiter_grant = match configuration.memory_mode {
92        MemoryMode::Disabled => {
93            info!("Memory limiting disabled.");
94            None
95        }
96        mode @ (MemoryMode::Permissive | MemoryMode::Strict) => match configured_grant {
97            Some(grant) => {
98                verify_bounds_for_mode(mode, grant, &component_registry)?;
99                Some(grant)
100            }
101            None => {
102                info!("No memory limit set for the process. Skipping memory bounds verification.");
103                None
104            }
105        },
106    };
107
108    let limiter = match limiter_grant {
109        Some(grant) if configuration.enable_global_limiter => MemoryLimiter::new(grant)
110            .ok_or_else(|| generic_error!("Memory statistics cannot be gathered on this system."))?,
111        _ => MemoryLimiter::noop(),
112    };
113
114    Ok(limiter)
115}
116
117fn verify_bounds_for_mode(
118    mode: MemoryMode, initial_grant: MemoryGrant, component_registry: &ComponentRegistryHandle,
119) -> Result<(), GenericError> {
120    match component_registry.verify_bounds(initial_grant) {
121        Ok(verified_bounds) => {
122            info!(
123				"Verified memory bounds. Minimum memory requirement of {}, with a calculated firm memory bound of {} out of {} available, from an initial {} grant.",
124				bytes_to_si_string(verified_bounds.total_minimum_required_bytes()),
125				bytes_to_si_string(verified_bounds.total_firm_limit_bytes()),
126				bytes_to_si_string(verified_bounds.total_available_bytes()),
127				bytes_to_si_string(initial_grant.initial_limit_bytes()),
128			);
129
130            print_bounds(verified_bounds.bounds());
131            Ok(())
132        }
133        Err(e) => {
134            let bounds = component_registry.as_bounds();
135            print_bounds(&bounds);
136
137            match mode {
138                MemoryMode::Strict => {
139                    error!("Failed to verify memory bounds: {}.", e);
140                    Err(generic_error!(
141                        "Configured memory limit is insufficient for the current configuration."
142                    ))
143                }
144                MemoryMode::Permissive => {
145                    warn!(
146                        "Configured memory limit ({}) may be insufficient for the current configuration. Memory limiting behavior will be best effort. Continuing.",
147                        bytes_to_si_string(initial_grant.initial_limit_bytes()),
148                    );
149                    Ok(())
150                }
151                MemoryMode::Disabled => unreachable!("verify_bounds_for_mode is never called with Disabled mode"),
152            }
153        }
154    }
155}
156
157fn print_bounds(bounds: &ComponentBounds) {
158    info!("Breakdown of verified bounds:");
159    info!(
160        "- (root): {} minimum, {} firm",
161        bytes_to_si_string(bounds.total_minimum_required_bytes()),
162        bytes_to_si_string(bounds.total_firm_limit_bytes()),
163    );
164
165    let mut to_visit = VecDeque::new();
166    to_visit.extend(
167        bounds
168            .subcomponents()
169            .into_iter()
170            .map(|(name, bounds)| (1, name, bounds)),
171    );
172
173    while let Some((depth, component_name, component_bounds)) = to_visit.pop_front() {
174        info!(
175            "{:indent$}- {}: {} minimum, {} firm",
176            "",
177            component_name,
178            bytes_to_si_string(component_bounds.total_minimum_required_bytes()),
179            bytes_to_si_string(component_bounds.total_firm_limit_bytes()),
180            indent = depth * 2
181        );
182
183        let mut subcomponents = component_bounds.subcomponents().into_iter().collect::<Vec<_>>();
184        while let Some((subcomponent_name, subcomponent_bounds)) = subcomponents.pop() {
185            to_visit.push_front((depth + 1, subcomponent_name, subcomponent_bounds));
186        }
187    }
188
189    info!("");
190}
191
192struct ResourceGroupMetrics {
193    totals: ResourceStatsSnapshot,
194    allocated_bytes_total: Counter,
195    allocated_bytes_live: Gauge,
196    allocated_objects_total: Counter,
197    allocated_objects_live: Gauge,
198    deallocated_bytes_total: Counter,
199    deallocated_objects_total: Counter,
200    cpu_time_nanos_total: Counter,
201}
202
203impl ResourceGroupMetrics {
204    fn new(group_name: &str) -> Self {
205        Self {
206            totals: ResourceStatsSnapshot::empty(),
207            allocated_bytes_total: counter!(level: Level::DEBUG, "group_allocated_bytes_total", "group_id" => group_name.to_string()),
208            allocated_bytes_live: gauge!(level: Level::DEBUG, "group_allocated_bytes_live", "group_id" => group_name.to_string()),
209            allocated_objects_total: counter!(level: Level::DEBUG, "group_allocated_objects_total", "group_id" => group_name.to_string()),
210            allocated_objects_live: gauge!(level: Level::DEBUG, "group_allocated_objects_live", "group_id" => group_name.to_string()),
211            deallocated_bytes_total: counter!(level: Level::DEBUG, "group_deallocated_bytes_total", "group_id" => group_name.to_string()),
212            deallocated_objects_total: counter!(level: Level::DEBUG, "group_deallocated_objects_total", "group_id" => group_name.to_string()),
213            cpu_time_nanos_total: counter!(level: Level::DEBUG, "group_cpu_time_nanos_total", "group_id" => group_name.to_string()),
214        }
215    }
216
217    fn update(&mut self, stats: &ResourceStats) {
218        let delta = stats.snapshot_delta(&self.totals);
219
220        self.allocated_bytes_total.increment(delta.allocated_bytes as u64);
221        self.allocated_objects_total.increment(delta.allocated_objects as u64);
222        self.deallocated_bytes_total.increment(delta.deallocated_bytes as u64);
223        self.deallocated_objects_total
224            .increment(delta.deallocated_objects as u64);
225        self.cpu_time_nanos_total.increment(delta.cpu_time_nanos);
226
227        self.totals.merge(&delta);
228        self.allocated_bytes_live
229            .set((self.totals.allocated_bytes - self.totals.deallocated_bytes) as f64);
230        self.allocated_objects_live
231            .set((self.totals.allocated_objects - self.totals.deallocated_objects) as f64);
232    }
233}
234
235/// A worker that periodically collects per-resource group memory usage statistics and emits the internal telemetry.
236///
237/// Additionally, asserts the memory API routes from the given [`ComponentRegistry`] as a [`DynamicRoute`] on the
238/// unprivileged API endpoint.
239pub struct ResourceTelemetryWorker {
240    component_registry: ComponentRegistryHandle,
241}
242
243impl ResourceTelemetryWorker {
244    /// Creates a new `ResourceTelemetryWorker` for the given component registry.
245    pub fn new(component_registry: &ComponentRegistry) -> Self {
246        Self {
247            component_registry: component_registry.root(),
248        }
249    }
250}
251
252#[async_trait]
253impl Supervisable for ResourceTelemetryWorker {
254    fn name(&self) -> &str {
255        "resource-telemetry"
256    }
257
258    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
259        // We can't enforce, at compile-time, that the tracking allocator must be installed if a caller is trying to
260        // initialize the allocator's reporting infrastructure... but we can at least warn them if we detect it's not
261        // installed here at runtime.
262        if !ResourceGroupRegistry::allocator_installed() {
263            warn!("Tracking allocator not installed. Memory telemetry will not be available.");
264        }
265
266        let memory_routes = DynamicRoute::http(EndpointType::Unprivileged, self.component_registry.api_handler());
267
268        let component_registry = self.component_registry.clone();
269
270        Ok(Box::pin(async move {
271            let dataspace =
272                DataspaceRegistry::try_current().ok_or_else(|| generic_error!("Dataspace not available."))?;
273
274            // Register our API routes before we actually start running.
275            dataspace.assert(memory_routes, "resource-telemetry-api");
276
277            // Expose our diagnostic artifact via the diagnostics control surface.
278            let diagnostics = DiagnosticsEmitter::from_dataspace(
279                SubsystemIdentifier::from_segments(["resource-telemetry"]),
280                dataspace,
281            );
282            diagnostics.register_collector("memory_status.json", move || {
283                component_registry.memory_snapshot_json().into_bytes()
284            });
285
286            select! {
287                _ = process_shutdown => {},
288                _ = run_resource_group_metrics_loop() => {},
289            }
290
291            Ok(())
292        }))
293    }
294}
295
296async fn run_resource_group_metrics_loop() {
297    let mut metrics = FastHashMap::default();
298
299    loop {
300        ResourceGroupRegistry::global().visit_resource_groups(|group_name, stats| {
301            let group_metrics = match metrics.get_mut(group_name) {
302                Some(group_metrics) => group_metrics,
303                None => metrics
304                    .entry(group_name.to_string())
305                    .or_insert_with(|| ResourceGroupMetrics::new(group_name)),
306            };
307
308            group_metrics.update(stats);
309        });
310
311        sleep(Duration::from_secs(1)).await;
312    }
313}
314
315fn detect_cgroup_memory_limit() -> Option<ByteSize> {
316    if !env::var("DOCKER_DD_AGENT").is_ok_and(|value| !value.is_empty()) {
317        return None;
318    }
319
320    let memory = CgroupMemoryParser.parse()?;
321    info!(
322        "Setting memory limit to {} based on detected cgroups limit.",
323        memory.display().si()
324    );
325
326    Some(memory)
327}
328
329struct CgroupMemoryParser;
330
331impl CgroupMemoryParser {
332    /// Parse memory limit from memory controller.
333    ///
334    /// Returns `None` if memory limit is set to max or if an error is encountered while parsing.
335    fn parse(self) -> Option<ByteSize> {
336        let contents = fs::read_to_string("/proc/self/cgroup").ok()?;
337        let parts: Vec<&str> = contents.trim().split("\n").collect();
338        // CgroupV2 has unified controllers.
339        if parts.len() == 1 {
340            return self.parse_controller_v2(parts[0]);
341        }
342        for line in parts {
343            if line.contains(":memory:") {
344                return self.parse_controller_v1(line);
345            }
346        }
347        None
348    }
349
350    fn parse_controller_v1(self, controller: &str) -> Option<ByteSize> {
351        let path = controller.split(":").nth(2)?;
352        let memory_path = format!("/sys/fs/cgroup/memory{}/memory.limit_in_bytes", path);
353        let raw_memory_limit = fs::read_to_string(memory_path).ok()?;
354        self.convert_to_bytesize(&raw_memory_limit)
355    }
356
357    fn parse_controller_v2(self, controller: &str) -> Option<ByteSize> {
358        let path = controller.split(":").nth(2)?;
359        let memory_path = format!("/sys/fs/cgroup{}/memory.max", path);
360        let raw_memory_limit = fs::read_to_string(memory_path).ok()?;
361        self.convert_to_bytesize(&raw_memory_limit)
362    }
363
364    fn convert_to_bytesize(self, s: &str) -> Option<ByteSize> {
365        let memory = s.trim().to_string();
366        if memory == "max" {
367            return None;
368        }
369        memory.parse::<ByteSize>().ok()
370    }
371}
372
373fn bytes_to_si_string(bytes: usize) -> bytesize::Display {
374    ByteSize::b(bytes as u64).display().si()
375}
376
377#[cfg(test)]
378mod tests {
379    use saluki_config::test_env_lock;
380    use saluki_core::support::SubsystemIdentifier;
381
382    use super::*;
383
384    fn config_with_limit(limit: ByteSize, memory_mode: MemoryMode) -> MemoryBoundsConfiguration {
385        MemoryBoundsConfiguration {
386            memory_limit: Some(limit),
387            memory_slop_factor: 0.25,
388            enable_global_limiter: false,
389            memory_mode,
390        }
391    }
392
393    fn registry_with_firm_bound(bytes: usize) -> ComponentRegistry {
394        let registry = ComponentRegistry::default();
395        registry
396            .bounds_builder(&SubsystemIdentifier::from_dotted("test"))
397            .firm()
398            .with_fixed_amount("buffer", bytes);
399        registry
400    }
401
402    #[test]
403    fn cgroup_memory_parser_converts_raw_limits_to_bytes() {
404        // The cgroup memory files hold a bare byte count, or the literal `max` when no limit is set. `max` and any
405        // unparseable value yield `None`; a numeric value parses to that many bytes (after trimming whitespace).
406        let cases: &[(&str, Option<u64>)] = &[
407            ("max", None),
408            ("1073741824", Some(1_073_741_824)),
409            ("  1048576\n", Some(1_048_576)),
410            ("not-a-number", None),
411        ];
412
413        for (raw, expected) in cases {
414            let actual = CgroupMemoryParser.convert_to_bytesize(raw).map(|bytes| bytes.as_u64());
415            assert_eq!(actual, *expected, "raw input: {raw:?}");
416        }
417    }
418
419    #[test]
420    fn out_of_range_slop_factor_is_rejected() {
421        let mut config = config_with_limit(ByteSize::mib(1), MemoryMode::Strict);
422        config.memory_slop_factor = 1.5;
423
424        let error = initialize_memory_bounds(config, ComponentRegistry::default().root())
425            .err()
426            .expect("a slop factor of 1.5 should be rejected");
427        assert!(error
428            .to_string()
429            .contains("Given memory limit and/or slop factor invalid."));
430    }
431
432    #[test]
433    fn disabled_memory_mode_skips_bounds_verification() {
434        let registry = registry_with_firm_bound(64 * 1024 * 1024);
435
436        initialize_memory_bounds(
437            config_with_limit(ByteSize::b(1024), MemoryMode::Disabled),
438            registry.root(),
439        )
440        .expect("disabled mode should not verify bounds");
441    }
442
443    #[test]
444    fn strict_memory_mode_rejects_bounds_exceeding_the_limit() {
445        let registry = registry_with_firm_bound(64 * 1024 * 1024);
446
447        let error = initialize_memory_bounds(
448            config_with_limit(ByteSize::b(1024), MemoryMode::Strict),
449            registry.root(),
450        )
451        .err()
452        .expect("bounds larger than the limit should be fatal in strict mode");
453        assert!(error
454            .to_string()
455            .contains("Configured memory limit is insufficient for the current configuration."));
456    }
457
458    #[test]
459    fn permissive_memory_mode_tolerates_bounds_exceeding_the_limit() {
460        let registry = registry_with_firm_bound(64 * 1024 * 1024);
461
462        initialize_memory_bounds(
463            config_with_limit(ByteSize::b(1024), MemoryMode::Permissive),
464            registry.root(),
465        )
466        .expect("bounds larger than the limit should be non-fatal in permissive mode");
467    }
468
469    #[test]
470    fn absent_memory_limit_skips_bounds_verification() {
471        let registry = registry_with_firm_bound(64 * 1024 * 1024);
472        let config = MemoryBoundsConfiguration {
473            memory_limit: None,
474            memory_slop_factor: 0.25,
475            enable_global_limiter: false,
476            memory_mode: MemoryMode::Strict,
477        };
478
479        let _env_guard = test_env_lock();
480        std::env::remove_var("DOCKER_DD_AGENT");
481
482        initialize_memory_bounds(config, registry.root()).expect("no limit means no bounds verification");
483    }
484
485    #[test]
486    fn cgroup_memory_limit_is_not_detected_outside_a_container() {
487        let _env_guard = test_env_lock();
488        std::env::remove_var("DOCKER_DD_AGENT");
489        assert_eq!(detect_cgroup_memory_limit(), None);
490
491        std::env::set_var("DOCKER_DD_AGENT", "");
492        assert_eq!(detect_cgroup_memory_limit(), None);
493        std::env::remove_var("DOCKER_DD_AGENT");
494    }
495}