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_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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
35#[serde(rename_all = "lowercase")]
36pub enum MemoryMode {
37 #[default]
39 Disabled,
40
41 Permissive,
45
46 Strict,
50}
51
52#[derive(Deserialize)]
54pub struct MemoryBoundsConfiguration {
55 #[serde(default)]
62 memory_limit: Option<ByteSize>,
63
64 #[serde(default = "default_memory_slop_factor")]
75 memory_slop_factor: f64,
76
77 #[serde(default = "default_enable_global_limiter")]
84 enable_global_limiter: bool,
85
86 #[serde(default)]
92 memory_mode: MemoryMode,
93}
94
95impl MemoryBoundsConfiguration {
96 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 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 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 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
140pub 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
300pub struct ResourceTelemetryWorker {
305 component_registry: ComponentRegistryHandle,
306}
307
308impl ResourceTelemetryWorker {
309 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 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 dataspace.assert(memory_routes, "resource-telemetry-api");
341
342 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 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 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 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 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 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}