use std::{
hash::{BuildHasher, Hash, RandomState},
num::NonZeroUsize,
time::Duration,
};
use lru::LruCache;
use crate::Timestamp;
#[derive(Debug, Clone, Copy)]
struct Seen {
first_seen: Timestamp,
last_seen: Timestamp,
}
#[derive(Debug)]
pub struct FirstSeen<K: Hash + Eq, S: BuildHasher = RandomState> {
ttl: Duration,
inner: LruCache<K, Seen, S>,
}
impl<K: Hash + Eq> FirstSeen<K> {
pub fn new(ttl: Duration, capacity: usize) -> Self {
Self::with_hasher(ttl, capacity, RandomState::new())
}
pub fn new_unbounded(ttl: Duration) -> Self {
Self {
ttl,
inner: LruCache::unbounded_with_hasher(RandomState::new()),
}
}
}
impl<K: Hash + Eq, S: BuildHasher> FirstSeen<K, S> {
pub fn with_hasher(ttl: Duration, capacity: usize, hasher_builder: S) -> Self {
let cap = NonZeroUsize::new(capacity.max(1)).unwrap();
Self {
ttl,
inner: LruCache::with_hasher(cap, hasher_builder),
}
}
fn expired(&self, seen: &Seen, now: Timestamp) -> bool {
now.saturating_sub(seen.last_seen) > self.ttl
}
pub fn observe(&mut self, key: K, now: Timestamp) -> bool {
if let Some(seen) = self.inner.get_mut(&key) {
if now.saturating_sub(seen.last_seen) > self.ttl {
seen.first_seen = now;
seen.last_seen = now;
true
} else {
seen.last_seen = now;
false
}
} else {
self.inner.put(
key,
Seen {
first_seen: now,
last_seen: now,
},
);
true
}
}
pub fn seen(&self, key: &K, now: Timestamp) -> bool {
self.inner
.peek(key)
.is_some_and(|seen| !self.expired(seen, now))
}
pub fn first_seen(&self, key: &K, now: Timestamp) -> Option<Timestamp> {
self.inner
.peek(key)
.filter(|seen| !self.expired(seen, now))
.map(|seen| seen.first_seen)
}
pub fn remove(&mut self, key: &K) -> Option<Timestamp> {
self.inner.pop(key).map(|seen| seen.first_seen)
}
pub fn evict_expired(&mut self, now: Timestamp)
where
K: Clone,
{
let expired: Vec<K> = self
.inner
.iter()
.filter(|(_, seen)| self.expired(seen, now))
.map(|(k, _)| k.clone())
.collect();
for k in expired {
self.inner.pop(&k);
}
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
impl<K: Hash + Eq, S: BuildHasher> crate::correlate::Mergeable for FirstSeen<K, S> {
fn merge(&mut self, other: Self) {
assert_eq!(
self.ttl, other.ttl,
"FirstSeen::merge requires matching ttl",
);
assert_eq!(
self.inner.cap(),
other.inner.cap(),
"FirstSeen::merge requires matching capacity",
);
let mut other = other;
while let Some((k, seen_other)) = other.inner.pop_lru() {
match self.inner.get_mut(&k) {
Some(seen_self) => {
if seen_other.first_seen < seen_self.first_seen {
seen_self.first_seen = seen_other.first_seen;
}
if seen_other.last_seen > seen_self.last_seen {
seen_self.last_seen = seen_other.last_seen;
}
}
None => {
self.inner.put(k, seen_other);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(secs: u32) -> Timestamp {
Timestamp::new(secs, 0)
}
#[test]
fn first_observe_is_new_second_is_not() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(10), 16);
assert!(f.observe(1, ts(0)));
assert!(!f.observe(1, ts(1)));
assert!(f.seen(&1, ts(2)));
}
#[test]
fn ttl_expiry_makes_key_new_again_and_resets_first_seen() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(10), 16);
f.observe(1, ts(0));
assert!(!f.seen(&1, ts(20)), "expired key is not seen");
assert!(f.observe(1, ts(20)), "expired key is new again");
assert_eq!(f.first_seen(&1, ts(21)), Some(ts(20)), "streak restarted");
}
#[test]
fn continuous_sightings_refresh_ttl() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(8), 16);
assert!(f.observe(1, ts(0)));
assert!(!f.observe(1, ts(5)));
assert!(!f.observe(1, ts(10)));
assert_eq!(f.first_seen(&1, ts(10)), Some(ts(0)), "streak preserved");
}
#[test]
fn seen_does_not_bump_lru_recency() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(100), 2);
f.observe(1, ts(0));
f.observe(2, ts(1));
assert!(f.seen(&1, ts(2)));
f.observe(3, ts(3)); assert!(!f.seen(&1, ts(4)), "peek didn't protect key 1");
assert!(f.seen(&2, ts(4)));
assert!(f.seen(&3, ts(4)));
}
#[test]
fn lru_eviction_causes_false_new_never_false_seen() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(100), 2);
f.observe(1, ts(0));
f.observe(2, ts(1));
f.observe(3, ts(2)); assert!(f.observe(1, ts(3)), "evicted live key re-reports new");
}
#[test]
fn evict_expired_reclaims_memory() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(10), 16);
f.observe(1, ts(0));
f.observe(2, ts(15));
assert_eq!(f.len(), 2);
f.evict_expired(ts(15));
assert_eq!(f.len(), 1, "key 1 expired and reclaimed");
assert!(f.seen(&2, ts(15)));
}
#[test]
fn remove_returns_first_seen() {
let mut f: FirstSeen<u32> = FirstSeen::new(Duration::from_secs(10), 16);
f.observe(1, ts(3));
assert_eq!(f.remove(&1), Some(ts(3)));
assert_eq!(f.remove(&1), None);
assert!(f.is_empty());
}
#[test]
fn unbounded_construction_works() {
let mut f: FirstSeen<u64> = FirstSeen::new_unbounded(Duration::from_secs(1));
for i in 0..10_000u64 {
assert!(f.observe(i, ts(0)));
}
assert_eq!(f.len(), 10_000);
}
}