//! Live pages' events (docs/live.md, oxygenhub#203): what a topic is, and //! the replay buffer that numbers each process's events for `Last-Event-ID`. //! They travel on the event bus ([`crate::bus`]), which carries them between //! processes too. use tokio::sync::broadcast; /// What changed, for live pages (docs/live.md, oxygenhub#203): a topic and /// a kind, never the data. A page that hears its topic fetches the part /// that changed from its normal route, which checks the viewer's access. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct LiveEvent { /// Increasing in this process: what `Last-Event-ID` resumes from. Each /// process numbers the events it hears, its own and other processes'. pub id: u64, pub topic: String, /// The topic's own version: how many events it has had. pub version: u64, /// What happened: `push`, `comment`, `review`, `check`, `jobs`, ... pub kind: String, } /// Topics (docs/live.md ยง4). pub mod topic { pub fn repo(id: i64) -> String { format!("repo:{id}") } pub fn proposal(repo: i64, n: i64) -> String { format!("repo:{repo}/proposal:{n}") } pub fn issue(repo: i64, n: i64) -> String { format!("repo:{repo}/issue:{n}") } pub fn run(id: i64) -> String { format!("run:{id}") } pub fn user(id: i64) -> String { format!("user:{id}") } pub fn diffusion(repo: i64) -> String { format!("diffusion:{repo}") } } /// Events kept for replay: a reconnecting page catches up from these, or is /// told to reload when it missed more. pub const REPLAY: usize = 1024; /// The live pages' buffer: one broadcast that subscribers filter by topic, a /// version per topic, and the last [`REPLAY`] events. pub struct LiveBus { tx: broadcast::Sender, state: std::sync::Mutex, } #[derive(Default)] struct BusState { next_id: u64, versions: std::collections::HashMap, recent: std::collections::VecDeque, } impl Default for LiveBus { fn default() -> Self { LiveBus { tx: broadcast::channel(1024).0, state: std::sync::Mutex::new(BusState { next_id: 1, ..Default::default() }) } } } /// What a reconnecting subscriber gets: the events it missed, or `Reload` /// when they are no longer kept. #[derive(Debug, PartialEq, Eq)] pub enum Replay { Events(Vec), Reload, } impl LiveBus { /// Record an event on `topic` and tell this process's subscribers. /// Producers publish through the bus ([`crate::bus::Bus::live`]), which /// records it here and in every other process. pub fn publish(&self, topic: &str, kind: &str) -> LiveEvent { let event = { let mut s = self.state.lock().unwrap_or_else(|p| p.into_inner()); let id = s.next_id; s.next_id += 1; let version = { let v = s.versions.entry(topic.to_string()).or_insert(0); *v += 1; *v }; let e = LiveEvent { id, topic: topic.into(), version, kind: kind.into() }; if s.recent.len() == REPLAY { s.recent.pop_front(); } s.recent.push_back(e.clone()); e }; let _ = self.tx.send(event.clone()); event } pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } /// The newest event id (0 before any). pub fn last_id(&self) -> u64 { self.state.lock().unwrap_or_else(|p| p.into_inner()).next_id - 1 } /// The events on `topics` after `last_id`, or `Reload` when some after /// it are no longer kept. pub fn since(&self, last_id: u64, topics: &[String]) -> Replay { let s = self.state.lock().unwrap_or_else(|p| p.into_inner()); // An id this process never gave: from before it restarted, or from // another process's numbering (a page that reconnected through // another web node, docs/roles.md). What it missed is unknown. if last_id >= s.next_id { return Replay::Reload; } let oldest = s.recent.front().map(|e| e.id).unwrap_or(s.next_id); if last_id + 1 < oldest { return Replay::Reload; } Replay::Events(s.recent.iter().filter(|e| e.id > last_id && topics.contains(&e.topic)).cloned().collect()) } } /// The live topics a webhook event touches, with the kind for each: the /// repository always, and the proposal or issue its data names. pub fn hook_topics(repo_id: i64, event: &str, data: &serde_json::Value) -> Vec<(String, String)> { let kind = event.split('.').next().unwrap_or(event).to_string(); let mut topics = vec![(topic::repo(repo_id), kind.clone())]; let number = data["number"].as_i64().or_else(|| data["issue"]["number"].as_i64()); let is_issue = event.starts_with("issue") || event == "comment.issue"; let is_proposal = event.starts_with("proposal") || matches!(event, "review" | "comment" | "comment.proposal"); match number { Some(n) if is_issue => topics.push((topic::issue(repo_id, n), kind)), Some(n) if is_proposal => topics.push((topic::proposal(repo_id, n), kind)), _ => {} } topics } #[cfg(test)] mod tests { use super::*; #[test] fn events_have_ids_and_versions_and_replay_or_say_reload() { let bus = LiveBus::default(); let mut rx = bus.subscribe(); let a = bus.publish("repo:1", "push"); let b = bus.publish("repo:2", "push"); let c = bus.publish("repo:1", "check"); assert_eq!((a.id, b.id, c.id), (1, 2, 3)); assert_eq!((a.version, b.version, c.version), (1, 1, 2), "versions count per topic"); assert_eq!(rx.try_recv().unwrap(), a, "subscribers hear every event"); assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Events(vec![c.clone()]), "only the topics asked for"); assert_eq!(bus.since(3, &["repo:1".into()]), Replay::Events(vec![])); assert_eq!(bus.since(4, &["repo:1".into()]), Replay::Reload, "an id never given here: another process's, or before a restart"); for _ in 0..REPLAY { bus.publish("repo:3", "push"); } assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Reload, "too old to replay"); assert_eq!(bus.last_id(), 3 + REPLAY as u64); } #[test] fn a_webhook_event_names_its_repository_and_its_proposal_or_issue() { let mut seen = vec![]; seen.extend(hook_topics(7, "review", &serde_json::json!({ "number": 4, "verdict": "approve" }))); seen.extend(hook_topics(7, "issue.opened", &serde_json::json!({ "issue": { "number": 9 } }))); seen.extend(hook_topics(7, "comment.issue", &serde_json::json!({ "number": 9 }))); seen.extend(hook_topics(7, "push", &serde_json::json!({ "updates": [] }))); assert_eq!( seen, [ ("repo:7", "review"), ("repo:7/proposal:4", "review"), ("repo:7", "issue"), ("repo:7/issue:9", "issue"), ("repo:7", "comment"), ("repo:7/issue:9", "comment"), ("repo:7", "push"), ] .map(|(t, k)| (t.to_string(), k.to_string())) ); } }