saluki_core/accounting/registry.rs
1use std::marker::PhantomData;
2use std::{
3 collections::HashMap,
4 sync::{Arc, Mutex, MutexGuard},
5};
6
7use saluki_common::resource_tracking::{ResourceGroupRegistry, ResourceGroupToken};
8
9use super::{
10 api::ResourceAPIHandler, BoundsVerifier, ComponentBounds, MemoryBounds, MemoryGrant, UsageExpr, VerifiedBounds,
11 VerifierError,
12};
13use crate::support::SubsystemIdentifier;
14
15pub(crate) struct ComponentMetadata {
16 full_name: Option<String>,
17 bounds: ComponentBounds,
18 token: Option<ResourceGroupToken>,
19 subcomponents: HashMap<String, Arc<Mutex<ComponentMetadata>>>,
20}
21
22impl ComponentMetadata {
23 fn from_full_name(full_name: Option<String>) -> Self {
24 Self {
25 full_name,
26 bounds: ComponentBounds::default(),
27 token: None,
28 subcomponents: HashMap::new(),
29 }
30 }
31
32 pub fn get_or_create<S>(&mut self, name: S) -> Arc<Mutex<Self>>
33 where
34 S: AsRef<str>,
35 {
36 // Split the name into the current level name and the remaining name.
37 //
38 // This lets us handle names which refer to a target nested component instead of having to chain a ton of calls
39 // together.
40 let name = name.as_ref();
41 let (current_level_name, remaining_name) = match name.split_once('.') {
42 Some((current_level_name, remaining_name)) => (current_level_name, Some(remaining_name)),
43 None => (name, None),
44 };
45
46 // Now we need to see if we have an existing component here or if we need to create a new one.
47 match self.subcomponents.get(current_level_name) {
48 Some(existing) => match remaining_name {
49 Some(remaining_name) => {
50 // We found an intermediate subcomponent, so keep recursing.
51 existing.lock().unwrap().get_or_create(remaining_name)
52 }
53 None => {
54 // We've found the leaf subcomponent.
55 Arc::clone(existing)
56 }
57 },
58 None => {
59 // We couldn't find the component at this level, so we need to create it.
60 //
61 // We do all of our name calculation and so on, but we also leave the token empty for now. We do this to
62 // avoid registering intermediate components that aren't actually used by the code, but are simply a
63 // consequence of wanting to having a nicely nested structure.
64 //
65 // We'll register a token for the component the first time it's requested.
66 let full_name = match self.full_name.as_ref() {
67 Some(parent_full_name) => format!("{}.{}", parent_full_name, current_level_name),
68 None => current_level_name.to_string(),
69 };
70
71 let inner = self
72 .subcomponents
73 .entry(current_level_name.to_string())
74 .or_insert_with(|| Arc::new(Mutex::new(Self::from_full_name(Some(full_name)))));
75
76 // If we still need to recurse further, do so here.. otherwise, return the subcomponent we just created
77 // as-is.
78 match remaining_name {
79 Some(remaining_name) => inner.lock().unwrap().get_or_create(remaining_name),
80 None => Arc::clone(inner),
81 }
82 }
83 }
84 }
85
86 fn token(&mut self) -> ResourceGroupToken {
87 match self.token {
88 Some(token) => token,
89 None => match self.full_name.as_deref() {
90 Some(full_name) => {
91 let allocator_component_registry = ResourceGroupRegistry::global();
92 let token = allocator_component_registry.register_resource_group(full_name);
93 self.token = Some(token);
94
95 token
96 }
97 None => ResourceGroupToken::root(),
98 },
99 }
100 }
101
102 fn reset(&mut self) {
103 self.bounds = ComponentBounds::default();
104 self.subcomponents.clear();
105 }
106
107 pub fn self_bounds(&self) -> &ComponentBounds {
108 &self.bounds
109 }
110
111 pub fn as_bounds(&self) -> ComponentBounds {
112 let mut bounds = ComponentBounds::default();
113 bounds.self_firm_limit_bytes = self.bounds.self_firm_limit_bytes.clone();
114 bounds.self_minimum_required_bytes = self.bounds.self_minimum_required_bytes.clone();
115
116 for (name, subcomponent) in self.subcomponents.iter() {
117 let subcomponent = subcomponent.lock().unwrap();
118 let subcomponent_bounds = subcomponent.as_bounds();
119 bounds.subcomponents.insert(name.clone(), subcomponent_bounds);
120 }
121
122 bounds
123 }
124}
125
126/// A registry for components for tracking memory bounds and runtime memory usage.
127///
128/// This registry provides a unified interface for declaring the memory bounds of a _component_, as well as registering
129/// that component for runtime memory usage tracking when using the tracking allocator implementation in `saluki-common`.
130///
131/// ## Components
132///
133/// **Components** are any logical grouping of memory usage within a program, and they can be arbitrarily nested.
134///
135/// For example, a data plane will generally have a topology that defines the components used to accept, process, and
136/// forward data. The topology itself could be considered a component, and each individual source, transform, and
137/// destination within it could be subcomponents of the topology.
138///
139/// Components are generally meant to be tied to something that has its own memory bounds and is somewhat standalone,
140/// but this isn't an absolute requirement and components can be nested more granularly for organizational/aesthetic
141/// purposes. Again, for example, one might opt to create a component in their topology for each component type --
142/// sources, transforms, and destinations -- and then add the actual instances of those components as subcomponents to
143/// each grouping, leading to a nested structure such as `topology/sources/source1`, `topology/transforms/transform1`,
144/// and so on.
145///
146/// ## Bounds
147///
148/// Every component is able to define memory bounds for itself and its subcomponents. A builder-style API is exposed to
149/// allow for ergonomically defining these bounds -- both minimum and firm -- for components, as well as extending the
150/// nestable aspect of the registry itself to the bounds builder, allowing for flexibility in where components are
151/// defined from and how they're nested.
152///
153/// ## Allocation tracking
154///
155/// Every component is also able to be registered with its own resource group when using the tracking allocator
156/// implementation. This is done on demand when the component's token is requested, which avoids polluting the tracking
157/// allocator with components that are never actually used, such as those used for organizational/aesthetic purposes.
158#[derive(Clone)]
159pub struct ComponentRegistry {
160 root: Arc<Mutex<ComponentMetadata>>,
161}
162
163impl ComponentRegistry {
164 /// Creates a handle to this registry.
165 ///
166 /// The handle provides read-only access to root-level operations like creating API handlers and verifying bounds. It
167 /// can be freely cloned and shared.
168 pub fn root(&self) -> ComponentRegistryHandle {
169 ComponentRegistryHandle {
170 inner: Arc::clone(&self.root),
171 }
172 }
173
174 /// Gets a bounds builder for the component identified by `id`, creating the component -- and any intermediate
175 /// components -- if it doesn't already exist.
176 ///
177 /// `id` is a fully qualified, absolute identifier: the component is addressed directly from the root, so callers
178 /// never need to track how deeply nested a registry is.
179 pub fn bounds_builder(&self, id: &SubsystemIdentifier) -> MemoryBoundsBuilder<'_> {
180 let node = self.root.lock().unwrap().get_or_create(id.to_string());
181 MemoryBoundsBuilder { node, _lt: PhantomData }
182 }
183
184 /// Gets the resource group tracking token for the component identified by `id`, creating the component -- and any
185 /// intermediate components -- if it doesn't already exist.
186 ///
187 /// The component's resource group is registered (using its full name) the first time its token is requested. `id`
188 /// is a fully qualified, absolute identifier, addressed directly from the root.
189 pub fn get_resource_group_token(&self, id: &SubsystemIdentifier) -> ResourceGroupToken {
190 let node = self.root.lock().unwrap().get_or_create(id.to_string());
191 let mut node = node.lock().unwrap();
192 node.token()
193 }
194
195 /// Gets the memory bounds for all components in the registry.
196 pub fn as_bounds(&self) -> ComponentBounds {
197 self.root.lock().unwrap().as_bounds()
198 }
199}
200
201/// A cloneable, read-only handle to a component registry.
202///
203/// This handle provides access to read-only operations such as creating an API handler or verifying bounds. Unlike
204/// [`ComponentRegistry`], it can be freely cloned and shared across ownership boundaries.
205///
206/// Obtained via [`ComponentRegistry::root`].
207#[derive(Clone)]
208pub struct ComponentRegistryHandle {
209 inner: Arc<Mutex<ComponentMetadata>>,
210}
211
212impl ComponentRegistryHandle {
213 /// Validates that all components are able to respect the calculated effective limit.
214 ///
215 /// If validation succeeds, `VerifiedBounds` is returned, which provides information about the effective limit that
216 /// can be used for allocating memory.
217 ///
218 /// ## Errors
219 ///
220 /// A number of invalid conditions are checked and will cause an error to be returned:
221 ///
222 /// - when a component has invalid bounds (for example, minimum required bytes higher than firm limit)
223 /// - when the combined total of the firm limit for all components exceeds the effective limit
224 pub fn verify_bounds(&self, initial_grant: MemoryGrant) -> Result<VerifiedBounds, VerifierError> {
225 let bounds = self.inner.lock().unwrap().as_bounds();
226 BoundsVerifier::new(initial_grant, bounds).verify()
227 }
228
229 /// Gets an API handler for reporting the memory bounds and resource usage of all component in the registry.
230 ///
231 /// This handler exposes routes for querying the memory bounds and usage of all registered components. See
232 /// [`ResourceAPIHandler`] for more information about routes and responses.
233 pub fn api_handler(&self) -> ResourceAPIHandler {
234 ResourceAPIHandler::from_state(Arc::clone(&self.inner))
235 }
236
237 /// Returns a JSON snapshot of the current memory bounds and live usage for all registered components.
238 ///
239 /// Each component appears as a key in the returned JSON object with `minimum_required_bytes`,
240 /// `firm_limit_bytes`, and `actual_live_bytes` fields. This produces the same data as the `/memory/status`
241 /// HTTP endpoint but is collected directly from the in-process registry for use outside of the HTTP handler
242 /// path (for example, when building a diagnostic artifact).
243 pub fn memory_snapshot_json(&self) -> String {
244 use std::collections::BTreeMap;
245
246 use saluki_common::resource_tracking::{ResourceGroupRegistry, ResourceStatsSnapshot};
247
248 #[derive(serde::Serialize)]
249 struct ComponentUsage {
250 minimum_required_bytes: usize,
251 firm_limit_bytes: usize,
252 actual_live_bytes: usize,
253 }
254
255 let empty_snapshot = ResourceStatsSnapshot::empty();
256 let mut component_usage: BTreeMap<String, ComponentUsage> = BTreeMap::new();
257 let mut inner = self.inner.lock().unwrap();
258
259 ResourceGroupRegistry::global().visit_resource_groups(|component_name, component_stats| {
260 let component_meta = inner.get_or_create(component_name);
261 let component_meta = component_meta.lock().unwrap();
262 let bounds = component_meta.self_bounds();
263 let stats_snapshot = component_stats.snapshot_delta(&empty_snapshot);
264
265 component_usage.insert(
266 component_name.to_string(),
267 ComponentUsage {
268 minimum_required_bytes: bounds.total_minimum_required_bytes(),
269 firm_limit_bytes: bounds.total_firm_limit_bytes(),
270 actual_live_bytes: stats_snapshot.live_bytes(),
271 },
272 );
273 });
274
275 serde_json::to_string_pretty(&component_usage).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
276 }
277
278 /// Gets the total minimum required bytes for all components in the registry.
279 ///
280 /// See [`ComponentRegistry::as_bounds`] for more details.
281 pub fn as_bounds(&self) -> ComponentBounds {
282 self.inner.lock().unwrap().as_bounds()
283 }
284}
285
286impl Default for ComponentRegistry {
287 fn default() -> Self {
288 Self {
289 root: Arc::new(Mutex::new(ComponentMetadata::from_full_name(None))),
290 }
291 }
292}
293
294pub struct Minimum;
295pub struct Firm;
296
297pub(crate) mod private {
298 pub trait Sealed {}
299
300 impl Sealed for super::Minimum {}
301 impl Sealed for super::Firm {}
302}
303
304// Simple trait-based builder state approach so we can use a single builder view to modify either the minimum required
305// or firm limit amounts.
306pub trait BoundsMutator: private::Sealed {
307 fn add_usage(bounds: &mut ComponentBounds, expr: UsageExpr);
308}
309
310impl BoundsMutator for Minimum {
311 fn add_usage(bounds: &mut ComponentBounds, expr: UsageExpr) {
312 bounds.self_minimum_required_bytes.push(expr)
313 }
314}
315
316impl BoundsMutator for Firm {
317 fn add_usage(bounds: &mut ComponentBounds, expr: UsageExpr) {
318 bounds.self_firm_limit_bytes.push(expr)
319 }
320}
321
322/// Builder for defining the memory bounds of a component and its subcomponents.
323///
324/// This builder provides a simple interface for defining the minimum and firm bounds of a component, as well as
325/// declaring subcomponents. For example, a topology can contain its own "self" memory bounds, and then define the
326/// individual bounds for each component in the topology.
327pub struct MemoryBoundsBuilder<'a> {
328 node: Arc<Mutex<ComponentMetadata>>,
329 _lt: PhantomData<&'a ()>,
330}
331
332impl MemoryBoundsBuilder<'static> {
333 #[cfg(test)]
334 pub(crate) fn for_test() -> Self {
335 Self {
336 node: Arc::new(Mutex::new(ComponentMetadata::from_full_name(None))),
337 _lt: PhantomData,
338 }
339 }
340}
341
342impl MemoryBoundsBuilder<'_> {
343 /// Resets the bounds of the current component to a default state.
344 ///
345 /// This can be used in scenarios where the bounds of a component need to be redefined after they have been
346 /// specified, as not all components are able to be defined in a single pass.
347 pub fn reset(&mut self) {
348 let mut node = self.node.lock().unwrap();
349 node.reset();
350 }
351
352 /// Gets a builder object for defining the minimum bounds of the current component.
353 pub fn minimum(&mut self) -> BoundsBuilder<'_, Minimum> {
354 let bounds = self.node.lock().unwrap();
355 BoundsBuilder::<'_, Minimum>::new(bounds)
356 }
357
358 /// Gets a builder object for defining the firm bounds of the current component.
359 ///
360 /// The firm limit is additive with the minimum required memory, so entries that are added via `minimum` don't need
361 /// to be added again here.
362 pub fn firm(&mut self) -> BoundsBuilder<'_, Firm> {
363 let bounds = self.node.lock().unwrap();
364 BoundsBuilder::<'_, Firm>::new(bounds)
365 }
366
367 /// Creates a nested subcomponent and gets a builder object for it.
368 ///
369 /// This allows for defining the bounds of various subcomponents within a larger component, which are then rolled up
370 /// into the calculated bounds for the parent component.
371 ///
372 /// `name` is a relative label (single segment, or a dotted path for further nesting) that is sanitized and appended
373 /// beneath the current component.
374 pub fn subcomponent<S>(&mut self, name: S) -> MemoryBoundsBuilder<'_>
375 where
376 S: AsRef<str>,
377 {
378 let node = self
379 .node
380 .lock()
381 .unwrap()
382 .get_or_create(SubsystemIdentifier::from_dotted(name.as_ref()).to_string());
383 MemoryBoundsBuilder { node, _lt: PhantomData }
384 }
385
386 /// Creates a nested subcomponent based on the given component.
387 ///
388 /// This allows for defining a subcomponent whose bounds come from an object that implements `MemoryBounds` directly.
389 pub fn with_subcomponent<S, C>(&mut self, name: S, component: &C) -> &mut Self
390 where
391 S: AsRef<str>,
392 C: MemoryBounds,
393 {
394 let mut builder = self.subcomponent(name);
395 component.specify_bounds(&mut builder);
396
397 self
398 }
399
400 #[cfg(test)]
401 pub(crate) fn as_bounds(&self) -> ComponentBounds {
402 self.node.lock().unwrap().as_bounds()
403 }
404}
405
406/// Bounds builder.
407///
408/// Helper type for defining the bounds of a component in a field-driven manner.
409pub struct BoundsBuilder<'a, S> {
410 inner: MutexGuard<'a, ComponentMetadata>,
411 _state: PhantomData<S>,
412}
413
414impl<'a, S: BoundsMutator> BoundsBuilder<'a, S> {
415 fn new(inner: MutexGuard<'a, ComponentMetadata>) -> Self {
416 Self {
417 inner,
418 _state: PhantomData,
419 }
420 }
421
422 /// Accounts for the in-memory size of a single value.
423 ///
424 /// This is useful for tracking the expected memory usage of a single instance of a type if that type is heap
425 /// allocated. For example, components that are spawned by a topology generally end up being boxed, which means a
426 /// heap allocation exists that's the size of the component type.
427 pub fn with_single_value<T>(&mut self, name: impl Into<String>) -> &mut Self {
428 S::add_usage(&mut self.inner.bounds, UsageExpr::struct_size::<T>(name));
429 self
430 }
431
432 /// Accounts for a fixed amount of memory usage.
433 ///
434 /// This is a catch-all for directly accounting for a specific number of bytes.
435 pub fn with_fixed_amount(&mut self, name: impl Into<String>, chunk_size: usize) -> &mut Self {
436 S::add_usage(&mut self.inner.bounds, UsageExpr::constant(name, chunk_size));
437 self
438 }
439
440 /// Accounts for an item container of the given length.
441 ///
442 /// This can be used to track the expected memory usage of generalized containers like `Vec<T>`, where items are
443 /// homogeneous and allocated contiguously.
444 pub fn with_array<T>(&mut self, name: impl Into<String>, len: usize) -> &mut Self {
445 S::add_usage(
446 &mut self.inner.bounds,
447 UsageExpr::product(
448 "array",
449 UsageExpr::struct_size::<T>(name),
450 UsageExpr::constant("len", len),
451 ),
452 );
453 self
454 }
455
456 /// Accounts for a map container of the given length.
457 ///
458 /// This can be used to track the expected memory usage of generalized maps like `HashMap<K, V>`, where keys and
459 /// values are
460 pub fn with_map<K, V>(&mut self, name: impl Into<String>, len: usize) -> &mut Self {
461 S::add_usage(
462 &mut self.inner.bounds,
463 UsageExpr::product(
464 "map",
465 UsageExpr::sum(
466 name,
467 UsageExpr::struct_size::<K>("key"),
468 UsageExpr::struct_size::<V>("value"),
469 ),
470 UsageExpr::constant("len", len),
471 ),
472 );
473 self
474 }
475
476 pub fn with_expr(&mut self, expr: UsageExpr) -> &mut Self {
477 S::add_usage(&mut self.inner.bounds, expr);
478 self
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 fn id(dotted: &str) -> SubsystemIdentifier {
487 SubsystemIdentifier::from_dotted(dotted)
488 }
489
490 #[test]
491 fn bounds_builder_records_bounds_under_absolute_id() {
492 let registry = ComponentRegistry::default();
493 registry
494 .bounds_builder(&id("topology.primary.sources.dsd_in"))
495 .firm()
496 .with_fixed_amount("buffer", 128);
497
498 assert_eq!(registry.as_bounds().total_firm_limit_bytes(), 128);
499 }
500
501 #[test]
502 fn repeated_bounds_builder_for_same_id_targets_the_same_node() {
503 // Two `bounds_builder` calls with the same identifier resolve to the same component node, so their bounds
504 // accumulate rather than replace.
505 let registry = ComponentRegistry::default();
506 let component = id("topology.primary.transforms.enrich");
507
508 registry
509 .bounds_builder(&component)
510 .firm()
511 .with_fixed_amount("first", 100);
512 registry
513 .bounds_builder(&component)
514 .firm()
515 .with_fixed_amount("second", 200);
516
517 assert_eq!(registry.as_bounds().total_firm_limit_bytes(), 300);
518 }
519
520 #[test]
521 fn distinct_absolute_ids_roll_up_to_the_root() {
522 // Distinct absolute identifiers that share a prefix create a nested structure whose bounds roll up.
523 let registry = ComponentRegistry::default();
524 registry
525 .bounds_builder(&id("topology.primary.sources.in"))
526 .firm()
527 .with_fixed_amount("a", 100);
528 registry
529 .bounds_builder(&id("topology.primary.destinations.out"))
530 .firm()
531 .with_fixed_amount("b", 200);
532
533 assert_eq!(registry.as_bounds().total_firm_limit_bytes(), 300);
534 }
535
536 #[test]
537 fn relative_subcomponents_roll_up_under_their_component() {
538 // The bounds builder's relative `subcomponent` nests beneath the component addressed by its absolute id.
539 let registry = ComponentRegistry::default();
540 {
541 let mut builder = registry.bounds_builder(&id("env_provider.workload.remote_agent"));
542 builder.firm().with_fixed_amount("self", 10);
543 builder
544 .subcomponent("string_interner")
545 .firm()
546 .with_fixed_amount("interner", 90);
547 }
548
549 assert_eq!(registry.as_bounds().total_firm_limit_bytes(), 100);
550 }
551
552 #[test]
553 fn resource_group_token_and_bounds_builder_share_the_same_node() {
554 // Taking a resource group token creates the component node; declaring bounds for the same identifier targets
555 // that same node, and the read-only handle observes the result.
556 let registry = ComponentRegistry::default();
557 let component = id("topology.primary.sources.dsd_in");
558
559 let _token = registry.get_resource_group_token(&component);
560 registry
561 .bounds_builder(&component)
562 .minimum()
563 .with_fixed_amount("buffer", 64);
564
565 assert_eq!(registry.root().as_bounds().total_minimum_required_bytes(), 64);
566 }
567
568 #[test]
569 fn with_array_accounts_for_element_size_times_length() {
570 // `with_array` builds a `product(struct_size::<T>, len)` expression, so a 10-element array of `u64` should
571 // account for exactly `size_of::<u64>() * 10` bytes.
572 let mut builder = MemoryBoundsBuilder::for_test();
573 builder.firm().with_array::<u64>("array", 10);
574
575 let bounds = builder.as_bounds();
576 assert_eq!(bounds.total_firm_limit_bytes(), std::mem::size_of::<u64>() * 10);
577 assert_eq!(bounds.total_minimum_required_bytes(), 0);
578 }
579
580 #[test]
581 fn with_map_accounts_for_entry_size_times_length() {
582 // `with_map` builds a `product(sum(struct_size::<K>, struct_size::<V>), len)` expression, so a 5-entry map
583 // of `u32 -> u64` should account for exactly `(size_of::<u32>() + size_of::<u64>()) * 5` bytes.
584 let mut builder = MemoryBoundsBuilder::for_test();
585 builder.firm().with_map::<u32, u64>("map", 5);
586
587 let bounds = builder.as_bounds();
588 let expected = (std::mem::size_of::<u32>() + std::mem::size_of::<u64>()) * 5;
589 assert_eq!(bounds.total_firm_limit_bytes(), expected);
590 assert_eq!(bounds.total_minimum_required_bytes(), 0);
591 }
592
593 #[test]
594 fn minimum_bounds_are_included_in_the_firm_total() {
595 // The firm limit is documented as additive with the minimum required bytes, so an array added to `minimum`
596 // and a map added to `firm` should both roll up into `total_firm_limit_bytes`.
597 let mut builder = MemoryBoundsBuilder::for_test();
598 builder.minimum().with_array::<u64>("array", 10);
599 builder.firm().with_map::<u32, u64>("map", 5);
600
601 let bounds = builder.as_bounds();
602 let array_bytes = std::mem::size_of::<u64>() * 10;
603 let map_bytes = (std::mem::size_of::<u32>() + std::mem::size_of::<u64>()) * 5;
604
605 assert_eq!(bounds.total_minimum_required_bytes(), array_bytes);
606 assert_eq!(bounds.total_firm_limit_bytes(), array_bytes + map_bytes);
607 }
608}