1use 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
41pub const DEFAULT_POW_TTL: Duration = Duration::from_hours(1);
49
50#[allow(clippy::unwrap_used)]
53pub const DEFAULT_POW_CAPACITY: NonZeroUsize = match NonZeroUsize::new(128) {
54 Some(value) => value,
55 None => NonZeroUsize::MIN,
56};
57
58const ZERO_FALLBACK_UNIX_SECS: u64 = 0;
63
64#[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
101pub 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 #[must_use]
146 pub fn new(capacity: NonZeroUsize, ttl: Duration) -> Self {
147 Self {
148 store: LruTtlStore::new(capacity, ttl),
149 }
150 }
151
152 #[must_use]
155 pub fn with_default_ttl(capacity: NonZeroUsize) -> Self {
156 Self::new(capacity, DEFAULT_POW_TTL)
157 }
158
159 #[must_use]
162 pub fn with_defaults() -> Self {
163 Self::new(DEFAULT_POW_CAPACITY, DEFAULT_POW_TTL)
164 }
165
166 #[must_use]
168 pub const fn ttl(&self) -> Duration {
169 self.store.ttl()
170 }
171
172 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 profile.recorded_at_unix_secs = current_unix_secs();
201 self.store.put(key, profile);
202 }
203
204 #[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 #[must_use]
220 pub fn len(&self) -> usize {
221 self.store.len()
222 }
223
224 #[must_use]
226 pub fn is_empty(&self) -> bool {
227 self.store.is_empty()
228 }
229
230 pub fn clear(&self) {
232 self.store.clear();
233 }
234
235 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}