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