dragoman 0.5.12

Server for scholarly metadata in commonmeta format stored in a SQLite database.
//! Where DOIs redirect to, kept for a few minutes so a DOI clicked again and
//! again is answered without touching SQLite.
//!
//! A redirect is most of what the server answers, and the same DOIs come up
//! over and over — a paper making the rounds, a reference list crawled. Each
//! target is kept for [`TTL`]: long enough to absorb a burst of clicks, short
//! enough that a DOI whose landing page moved, once re-imported, resolves to
//! the new one within minutes. The table holds at most [`CAPACITY`] targets;
//! when it is full, expired targets go first and, if that frees nothing, all
//! of them — a cold start for a few lookups is cheaper than tracking which
//! target was used last.

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// How long a target is kept.
pub const TTL: Duration = Duration::from_secs(300);
/// How many targets are kept at most: about 20 MB of DOIs and URLs.
pub const CAPACITY: usize = 100_000;

/// The redirect targets of recently resolved DOIs.
pub struct Redirects {
    ttl: Duration,
    capacity: usize,
    targets: Mutex<HashMap<String, (Instant, String)>>,
}

impl Default for Redirects {
    fn default() -> Self {
        Self::new(TTL, CAPACITY)
    }
}

impl Redirects {
    pub fn new(ttl: Duration, capacity: usize) -> Self {
        Self {
            ttl,
            capacity,
            targets: Mutex::new(HashMap::new()),
        }
    }

    /// The target kept for `key`, unless it has expired.
    pub fn get(&self, key: &str) -> Option<String> {
        self.get_at(key, Instant::now())
    }

    /// Keep `target` for `key`.
    pub fn insert(&self, key: String, target: String) {
        self.insert_at(key, target, Instant::now());
    }

    fn get_at(&self, key: &str, now: Instant) -> Option<String> {
        let targets = self.targets.lock().unwrap_or_else(|e| e.into_inner());
        let (kept, target) = targets.get(key)?;
        (now.saturating_duration_since(*kept) < self.ttl).then(|| target.clone())
    }

    fn insert_at(&self, key: String, target: String, now: Instant) {
        let mut targets = self.targets.lock().unwrap_or_else(|e| e.into_inner());
        if targets.len() >= self.capacity && !targets.contains_key(&key) {
            targets.retain(|_, (kept, _)| now.saturating_duration_since(*kept) < self.ttl);
            if targets.len() >= self.capacity {
                targets.clear();
            }
        }
        targets.insert(key, (now, target));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_target_is_kept_until_it_expires() {
        let redirects = Redirects::new(Duration::from_secs(60), 10);
        let t0 = Instant::now();
        redirects.insert_at("10.5555/1".into(), "https://example.org/1".into(), t0);
        assert_eq!(
            redirects
                .get_at("10.5555/1", t0 + Duration::from_secs(59))
                .as_deref(),
            Some("https://example.org/1")
        );
        assert_eq!(
            redirects.get_at("10.5555/1", t0 + Duration::from_secs(60)),
            None
        );
        assert_eq!(redirects.get_at("10.5555/2", t0), None);
    }

    #[test]
    fn a_full_table_drops_expired_targets_first_then_everything() {
        let redirects = Redirects::new(Duration::from_secs(60), 2);
        let t0 = Instant::now();
        redirects.insert_at("old".into(), "o".into(), t0);
        redirects.insert_at("live".into(), "l".into(), t0 + Duration::from_secs(30));
        // Full: the expired one makes room, the live one stays.
        let t1 = t0 + Duration::from_secs(70);
        redirects.insert_at("new".into(), "n".into(), t1);
        assert_eq!(redirects.get_at("live", t1).as_deref(), Some("l"));
        assert_eq!(redirects.get_at("new", t1).as_deref(), Some("n"));
        // Full of live targets: the table starts over with the newcomer.
        redirects.insert_at("newer".into(), "m".into(), t1);
        assert_eq!(redirects.get_at("live", t1), None);
        assert_eq!(redirects.get_at("newer", t1).as_deref(), Some("m"));
        // Replacing a kept target never empties the table.
        redirects.insert_at("newer".into(), "m2".into(), t1);
        assert_eq!(redirects.get_at("newer", t1).as_deref(), Some("m2"));
    }
}