OXYGENHUB
◆ v0.9.1 oxygenhub / crates / hub / src / events.rs
7.2 KiB · Rust · last changed Carry events between the hub's processes on an event bus 16 days ago source blame history raw
33d98147 Andy McCall 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.
b64e2537 Andy McCall ↶ 5
6
use tokio::sync::broadcast;
7
7e01a766 Andy McCall ↶ 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 {
33d98147 Andy McCall 13
    /// Increasing in this process: what `Last-Event-ID` resumes from. Each
14
    /// process numbers the events it hears, its own and other processes'.
7e01a766 Andy McCall ↶ 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 {
25
    pub fn repo(id: i64) -> String {
26
        format!("repo:{id}")
27
    }
28
    pub fn proposal(repo: i64, n: i64) -> String {
29
        format!("repo:{repo}/proposal:{n}")
30
    }
31
    pub fn issue(repo: i64, n: i64) -> String {
32
        format!("repo:{repo}/issue:{n}")
33
    }
34
    pub fn run(id: i64) -> String {
35
        format!("run:{id}")
36
    }
37
    pub fn user(id: i64) -> String {
38
        format!("user:{id}")
39
    }
40
    pub fn diffusion(repo: i64) -> String {
41
        format!("diffusion:{repo}")
42
    }
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
33d98147 Andy McCall 49
/// The live pages' buffer: one broadcast that subscribers filter by topic, a
7e01a766 Andy McCall ↶ 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 {
64
    fn default() -> Self {
65
        LiveBus { tx: broadcast::channel(1024).0, state: std::sync::Mutex::new(BusState { next_id: 1, ..Default::default() }) }
66
    }
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 {
33d98147 Andy McCall 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.
7e01a766 Andy McCall ↶ 81
    pub fn publish(&self, topic: &str, kind: &str) -> LiveEvent {
82
        let event = {
83
            let mut s = self.state.lock().unwrap_or_else(|p| p.into_inner());
84
            let id = s.next_id;
85
            s.next_id += 1;
86
            let version = {
87
                let v = s.versions.entry(topic.to_string()).or_insert(0);
88
                *v += 1;
89
                *v
90
            };
91
            let e = LiveEvent { id, topic: topic.into(), version, kind: kind.into() };
92
            if s.recent.len() == REPLAY {
93
                s.recent.pop_front();
94
            }
95
            s.recent.push_back(e.clone());
96
            e
97
        };
98
        let _ = self.tx.send(event.clone());
99
        event
100
    }
101
102
    pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
103
        self.tx.subscribe()
104
    }
105
106
    /// The newest event id (0 before any).
107
    pub fn last_id(&self) -> u64 {
108
        self.state.lock().unwrap_or_else(|p| p.into_inner()).next_id - 1
109
    }
110
111
    /// The events on `topics` after `last_id`, or `Reload` when some after
112
    /// it are no longer kept.
113
    pub fn since(&self, last_id: u64, topics: &[String]) -> Replay {
114
        let s = self.state.lock().unwrap_or_else(|p| p.into_inner());
33d98147 Andy McCall 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.
118
        if last_id >= s.next_id {
119
            return Replay::Reload;
120
        }
7e01a766 Andy McCall ↶ 121
        let oldest = s.recent.front().map(|e| e.id).unwrap_or(s.next_id);
122
        if last_id + 1 < oldest {
123
            return Replay::Reload;
124
        }
125
        Replay::Events(s.recent.iter().filter(|e| e.id > last_id && topics.contains(&e.topic)).cloned().collect())
126
    }
33d98147 Andy McCall 127
}
7e01a766 Andy McCall ↶ 128
33d98147 Andy McCall 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.
131
pub fn hook_topics(repo_id: i64, event: &str, data: &serde_json::Value) -> Vec<(String, String)> {
132
    let kind = event.split('.').next().unwrap_or(event).to_string();
133
    let mut topics = vec![(topic::repo(repo_id), kind.clone())];
134
    let number = data["number"].as_i64().or_else(|| data["issue"]["number"].as_i64());
135
    let is_issue = event.starts_with("issue") || event == "comment.issue";
136
    let is_proposal = event.starts_with("proposal") || matches!(event, "review" | "comment" | "comment.proposal");
137
    match number {
138
        Some(n) if is_issue => topics.push((topic::issue(repo_id, n), kind)),
139
        Some(n) if is_proposal => topics.push((topic::proposal(repo_id, n), kind)),
140
        _ => {}
7e01a766 Andy McCall ↶ 141
    }
33d98147 Andy McCall 142
    topics
7e01a766 Andy McCall ↶ 143
}
144
145
#[cfg(test)]
146
mod tests {
147
    use super::*;
148
149
    #[test]
150
    fn events_have_ids_and_versions_and_replay_or_say_reload() {
151
        let bus = LiveBus::default();
152
        let mut rx = bus.subscribe();
153
        let a = bus.publish("repo:1", "push");
154
        let b = bus.publish("repo:2", "push");
155
        let c = bus.publish("repo:1", "check");
156
        assert_eq!((a.id, b.id, c.id), (1, 2, 3));
157
        assert_eq!((a.version, b.version, c.version), (1, 1, 2), "versions count per topic");
158
        assert_eq!(rx.try_recv().unwrap(), a, "subscribers hear every event");
159
        assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Events(vec![c.clone()]), "only the topics asked for");
160
        assert_eq!(bus.since(3, &["repo:1".into()]), Replay::Events(vec![]));
33d98147 Andy McCall 161
        assert_eq!(bus.since(4, &["repo:1".into()]), Replay::Reload, "an id never given here: another process's, or before a restart");
7e01a766 Andy McCall ↶ 162
        for _ in 0..REPLAY {
163
            bus.publish("repo:3", "push");
164
        }
165
        assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Reload, "too old to replay");
166
        assert_eq!(bus.last_id(), 3 + REPLAY as u64);
167
    }
168
169
    #[test]
170
    fn a_webhook_event_names_its_repository_and_its_proposal_or_issue() {
33d98147 Andy McCall 171
        let mut seen = vec![];
172
        seen.extend(hook_topics(7, "review", &serde_json::json!({ "number": 4, "verdict": "approve" })));
173
        seen.extend(hook_topics(7, "issue.opened", &serde_json::json!({ "issue": { "number": 9 } })));
174
        seen.extend(hook_topics(7, "comment.issue", &serde_json::json!({ "number": 9 })));
175
        seen.extend(hook_topics(7, "push", &serde_json::json!({ "updates": [] })));
7e01a766 Andy McCall ↶ 176
        assert_eq!(
177
            seen,
178
            [
179
                ("repo:7", "review"),
180
                ("repo:7/proposal:4", "review"),
181
                ("repo:7", "issue"),
182
                ("repo:7/issue:9", "issue"),
183
                ("repo:7", "comment"),
184
                ("repo:7/issue:9", "comment"),
185
                ("repo:7", "push"),
186
            ]
187
            .map(|(t, k)| (t.to_string(), k.to_string()))
188
        );
189
    }
190
}