Skip to main content

stygian_charon/pow_profile/
store.rs

1//! `PoW` capability profile store (T93).
2//!
3//! The store accumulates [`PowCapabilitySample`]s into
4//! [`PowCapabilityProfile`]s keyed by
5//! `(domain, target_class, vendor_family)`. It reuses the
6//! same [`LruTtlStore`][crate::cache::LruTtlStore] primitive
7//! the T83 [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
8//! and the T91 [`NonceBook`][crate::token_lifecycle::NonceBook]
9//! use — that keeps eviction + expiry semantics consistent
10//! across all three short-horizon stores and satisfies the
11//! "no new cache store" requirement.
12//!
13//! ## Key namespace
14//!
15//! The store keys are namespaced under `charon:pow:...` (see
16//! [`pow_profile_key`]) so `PoW` entries never collide with
17//! challenge-memory entries (`charon:challenge:...`) or
18//! token-nonce entries (`charon:token_nonce:...`) on a
19//! shared backing primitive. The namespace is
20//! **prefix-stable** so operators can grep for it in a
21//! future Redis-backed variant without renaming.
22//!
23//! ## Sampling window
24//!
25//! The store's TTL is the per-entry expiry horizon. The
26//! **profile's** `observation_window_secs` is independent —
27//! it documents how wide a window the profile was built
28//! over. Callers that want the profile to also expire after
29//! the window elapses can configure a TTL equal to the
30//! window (the default TTL of 1 hour matches the default
31//! sampling window).
32
33use std::num::NonZeroUsize;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use crate::cache::LruTtlStore;
37use crate::pow_profile::profile::{PowCapabilityProfile, PowCapabilitySample};
38use crate::types::TargetClass;
39use crate::vendor_classifier::VendorId;
40
41/// Default TTL for the `PoW` capability store: **1 hour**.
42///
43/// Matches [`DEFAULT_SAMPLE_WINDOW_SECS`][crate::pow_profile::DEFAULT_SAMPLE_WINDOW_SECS]
44/// (the default sampling window) so a profile that was
45/// built over the default window expires exactly when the
46/// window elapses. Operators that want a longer horizon can
47/// call [`PowCapabilityStore::new`] with an explicit TTL.
48pub const DEFAULT_POW_TTL: Duration = Duration::from_hours(1);
49
50/// Default capacity (in `(domain, target_class, vendor)` triples)
51/// for the `PoW` capability store.
52#[allow(clippy::unwrap_used)]
53pub const DEFAULT_POW_CAPACITY: NonZeroUsize = match NonZeroUsize::new(128) {
54    Some(value) => value,
55    None => NonZeroUsize::MIN,
56};
57
58/// Default system-clock fallback when wall-clock time is
59/// unavailable. Small enough that a zero-second
60/// `recorded_at_unix_secs` is distinguishable from a real
61/// timestamp while still being a valid serialisation.
62const ZERO_FALLBACK_UNIX_SECS: u64 = 0;
63
64/// Build a stable, lower-cased key for the `PoW` capability
65/// store.
66///
67/// The key uses a `charon:pow:...` namespace so `PoW` entries
68/// never collide with `charon:challenge:...` (T83) or
69/// `charon:token_nonce:...` (T91) on a shared backing
70/// primitive.
71///
72/// # Example
73///
74/// ```
75/// use stygian_charon::pow_profile::pow_profile_key;
76/// use stygian_charon::types::TargetClass;
77/// use stygian_charon::vendor_classifier::VendorId;
78///
79/// let key = pow_profile_key("Example.COM", TargetClass::Api, VendorId::Akamai);
80/// assert!(key.starts_with("charon:pow:example.com:api:akamai"));
81/// ```
82#[must_use]
83pub fn pow_profile_key(domain: &str, target_class: TargetClass, vendor: VendorId) -> String {
84    format!(
85        "charon:pow:{}:{}:{}",
86        domain.to_ascii_lowercase(),
87        target_class_label(target_class),
88        vendor.label()
89    )
90}
91
92const fn target_class_label(c: TargetClass) -> &'static str {
93    match c {
94        TargetClass::Api => "api",
95        TargetClass::ContentSite => "content_site",
96        TargetClass::HighSecurity => "high_security",
97        TargetClass::Unknown => "unknown",
98    }
99}
100
101/// Capacity-bounded LRU+TTL store of
102/// [`PowCapabilityProfile`]s.
103///
104/// Reuses the same `LruTtlStore`
105/// primitive the T83 [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
106/// and the T91 [`NonceBook`][crate::token_lifecycle::NonceBook]
107/// use. That keeps eviction + expiry semantics consistent
108/// across all three short-horizon stores and satisfies the
109/// "no new cache store" constraint.
110///
111/// # Example
112///
113/// ```
114/// use stygian_charon::pow_profile::{PowCapabilitySample, PowCapabilityStore};
115/// use stygian_charon::types::TargetClass;
116/// use stygian_charon::vendor_classifier::VendorId;
117///
118/// let store = PowCapabilityStore::with_defaults();
119/// store.record_sample(
120///     "example.com",
121///     TargetClass::ContentSite,
122///     VendorId::Cloudflare,
123///     &PowCapabilitySample::solved(1_000, 0),
124/// );
125/// let profile = store
126///     .lookup("example.com", TargetClass::ContentSite, VendorId::Cloudflare)
127///     .expect("profile");
128/// assert_eq!(profile.solved_count, 1);
129/// ```
130pub struct PowCapabilityStore {
131    store: LruTtlStore<PowCapabilityProfile>,
132}
133
134impl std::fmt::Debug for PowCapabilityStore {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.debug_struct("PowCapabilityStore")
137            .field("ttl", &self.store.ttl())
138            .field("len", &self.store.len())
139            .finish()
140    }
141}
142
143impl PowCapabilityStore {
144    /// Create a new store with explicit capacity and TTL.
145    #[must_use]
146    pub fn new(capacity: NonZeroUsize, ttl: Duration) -> Self {
147        Self {
148            store: LruTtlStore::new(capacity, ttl),
149        }
150    }
151
152    /// Capacity-bounded [`PowCapabilityStore`] with
153    /// [`DEFAULT_POW_TTL`].
154    #[must_use]
155    pub fn with_default_ttl(capacity: NonZeroUsize) -> Self {
156        Self::new(capacity, DEFAULT_POW_TTL)
157    }
158
159    /// Capacity-bounded [`PowCapabilityStore`] with
160    /// [`DEFAULT_POW_CAPACITY`] and [`DEFAULT_POW_TTL`].
161    #[must_use]
162    pub fn with_defaults() -> Self {
163        Self::new(DEFAULT_POW_CAPACITY, DEFAULT_POW_TTL)
164    }
165
166    /// Configured TTL for the backing store.
167    #[must_use]
168    pub const fn ttl(&self) -> Duration {
169        self.store.ttl()
170    }
171
172    /// Record a [`PowCapabilitySample`] for a
173    /// `(domain, target_class, vendor)` triple. The store
174    /// looks up the existing profile (if any), merges the
175    /// sample into it, and re-inserts the updated profile
176    /// under the LRU+TTL semantics shared with
177    /// `LruTtlStore`.
178    ///
179    /// The LRU recency is **not** bumped on the read so a
180    /// high-volume key does not crowd out less common keys
181    /// (matches the T83 / T91 pattern).
182    pub fn record_sample(
183        &self,
184        domain: &str,
185        target_class: TargetClass,
186        vendor: VendorId,
187        sample: &PowCapabilitySample,
188    ) {
189        let key = pow_profile_key(domain, target_class, vendor);
190        let mut profile = self
191            .store
192            .peek(&key)
193            .unwrap_or_else(|| PowCapabilityProfile::new(domain, target_class, vendor));
194        profile.merge(sample);
195        // Refresh the recorded_at_unix_secs to the current
196        // wall clock so the merge timestamp stays useful
197        // even when peek returned a freshly-built default
198        // (whose merge() already set the timestamp, but
199        // the new path is explicit for readability).
200        profile.recorded_at_unix_secs = current_unix_secs();
201        self.store.put(key, profile);
202    }
203
204    /// Look up the current profile for a
205    /// `(domain, target_class, vendor)` triple. Returns
206    /// `None` if the key is absent or has expired.
207    #[must_use]
208    pub fn lookup(
209        &self,
210        domain: &str,
211        target_class: TargetClass,
212        vendor: VendorId,
213    ) -> Option<PowCapabilityProfile> {
214        self.store
215            .get(&pow_profile_key(domain, target_class, vendor))
216    }
217
218    /// Number of profiles currently retained.
219    #[must_use]
220    pub fn len(&self) -> usize {
221        self.store.len()
222    }
223
224    /// `true` when the store has zero profiles.
225    #[must_use]
226    pub fn is_empty(&self) -> bool {
227        self.store.is_empty()
228    }
229
230    /// Remove all profiles.
231    pub fn clear(&self) {
232        self.store.clear();
233    }
234
235    /// Invalidate a single `(domain, target_class, vendor)`
236    /// key.
237    pub fn invalidate(&self, domain: &str, target_class: TargetClass, vendor: VendorId) {
238        self.store
239            .invalidate(&pow_profile_key(domain, target_class, vendor));
240    }
241}
242
243fn current_unix_secs() -> u64 {
244    SystemTime::now()
245        .duration_since(UNIX_EPOCH)
246        .map_or(ZERO_FALLBACK_UNIX_SECS, |duration| duration.as_secs())
247}
248
249#[cfg(test)]
250#[allow(
251    clippy::unwrap_used,
252    clippy::expect_used,
253    clippy::panic,
254    clippy::indexing_slicing
255)]
256mod tests {
257    use super::*;
258    use std::thread;
259
260    #[test]
261    fn record_sample_creates_new_profile_on_first_call() {
262        let store = PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), DEFAULT_POW_TTL);
263        store.record_sample(
264            "example.com",
265            TargetClass::ContentSite,
266            VendorId::Cloudflare,
267            &PowCapabilitySample::solved(1_000, 0),
268        );
269        let profile = store
270            .lookup(
271                "example.com",
272                TargetClass::ContentSite,
273                VendorId::Cloudflare,
274            )
275            .expect("profile");
276        assert_eq!(profile.domain, "example.com");
277        assert_eq!(profile.solved_count, 1);
278        assert_eq!(profile.failed_count, 0);
279        assert_eq!(profile.vendor_family, VendorId::Cloudflare);
280    }
281
282    #[test]
283    fn record_sample_merges_into_existing_profile() {
284        let store = PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), DEFAULT_POW_TTL);
285        let key = (
286            "example.com",
287            TargetClass::ContentSite,
288            VendorId::Cloudflare,
289        );
290        store.record_sample(key.0, key.1, key.2, &PowCapabilitySample::solved(1_000, 0));
291        store.record_sample(key.0, key.1, key.2, &PowCapabilitySample::solved(1_500, 1));
292        store.record_sample(
293            key.0,
294            key.1,
295            key.2,
296            &PowCapabilitySample::failed(
297                2_000,
298                1,
299                crate::pow_profile::profile::PowFailureMode::Timeout,
300            ),
301        );
302        let profile = store.lookup(key.0, key.1, key.2).expect("profile");
303        assert_eq!(profile.solved_count, 2);
304        assert_eq!(profile.failed_count, 1);
305        assert_eq!(profile.retry_count, 2);
306        assert_eq!(
307            profile
308                .failure_modes
309                .get(&crate::pow_profile::profile::PowFailureMode::Timeout),
310            Some(&1)
311        );
312    }
313
314    #[test]
315    fn distinct_keys_keep_distinct_profiles() {
316        let store = PowCapabilityStore::new(NonZeroUsize::new(8).unwrap(), DEFAULT_POW_TTL);
317        store.record_sample(
318            "example.com",
319            TargetClass::ContentSite,
320            VendorId::Cloudflare,
321            &PowCapabilitySample::solved(1_000, 0),
322        );
323        store.record_sample(
324            "example.com",
325            TargetClass::Api,
326            VendorId::Cloudflare,
327            &PowCapabilitySample::solved(2_000, 0),
328        );
329        store.record_sample(
330            "example.com",
331            TargetClass::ContentSite,
332            VendorId::Akamai,
333            &PowCapabilitySample::solved(3_000, 0),
334        );
335        let cs_cf = store
336            .lookup(
337                "example.com",
338                TargetClass::ContentSite,
339                VendorId::Cloudflare,
340            )
341            .unwrap();
342        let api_cf = store
343            .lookup("example.com", TargetClass::Api, VendorId::Cloudflare)
344            .unwrap();
345        let cs_ak = store
346            .lookup("example.com", TargetClass::ContentSite, VendorId::Akamai)
347            .unwrap();
348        assert_eq!(cs_cf.solve_latency_ms_p50, Some(1_000));
349        assert_eq!(api_cf.solve_latency_ms_p50, Some(2_000));
350        assert_eq!(cs_ak.solve_latency_ms_p50, Some(3_000));
351    }
352
353    #[test]
354    fn domain_is_normalised_to_lower_case() {
355        let store = PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), DEFAULT_POW_TTL);
356        store.record_sample(
357            "Example.COM",
358            TargetClass::Api,
359            VendorId::Cloudflare,
360            &PowCapabilitySample::solved(1_000, 0),
361        );
362        let profile = store
363            .lookup("EXAMPLE.com", TargetClass::Api, VendorId::Cloudflare)
364            .expect("profile");
365        assert_eq!(profile.domain, "example.com");
366    }
367
368    #[test]
369    fn entries_decay_after_ttl() {
370        let store =
371            PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), Duration::from_millis(1));
372        store.record_sample(
373            "example.com",
374            TargetClass::Api,
375            VendorId::Cloudflare,
376            &PowCapabilitySample::solved(1_000, 0),
377        );
378        thread::sleep(Duration::from_millis(5));
379        assert!(
380            store
381                .lookup("example.com", TargetClass::Api, VendorId::Cloudflare)
382                .is_none()
383        );
384    }
385
386    #[test]
387    fn clear_drops_everything() {
388        let store = PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), DEFAULT_POW_TTL);
389        store.record_sample(
390            "a.example",
391            TargetClass::Api,
392            VendorId::Cloudflare,
393            &PowCapabilitySample::solved(1_000, 0),
394        );
395        store.record_sample(
396            "b.example",
397            TargetClass::Api,
398            VendorId::Cloudflare,
399            &PowCapabilitySample::solved(1_000, 0),
400        );
401        assert_eq!(store.len(), 2);
402        store.clear();
403        assert!(store.is_empty());
404    }
405
406    #[test]
407    fn invalidate_drops_single_key() {
408        let store = PowCapabilityStore::new(NonZeroUsize::new(4).unwrap(), DEFAULT_POW_TTL);
409        store.record_sample(
410            "a.example",
411            TargetClass::Api,
412            VendorId::Cloudflare,
413            &PowCapabilitySample::solved(1_000, 0),
414        );
415        store.record_sample(
416            "b.example",
417            TargetClass::Api,
418            VendorId::Cloudflare,
419            &PowCapabilitySample::solved(1_000, 0),
420        );
421        store.invalidate("a.example", TargetClass::Api, VendorId::Cloudflare);
422        assert!(
423            store
424                .lookup("a.example", TargetClass::Api, VendorId::Cloudflare)
425                .is_none()
426        );
427        assert!(
428            store
429                .lookup("b.example", TargetClass::Api, VendorId::Cloudflare)
430                .is_some()
431        );
432    }
433
434    #[test]
435    fn lru_capacity_is_respected() {
436        let store = PowCapabilityStore::new(NonZeroUsize::new(2).unwrap(), DEFAULT_POW_TTL);
437        store.record_sample(
438            "a.example",
439            TargetClass::Api,
440            VendorId::Cloudflare,
441            &PowCapabilitySample::solved(1_000, 0),
442        );
443        store.record_sample(
444            "b.example",
445            TargetClass::Api,
446            VendorId::Cloudflare,
447            &PowCapabilitySample::solved(1_000, 0),
448        );
449        store.record_sample(
450            "c.example",
451            TargetClass::Api,
452            VendorId::Cloudflare,
453            &PowCapabilitySample::solved(1_000, 0),
454        );
455        assert!(store.len() <= 2);
456    }
457
458    #[test]
459    fn key_namespace_is_pow_prefixed() {
460        let key = pow_profile_key("Example.COM", TargetClass::Api, VendorId::Akamai);
461        assert_eq!(key, "charon:pow:example.com:api:akamai");
462    }
463
464    #[test]
465    fn default_ttl_matches_default_sample_window() {
466        assert_eq!(
467            DEFAULT_POW_TTL.as_secs(),
468            crate::pow_profile::DEFAULT_SAMPLE_WINDOW_SECS
469        );
470    }
471}