1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub enum MemoryMode {
26 #[default]
28 Disabled,
29
30 Permissive,
34
35 Strict,
39}
40
41pub struct MemoryBoundsConfiguration {
43 pub memory_limit: Option<ByteSize>,
45
46 pub memory_slop_factor: f64,
57
58 pub enable_global_limiter: bool,
63
64 pub memory_mode: MemoryMode,
68}
69
70pub 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
235pub struct ResourceTelemetryWorker {
240 component_registry: ComponentRegistryHandle,
241}
242
243impl ResourceTelemetryWorker {
244 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 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 dataspace.assert(memory_routes, "resource-telemetry-api");
276
277 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 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 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 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}