OXYGENHUB

Coverage / crates/hub/src/events.rs 97.3%

107 of 110 lines run, 28 of 37 functions · run #539 on main · run missed

1
//! Live pages' events (docs/live.md, oxygenhub#203): what a topic is, and
2
//! the replay buffer that numbers each process's events for `Last-Event-ID`.
3
//! They travel on the event bus ([`crate::bus`]), which carries them between
4
//! processes too.
5
6
use tokio::sync::broadcast;
7
8
/// What changed, for live pages (docs/live.md, oxygenhub#203): a topic and
9
/// a kind, never the data. A page that hears its topic fetches the part
10
/// that changed from its normal route, which checks the viewer's access.
11
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
12
pub struct LiveEvent {
13
    /// Increasing in this process: what `Last-Event-ID` resumes from. Each
14
    /// process numbers the events it hears, its own and other processes'.
15
    pub id: u64,
16
    pub topic: String,
17
    /// The topic's own version: how many events it has had.
18
    pub version: u64,
19
    /// What happened: `push`, `comment`, `review`, `check`, `jobs`, ...
20
    pub kind: String,
21
}
22
23
/// Topics (docs/live.md §4).
24
pub mod topic {
251349×
    pub fn repo(id: i64) -> String {
261349×
        format!("repo:{id}")
271349×
    }
28155×
    pub fn proposal(repo: i64, n: i64) -> String {
29155×
        format!("repo:{repo}/proposal:{n}")
30155×
    }
3172×
    pub fn issue(repo: i64, n: i64) -> String {
3272×
        format!("repo:{repo}/issue:{n}")
3372×
    }
34145×
    pub fn run(id: i64) -> String {
35145×
        format!("run:{id}")
36145×
    }
37106×
    pub fn user(id: i64) -> String {
38106×
        format!("user:{id}")
39106×
    }
4023×
    pub fn diffusion(repo: i64) -> String {
4123×
        format!("diffusion:{repo}")
4223×
    }
43
}
44
45
/// Events kept for replay: a reconnecting page catches up from these, or is
46
/// told to reload when it missed more.
47
pub const REPLAY: usize = 1024;
48
49
/// The live pages' buffer: one broadcast that subscribers filter by topic, a
50
/// version per topic, and the last [`REPLAY`] events.
51
pub struct LiveBus {
52
    tx: broadcast::Sender<LiveEvent>,
53
    state: std::sync::Mutex<BusState>,
54
}
55
56
#[derive(Default)]
57
struct BusState {
58
    next_id: u64,
59
    versions: std::collections::HashMap<String, u64>,
60
    recent: std::collections::VecDeque<LiveEvent>,
61
}
62
63
impl Default for LiveBus {
64210×
    fn default() -> Self {
65210×
        LiveBus { tx: broadcast::channel(1024).0, state: std::sync::Mutex::new(BusState { next_id: 1, ..Default::default() }) }
66210×
    }
67
}
68
69
/// What a reconnecting subscriber gets: the events it missed, or `Reload`
70
/// when they are no longer kept.
71
#[derive(Debug, PartialEq, Eq)]
72
pub enum Replay {
73
    Events(Vec<LiveEvent>),
74
    Reload,
75
}
76
77
impl LiveBus {
78
    /// Record an event on `topic` and tell this process's subscribers.
79
    /// Producers publish through the bus ([`crate::bus::Bus::live`]), which
80
    /// records it here and in every other process.
813586×
    pub fn publish(&self, topic: &str, kind: &str) -> LiveEvent {
823586×
        let event = {
833586×
            let mut s = self.state.lock().unwrap_or_else(|p| p.into_inner());
843586×
            let id = s.next_id;
853586×
            s.next_id += 1;
863586×
            let version = {
873586×
                let v = s.versions.entry(topic.to_string()).or_insert(0);
883586×
                *v += 1;
893586×
                *v
90
            };
913586×
            let e = LiveEvent { id, topic: topic.into(), version, kind: kind.into() };
923586×
            if s.recent.len() == REPLAY {
936×
                s.recent.pop_front();
943580×
            }
953586×
            s.recent.push_back(e.clone());
963586×
            e
97
        };
983586×
        let _ = self.tx.send(event.clone());
993586×
        event
1003586×
    }
101
1024×
    pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
1034×
        self.tx.subscribe()
1044×
    }
105
106
    /// The newest event id (0 before any).
1074×
    pub fn last_id(&self) -> u64 {
1084×
        self.state.lock().unwrap_or_else(|p| p.into_inner()).next_id - 1
1094×
    }
