//! In-process event bus: nudges the webhook worker and streams proposal //! updates to open pages over server-sent events (hub spec §13.1). use hub_api::ProposalEvent; use tokio::sync::broadcast; pub struct Events { pub deliveries: broadcast::Sender<()>, pub proposals: broadcast::Sender<(i64, ProposalEvent)>, /// Topics for live pages (oxygenhub#203). pub live: LiveBus, } impl Default for Events { fn default() -> Self { Events { deliveries: broadcast::channel(16).0, proposals: broadcast::channel(256).0, live: LiveBus::default() } } } impl Events { pub fn notify_deliveries(&self) { let _ = self.deliveries.send(()); } /// Announce something about a proposal of a repository. pub fn proposal(&self, repo_id: i64, event: ProposalEvent) { let _ = self.proposals.send((repo_id, event)); } } /// 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 across the hub: what `Last-Event-ID` resumes from. 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 event bus: 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 { /// Publish an event on `topic`. 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()); 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 topics a webhook event touches: the repository always, and the /// proposal or issue its data names. pub fn publish_hook(&self, repo_id: i64, event: &str, data: &serde_json::Value) { let kind = event.split('.').next().unwrap_or(event); self.publish(&topic::repo(repo_id), kind); 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 => { self.publish(&topic::issue(repo_id, n), kind); } Some(n) if is_proposal => { self.publish(&topic::proposal(repo_id, n), kind); } _ => {} } } } #[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![])); 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 bus = LiveBus::default(); bus.publish_hook(7, "review", &serde_json::json!({ "number": 4, "verdict": "approve" })); bus.publish_hook(7, "issue.opened", &serde_json::json!({ "issue": { "number": 9 } })); bus.publish_hook(7, "comment.issue", &serde_json::json!({ "number": 9 })); bus.publish_hook(7, "push", &serde_json::json!({ "updates": [] })); let Replay::Events(all) = bus.since(0, &["repo:7".into(), "repo:7/proposal:4".into(), "repo:7/issue:9".into()]) else { panic!() }; let seen: Vec<(String, String)> = all.into_iter().map(|e| (e.topic, e.kind)).collect(); 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())) ); } }