relay_cardinality/redis/
quota.rs

1use hash32::Hasher;
2use std::fmt::{self, Write};
3use std::hash::Hash;
4
5use relay_base_schema::metrics::{MetricName, MetricNamespace, MetricType};
6use relay_base_schema::organization::OrganizationId;
7use relay_base_schema::project::ProjectId;
8use relay_common::time::UnixTimestamp;
9
10use crate::limiter::Entry;
11use crate::redis::{KEY_PREFIX, KEY_VERSION};
12use crate::window::Slot;
13use crate::{CardinalityLimit, CardinalityScope, Scoping, SlidingWindow};
14
15/// A quota scoping extracted from a [`CardinalityLimit`] and a [`Scoping`].
16///
17/// The partial quota scoping can be used to select/match on cardinality entries
18/// but it needs to be completed into a [`QuotaScoping`] by using
19/// [`PartialQuotaScoping::complete`].
20#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
21pub struct PartialQuotaScoping {
22    pub organization_id: Option<OrganizationId>,
23    pub project_id: Option<ProjectId>,
24    pub namespace: Option<MetricNamespace>,
25    window: SlidingWindow,
26    scope: CardinalityScope,
27}
28
29impl PartialQuotaScoping {
30    /// Creates a new [`PartialQuotaScoping`] from a [`Scoping`] and [`CardinalityLimit`].
31    ///
32    /// Returns `None` for limits with scope [`CardinalityScope::Unknown`].
33    pub fn new(scoping: Scoping, limit: &CardinalityLimit) -> Option<Self> {
34        let (organization_id, project_id) = match limit.scope {
35            CardinalityScope::Organization => (Some(scoping.organization_id), None),
36            CardinalityScope::Project => (Some(scoping.organization_id), Some(scoping.project_id)),
37            CardinalityScope::Type => (Some(scoping.organization_id), Some(scoping.project_id)),
38            CardinalityScope::Name => (Some(scoping.organization_id), Some(scoping.project_id)),
39            // Invalid/unknown scope -> ignore the limit.
40            CardinalityScope::Unknown => return None,
41        };
42
43        Some(Self {
44            organization_id,
45            project_id,
46            namespace: limit.namespace,
47            window: limit.window,
48            scope: limit.scope,
49        })
50    }
51
52    /// Wether the scoping applies to the passed entry.
53    pub fn matches(&self, entry: &Entry) -> bool {
54        self.namespace.is_none() || self.namespace == Some(entry.namespace)
55    }
56
57    /// Returns the currently active slot.
58    pub fn active_slot(&self, timestamp: UnixTimestamp) -> Slot {
59        self.window.active_slot(self.shifted(timestamp))
60    }
61
62    /// Returns all slots of the sliding window for a specific timestamp.
63    pub fn slots(&self, timestamp: UnixTimestamp) -> impl Iterator<Item = Slot> {
64        self.window.iter(self.shifted(timestamp))
65    }
66
67    /// Applies a timeshift based on the granularity of the sliding window to the passed timestamp.
68    ///
69    /// The shift is used to evenly distribute cache and Redis operations across
70    /// the sliding window's granule.
71    fn shifted(&self, timestamp: UnixTimestamp) -> UnixTimestamp {
72        let shift = self
73            .organization_id
74            .map(|o| o.value() % self.window.granularity_seconds)
75            .unwrap_or(0);
76
77        UnixTimestamp::from_secs(timestamp.as_secs() + shift)
78    }
79
80    /// Creates a [`QuotaScoping`] from the partial scoping and the passed [`Entry`].
81    ///
82    /// This unconditionally creates a quota scoping from the passed entry and
83    /// does not check whether the scoping even applies to the entry. The caller
84    /// needs to ensure this by calling [`Self::matches`] prior to calling `complete`.
85    pub fn complete(self, entry: Entry<'_>) -> QuotaScoping {
86        let metric_name = match self.scope {
87            CardinalityScope::Name => Some(entry.name.clone()),
88            _ => None,
89        };
90        let metric_type = match self.scope {
91            CardinalityScope::Type => entry.name.try_type(),
92            _ => None,
93        };
94
95        QuotaScoping {
96            parent: self,
97            metric_type,
98            metric_name,
99        }
100    }
101}
102
103/// A quota scoping extracted from a [`CardinalityLimit`], a [`Scoping`]
104/// and completed with a [`CardinalityItem`](crate::CardinalityItem).
105///
106/// The scoping must be created using [`PartialQuotaScoping::complete`].
107/// and a [`CardinalityItem`](crate::CardinalityItem).
108#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
109pub struct QuotaScoping {
110    parent: PartialQuotaScoping,
111    pub metric_type: Option<MetricType>,
112    pub metric_name: Option<MetricName>,
113}
114
115impl QuotaScoping {
116    /// Returns the minimum TTL for a Redis key created by [`Self::to_redis_key`].
117    pub fn redis_key_ttl(&self) -> u64 {
118        self.window.window_seconds
119    }
120
121    /// Turns the scoping into a Redis key for the passed slot.
122    pub fn to_redis_key(&self, slot: Slot) -> String {
123        let organization_id = self.organization_id.unwrap_or(OrganizationId::new(0));
124        let project_id = self.project_id.map(|p| p.value()).unwrap_or(0);
125        let namespace = self.namespace.map(|ns| ns.as_str()).unwrap_or("");
126        let metric_type = DisplayOptMinus(self.metric_type);
127        let metric_name = DisplayOptMinus(self.metric_name.as_deref().map(fnv32));
128
129        // Use a pre-allocated buffer instead of `format!()`, benchmarks have shown
130        // this does have quite a big impact when cardinality limiting a high amount
131        // of different metric names.
132        let mut result = String::with_capacity(200);
133        write!(
134            &mut result,
135            "{KEY_PREFIX}:{KEY_VERSION}:scope-{{{organization_id}-{project_id}-{namespace}}}-{metric_type}{metric_name}{slot}"
136        )
137        .expect("formatting into a string never fails");
138
139        result
140    }
141}
142
143impl std::ops::Deref for QuotaScoping {
144    type Target = PartialQuotaScoping;
145
146    fn deref(&self) -> &Self::Target {
147        &self.parent
148    }
149}
150
151// Required for hashbrown's `entry_ref`.
152impl From<&QuotaScoping> for QuotaScoping {
153    fn from(value: &QuotaScoping) -> Self {
154        value.clone()
155    }
156}
157
158impl fmt::Debug for QuotaScoping {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        let PartialQuotaScoping {
161            organization_id,
162            project_id,
163            namespace,
164            window,
165            scope,
166        } = &self.parent;
167
168        f.debug_struct("QuotaScoping")
169            .field("organization_id", organization_id)
170            .field("project_id", project_id)
171            .field("namespace", namespace)
172            .field("window", window)
173            .field("scope", scope)
174            .field("metric_type", &self.metric_type)
175            .field("metric_name", &self.metric_name)
176            .finish()
177    }
178}
179
180struct DisplayOptMinus<T>(Option<T>);
181
182impl<T: fmt::Display> fmt::Display for DisplayOptMinus<T> {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        if let Some(inner) = self.0.as_ref() {
185            write!(f, "{inner}-")
186        } else {
187            Ok(())
188        }
189    }
190}
191
192fn fnv32(s: &str) -> u32 {
193    let mut hasher = hash32::FnvHasher::default();
194    s.hash(&mut hasher);
195    hasher.finish32()
196}