From fbd8a8aa6694b0753005f7c31c3da305af98114f Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Fri, 17 Apr 2026 13:07:18 -0700 Subject: [PATCH 1/3] fix: replace panicking expect() with map_or() for file_name() check Path::file_name() returns None for paths ending in '..' which caused a panic. Build cache tools should handle unusual paths gracefully. --- src/lru_disk_cache/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lru_disk_cache/mod.rs b/src/lru_disk_cache/mod.rs index 65f7447616..174d30ec7c 100644 --- a/src/lru_disk_cache/mod.rs +++ b/src/lru_disk_cache/mod.rs @@ -188,8 +188,7 @@ impl LruDiskCache { for (file, size) in get_all_files(&self.root) { if file .file_name() - .expect("Bad path?") - .starts_with(TEMPFILE_PREFIX) + .map_or(false, |name| name.starts_with(TEMPFILE_PREFIX)) { fs::remove_file(&file).unwrap_or_else(|e| { error!("Error removing temporary file `{}`: {}", file.display(), e); From a98405e833e5ffb47c646a88e4f7b264b8582050 Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Fri, 24 Apr 2026 22:07:31 -0700 Subject: [PATCH 2/3] fix: rollback pending state on tempfile failure in prepare_add prepare_add updated pending size before tempfile_in(), which never rolled back on failure. This caused make_space() to panic later when encountering negative available space. Moved state update after successful tempfile creation and replaced .expect() with ? operator. Signed-off-by: Srikanth Patchava Signed-off-by: Srikanth Patchava --- .github/actions/nvcc-toolchain/install-cuda.sh | 0 scripts/extratest.sh | 0 scripts/freebsd-ci-test.sh | 0 src/lru_disk_cache/mod.rs | 11 +++++------ src/server.rs | 4 ++-- tests/integration/scripts/test-autotools.sh | 0 tests/integration/scripts/test-backend.sh | 0 tests/integration/scripts/test-basedirs.sh | 0 tests/integration/scripts/test-clang.sh | 0 tests/integration/scripts/test-cmake-modules-v4.sh | 0 tests/integration/scripts/test-cmake-modules.sh | 0 tests/integration/scripts/test-cmake.sh | 0 tests/integration/scripts/test-coverage.sh | 0 tests/integration/scripts/test-gcc.sh | 0 tests/integration/scripts/test-multilevel-chain.sh | 0 tests/integration/scripts/test-multilevel.sh | 0 tests/integration/scripts/test-zstd.sh | 0 17 files changed, 7 insertions(+), 8 deletions(-) mode change 100755 => 100644 .github/actions/nvcc-toolchain/install-cuda.sh mode change 100755 => 100644 scripts/extratest.sh mode change 100755 => 100644 scripts/freebsd-ci-test.sh mode change 100755 => 100644 tests/integration/scripts/test-autotools.sh mode change 100755 => 100644 tests/integration/scripts/test-backend.sh mode change 100755 => 100644 tests/integration/scripts/test-basedirs.sh mode change 100755 => 100644 tests/integration/scripts/test-clang.sh mode change 100755 => 100644 tests/integration/scripts/test-cmake-modules-v4.sh mode change 100755 => 100644 tests/integration/scripts/test-cmake-modules.sh mode change 100755 => 100644 tests/integration/scripts/test-cmake.sh mode change 100755 => 100644 tests/integration/scripts/test-coverage.sh mode change 100755 => 100644 tests/integration/scripts/test-gcc.sh mode change 100755 => 100644 tests/integration/scripts/test-multilevel-chain.sh mode change 100755 => 100644 tests/integration/scripts/test-multilevel.sh mode change 100755 => 100644 tests/integration/scripts/test-zstd.sh diff --git a/.github/actions/nvcc-toolchain/install-cuda.sh b/.github/actions/nvcc-toolchain/install-cuda.sh old mode 100755 new mode 100644 diff --git a/scripts/extratest.sh b/scripts/extratest.sh old mode 100755 new mode 100644 diff --git a/scripts/freebsd-ci-test.sh b/scripts/freebsd-ci-test.sh old mode 100755 new mode 100644 diff --git a/src/lru_disk_cache/mod.rs b/src/lru_disk_cache/mod.rs index 174d30ec7c..9690e9ac70 100644 --- a/src/lru_disk_cache/mod.rs +++ b/src/lru_disk_cache/mod.rs @@ -219,7 +219,7 @@ impl LruDiskCache { } //TODO: ideally LRUCache::insert would give us back the entries it had to remove. while self.size() + size > self.capacity() { - let (rel_path, _) = self.lru.remove_lru().expect("Unexpectedly empty cache!"); + let (rel_path, _) = self.lru.remove_lru().ok_or(Error::FileTooLarge)?; let remove_path = self.rel_to_abs_path(rel_path); //TODO: check that files are removable during `init`, so that this is only // due to outside interference. @@ -331,13 +331,12 @@ impl LruDiskCache { // Ensure we have enough space for the advertized space. self.make_space(size)?; let key = key.as_ref().to_owned(); + let file = tempfile::Builder::new() + .prefix(TEMPFILE_PREFIX) + .tempfile_in(&self.root)?; self.pending.push(key.clone()); self.pending_size += size; - tempfile::Builder::new() - .prefix(TEMPFILE_PREFIX) - .tempfile_in(&self.root) - .map(|file| LruDiskCacheAddEntry { file, key, size }) - .map_err(Into::into) + Ok(LruDiskCacheAddEntry { file, key, size }) } /// Commit an entry coming from `LruDiskCache::prepare_add`. diff --git a/src/server.rs b/src/server.rs index be49c1dfd2..5d52308ff7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -10,7 +10,7 @@ // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and -// limitations under the License.SCCACHE_MAX_FRAME_LENGTH +// limitations under the License. use crate::cache::readonly::ReadOnlyStorage; use crate::cache::{CacheMode, Storage, storage_from_config}; @@ -84,7 +84,7 @@ const DIST_CLIENT_RECREATE_TIMEOUT: Duration = Duration::from_secs(30); pub enum ServerStartup { /// Server started successfully on `addr`. Ok { addr: String }, - /// Server Addr already in suse + /// Server Addr already in use AddrInUse, /// Timed out waiting for server startup. TimedOut, diff --git a/tests/integration/scripts/test-autotools.sh b/tests/integration/scripts/test-autotools.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-backend.sh b/tests/integration/scripts/test-backend.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-basedirs.sh b/tests/integration/scripts/test-basedirs.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-clang.sh b/tests/integration/scripts/test-clang.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-cmake-modules-v4.sh b/tests/integration/scripts/test-cmake-modules-v4.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-cmake-modules.sh b/tests/integration/scripts/test-cmake-modules.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-cmake.sh b/tests/integration/scripts/test-cmake.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-coverage.sh b/tests/integration/scripts/test-coverage.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-gcc.sh b/tests/integration/scripts/test-gcc.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-multilevel-chain.sh b/tests/integration/scripts/test-multilevel-chain.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-multilevel.sh b/tests/integration/scripts/test-multilevel.sh old mode 100755 new mode 100644 diff --git a/tests/integration/scripts/test-zstd.sh b/tests/integration/scripts/test-zstd.sh old mode 100755 new mode 100644 From 4b5da5d068aa4fccadccbc0c07828c3177adc65a Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Sat, 25 Apr 2026 01:28:25 -0700 Subject: [PATCH 3/3] feat: add cache statistics reporting module Add comprehensive cache statistics tracking with: - Thread-safe atomic counters for hit/miss/eviction tracking - Cache size and entry count monitoring - JSON and human-readable output formats - Time-window aggregation (minute, hour, day) - Statistics snapshot and reset API - Fix TOCTOU race condition in DiskCache::put_raw Includes extensive test suite with thread safety tests. Signed-off-by: Srikanth Patchava --- src/cache/disk.rs | 10 +- src/lib.rs | 1 + src/stats.rs | 515 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 src/stats.rs diff --git a/src/cache/disk.rs b/src/cache/disk.rs index ebd65d1a3f..3f0dab2317 100644 --- a/src/cache/disk.rs +++ b/src/cache/disk.rs @@ -152,13 +152,11 @@ impl Storage for DiskCache { self.pool .spawn_blocking(move || { let start = Instant::now(); - let mut f = lru - .lock() - .unwrap() - .get_or_init()? - .prepare_add(key, data.len() as u64)?; + let mut lru_guard = lru.lock().unwrap(); + let cache = lru_guard.get_or_init()?; + let mut f = cache.prepare_add(key, data.len() as u64)?; f.as_file_mut().write_all(&data)?; - lru.lock().unwrap().get().unwrap().commit(f)?; + cache.commit(f)?; Ok(start.elapsed()) }) .await? diff --git a/src/lib.rs b/src/lib.rs index d5aa6a6032..ae57a17344 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ mod mock_command; mod net; mod protocol; pub mod server; +pub mod stats; #[doc(hidden)] pub mod util; diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000000..e3655186ab --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,515 @@ +// Copyright 2024 Mozilla Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Cache statistics tracking and reporting module. +//! +//! Provides thread-safe counters for cache hit/miss rates, eviction counts, +//! cache size monitoring, and time-window aggregation with both JSON and +//! human-readable output formats. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; +use std::fmt; + +/// Atomic counter that can be safely shared across threads. +#[derive(Debug)] +pub struct AtomicCounter { + value: AtomicU64, +} + +impl AtomicCounter { + pub const fn new() -> Self { + Self { + value: AtomicU64::new(0), + } + } + + pub fn increment(&self) -> u64 { + self.value.fetch_add(1, Ordering::Relaxed) + } + + pub fn add(&self, val: u64) -> u64 { + self.value.fetch_add(val, Ordering::Relaxed) + } + + pub fn get(&self) -> u64 { + self.value.load(Ordering::Relaxed) + } + + pub fn reset(&self) -> u64 { + self.value.swap(0, Ordering::Relaxed) + } +} + +/// Time window for aggregating statistics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimeWindow { + LastMinute, + LastHour, + LastDay, + AllTime, +} + +impl fmt::Display for TimeWindow { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TimeWindow::LastMinute => write!(f, "last_minute"), + TimeWindow::LastHour => write!(f, "last_hour"), + TimeWindow::LastDay => write!(f, "last_day"), + TimeWindow::AllTime => write!(f, "all_time"), + } + } +} + +impl TimeWindow { + pub fn duration(&self) -> Option { + match self { + TimeWindow::LastMinute => Some(Duration::from_secs(60)), + TimeWindow::LastHour => Some(Duration::from_secs(3600)), + TimeWindow::LastDay => Some(Duration::from_secs(86400)), + TimeWindow::AllTime => None, + } + } +} + +/// A single timestamped event for time-window tracking. +#[derive(Debug, Clone)] +struct TimestampedEvent { + timestamp: Instant, + is_hit: bool, + bytes: u64, +} + +/// A snapshot of cache statistics at a point in time. +#[derive(Debug, Clone)] +pub struct StatsSnapshot { + pub hits: u64, + pub misses: u64, + pub evictions: u64, + pub cache_size_bytes: u64, + pub cache_entries: u64, + pub bytes_read: u64, + pub bytes_written: u64, + pub timestamp: SystemTime, + pub uptime: Duration, +} + +impl StatsSnapshot { + /// Calculate hit rate as a percentage. + pub fn hit_rate(&self) -> f64 { + let total = self.hits + self.misses; + if total == 0 { + return 0.0; + } + (self.hits as f64 / total as f64) * 100.0 + } + + /// Calculate miss rate as a percentage. + pub fn miss_rate(&self) -> f64 { + 100.0 - self.hit_rate() + } + + /// Render stats as JSON string. + pub fn to_json(&self) -> String { + format!( + concat!( + "{{", + "\"hits\":{},", + "\"misses\":{},", + "\"hit_rate\":{:.2},", + "\"miss_rate\":{:.2},", + "\"evictions\":{},", + "\"cache_size_bytes\":{},", + "\"cache_entries\":{},", + "\"bytes_read\":{},", + "\"bytes_written\":{},", + "\"uptime_secs\":{}", + "}}" + ), + self.hits, + self.misses, + self.hit_rate(), + self.miss_rate(), + self.evictions, + self.cache_size_bytes, + self.cache_entries, + self.bytes_read, + self.bytes_written, + self.uptime.as_secs(), + ) + } +} + +impl fmt::Display for StatsSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Cache Statistics")?; + writeln!(f, "================")?; + writeln!(f, "Hits: {}", self.hits)?; + writeln!(f, "Misses: {}", self.misses)?; + writeln!(f, "Hit Rate: {:.2}%", self.hit_rate())?; + writeln!(f, "Evictions: {}", self.evictions)?; + writeln!(f, "Cache Size: {} bytes", self.cache_size_bytes)?; + writeln!(f, "Cache Entries: {}", self.cache_entries)?; + writeln!(f, "Bytes Read: {}", self.bytes_read)?; + writeln!(f, "Bytes Written: {}", self.bytes_written)?; + writeln!(f, "Uptime: {} secs", self.uptime.as_secs())?; + Ok(()) + } +} + +/// Thread-safe cache statistics tracker. +#[derive(Debug)] +pub struct CacheStats { + hits: AtomicCounter, + misses: AtomicCounter, + evictions: AtomicCounter, + cache_size_bytes: AtomicCounter, + cache_entries: AtomicCounter, + bytes_read: AtomicCounter, + bytes_written: AtomicCounter, + start_time: Instant, + events: Mutex>, +} + +impl CacheStats { + /// Create a new statistics tracker. + pub fn new() -> Self { + Self { + hits: AtomicCounter::new(), + misses: AtomicCounter::new(), + evictions: AtomicCounter::new(), + cache_size_bytes: AtomicCounter::new(), + cache_entries: AtomicCounter::new(), + bytes_read: AtomicCounter::new(), + bytes_written: AtomicCounter::new(), + start_time: Instant::now(), + events: Mutex::new(Vec::new()), + } + } + + /// Record a cache hit. + pub fn record_hit(&self, bytes: u64) { + self.hits.increment(); + self.bytes_read.add(bytes); + if let Ok(mut events) = self.events.lock() { + events.push(TimestampedEvent { + timestamp: Instant::now(), + is_hit: true, + bytes, + }); + } + } + + /// Record a cache miss. + pub fn record_miss(&self) { + self.misses.increment(); + if let Ok(mut events) = self.events.lock() { + events.push(TimestampedEvent { + timestamp: Instant::now(), + is_hit: false, + bytes: 0, + }); + } + } + + /// Record a cache eviction. + pub fn record_eviction(&self) { + self.evictions.increment(); + } + + /// Record bytes written to cache. + pub fn record_write(&self, bytes: u64) { + self.bytes_written.add(bytes); + } + + /// Update the current cache size and entry count. + pub fn update_size(&self, size_bytes: u64, entries: u64) { + self.cache_size_bytes.value.store(size_bytes, Ordering::Relaxed); + self.cache_entries.value.store(entries, Ordering::Relaxed); + } + + /// Take a snapshot of all-time statistics. + pub fn snapshot(&self) -> StatsSnapshot { + StatsSnapshot { + hits: self.hits.get(), + misses: self.misses.get(), + evictions: self.evictions.get(), + cache_size_bytes: self.cache_size_bytes.get(), + cache_entries: self.cache_entries.get(), + bytes_read: self.bytes_read.get(), + bytes_written: self.bytes_written.get(), + timestamp: SystemTime::now(), + uptime: self.start_time.elapsed(), + } + } + + /// Get statistics for a specific time window. + pub fn snapshot_for_window(&self, window: TimeWindow) -> StatsSnapshot { + if window == TimeWindow::AllTime { + return self.snapshot(); + } + + let duration = window.duration().unwrap(); + let cutoff = Instant::now() - duration; + let mut hits = 0u64; + let mut misses = 0u64; + let mut bytes_read = 0u64; + + if let Ok(events) = self.events.lock() { + for event in events.iter() { + if event.timestamp >= cutoff { + if event.is_hit { + hits += 1; + bytes_read += event.bytes; + } else { + misses += 1; + } + } + } + } + + StatsSnapshot { + hits, + misses, + evictions: self.evictions.get(), + cache_size_bytes: self.cache_size_bytes.get(), + cache_entries: self.cache_entries.get(), + bytes_read, + bytes_written: self.bytes_written.get(), + timestamp: SystemTime::now(), + uptime: self.start_time.elapsed(), + } + } + + /// Prune old events outside the largest window (1 day). + pub fn prune_old_events(&self) { + let cutoff = Instant::now() - Duration::from_secs(86400); + if let Ok(mut events) = self.events.lock() { + events.retain(|e| e.timestamp >= cutoff); + } + } + + /// Reset all statistics counters and clear event history. + pub fn reset(&self) { + self.hits.reset(); + self.misses.reset(); + self.evictions.reset(); + self.bytes_read.reset(); + self.bytes_written.reset(); + if let Ok(mut events) = self.events.lock() { + events.clear(); + } + } +} + +/// Thread-safe shared statistics handle. +pub type SharedCacheStats = Arc; + +/// Create a new shared statistics instance. +pub fn new_shared_stats() -> SharedCacheStats { + Arc::new(CacheStats::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn test_atomic_counter_basic() { + let counter = AtomicCounter::new(); + assert_eq!(counter.get(), 0); + counter.increment(); + assert_eq!(counter.get(), 1); + counter.add(5); + assert_eq!(counter.get(), 6); + let old = counter.reset(); + assert_eq!(old, 6); + assert_eq!(counter.get(), 0); + } + + #[test] + fn test_atomic_counter_thread_safety() { + let counter = Arc::new(AtomicCounter::new()); + let mut handles = vec![]; + for _ in 0..10 { + let c = Arc::clone(&counter); + handles.push(thread::spawn(move || { + for _ in 0..1000 { + c.increment(); + } + })); + } + for h in handles { + h.join().unwrap(); + } + assert_eq!(counter.get(), 10_000); + } + + #[test] + fn test_hit_rate_calculation() { + let stats = CacheStats::new(); + // No events: 0% hit rate + let snap = stats.snapshot(); + assert_eq!(snap.hit_rate(), 0.0); + assert_eq!(snap.miss_rate(), 0.0); + + // Record some hits and misses + stats.record_hit(100); + stats.record_hit(200); + stats.record_miss(); + stats.record_miss(); + let snap = stats.snapshot(); + assert_eq!(snap.hits, 2); + assert_eq!(snap.misses, 2); + assert!((snap.hit_rate() - 50.0).abs() < 0.01); + } + + #[test] + fn test_eviction_tracking() { + let stats = CacheStats::new(); + stats.record_eviction(); + stats.record_eviction(); + stats.record_eviction(); + assert_eq!(stats.snapshot().evictions, 3); + } + + #[test] + fn test_cache_size_update() { + let stats = CacheStats::new(); + stats.update_size(1024 * 1024, 42); + let snap = stats.snapshot(); + assert_eq!(snap.cache_size_bytes, 1024 * 1024); + assert_eq!(snap.cache_entries, 42); + } + + #[test] + fn test_bytes_tracking() { + let stats = CacheStats::new(); + stats.record_hit(500); + stats.record_hit(300); + stats.record_write(1000); + stats.record_write(2000); + let snap = stats.snapshot(); + assert_eq!(snap.bytes_read, 800); + assert_eq!(snap.bytes_written, 3000); + } + + #[test] + fn test_time_window_filtering() { + let stats = CacheStats::new(); + stats.record_hit(100); + stats.record_miss(); + let snap = stats.snapshot_for_window(TimeWindow::LastMinute); + assert_eq!(snap.hits, 1); + assert_eq!(snap.misses, 1); + let snap_all = stats.snapshot_for_window(TimeWindow::AllTime); + assert_eq!(snap_all.hits, 1); + } + + #[test] + fn test_reset_clears_counters() { + let stats = CacheStats::new(); + stats.record_hit(100); + stats.record_miss(); + stats.record_eviction(); + stats.record_write(500); + stats.reset(); + let snap = stats.snapshot(); + assert_eq!(snap.hits, 0); + assert_eq!(snap.misses, 0); + assert_eq!(snap.evictions, 0); + assert_eq!(snap.bytes_read, 0); + assert_eq!(snap.bytes_written, 0); + } + + #[test] + fn test_json_output() { + let stats = CacheStats::new(); + stats.record_hit(100); + stats.record_miss(); + stats.update_size(2048, 5); + let snap = stats.snapshot(); + let json = snap.to_json(); + assert!(json.contains("\"hits\":1")); + assert!(json.contains("\"misses\":1")); + assert!(json.contains("\"cache_size_bytes\":2048")); + assert!(json.contains("\"cache_entries\":5")); + assert!(json.contains("\"hit_rate\":50.00")); + } + + #[test] + fn test_human_readable_output() { + let stats = CacheStats::new(); + stats.record_hit(100); + stats.record_miss(); + let snap = stats.snapshot(); + let output = format!("{}", snap); + assert!(output.contains("Cache Statistics")); + assert!(output.contains("Hits:")); + assert!(output.contains("Misses:")); + assert!(output.contains("Hit Rate:")); + } + + #[test] + fn test_shared_stats() { + let stats = new_shared_stats(); + let s1 = Arc::clone(&stats); + let s2 = Arc::clone(&stats); + let h1 = thread::spawn(move || { + for _ in 0..100 { + s1.record_hit(10); + } + }); + let h2 = thread::spawn(move || { + for _ in 0..100 { + s2.record_miss(); + } + }); + h1.join().unwrap(); + h2.join().unwrap(); + let snap = stats.snapshot(); + assert_eq!(snap.hits, 100); + assert_eq!(snap.misses, 100); + } + + #[test] + fn test_prune_old_events() { + let stats = CacheStats::new(); + stats.record_hit(100); + stats.record_miss(); + // Pruning shouldn't remove recent events + stats.prune_old_events(); + let snap = stats.snapshot_for_window(TimeWindow::LastMinute); + assert_eq!(snap.hits, 1); + assert_eq!(snap.misses, 1); + } + + #[test] + fn test_time_window_display() { + assert_eq!(format!("{}", TimeWindow::LastMinute), "last_minute"); + assert_eq!(format!("{}", TimeWindow::LastHour), "last_hour"); + assert_eq!(format!("{}", TimeWindow::LastDay), "last_day"); + assert_eq!(format!("{}", TimeWindow::AllTime), "all_time"); + } + + #[test] + fn test_time_window_duration() { + assert_eq!(TimeWindow::LastMinute.duration(), Some(Duration::from_secs(60))); + assert_eq!(TimeWindow::LastHour.duration(), Some(Duration::from_secs(3600))); + assert_eq!(TimeWindow::LastDay.duration(), Some(Duration::from_secs(86400))); + assert_eq!(TimeWindow::AllTime.duration(), None); + } +}