Live pages L1: an event bus of topics, versions and a replay buffer 7e01a7665aa5
events::LiveBus: every write that webhooks, notifications, Flow and
Diffusion already see publishes {id, topic, version, kind} (never data) on
one broadcast that subscribers filter by topic: repo:<id>, the proposal's
or issue's topic a webhook event names, user:<id>, run:<id>,
diffusion:<repo>. The last 1024 events are kept for Last-Event-ID; older
asks for a reload. Unit tests (ids, versions, replay, reload, topics of
webhook events); the approval test hears a push and a run's jobs. Also: a
Diffusion test raced the push's run creation under load (pass_all could
finish before it existed); it now waits for it. docs/live.md 5a.
Closes #203
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 05:01 UTC
- Signature
- ✔ signed by Claude Code
- Hash
7e01a7665aa589228faa3fd84db2132fe93108f5d786169fbab6c312ec50dbd5- Depends on
- 070a7b64b368 18e381ea88c2 3776b70d9b17 417184c73f4e 42fa95540468 a7e6e8e6094c b64e2537d702 bff9106edcba c0e39717895d dc59d1b56c3d f4d6ef418a96 f677d6270298
- Depended on by
- 33d98147511b 86a69b53aff0 3b1bc27fe805 67be8ac00c24
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub/src/diffusion.rs +2 −0
| … | 896 unchanged lines |
||
| 897 | 897 | // Its pipeline runs as for a push, so the proposal is tested. |
|
| 898 | 898 | tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped)); |
|
| 899 | 899 | let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await; |
|
| 900 | + | app.events.live.publish(&crate::events::topic::diffusion(consumer.id), "bump"); |
|
| 901 | + | app.events.live.publish(&crate::events::topic::diffusion(upstream.id), "bump"); |
|
| 900 | 902 | tracing::info!( |
|
| 901 | 903 | "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})", |
|
| 902 | 904 | consumer.owner, |
|
| … | 420 unchanged lines |
modified crates/hub/src/events.rs +182 −1
| … | 6 unchanged lines |
||
| 7 | 7 | pub struct Events { |
|
| 8 | 8 | pub deliveries: broadcast::Sender<()>, |
|
| 9 | 9 | pub proposals: broadcast::Sender<(i64, ProposalEvent)>, |
|
| 10 | + | /// Topics for live pages (oxygenhub#203). |
|
| 11 | + | pub live: LiveBus, |
|
| 10 | 12 | } |
|
| 11 | 13 | ||
| 12 | 14 | impl Default for Events { |
|
| 13 | 15 | fn default() -> Self { |
|
| 14 | - | Events { deliveries: broadcast::channel(16).0, proposals: broadcast::channel(256).0 } |
|
| 16 | + | Events { deliveries: broadcast::channel(16).0, proposals: broadcast::channel(256).0, live: LiveBus::default() } |
|
| 15 | 17 | } |
|
| 16 | 18 | } |
|
| 17 | 19 | ||
| … | 7 unchanged lines |
||
| 25 | 27 | let _ = self.proposals.send((repo_id, event)); |
|
| 26 | 28 | } |
|
| 27 | 29 | } |
|
| 30 | + | ||
| 31 | + | /// What changed, for live pages (docs/live.md, oxygenhub#203): a topic and |
|
| 32 | + | /// a kind, never the data. A page that hears its topic fetches the part |
|
| 33 | + | /// that changed from its normal route, which checks the viewer's access. |
|
| 34 | + | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] |
|
| 35 | + | pub struct LiveEvent { |
|
| 36 | + | /// Increasing across the hub: what `Last-Event-ID` resumes from. |
|
| 37 | + | pub id: u64, |
|
| 38 | + | pub topic: String, |
|
| 39 | + | /// The topic's own version: how many events it has had. |
|
| 40 | + | pub version: u64, |
|
| 41 | + | /// What happened: `push`, `comment`, `review`, `check`, `jobs`, ... |
|
| 42 | + | pub kind: String, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// Topics (docs/live.md §4). |
|
| 46 | + | pub mod topic { |
|
| 47 | + | pub fn repo(id: i64) -> String { |
|
| 48 | + | format!("repo:{id}") |
|
| 49 | + | } |
|
| 50 | + | pub fn proposal(repo: i64, n: i64) -> String { |
|
| 51 | + | format!("repo:{repo}/proposal:{n}") |
|
| 52 | + | } |
|
| 53 | + | pub fn issue(repo: i64, n: i64) -> String { |
|
| 54 | + | format!("repo:{repo}/issue:{n}") |
|
| 55 | + | } |
|
| 56 | + | pub fn run(id: i64) -> String { |
|
| 57 | + | format!("run:{id}") |
|
| 58 | + | } |
|
| 59 | + | pub fn user(id: i64) -> String { |
|
| 60 | + | format!("user:{id}") |
|
| 61 | + | } |
|
| 62 | + | pub fn diffusion(repo: i64) -> String { |
|
| 63 | + | format!("diffusion:{repo}") |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// Events kept for replay: a reconnecting page catches up from these, or is |
|
| 68 | + | /// told to reload when it missed more. |
|
| 69 | + | pub const REPLAY: usize = 1024; |
|
| 70 | + | ||
| 71 | + | /// The live event bus: one broadcast that subscribers filter by topic, a |
|
| 72 | + | /// version per topic, and the last [`REPLAY`] events. |
|
| 73 | + | pub struct LiveBus { |
|
| 74 | + | tx: broadcast::Sender<LiveEvent>, |
|
| 75 | + | state: std::sync::Mutex<BusState>, |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | #[derive(Default)] |
|
| 79 | + | struct BusState { |
|
| 80 | + | next_id: u64, |
|
| 81 | + | versions: std::collections::HashMap<String, u64>, |
|
| 82 | + | recent: std::collections::VecDeque<LiveEvent>, |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | impl Default for LiveBus { |
|
| 86 | + | fn default() -> Self { |
|
| 87 | + | LiveBus { tx: broadcast::channel(1024).0, state: std::sync::Mutex::new(BusState { next_id: 1, ..Default::default() }) } |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | /// What a reconnecting subscriber gets: the events it missed, or `Reload` |
|
| 92 | + | /// when they are no longer kept. |
|
| 93 | + | #[derive(Debug, PartialEq, Eq)] |
|
| 94 | + | pub enum Replay { |
|
| 95 | + | Events(Vec<LiveEvent>), |
|
| 96 | + | Reload, |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | impl LiveBus { |
|
| 100 | + | /// Publish an event on `topic`. |
|
| 101 | + | pub fn publish(&self, topic: &str, kind: &str) -> LiveEvent { |
|
| 102 | + | let event = { |
|
| 103 | + | let mut s = self.state.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 104 | + | let id = s.next_id; |
|
| 105 | + | s.next_id += 1; |
|
| 106 | + | let version = { |
|
| 107 | + | let v = s.versions.entry(topic.to_string()).or_insert(0); |
|
| 108 | + | *v += 1; |
|
| 109 | + | *v |
|
| 110 | + | }; |
|
| 111 | + | let e = LiveEvent { id, topic: topic.into(), version, kind: kind.into() }; |
|
| 112 | + | if s.recent.len() == REPLAY { |
|
| 113 | + | s.recent.pop_front(); |
|
| 114 | + | } |
|
| 115 | + | s.recent.push_back(e.clone()); |
|
| 116 | + | e |
|
| 117 | + | }; |
|
| 118 | + | let _ = self.tx.send(event.clone()); |
|
| 119 | + | event |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> { |
|
| 123 | + | self.tx.subscribe() |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | /// The newest event id (0 before any). |
|
| 127 | + | pub fn last_id(&self) -> u64 { |
|
| 128 | + | self.state.lock().unwrap_or_else(|p| p.into_inner()).next_id - 1 |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// The events on `topics` after `last_id`, or `Reload` when some after |
|
| 132 | + | /// it are no longer kept. |
|
| 133 | + | pub fn since(&self, last_id: u64, topics: &[String]) -> Replay { |
|
| 134 | + | let s = self.state.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 135 | + | let oldest = s.recent.front().map(|e| e.id).unwrap_or(s.next_id); |
|
| 136 | + | if last_id + 1 < oldest { |
|
| 137 | + | return Replay::Reload; |
|
| 138 | + | } |
|
| 139 | + | Replay::Events(s.recent.iter().filter(|e| e.id > last_id && topics.contains(&e.topic)).cloned().collect()) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// The topics a webhook event touches: the repository always, and the |
|
| 143 | + | /// proposal or issue its data names. |
|
| 144 | + | pub fn publish_hook(&self, repo_id: i64, event: &str, data: &serde_json::Value) { |
|
| 145 | + | let kind = event.split('.').next().unwrap_or(event); |
|
| 146 | + | self.publish(&topic::repo(repo_id), kind); |
|
| 147 | + | let number = data["number"].as_i64().or_else(|| data["issue"]["number"].as_i64()); |
|
| 148 | + | let is_issue = event.starts_with("issue") || event == "comment.issue"; |
|
| 149 | + | let is_proposal = event.starts_with("proposal") || matches!(event, "review" | "comment" | "comment.proposal"); |
|
| 150 | + | match number { |
|
| 151 | + | Some(n) if is_issue => { |
|
| 152 | + | self.publish(&topic::issue(repo_id, n), kind); |
|
| 153 | + | } |
|
| 154 | + | Some(n) if is_proposal => { |
|
| 155 | + | self.publish(&topic::proposal(repo_id, n), kind); |
|
| 156 | + | } |
|
| 157 | + | _ => {} |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | #[cfg(test)] |
|
| 163 | + | mod tests { |
|
| 164 | + | use super::*; |
|
| 165 | + | ||
| 166 | + | #[test] |
|
| 167 | + | fn events_have_ids_and_versions_and_replay_or_say_reload() { |
|
| 168 | + | let bus = LiveBus::default(); |
|
| 169 | + | let mut rx = bus.subscribe(); |
|
| 170 | + | let a = bus.publish("repo:1", "push"); |
|
| 171 | + | let b = bus.publish("repo:2", "push"); |
|
| 172 | + | let c = bus.publish("repo:1", "check"); |
|
| 173 | + | assert_eq!((a.id, b.id, c.id), (1, 2, 3)); |
|
| 174 | + | assert_eq!((a.version, b.version, c.version), (1, 1, 2), "versions count per topic"); |
|
| 175 | + | assert_eq!(rx.try_recv().unwrap(), a, "subscribers hear every event"); |
|
| 176 | + | assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Events(vec![c.clone()]), "only the topics asked for"); |
|
| 177 | + | assert_eq!(bus.since(3, &["repo:1".into()]), Replay::Events(vec![])); |
|
| 178 | + | for _ in 0..REPLAY { |
|
| 179 | + | bus.publish("repo:3", "push"); |
|
| 180 | + | } |
|
| 181 | + | assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Reload, "too old to replay"); |
|
| 182 | + | assert_eq!(bus.last_id(), 3 + REPLAY as u64); |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | #[test] |
|
| 186 | + | fn a_webhook_event_names_its_repository_and_its_proposal_or_issue() { |
|
| 187 | + | let bus = LiveBus::default(); |
|
| 188 | + | bus.publish_hook(7, "review", &serde_json::json!({ "number": 4, "verdict": "approve" })); |
|
| 189 | + | bus.publish_hook(7, "issue.opened", &serde_json::json!({ "issue": { "number": 9 } })); |
|
| 190 | + | bus.publish_hook(7, "comment.issue", &serde_json::json!({ "number": 9 })); |
|
| 191 | + | bus.publish_hook(7, "push", &serde_json::json!({ "updates": [] })); |
|
| 192 | + | let Replay::Events(all) = bus.since(0, &["repo:7".into(), "repo:7/proposal:4".into(), "repo:7/issue:9".into()]) else { panic!() }; |
|
| 193 | + | let seen: Vec<(String, String)> = all.into_iter().map(|e| (e.topic, e.kind)).collect(); |
|
| 194 | + | assert_eq!( |
|
| 195 | + | seen, |
|
| 196 | + | [ |
|
| 197 | + | ("repo:7", "review"), |
|
| 198 | + | ("repo:7/proposal:4", "review"), |
|
| 199 | + | ("repo:7", "issue"), |
|
| 200 | + | ("repo:7/issue:9", "issue"), |
|
| 201 | + | ("repo:7", "comment"), |
|
| 202 | + | ("repo:7/issue:9", "comment"), |
|
| 203 | + | ("repo:7", "push"), |
|
| 204 | + | ] |
|
| 205 | + | .map(|(t, k)| (t.to_string(), k.to_string())) |
|
| 206 | + | ); |
|
| 207 | + | } |
|
| 208 | + | } |
modified crates/hub/src/flow.rs +3 −0
| … | 1849 unchanged lines |
||
| 1850 | 1850 | }; |
|
| 1851 | 1851 | let mut errored_on: Vec<i64> = Vec::new(); |
|
| 1852 | 1852 | let some_passed = changed.iter().any(|j| j.status == "passed"); |
|
| 1853 | + | if !changed.is_empty() { |
|
| 1854 | + | app.events.live.publish(&crate::events::topic::run(run_id), "jobs"); |
|
| 1855 | + | } |
|
| 1853 | 1856 | for j in changed { |
|
| 1854 | 1857 | let summary = match j.status.as_str() { |
|
| 1855 | 1858 | "passed" => format!("passed in {}", duration(&j)), |
|
| … | 529 unchanged lines |
modified crates/hub/src/hooks.rs +2 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | /// Queue `event` for the hooks of a repository (and its organisation). |
|
| 9 | 9 | pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) { |
|
| 10 | + | // Live pages hear of it whether or not any hook does (oxygenhub#203). |
|
| 11 | + | app.events.live.publish_hook(repo.id, event, &data); |
|
| 10 | 12 | let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await { |
|
| 11 | 13 | Ok(h) => h, |
|
| 12 | 14 | Err(e) => { |
|
| … | 289 unchanged lines |
modified crates/hub/src/notify/mod.rs +1 −0
| … | 84 unchanged lines |
||
| 85 | 85 | let pool = &app.db; |
|
| 86 | 86 | for (id, reason) in targets { |
|
| 87 | 87 | ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok(); |
|
| 88 | + | app.events.live.publish(&crate::events::topic::user(id), "notification"); |
|
| 88 | 89 | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
|
| 89 | 90 | // Only to addresses the account has proven it reads (hub spec §5.1.1). |
|
| 90 | 91 | if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { |
|
| … | 24 unchanged lines |
modified crates/hub/tests/flow.rs +17 −0
| … | 3538 unchanged lines |
||
| 3539 | 3539 | write(api_work.path(), "a5.c", "v5\n"); |
|
| 3540 | 3540 | api.snapshot("t").unwrap(); |
|
| 3541 | 3541 | api.commit("api v5", &[]).unwrap(); |
|
| 3542 | + | let api_row = db::repos::get(&h.app.db, "andy", "api").await.unwrap().unwrap(); |
|
| 3543 | + | let runs_before = db::flow::runs(&h.app.db, api_row.id, 100, None).await.unwrap().len(); |
|
| 3542 | 3544 | assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3545 | + | // The push's run is made in the background: wait for it, or pass_all |
|
| 3546 | + | // can finish before it exists and v5 never passes (a flake under load). |
|
| 3547 | + | for _ in 0..300 { |
|
| 3548 | + | if db::flow::runs(&h.app.db, api_row.id, 100, None).await.unwrap().len() > runs_before { |
|
| 3549 | + | break; |
|
| 3550 | + | } |
|
| 3551 | + | tokio::time::sleep(std::time::Duration::from_millis(20)).await; |
|
| 3552 | + | } |
|
| 3543 | 3553 | pass_all().await; |
|
| 3544 | 3554 | let g2_row = db::repos::get(&h.app.db, "andy", "game2").await.unwrap().unwrap(); |
|
| 3545 | 3555 | for _ in 0..100 { |
|
| … | 763 unchanged lines |
||
| 4309 | 4319 | // Run 1: apply waits; only someone with the role, other than the pusher, approves it. |
|
| 4310 | 4320 | push(); |
|
| 4311 | 4321 | let run = runs_of(&h.app, "andy", "gated").await[0].clone(); |
|
| 4322 | + | // Live pages hear of the push (oxygenhub#203). |
|
| 4323 | + | let heard = |topic: String| match h.app.events.live.since(0, &[topic]) { |
|
| 4324 | + | oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(), |
|
| 4325 | + | oxygenhub::events::Replay::Reload => vec!["reload".into()], |
|
| 4326 | + | }; |
|
| 4327 | + | assert!(heard(oxygenhub::events::topic::repo(row.id)).contains(&"push".to_string()), "the push is on the repository's topic"); |
|
| 4312 | 4328 | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4313 | 4329 | let early = oxygenhub::flow::decide(&h.app, &row, &run, "apply", &carol, true).await.unwrap_err(); |
|
| 4314 | 4330 | assert!(early.contains("waits for plan first"), "{early}"); |
|
| 4315 | 4331 | let run = plan_passes(run.number).await; |
|
| 4316 | 4332 | assert!(claim(&h.app).await.is_none(), "apply waits for a person, so no runner gets it"); |
|
| 4333 | + | assert!(heard(oxygenhub::events::topic::run(run.id)).contains(&"jobs".to_string()), "the run's topic hears its jobs"); |
|
| 4317 | 4334 | // Those who may approve are told, once; the pusher (others-only) and |
|
| 4318 | 4335 | // someone without the role are not. |
|
| 4319 | 4336 | let told = |id: i64| { |
|
| … | 39 unchanged lines |
modified docs/live.md +10 −0
| … | 91 unchanged lines |
||
| 92 | 92 | - **Later:** WebTransport where streams need to be bidirectional and |
|
| 93 | 93 | many; SSE stays the default. |
|
| 94 | 94 | ||
| 95 | + | ## 5a. Status |
|
| 96 | + | ||
| 97 | + | - **L1 (done, #203):** `events::LiveBus` in the hub: one broadcast of |
|
| 98 | + | `{id, topic, version, kind}` that subscribers filter by topic (a channel |
|
| 99 | + | per topic buys nothing at this size), a version per topic, and the last |
|
| 100 | + | 1024 events for `Last-Event-ID` (older: "reload"). Published from |
|
| 101 | + | `hooks::emit` (the repository's topic, and the proposal's or issue's the |
|
| 102 | + | event names), `notify::tell` (`user:<id>`), Flow job changes |
|
| 103 | + | (`run:<id>`) and Diffusion bumps (`diffusion:<repo id>`, both sides). |
|
| 104 | + | ||
| 95 | 105 | ## 6. Order |
|
| 96 | 106 | ||
| 97 | 107 | | Step | What | Issue | |
|
| … | 8 unchanged lines |