110
111
    /// The events on `topics` after `last_id`, or `Reload` when some after
112
    /// it are no longer kept.
11310×
    pub fn since(&self, last_id: u64, topics: &[String]) -> Replay {
11410×
        let s = self.state.lock().unwrap_or_else(|p| p.into_inner());
115
        // An id this process never gave: from before it restarted, or from
116
        // another process's numbering (a page that reconnected through
117
        // another web node, docs/roles.md). What it missed is unknown.
11810×
        if last_id >= s.next_id {
1191×
            return Replay::Reload;
1209×
        }
1219×
        let oldest = s.recent.front().map(|e| e.id).unwrap_or(s.next_id);
1229×
        if last_id + 1 < oldest {
1232×
            return Replay::Reload;
1247×
        }
12529×
        Replay::Events(s.recent.iter().filter(|e| e.id > last_id && topics.contains(&e.topic)).cloned().collect())
12610×
    }
127
}
128
129
/// The live topics a webhook event touches, with the kind for each: the
130
/// repository always, and the proposal or issue its data names.
1311146×
pub fn hook_topics(repo_id: i64, event: &str, data: &serde_json::Value) -> Vec<(String, String)> {
1321146×
    let kind = event.split('.').next().unwrap_or(event).to_string();
1331146×
    let mut topics = vec![(topic::repo(repo_id), kind.clone())];
1341146×
    let number = data["number"].as_i64().or_else(|| data["issue"]["number"].as_i64());
1351146×
    let is_issue = event.starts_with("issue") || event == "comment.issue";
1361146×
    let is_proposal = event.starts_with("proposal") || matches!(event, "review" | "comment" | "comment.proposal");
13771×
    match number {
13843×
        Some(n) if is_issue => topics.push((topic::issue(repo_id, n), kind)),
13971×
        Some(n) if is_proposal => topics.push((topic::proposal(repo_id, n), kind)),
1401032×
        _ => {}
141
    }
1421146×
    topics
1431146×
}
144
145
#[cfg(test)]
146
mod tests {
147
    use super::*;
148
149
    #[test]
1501×
    fn events_have_ids_and_versions_and_replay_or_say_reload() {
1511×
        let bus = LiveBus::default();
1521×
        let mut rx = bus.subscribe();
1531×
        let a = bus.publish("repo:1", "push");
1541×
        let b = bus.publish("repo:2", "push");
1551×
        let c = bus.publish("repo:1", "check");
1561×
        assert_eq!((a.id, b.id, c.id), (1, 2, 3));
1571×
        assert_eq!((a.version, b.version, c.version), (1, 1, 2), "versions count per topic");
1581×
        assert_eq!(rx.try_recv().unwrap(), a, "subscribers hear every event");
1591×
        assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Events(vec![c.clone()]), "only the topics asked for");
1601×
        assert_eq!(bus.since(3, &["repo:1".into()]), Replay::Events(vec![]));
1611×
        assert_eq!(bus.since(4, &["repo:1".into()]), Replay::Reload, "an id never given here: another process's, or before a restart");
1621024×
        for _ in 0..REPLAY {
1631024×
            bus.publish("repo:3", "push");
1641024×
        }
1651×
        assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Reload, "too old to replay");
1661×
        assert_eq!(bus.last_id(), 3 + REPLAY as u64);
1671×
    }
168
169
    #[test]
1701×
    fn a_webhook_event_names_its_repository_and_its_proposal_or_issue() {
1711×
        let mut seen = vec![];
1721×
        seen.extend(hook_topics(7, "review", &serde_json::json!({ "number": 4, "verdict": "approve" })));
1731×
        seen.extend(hook_topics(7, "issue.opened", &serde_json::json!({ "issue": { "number": 9 } })));
1741×
        seen.extend(hook_topics(7, "comment.issue", &serde_json::json!({ "number": 9 })));
1751×
        seen.extend(hook_topics(7, "push", &serde_json::json!({ "updates": [] })));
1761×
        assert_eq!(
177
            seen,
1781×
            [
1791×
                ("repo:7", "review"),
1801×
                ("repo:7/proposal:4", "review"),
1811×
                ("repo:7", "issue"),
1821×
                ("repo:7/issue:9", "issue"),
1831×
                ("repo:7", "comment"),
1841×
                ("repo:7/issue:9", "comment"),
1851×
                ("repo:7", "push"),
1861×
            ]
1877×
            .map(|(t, k)| (t.to_string(), k.to_string()))
188
        );
1891×
    }
190
}