use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tracing::{info, warn};
use waf_state_redis::{DistributedLock, ReputationWriter};
use crate::ReputationFeed;
const FEED_LOCK_NAME: &str = "reputation-feed";
#[derive(Clone, Copy, Debug)]
pub struct FeedConfig {
pub refresh_interval: Duration,
pub entry_ttl: Duration,
pub lock_ttl: Duration,
}
impl Default for FeedConfig {
fn default() -> Self {
let refresh = Duration::from_secs(300);
Self {
refresh_interval: refresh,
entry_ttl: refresh * 3,
lock_ttl: Duration::from_secs(90),
}
}
}
fn make_node_id() -> String {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
}
async fn run_cycle(
sources: &[Box<dyn ReputationFeed>],
writer: &dyn ReputationWriter,
lock: &dyn DistributedLock,
cfg: &FeedConfig,
node_id: &str,
) -> bool {
if !lock.acquire(FEED_LOCK_NAME, node_id, cfg.lock_ttl) {
return false;
}
let mut entries = Vec::new();
for source in sources {
match source.fetch().await {
Ok(mut e) => entries.append(&mut e),
Err(err) => {
warn!(source = source.name(), error = %err, "reputation feed: source fetch failed — keeping previous snapshot");
}
}
}
let written = writer.apply_snapshot(&entries, cfg.entry_ttl);
lock.release(FEED_LOCK_NAME, node_id);
info!(node = node_id, sources = sources.len(), written, "reputation feed: snapshot published");
true
}
pub fn start_reputation_feed(
sources: Vec<Box<dyn ReputationFeed>>,
writer: Arc<dyn ReputationWriter>,
lock: Arc<dyn DistributedLock>,
cfg: FeedConfig,
) -> JoinHandle<()> {
let node_id = make_node_id();
info!(node = %node_id, sources = sources.len(), "reputation feed: starting driver");
tokio::spawn(async move {
loop {
run_cycle(&sources, writer.as_ref(), lock.as_ref(), &cfg, &node_id).await;
sleep(cfg.refresh_interval).await;
}
})
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
use std::sync::Mutex;
use async_trait::async_trait;
use waf_state_redis::Reputation;
use super::*;
use crate::FeedError;
#[derive(Default)]
struct MockLock {
holder: Mutex<Option<String>>,
}
impl DistributedLock for MockLock {
fn acquire(&self, _name: &str, holder: &str, _ttl: Duration) -> bool {
let mut h = self.holder.lock().unwrap();
if h.is_none() {
*h = Some(holder.to_string());
true
} else {
false
}
}
fn release(&self, _name: &str, holder: &str) {
let mut h = self.holder.lock().unwrap();
if h.as_deref() == Some(holder) {
*h = None;
}
}
}
#[derive(Default)]
struct MockWriter {
calls: Mutex<Vec<Vec<(IpAddr, Reputation)>>>,
}
impl ReputationWriter for MockWriter {
fn apply_snapshot(&self, entries: &[(IpAddr, Reputation)], _ttl: Duration) -> usize {
self.calls.lock().unwrap().push(entries.to_vec());
entries.len()
}
}
struct OkFeed(&'static str, Vec<(IpAddr, Reputation)>);
#[async_trait]
impl ReputationFeed for OkFeed {
fn name(&self) -> &str {
self.0
}
async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
Ok(self.1.clone())
}
}
struct ErrFeed;
#[async_trait]
impl ReputationFeed for ErrFeed {
fn name(&self) -> &str {
"err"
}
async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
Err(FeedError::Http("boom".into()))
}
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[tokio::test]
async fn only_the_lock_holder_publishes_per_cycle() {
let lock = MockLock::default();
let writer = MockWriter::default();
let cfg = FeedConfig::default();
let sources: Vec<Box<dyn ReputationFeed>> =
vec![Box::new(OkFeed("s", vec![(ip("1.2.3.4"), Reputation::Malicious)]))];
assert!(lock.acquire(FEED_LOCK_NAME, "other", cfg.lock_ttl), "someone else holds it");
let was_leader = run_cycle(&sources, &writer, &lock, &cfg, "B").await;
assert!(!was_leader, "B must be a follower while the lock is held");
assert!(writer.calls.lock().unwrap().is_empty(), "a follower must NOT write");
lock.release(FEED_LOCK_NAME, "other");
let was_leader = run_cycle(&sources, &writer, &lock, &cfg, "A").await;
assert!(was_leader, "A must lead once the lock is free");
let calls = writer.calls.lock().unwrap();
assert_eq!(calls.len(), 1, "leader publishes exactly one snapshot");
assert_eq!(calls[0], vec![(ip("1.2.3.4"), Reputation::Malicious)]);
}
#[tokio::test]
async fn leader_releases_so_next_cycle_is_contendable() {
let lock = MockLock::default();
let writer = MockWriter::default();
let cfg = FeedConfig::default();
let sources: Vec<Box<dyn ReputationFeed>> = vec![Box::new(OkFeed("s", vec![]))];
assert!(run_cycle(&sources, &writer, &lock, &cfg, "A").await, "A leads");
assert!(run_cycle(&sources, &writer, &lock, &cfg, "B").await, "B can lead next cycle");
}
#[tokio::test]
async fn a_failing_source_does_not_sink_the_others() {
let lock = MockLock::default();
let writer = MockWriter::default();
let cfg = FeedConfig::default();
let good = (ip("9.9.9.9"), Reputation::Suspicious);
let sources: Vec<Box<dyn ReputationFeed>> =
vec![Box::new(ErrFeed), Box::new(OkFeed("good", vec![good])), Box::new(ErrFeed)];
assert!(run_cycle(&sources, &writer, &lock, &cfg, "A").await);
let calls = writer.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], vec![good], "only the surviving source's entries are published");
}
}