Skip to main content

aion_server/worker/
quota_cache.rs

1//! Short-TTL in-process cache of per-namespace concurrency quotas, read by the
2//! non-replayed outbox dispatcher's keyed backpressure (Control-Plane Phase 2,
3//! P2-Q2).
4//!
5//! The dispatcher consults a namespace's cluster-wide `max_in_flight_activities`
6//! ceiling on every sweep to compute its per-node headroom. Reading it straight
7//! from the durable [`NamespaceStore`] each sweep would be a per-sweep quorum read
8//! on the hot claim loop. This cache front-runs `get_namespace`, holding each
9//! namespace's quota for a short TTL so the steady-state path is a lock +
10//! map-lookup, never a store round-trip — the same pattern as
11//! [`PlacementCache`](crate::worker::PlacementCache).
12//!
13//! Staleness is benign: the quota is the *cluster-wide* contract, enforced
14//! per-node as a proportional share with generous defaults, so a stale entry only
15//! over- or under-admits slightly for at most one TTL window and self-corrects on
16//! the next refresh. Backpressure never drops or fails a row — an over-admit at
17//! worst defers a Pending row one extra sweep — so cache staleness can never
18//! perturb correctness or replay (the claim shapes timing only, CP-Phase-2 §3.4).
19
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24use aion_store::NamespaceStore;
25
26/// One cached quota entry plus the instant it was read, for TTL expiry.
27#[derive(Clone, Copy)]
28struct CachedQuota {
29    /// The namespace's resolved cluster-wide ceiling: its explicit
30    /// `max_in_flight_activities` override, or the platform default when unset /
31    /// the record is absent.
32    ceiling: u32,
33    fetched_at: Instant,
34}
35
36/// A short-TTL cache over [`NamespaceStore::get_namespace`]'s
37/// `config.max_in_flight_activities`, resolving the platform default when a
38/// namespace sets no explicit override.
39///
40/// Cheap to clone (shares the inner store handle + map). A miss / expired entry
41/// reads the durable store once and re-caches; a backend error or an absent
42/// record resolves to the generous `platform_default` (so a registry hiccup
43/// admits at the default headroom rather than throttling to zero).
44#[derive(Clone)]
45pub struct QuotaCache {
46    store: Arc<dyn NamespaceStore>,
47    platform_default: u32,
48    ttl: Duration,
49    entries: Arc<Mutex<HashMap<String, CachedQuota>>>,
50}
51
52impl std::fmt::Debug for QuotaCache {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("QuotaCache")
55            .field("platform_default", &self.platform_default)
56            .field("ttl", &self.ttl)
57            .finish_non_exhaustive()
58    }
59}
60
61impl QuotaCache {
62    /// Build a cache over the durable namespace store with the given platform
63    /// default ceiling and entry TTL.
64    #[must_use]
65    pub fn new(store: Arc<dyn NamespaceStore>, platform_default: u32, ttl: Duration) -> Self {
66        Self {
67            store,
68            platform_default,
69            ttl,
70            entries: Arc::new(Mutex::new(HashMap::new())),
71        }
72    }
73
74    /// Return the namespace's resolved cluster-wide ceiling, serving a fresh cache
75    /// hit without a store read and refreshing on a miss / expiry.
76    ///
77    /// A poisoned cache lock or a store-read failure falls back to the generous
78    /// `platform_default` — the dispatch then admits at default headroom, so a
79    /// registry hiccup never throttles a tenant to zero.
80    pub async fn ceiling(&self, namespace: &str) -> u32 {
81        if let Some(hit) = self.fresh_hit(namespace) {
82            return hit;
83        }
84        let ceiling = match self.store.get_namespace(namespace).await {
85            Ok(Some(record)) => record
86                .config
87                .max_in_flight_activities
88                .unwrap_or(self.platform_default),
89            // An absent registry row (or a backend error) means no explicit
90            // override applies: resolve to the generous platform default.
91            Ok(None) | Err(_) => self.platform_default,
92        };
93        self.store_entry(namespace, ceiling);
94        ceiling
95    }
96
97    /// Return a still-fresh cached ceiling, or `None` on a miss / expiry / a
98    /// poisoned lock (treated as a miss so the caller re-reads).
99    fn fresh_hit(&self, namespace: &str) -> Option<u32> {
100        let entries = self.entries.lock().ok()?;
101        let entry = entries.get(namespace)?;
102        if entry.fetched_at.elapsed() < self.ttl {
103            Some(entry.ceiling)
104        } else {
105            None
106        }
107    }
108
109    /// Record `ceiling` for `namespace` with a fresh fetch instant. A poisoned
110    /// lock is a silent no-op: the next read simply re-fetches.
111    fn store_entry(&self, namespace: &str, ceiling: u32) {
112        if let Ok(mut entries) = self.entries.lock() {
113            entries.insert(
114                namespace.to_owned(),
115                CachedQuota {
116                    ceiling,
117                    fetched_at: Instant::now(),
118                },
119            );
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    #![allow(clippy::expect_used)]
127
128    use std::sync::Arc;
129    use std::time::Duration;
130
131    use aion_store::{InMemoryStore, NamespaceOrigin, NamespaceRecord, NamespaceStore};
132    use chrono::Utc;
133
134    use super::QuotaCache;
135
136    /// Register `namespace` carrying an explicit `max_in_flight_activities` override.
137    async fn register_with_quota(
138        store: &Arc<dyn NamespaceStore>,
139        namespace: &str,
140        quota: Option<u32>,
141    ) -> Result<(), Box<dyn std::error::Error>> {
142        let mut record =
143            NamespaceRecord::new_minted(namespace, NamespaceOrigin::Explicit, Utc::now());
144        record.config.max_in_flight_activities = quota;
145        store.put_namespace(record).await?;
146        Ok(())
147    }
148
149    #[tokio::test]
150    async fn explicit_override_is_read_from_store_and_cached()
151    -> Result<(), Box<dyn std::error::Error>> {
152        let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
153        register_with_quota(&store, "capped", Some(7)).await?;
154        let cache = QuotaCache::new(Arc::clone(&store), 1024, Duration::from_secs(60));
155
156        assert_eq!(
157            cache.ceiling("capped").await,
158            7,
159            "the explicit override is read"
160        );
161
162        // Mutate the durable record AFTER the cache filled: a fresh hit still
163        // serves the cached value (proving the second read did not hit the store).
164        register_with_quota(&store, "capped", Some(99)).await?;
165        assert_eq!(
166            cache.ceiling("capped").await,
167            7,
168            "a fresh cache hit must not re-read the mutated durable record"
169        );
170        Ok(())
171    }
172
173    #[tokio::test]
174    async fn unset_override_resolves_to_platform_default() -> Result<(), Box<dyn std::error::Error>>
175    {
176        let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
177        register_with_quota(&store, "uncapped", None).await?;
178        let cache = QuotaCache::new(store, 1024, Duration::from_secs(60));
179        assert_eq!(
180            cache.ceiling("uncapped").await,
181            1024,
182            "a namespace with no override resolves to the generous platform default"
183        );
184        Ok(())
185    }
186
187    #[tokio::test]
188    async fn absent_namespace_resolves_to_platform_default()
189    -> Result<(), Box<dyn std::error::Error>> {
190        let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
191        let cache = QuotaCache::new(store, 512, Duration::from_secs(60));
192        assert_eq!(
193            cache.ceiling("never-seen").await,
194            512,
195            "an absent registry row resolves to the platform default, never zero"
196        );
197        Ok(())
198    }
199
200    #[tokio::test]
201    async fn refreshes_after_ttl_expiry() -> Result<(), Box<dyn std::error::Error>> {
202        let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
203        // A zero TTL forces every read to be a miss, so a later durable change is
204        // observed on the next read. Start with an absent record → platform default.
205        let cache = QuotaCache::new(Arc::clone(&store), 1024, Duration::ZERO);
206        assert_eq!(
207            cache.ceiling("capped").await,
208            1024,
209            "an absent record resolves to the platform default"
210        );
211
212        // Register the namespace with an explicit override AFTER the first read: the
213        // expired (zero-TTL) entry re-reads the durable store and picks it up.
214        register_with_quota(&store, "capped", Some(7)).await?;
215        assert_eq!(
216            cache.ceiling("capped").await,
217            7,
218            "an expired entry re-reads the durable record and sees the new override"
219        );
220        Ok(())
221    }
222}