saluki_context/resolver.rs
1use std::{num::NonZeroUsize, sync::Arc, time::Duration};
2
3use saluki_common::{
4 cache::{weight::ItemCountWeighter, Cache, CacheBuilder},
5 collections::PrehashedHashSet,
6 hash::NoopU64BuildHasher,
7};
8use saluki_error::{generic_error, GenericError};
9use saluki_metrics::static_metrics;
10use stringtheory::{
11 interning::{GenericMapInterner, Interner as _},
12 CheapMetaString, MetaString,
13};
14use tokio::time::sleep;
15use tracing::debug;
16
17use crate::{
18 context::{Context, ContextInner},
19 hash::{hash_context_with_seen, ContextKey, TagSetKey},
20 origin::{OriginTagsResolver, RawOrigin},
21 tags::{SharedTagSet, TagSet},
22};
23
24// SAFETY: We know, unquestionably, that this value is not zero.
25const DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT: NonZeroUsize = NonZeroUsize::new(500_000).unwrap();
26
27// SAFETY: We know, unquestionably, that this value is not zero.
28const DEFAULT_CONTEXT_RESOLVER_INTERNER_CAPACITY_BYTES: NonZeroUsize = NonZeroUsize::new(2 * 1024 * 1024).unwrap();
29
30const SEEN_HASHSET_INITIAL_CAPACITY: usize = 128;
31
32type ContextCache = Cache<ContextKey, Context, ItemCountWeighter, NoopU64BuildHasher>;
33type TagSetCache = Cache<TagSetKey, SharedTagSet, ItemCountWeighter, NoopU64BuildHasher>;
34
35static_metrics! {
36 name => Telemetry,
37 prefix => context_resolver,
38 labels => [resolver_id: String],
39 metrics => [
40 gauge(interner_capacity_bytes),
41 gauge(interner_len_bytes),
42 gauge(interner_entries),
43 debug_counter(intern_fallback_total),
44
45 debug_counter(resolved_existing_context_total),
46 debug_counter(resolved_new_context_total),
47 gauge(active_contexts),
48
49 debug_counter(resolved_existing_tagset_total),
50 debug_counter(resolved_new_tagset_total),
51 ],
52}
53
54/// Builder for creating a [`ContextResolver`].
55///
56/// # Missing
57///
58/// - Support for configuring the size limit of cached contexts.
59pub struct ContextResolverBuilder {
60 name: String,
61 caching_enabled: bool,
62 cached_contexts_limit: Option<NonZeroUsize>,
63 idle_context_expiration: Option<Duration>,
64 interner_capacity_bytes: Option<NonZeroUsize>,
65 allow_heap_allocations: Option<bool>,
66 tags_resolver: Option<TagsResolver>,
67 interner: Option<GenericMapInterner>,
68 origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
69 telemetry_enabled: bool,
70}
71
72impl ContextResolverBuilder {
73 /// Creates a new `ContextResolverBuilder` with the given resolver name.
74 ///
75 /// The resolver name _should_ be unique, but it isn't required to be. Metrics for the resolver will be
76 /// emitted using the given name, so in cases where the name isn't unique, those metrics will be aggregated
77 /// together and it won't be possible to distinguish between the different resolvers.
78 ///
79 /// # Errors
80 ///
81 /// If the given resolver name is empty, an error is returned.
82 pub fn from_name<S: Into<String>>(name: S) -> Result<Self, GenericError> {
83 let name = name.into();
84 if name.is_empty() {
85 return Err(generic_error!("resolver name must not be empty"));
86 }
87
88 Ok(Self {
89 name,
90 caching_enabled: true,
91 cached_contexts_limit: None,
92 idle_context_expiration: None,
93 interner_capacity_bytes: None,
94 allow_heap_allocations: None,
95 tags_resolver: None,
96 interner: None,
97 origin_tags_resolver: None,
98 telemetry_enabled: true,
99 })
100 }
101
102 /// Sets whether or not to enable caching of resolved contexts.
103 ///
104 /// [`ContextResolver`] provides two main benefits: consistent behavior for resolving contexts (interning, origin
105 /// tags, etc), and the caching of those resolved contexts to speed up future resolutions. However, caching contexts
106 /// means that we pay a memory cost for the cache itself, even if the contexts aren't ever reused or are seen
107 /// infrequently. While expiration can help free up cache capacity, it can't help recover the memory used by the
108 /// underlying cache data structure once they have expanded to hold the contexts.
109 ///
110 /// Disabling caching allows normal resolving to take place without the overhead of caching the contexts. This can
111 /// lead to lower average memory usage, as contexts will only live as long as they're needed, but it will reduce
112 /// memory determinism as memory will be allocated for every resolved context (minus interned strings), which means
113 /// that resolving the same context ten times in a row will result in ten separate allocations, and so on.
114 ///
115 /// Defaults to caching enabled.
116 pub fn without_caching(mut self) -> Self {
117 self.caching_enabled = false;
118 self.idle_context_expiration = None;
119 self
120 }
121
122 /// Sets the limit on the number of cached contexts.
123 ///
124 /// This is the maximum number of resolved contexts that can be cached at any given time. This limit doesn't affect
125 /// the total number of contexts that can be _alive_ at any given time, which is dependent on the interner capacity
126 /// and whether or not heap allocations are allowed.
127 ///
128 /// Caching contexts is beneficial when the same context is resolved frequently, and it's generally worth
129 /// allowing for higher limits on cached contexts when heap allocations are allowed, as this can better amortize the
130 /// cost of those heap allocations.
131 ///
132 /// If value is zero, caching will be disabled, and no contexts will be cached. This is equivalent to calling
133 /// `without_caching`.
134 ///
135 /// Defaults to 500,000.
136 pub fn with_cached_contexts_limit(mut self, limit: usize) -> Self {
137 match NonZeroUsize::new(limit) {
138 Some(limit) => {
139 self.cached_contexts_limit = Some(limit);
140 self
141 }
142 None => self.without_caching(),
143 }
144 }
145
146 /// Sets the time before contexts are considered "idle" and eligible for expiration.
147 ///
148 /// This controls how long a context will be kept in the cache after its last access or creation time. This value is
149 /// a lower bound, as contexts eligible for expiration may not be expired immediately. Contexts may still be removed
150 /// prior to their natural expiration time if the cache is full and evictions are required to make room for a new
151 /// context.
152 ///
153 /// Defaults to no expiration.
154 pub fn with_idle_context_expiration(mut self, time_to_idle: Duration) -> Self {
155 self.idle_context_expiration = Some(time_to_idle);
156 self
157 }
158
159 /// Sets the capacity of the string interner, in bytes.
160 ///
161 /// This is the maximum number of bytes that the interner will use for interning strings that are present in
162 /// contexts being resolved. This capacity may or may not be allocated entirely when the resolver is built, but the
163 /// interner won't exceed the configured capacity when allocating any backing storage.
164 ///
165 /// This value directly impacts the number of contexts that can be resolved when heap allocations are disabled, as
166 /// all resolved contexts must either have values (name or tags) that can be inlined or interned. Once the interner
167 /// is full, contexts may fail to be resolved if heap allocations are disabled.
168 ///
169 /// The optimal value will almost always be workload-dependent, but a good starting point can be to estimate around
170 /// 150 - 200 bytes per context based on empirical measurements around common metric name and tag lengths. This
171 /// translate to around 5000 unique contexts per 1 MB of interner size.
172 ///
173 /// Defaults to 2 MB.
174 pub fn with_interner_capacity_bytes(mut self, capacity: NonZeroUsize) -> Self {
175 self.interner_capacity_bytes = Some(capacity);
176 self
177 }
178
179 /// Sets whether or not to allow heap allocations when interning strings.
180 ///
181 /// In cases where the interner is full, this setting determines whether or not we refuse to resolve a context, or
182 /// if we allow it be resolved by allocating strings on the heap. When heap allocations are enabled, the amount of
183 /// memory that can be used by the interner is effectively unlimited, as contexts that can't be interned will be
184 /// simply spill to the heap instead of being limited in any way.
185 ///
186 /// Defaults to `true`.
187 pub fn with_heap_allocations(mut self, allow: bool) -> Self {
188 self.allow_heap_allocations = Some(allow);
189 self
190 }
191
192 /// Sets the tags resolver.
193 ///
194 /// Defaults to unset.
195 pub fn with_tags_resolver(mut self, resolver: Option<TagsResolver>) -> Self {
196 self.tags_resolver = resolver;
197 self
198 }
199
200 /// Sets whether or not to enable telemetry for this resolver.
201 ///
202 /// Reporting the telemetry of the resolver requires running an asynchronous task to override adding additional
203 /// overhead in the hot path of resolving contexts. In some cases, it may be cumbersome to always create the
204 /// resolver in an asynchronous context so that the telemetry task can be spawned. This method allows disabling
205 /// telemetry reporting in those cases.
206 ///
207 /// Defaults to telemetry enabled.
208 pub fn without_telemetry(mut self) -> Self {
209 self.telemetry_enabled = false;
210 self
211 }
212
213 /// Sets the interner to use for this resolver.
214 ///
215 /// If an interner isn't provided, an interner will be created in [`ContextResolverBuilder::build`]
216 pub fn with_interner(mut self, interner: GenericMapInterner) -> Self {
217 self.interner = Some(interner);
218 self
219 }
220
221 /// Configures a [`ContextResolverBuilder`] that's suitable for tests.
222 ///
223 /// This configures the builder with the following defaults:
224 ///
225 /// - resolver name of "noop"
226 /// - unlimited cache capacity
227 /// - no-op interner (all strings are heap-allocated)
228 /// - heap allocations allowed
229 /// - telemetry disabled
230 ///
231 /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate
232 /// testing scenarios.
233 pub fn for_tests() -> Self {
234 ContextResolverBuilder::from_name("noop")
235 .expect("resolver name not empty")
236 .with_cached_contexts_limit(usize::MAX)
237 .with_interner_capacity_bytes(NonZeroUsize::new(1).expect("not zero"))
238 .with_heap_allocations(true)
239 .with_tags_resolver(Some(TagsResolverBuilder::for_tests().build()))
240 .without_telemetry()
241 }
242
243 /// Builds a [`ContextResolver`] from the current configuration.
244 pub fn build(self) -> ContextResolver {
245 let interner_capacity_bytes = self
246 .interner_capacity_bytes
247 .unwrap_or(DEFAULT_CONTEXT_RESOLVER_INTERNER_CAPACITY_BYTES);
248
249 let interner = match self.interner {
250 Some(interner) => interner,
251 None => GenericMapInterner::new(interner_capacity_bytes),
252 };
253
254 let cached_context_limit = self
255 .cached_contexts_limit
256 .unwrap_or(DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT);
257
258 let allow_heap_allocations = self.allow_heap_allocations.unwrap_or(true);
259
260 let telemetry = Telemetry::new(self.name.clone());
261 telemetry
262 .interner_capacity_bytes()
263 .set(interner.capacity_bytes() as f64);
264
265 // NOTE: We should switch to using a size-based weighter so that we can do more firm bounding of what we cache.
266 let context_cache = CacheBuilder::from_identifier(format!("{}/contexts", self.name))
267 .expect("cache identifier cannot possibly be empty")
268 .with_capacity(cached_context_limit)
269 .with_time_to_idle(self.idle_context_expiration)
270 .with_hasher::<NoopU64BuildHasher>()
271 .with_telemetry(self.telemetry_enabled)
272 .build();
273
274 // If no tags resolver is provided, we need to create one using the same interner used for the context resolver.
275 let tags_resolver = match self.tags_resolver {
276 Some(tags_resolver) => tags_resolver,
277 None => TagsResolverBuilder::new(format!("{}/tags", self.name), interner.clone())
278 .expect("tags resolver name not empty")
279 .with_cached_tagsets_limit(cached_context_limit.get())
280 .with_idle_tagsets_expiration(self.idle_context_expiration.unwrap_or_default())
281 .with_heap_allocations(allow_heap_allocations)
282 .with_origin_tags_resolver(self.origin_tags_resolver.clone())
283 .build(),
284 };
285
286 if self.telemetry_enabled {
287 tokio::spawn(drive_telemetry(interner.clone(), telemetry.clone()));
288 }
289
290 ContextResolver {
291 telemetry,
292 interner,
293 caching_enabled: self.caching_enabled,
294 context_cache,
295 hash_seen_buffer: PrehashedHashSet::with_capacity_and_hasher(
296 SEEN_HASHSET_INITIAL_CAPACITY,
297 NoopU64BuildHasher,
298 ),
299 allow_heap_allocations,
300 tags_resolver,
301 }
302 }
303}
304
305/// A centralized store for resolved contexts.
306///
307/// Contexts are the combination of a name and a set of tags. They're used to identify a specific metric series. As
308/// contexts are constructed entirely of strings, they're expensive to construct in a way that allows sending between
309/// tasks, as this usually requires allocations. Additionally, some context are "hotter" than others, used frequently by
310/// the applications/services sending us metrics.
311///
312/// In order to optimize this, the context resolver is responsible for both interning the strings involved where
313/// possible, as well as keeping a map of contexts that can be referred to with a cheap handle. We can cheaply search
314/// for an existing context without needing to allocate an entirely new one, and get a clone of the handle to use going
315/// forward.
316///
317/// # Design
318///
319/// `ContextResolver` specifically manages interning and mapping of contexts. It can be cheaply cloned itself.
320///
321/// In order to resolve a context, `resolve` must be called which requires taking a lock to check for an existing
322/// context. A read/write lock is used in order to prioritize lookups over inserts, as lookups are expected to be more
323/// common given how often a given context is used and resolved.
324///
325/// Once a context is resolved, a cheap handle -- `Context` -- is returned. This handle, like `ContextResolver`, can be
326/// cheaply cloned. It points directly to the underlying context data (name and tags) and provides access to these
327/// components.
328pub struct ContextResolver {
329 telemetry: Telemetry,
330 interner: GenericMapInterner,
331 caching_enabled: bool,
332 context_cache: ContextCache,
333 hash_seen_buffer: PrehashedHashSet<u64>,
334 allow_heap_allocations: bool,
335 tags_resolver: TagsResolver,
336}
337
338impl ContextResolver {
339 fn intern<S>(&self, s: S) -> Option<MetaString>
340 where
341 S: AsRef<str> + CheapMetaString,
342 {
343 // Try to cheaply clone the string, and if that fails, try to intern it. If that fails, then we fall back to
344 // allocating it on the heap if we allow it.
345 s.try_cheap_clone()
346 .or_else(|| self.interner.try_intern(s.as_ref()).map(MetaString::from))
347 .or_else(|| {
348 self.allow_heap_allocations.then(|| {
349 // Heap spill: with `allow_context_heap_allocations` true (the default), a full interner silently
350 // falls back to the heap, so the bounded-memory guarantee no longer holds. Anchor that this path is
351 // reached so the unbounded-growth behavior is observable.
352 saluki_antithesis::sometimes!(
353 true,
354 "context string interner spilled to the heap (unbounded under default config)"
355 );
356 self.telemetry.intern_fallback_total().increment(1);
357 MetaString::from(s.as_ref())
358 })
359 })
360 }
361
362 fn create_context_key<N, I, I2, T, T2>(&mut self, name: N, tags: I, origin_tags: I2) -> (ContextKey, TagSetKey)
363 where
364 N: AsRef<str>,
365 I: IntoIterator<Item = T>,
366 T: AsRef<str>,
367 I2: IntoIterator<Item = T2>,
368 T2: AsRef<str>,
369 {
370 hash_context_with_seen(name.as_ref(), tags, origin_tags, &mut self.hash_seen_buffer)
371 }
372
373 fn create_context<N>(
374 &self, key: ContextKey, name: N, context_tags: SharedTagSet, origin_tags: SharedTagSet,
375 ) -> Option<Context>
376 where
377 N: AsRef<str> + CheapMetaString,
378 {
379 // Intern the name and tags of the context.
380 let context_name = self.intern(name)?;
381
382 self.telemetry.resolved_new_context_total().increment(1);
383 self.telemetry.active_contexts().increment(1);
384
385 Some(Context::from_inner(ContextInner::from_parts(
386 key,
387 context_name,
388 context_tags.into(),
389 origin_tags.into(),
390 self.telemetry.active_contexts().clone(),
391 )))
392 }
393
394 /// Resolves the given context.
395 ///
396 /// If the context hasn't yet been resolved, the name and tags are interned and a new context is created and
397 /// stored. Otherwise, the existing context is returned. If an origin tags resolver is configured, and origin info
398 /// is available, any enriched tags will be added to the context.
399 ///
400 /// `None` may be returned if the interner is full and outside allocations are disallowed. See
401 /// `allow_heap_allocations` for more information.
402 pub fn resolve<N, I, T>(&mut self, name: N, tags: I, maybe_origin: Option<RawOrigin<'_>>) -> Option<Context>
403 where
404 N: AsRef<str> + CheapMetaString,
405 I: IntoIterator<Item = T> + Clone,
406 T: AsRef<str> + CheapMetaString,
407 {
408 // Try and resolve our origin tags from the provided origin information, if any.
409 let origin_tags = self.tags_resolver.resolve_origin_tags(maybe_origin);
410
411 self.resolve_inner(name, tags, origin_tags)
412 }
413
414 /// Resolves the given context using the provided origin tags.
415 ///
416 /// If the context hasn't yet been resolved, the name and tags are interned and a new context is created and
417 /// stored. Otherwise, the existing context is returned. The provided origin tags are used to enrich the context.
418 ///
419 /// `None` may be returned if the interner is full and outside allocations are disallowed. See
420 /// `allow_heap_allocations` for more information.
421 ///
422 /// ## Origin tags resolver mismatch
423 ///
424 /// When passing in origin tags, they will be inherently tied to a specific `OriginTagsResolver`, which may
425 /// differ from the configured origin tags resolver in this context resolver. This means that the context that's
426 /// generated and cached may not be reused in the future if an attempt is made to resolve it using the raw origin
427 /// information instead.
428 ///
429 /// This method is intended primarily to allow for resolving contexts in a consistent way while _reusing_ the origin
430 /// tags from another context, such as when remapping the name and/or instrumented tags of a given metric, while
431 /// maintaining its origin association.
432 pub fn resolve_with_origin_tags<N, I, T>(
433 &mut self, name: N, tags: I, origin_tags: impl Into<SharedTagSet>,
434 ) -> Option<Context>
435 where
436 N: AsRef<str> + CheapMetaString,
437 I: IntoIterator<Item = T> + Clone,
438 T: AsRef<str> + CheapMetaString,
439 {
440 self.resolve_inner(name, tags, origin_tags.into())
441 }
442
443 fn resolve_inner<N, I, T>(&mut self, name: N, tags: I, origin_tags: SharedTagSet) -> Option<Context>
444 where
445 N: AsRef<str> + CheapMetaString,
446 I: IntoIterator<Item = T> + Clone,
447 T: AsRef<str> + CheapMetaString,
448 {
449 let (context_key, tagset_key) = self.create_context_key(&name, tags.clone(), &origin_tags);
450
451 // Fast path to avoid looking up the context in the cache if caching is disabled.
452 if !self.caching_enabled {
453 let tag_set = self.tags_resolver.create_tag_set(tags).unwrap_or_default();
454
455 let context = self.create_context(context_key, name, tag_set, origin_tags)?;
456
457 debug!(?context_key, ?context, "Resolved new non-cached context.");
458 return Some(context);
459 }
460
461 match self.context_cache.get(&context_key) {
462 Some(context) => {
463 self.telemetry.resolved_existing_context_total().increment(1);
464 Some(context)
465 }
466 None => {
467 // Try seeing if we have the tagset cached already, and create it if not.
468 let tag_set = match self.tags_resolver.get_tag_set(tagset_key) {
469 Some(tag_set) => {
470 self.telemetry.resolved_existing_tagset_total().increment(1);
471 tag_set
472 }
473 None => {
474 // If the tagset is not cached, we need to create it.
475 let tag_set = self.tags_resolver.create_tag_set(tags.clone()).unwrap_or_default();
476
477 self.tags_resolver.insert_tag_set(tagset_key, tag_set.clone());
478
479 tag_set
480 }
481 };
482
483 let context = self.create_context(context_key, name, tag_set, origin_tags)?;
484 self.context_cache.insert(context_key, context.clone());
485
486 debug!(?context_key, ?context, "Resolved new context.");
487 Some(context)
488 }
489 }
490 }
491}
492
493impl Clone for ContextResolver {
494 fn clone(&self) -> Self {
495 Self {
496 telemetry: self.telemetry.clone(),
497 interner: self.interner.clone(),
498 caching_enabled: self.caching_enabled,
499 context_cache: self.context_cache.clone(),
500 hash_seen_buffer: PrehashedHashSet::with_capacity_and_hasher(
501 SEEN_HASHSET_INITIAL_CAPACITY,
502 NoopU64BuildHasher,
503 ),
504 allow_heap_allocations: self.allow_heap_allocations,
505 tags_resolver: self.tags_resolver.clone(),
506 }
507 }
508}
509
510async fn drive_telemetry(interner: GenericMapInterner, telemetry: Telemetry) {
511 loop {
512 sleep(Duration::from_secs(1)).await;
513
514 telemetry.interner_entries().set(interner.len() as f64);
515 telemetry
516 .interner_capacity_bytes()
517 .set(interner.capacity_bytes() as f64);
518 telemetry.interner_len_bytes().set(interner.len_bytes() as f64);
519 }
520}
521
522/// A builder for a tag resolver.
523pub struct TagsResolverBuilder {
524 name: String,
525 caching_enabled: bool,
526 cached_tagset_limit: Option<NonZeroUsize>,
527 idle_tagset_expiration: Option<Duration>,
528 allow_heap_allocations: Option<bool>,
529 origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
530 telemetry_enabled: bool,
531 interner: GenericMapInterner,
532}
533
534impl TagsResolverBuilder {
535 /// Creates a new [`TagsResolverBuilder`] with the given name and interner.
536 pub fn new<S: Into<String>>(name: S, interner: GenericMapInterner) -> Result<Self, GenericError> {
537 let name = name.into();
538 if name.is_empty() {
539 return Err(generic_error!("resolver name must not be empty"));
540 }
541
542 Ok(Self {
543 name,
544 caching_enabled: true,
545 cached_tagset_limit: None,
546 idle_tagset_expiration: None,
547 allow_heap_allocations: None,
548 origin_tags_resolver: None,
549 telemetry_enabled: true,
550 interner,
551 })
552 }
553
554 /// Sets the interner to use for this resolver.
555 ///
556 /// This is used when we want to use a separate internet for tagsets, different from the one used for contexts.
557 ///
558 /// Defaults to using the interner passed to the builder.
559 pub fn with_interner(mut self, interner: GenericMapInterner) -> Self {
560 self.interner = interner;
561 self
562 }
563
564 /// Sets whether or not to enable caching of resolved tag sets.
565 ///
566 /// [`TagsResolver`] provides two main benefits: consistent behavior for resolving tag sets (interning, origin
567 /// tags, etc), and the caching of those resolved tag sets to speed up future resolutions. However, caching tag
568 /// sets means that we pay a memory cost for the cache itself, even if the tag sets aren't ever reused or are seen
569 /// infrequently. While expiration can help free up cache capacity, it can't help recover the memory used by the
570 /// underlying cache data structure once they have expanded to hold the tag sets.
571 ///
572 /// Disabling caching allows normal resolving to take place without the overhead of caching the tag sets. This can
573 /// lead to lower average memory usage, as tag sets will only live as long as they're needed, but it will reduce
574 /// memory determinism as memory will be allocated for every resolved tag set (minus interned strings), which means
575 /// that resolving the same tag set ten times in a row will result in ten separate allocations, and so on.
576 ///
577 /// Defaults to caching enabled.
578 pub fn without_caching(mut self) -> Self {
579 self.caching_enabled = false;
580 self.idle_tagset_expiration = None;
581 self
582 }
583
584 /// Sets the limit on the number of cached tagsets.
585 ///
586 /// This is the maximum number of resolved tag sets that can be cached at any given time. This limit doesn't affect
587 /// the total number of tag sets that can be _alive_ at any given time, which is dependent on the interner capacity
588 /// and whether or not heap allocations are allowed.
589 ///
590 /// Caching tag sets is beneficial when the same tag set is resolved frequently, and it's generally worth
591 /// allowing for higher limits on cached tag sets when heap allocations are allowed, as this can better amortize the
592 /// cost of those heap allocations.
593 ///
594 /// If value is zero, caching will be disabled, and no tag sets will be cached. This is equivalent to calling
595 /// `without_caching`.
596 ///
597 /// Defaults to 500,000.
598 pub fn with_cached_tagsets_limit(mut self, limit: usize) -> Self {
599 match NonZeroUsize::new(limit) {
600 Some(limit) => {
601 self.cached_tagset_limit = Some(limit);
602 self
603 }
604 None => self.without_caching(),
605 }
606 }
607
608 /// Sets the time before tag sets are considered "idle" and eligible for expiration.
609 ///
610 /// This controls how long a tag set will be kept in the cache after its last access or creation time. This value is
611 /// a lower bound, as tag sets eligible for expiration may not be expired immediately. Tag sets may still be removed
612 /// prior to their natural expiration time if the cache is full and evictions are required to make room for a new
613 /// context.
614 ///
615 /// Defaults to no expiration.
616 pub fn with_idle_tagsets_expiration(mut self, time_to_idle: Duration) -> Self {
617 self.idle_tagset_expiration = Some(time_to_idle);
618 self
619 }
620
621 /// Sets whether or not to allow heap allocations when interning strings.
622 ///
623 /// In cases where the interner is full, this setting determines whether or not we refuse to resolve a context, or
624 /// if we allow it be resolved by allocating strings on the heap. When heap allocations are enabled, the amount of
625 /// memory that can be used by the interner is effectively unlimited, as contexts that can't be interned will be
626 /// simply spill to the heap instead of being limited in any way.
627 ///
628 /// Defaults to `true`.
629 pub fn with_heap_allocations(mut self, allow: bool) -> Self {
630 self.allow_heap_allocations = Some(allow);
631 self
632 }
633
634 /// Sets the origin tags resolver to use when building a context.
635 ///
636 /// In some cases, metrics, events, and service checks may have enriched tags based on their origin -- the
637 /// application/host/container/etc that emitted the metric -- which has to be considered when building the context
638 /// itself. As this can be expensive, it's useful to split the logic of actually grabbing the enriched tags based
639 /// on the available origin info into a separate phase, and implementation, that can run separately from the
640 /// initial hash-based approach of checking if a context has already been resolved.
641 ///
642 /// When set, any origin information provided will be considered during hashing when looking up a context, and any
643 /// enriched tags attached to the detected origin will be accessible from the context.
644 ///
645 /// Defaults to unset.
646 pub fn with_origin_tags_resolver(mut self, resolver: Option<Arc<dyn OriginTagsResolver>>) -> Self {
647 self.origin_tags_resolver = resolver;
648 self
649 }
650
651 /// Sets whether or not to enable telemetry for this resolver.
652 ///
653 /// Reporting the telemetry of the resolver requires running an asynchronous task to override adding additional
654 /// overhead in the hot path of resolving contexts. In some cases, it may be cumbersome to always create the
655 /// resolver in an asynchronous context so that the telemetry task can be spawned. This method allows disabling
656 /// telemetry reporting in those cases.
657 ///
658 /// Defaults to telemetry enabled.
659 pub fn without_telemetry(mut self) -> Self {
660 self.telemetry_enabled = false;
661 self
662 }
663
664 /// Builds a [`TagsResolver`] from the current configuration.
665 pub fn build(self) -> TagsResolver {
666 let cached_tagsets_limit = self
667 .cached_tagset_limit
668 .unwrap_or(DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT);
669
670 let allow_heap_allocations = self.allow_heap_allocations.unwrap_or(true);
671
672 let telemetry = Telemetry::new(self.name.clone());
673 telemetry
674 .interner_capacity_bytes()
675 .set(self.interner.capacity_bytes() as f64);
676
677 let tagset_cache = CacheBuilder::from_identifier(format!("{}/tagsets", self.name))
678 .expect("cache identifier cannot possibly be empty")
679 .with_capacity(cached_tagsets_limit)
680 .with_time_to_idle(self.idle_tagset_expiration)
681 .with_hasher::<NoopU64BuildHasher>()
682 .with_telemetry(self.telemetry_enabled)
683 .build();
684
685 TagsResolver {
686 telemetry,
687 interner: self.interner,
688 caching_enabled: self.caching_enabled,
689 tagset_cache,
690 origin_tags_resolver: self.origin_tags_resolver,
691 allow_heap_allocations,
692 }
693 }
694
695 /// Configures a [`TagsResolverBuilder`] that's suitable for tests.
696 ///
697 /// This configures the builder with the following defaults:
698 ///
699 /// - resolver name of "noop"
700 /// - unlimited cache capacity
701 /// - no-op interner (all strings are heap-allocated)
702 /// - heap allocations allowed
703 /// - telemetry disabled
704 ///
705 /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate
706 /// testing scenarios.
707 pub fn for_tests() -> Self {
708 TagsResolverBuilder::new("noop", GenericMapInterner::new(NonZeroUsize::new(1).expect("not zero")))
709 .expect("resolver name not empty")
710 .with_cached_tagsets_limit(usize::MAX)
711 .with_heap_allocations(true)
712 .without_telemetry()
713 }
714}
715
716/// A resolver for tags.
717pub struct TagsResolver {
718 telemetry: Telemetry,
719 interner: GenericMapInterner,
720 caching_enabled: bool,
721 tagset_cache: TagSetCache,
722 origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
723 allow_heap_allocations: bool,
724}
725
726impl TagsResolver {
727 fn intern<S>(&self, s: S) -> Option<MetaString>
728 where
729 S: AsRef<str> + CheapMetaString,
730 {
731 // Try to cheaply clone the string, and if that fails, try to intern it. If that fails, then we fall back to
732 // allocating it on the heap if we allow it.
733 s.try_cheap_clone()
734 .or_else(|| self.interner.try_intern(s.as_ref()).map(MetaString::from))
735 .or_else(|| {
736 self.allow_heap_allocations.then(|| {
737 // Heap spill: with `allow_context_heap_allocations` true (the default), a full interner silently
738 // falls back to the heap, so the bounded-memory guarantee no longer holds. Anchor that this path is
739 // reached so the unbounded-growth behavior is observable.
740 saluki_antithesis::sometimes!(
741 true,
742 "tag string interner spilled to the heap (unbounded under default config)"
743 );
744 self.telemetry.intern_fallback_total().increment(1);
745 MetaString::from(s.as_ref())
746 })
747 })
748 }
749
750 /// Creates a new tag set from the given tags.
751 ///
752 /// This will intern the tags, and then return a shared tag set. If the interner is full, and heap allocations are
753 /// not allowed, then this will return `None`.
754 ///
755 /// If heap allocations are allowed, then this will return a shared tag set, and the tag set will be cached.
756 pub fn create_tag_set<I, T>(&mut self, tags: I) -> Option<SharedTagSet>
757 where
758 I: IntoIterator<Item = T>,
759 T: AsRef<str> + CheapMetaString,
760 {
761 let mut tag_set = TagSet::default();
762 for tag in tags {
763 let tag = self.intern(tag)?;
764 tag_set.insert_tag(tag);
765 }
766
767 self.telemetry.resolved_new_tagset_total().increment(1);
768
769 Some(tag_set.into_shared())
770 }
771
772 /// Resolves the origin tags for the given origin.
773 ///
774 /// This will return the origin tags for the given origin, or an empty tag set if no origin tags resolver is set.
775 pub fn resolve_origin_tags(&self, maybe_origin: Option<RawOrigin<'_>>) -> SharedTagSet {
776 self.origin_tags_resolver
777 .as_ref()
778 .and_then(|resolver| maybe_origin.map(|origin| resolver.resolve_origin_tags(origin)))
779 .unwrap_or_default()
780 }
781
782 fn get_tag_set(&self, key: TagSetKey) -> Option<SharedTagSet> {
783 self.tagset_cache.get(&key)
784 }
785
786 fn insert_tag_set(&self, key: TagSetKey, tag_set: SharedTagSet) {
787 self.tagset_cache.insert(key, tag_set);
788 }
789}
790
791impl Clone for TagsResolver {
792 fn clone(&self) -> Self {
793 Self {
794 telemetry: self.telemetry.clone(),
795 interner: self.interner.clone(),
796 caching_enabled: self.caching_enabled,
797 tagset_cache: self.tagset_cache.clone(),
798 origin_tags_resolver: self.origin_tags_resolver.clone(),
799 allow_heap_allocations: self.allow_heap_allocations,
800 }
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use metrics::{SharedString, Unit};
807 use metrics_util::{
808 debugging::{DebugValue, DebuggingRecorder},
809 CompositeKey,
810 };
811 use saluki_common::hash::hash_single_fast;
812
813 use super::*;
814
815 fn get_gauge_value(metrics: &[(CompositeKey, Option<Unit>, Option<SharedString>, DebugValue)], key: &str) -> f64 {
816 metrics
817 .iter()
818 .find(|(k, _, _, _)| k.key().name() == key)
819 .map(|(_, _, _, value)| match value {
820 DebugValue::Gauge(value) => value.into_inner(),
821 other => panic!("expected a gauge, got: {:?}", other),
822 })
823 .unwrap_or_else(|| panic!("no metric found with key: {}", key))
824 }
825
826 struct DummyOriginTagsResolver;
827
828 impl OriginTagsResolver for DummyOriginTagsResolver {
829 fn resolve_origin_tags(&self, origin: RawOrigin<'_>) -> SharedTagSet {
830 let origin_key = hash_single_fast(origin);
831
832 let mut tags = TagSet::default();
833 tags.insert_tag(format!("origin_key:{}", origin_key));
834 tags.into_shared()
835 }
836 }
837
838 #[test]
839 fn basic() {
840 let mut resolver = ContextResolverBuilder::for_tests().build();
841
842 // Create two distinct contexts with the same name but different tags:
843 let name = "metric_name";
844 let tags1: [&str; 0] = [];
845 let tags2 = ["tag1"];
846
847 assert_ne!(&tags1[..], &tags2[..]);
848
849 let context1 = resolver
850 .resolve(name, &tags1[..], None)
851 .expect("should not fail to resolve");
852 let context2 = resolver
853 .resolve(name, &tags2[..], None)
854 .expect("should not fail to resolve");
855
856 // The contexts should not be equal to each other, and should have distinct underlying pointers to the shared
857 // context state:
858 assert_ne!(context1, context2);
859 assert!(!context1.ptr_eq(&context2));
860
861 // If we create the context references again, we _should_ get back the same contexts as before:
862 let context1_redo = resolver
863 .resolve(name, &tags1[..], None)
864 .expect("should not fail to resolve");
865 let context2_redo = resolver
866 .resolve(name, &tags2[..], None)
867 .expect("should not fail to resolve");
868
869 assert_ne!(context1_redo, context2_redo);
870 assert_eq!(context1, context1_redo);
871 assert_eq!(context2, context2_redo);
872 assert!(context1.ptr_eq(&context1_redo));
873 assert!(context2.ptr_eq(&context2_redo));
874 }
875
876 #[test]
877 fn tag_order() {
878 let mut resolver = ContextResolverBuilder::for_tests().build();
879
880 // Create two distinct contexts with the same name and tags, but with the tags in a different order:
881 let name = "metric_name";
882 let tags1 = ["tag1", "tag2"];
883 let tags2 = ["tag2", "tag1"];
884
885 assert_ne!(&tags1[..], &tags2[..]);
886
887 let context1 = resolver
888 .resolve(name, &tags1[..], None)
889 .expect("should not fail to resolve");
890 let context2 = resolver
891 .resolve(name, &tags2[..], None)
892 .expect("should not fail to resolve");
893
894 // The contexts should be equal to each other, and should have the same underlying pointer to the shared context
895 // state:
896 assert_eq!(context1, context2);
897 assert!(context1.ptr_eq(&context2));
898 }
899
900 #[test]
901 fn active_contexts() {
902 let recorder = DebuggingRecorder::new();
903 let snapshotter = recorder.snapshotter();
904
905 // Create our resolver and then create a context, which will have its metrics attached to our local recorder:
906 let context = metrics::with_local_recorder(&recorder, || {
907 let mut resolver = ContextResolverBuilder::for_tests().build();
908 resolver
909 .resolve("name", &["tag"][..], None)
910 .expect("should not fail to resolve")
911 });
912
913 // We should be able to see that the active context count is one, representing the context we created:
914 let metrics_before = snapshotter.snapshot().into_vec();
915 let active_contexts = get_gauge_value(&metrics_before, Telemetry::active_contexts_name());
916 assert_eq!(active_contexts, 1.0);
917
918 // Now drop the context, and observe the active context count is negative one, representing the context we dropped:
919 drop(context);
920 let metrics_after = snapshotter.snapshot().into_vec();
921 let active_contexts = get_gauge_value(&metrics_after, Telemetry::active_contexts_name());
922 assert_eq!(active_contexts, -1.0);
923 }
924
925 #[test]
926 fn duplicate_tags() {
927 let mut resolver = ContextResolverBuilder::for_tests().build();
928
929 // Two contexts with the same name, but each with a different set of duplicate tags:
930 let name = "metric_name";
931 let tags1 = ["tag1"];
932 let tags1_duplicated = ["tag1", "tag1"];
933 let tags2 = ["tag2"];
934 let tags2_duplicated = ["tag2", "tag2"];
935
936 let context1 = resolver
937 .resolve(name, &tags1[..], None)
938 .expect("should not fail to resolve");
939 let context1_duplicated = resolver
940 .resolve(name, &tags1_duplicated[..], None)
941 .expect("should not fail to resolve");
942 let context2 = resolver
943 .resolve(name, &tags2[..], None)
944 .expect("should not fail to resolve");
945 let context2_duplicated = resolver
946 .resolve(name, &tags2_duplicated[..], None)
947 .expect("should not fail to resolve");
948
949 // Each non-duplicated/duplicated context pair should be equal to one another:
950 assert_eq!(context1, context1_duplicated);
951 assert_eq!(context2, context2_duplicated);
952
953 // Each pair should not be equal to the other pair, however.
954 //
955 // What we're asserting here is that, if we didn't handle duplicate tags correctly, the XOR hashing of [tag1,
956 // tag1] and [tag2, tag2] would result in the same hash value, since the second duplicate hash of tag1/tag2
957 // would cancel out the first... and thus all that would be left is the hash of the name itself, which is the
958 // same in this test. This would lead to the contexts being equal, which is obviously wrong.
959 //
960 // If we're handling duplicates properly, then the resulting context hashes _shouldn't_ be equal.
961 assert_ne!(context1, context2);
962 assert_ne!(context1_duplicated, context2_duplicated);
963 assert_ne!(context1, context2_duplicated);
964 assert_ne!(context2, context1_duplicated);
965 }
966
967 #[test]
968 fn differing_origins_with_without_resolver() {
969 // Create a regular context resolver, without any origin tags resolver, which should result in contexts being
970 // the same so long as the name and tags are the same, disregarding any difference in origin information:
971 let mut resolver = ContextResolverBuilder::for_tests().build();
972
973 let name = "metric_name";
974 let tags = ["tag1"];
975 let mut origin1 = RawOrigin::default();
976 origin1.set_local_data("container1");
977 let mut origin2 = RawOrigin::default();
978 origin2.set_local_data("container2");
979
980 let context1 = resolver
981 .resolve(name, &tags[..], Some(origin1.clone()))
982 .expect("should not fail to resolve");
983 let context2 = resolver
984 .resolve(name, &tags[..], Some(origin2.clone()))
985 .expect("should not fail to resolve");
986
987 assert_eq!(context1, context2);
988
989 let tags_resolver = TagsResolverBuilder::for_tests()
990 .with_origin_tags_resolver(Some(Arc::new(DummyOriginTagsResolver)))
991 .build();
992 // Now build a context resolver with an origin tags resolver that trivially returns the hash of the origin info
993 // as a tag, which should result in differeing sets of origin tags between the two origins, thus no longer
994 // comparing as equal:
995 let mut resolver = ContextResolverBuilder::for_tests()
996 .with_tags_resolver(Some(tags_resolver))
997 .build();
998
999 let context1 = resolver
1000 .resolve(name, &tags[..], Some(origin1))
1001 .expect("should not fail to resolve");
1002 let context2 = resolver
1003 .resolve(name, &tags[..], Some(origin2))
1004 .expect("should not fail to resolve");
1005
1006 assert_ne!(context1, context2);
1007 }
1008
1009 #[test]
1010 fn caching_disabled() {
1011 let tags_resolver = TagsResolverBuilder::for_tests()
1012 .with_origin_tags_resolver(Some(Arc::new(DummyOriginTagsResolver)))
1013 .build();
1014 let mut resolver = ContextResolverBuilder::for_tests()
1015 .without_caching()
1016 .with_tags_resolver(Some(tags_resolver))
1017 .build();
1018
1019 let name = "metric_name";
1020 let tags = ["tag1"];
1021 let mut origin1 = RawOrigin::default();
1022 origin1.set_local_data("container1");
1023
1024 // Create a context with caching disabled, and verify that the context is not cached:
1025 let context1 = resolver
1026 .resolve(name, &tags[..], Some(origin1.clone()))
1027 .expect("should not fail to resolve");
1028 assert_eq!(resolver.context_cache.len(), 0);
1029
1030 // Create a second context with the same name and tags, and verify that it is not cached:
1031 let context2 = resolver
1032 .resolve(name, &tags[..], Some(origin1))
1033 .expect("should not fail to resolve");
1034 assert_eq!(resolver.context_cache.len(), 0);
1035
1036 // The contexts should be equal to each other, but the underlying `Arc` pointers should be different since
1037 // they're two distinct contexts in terms of not being cached:
1038 assert_eq!(context1, context2);
1039 assert!(!context1.ptr_eq(&context2));
1040 }
1041
1042 #[test]
1043 fn cheaply_cloneable_name_and_tags() {
1044 const BIG_TAG_ONE: &str = "long-tag-that-cannot-be-inlined-just-to-be-doubly-sure-on-top-of-being-static";
1045 const BIG_TAG_TWO: &str = "another-long-boye-that-we-are-also-sure-wont-be-inlined-and-we-stand-on-that";
1046
1047 // Create a context resolver with a proper string interner configured:
1048 let mut resolver = ContextResolverBuilder::for_tests()
1049 .with_interner_capacity_bytes(NonZeroUsize::new(1024).expect("not zero"))
1050 .build();
1051
1052 // Create our context with cheaply cloneable tags, aka static strings:
1053 let name = MetaString::from_static("long-metric-name-that-shouldnt-be-inlined-and-should-end-up-interned");
1054 let tags = [
1055 MetaString::from_static(BIG_TAG_ONE),
1056 MetaString::from_static(BIG_TAG_TWO),
1057 ];
1058 assert!(tags[0].is_cheaply_cloneable());
1059 assert!(tags[1].is_cheaply_cloneable());
1060
1061 // Make sure the interner is empty before we resolve the context, and that it's empty afterwards, since we
1062 // should be able to cheaply clone both the metric name and both tags:
1063 assert_eq!(resolver.interner.len(), 0);
1064 assert_eq!(resolver.interner.len_bytes(), 0);
1065
1066 let context = resolver
1067 .resolve(&name, &tags[..], None)
1068 .expect("should not fail to resolve");
1069 assert_eq!(resolver.interner.len(), 0);
1070 assert_eq!(resolver.interner.len_bytes(), 0);
1071
1072 // And just a sanity check that we have the expected name and tags in the context:
1073 assert_eq!(context.name(), &name);
1074
1075 let context_tags = context.tags();
1076 assert_eq!(context_tags.len(), 2);
1077 assert!(context_tags.has_tag(&tags[0]));
1078 assert!(context_tags.has_tag(&tags[1]));
1079 }
1080}