OXYGENHUB

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