OXYGENHUB

Carry events between the hub's processes on an event bus 33d98147511b

crates/hub/src/bus.rs: publish(event) and subscribe(topic) for the
deliveries nudge, proposal events, live pages' topics and resolver rooms.
In process on SQLite; on PostgreSQL each event also goes out with
pg_notify on a per-schema channel per topic, and one listening connection
per process relays other processes' events locally (own events skipped by
origin id). A lost listening connection is replaced, the gap logged, and
the webhook worker nudged to look now. Live pages' replay ids stay per
process; an id a process never gave now gets a reload.

Closes #273
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8683c54c345b
When
· 2026-10-19 23:33 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
33d98147511b2f1705c62ca3f1b6b951a62567e8fbc8bd75acd7f02701b5f352
Depends on
18e381ea88c2 1c8276b90314 1f78655a0466 3d5c825aebba 42fa95540468 5e6d6484c28e 67be8ac00c24 6b195ed3b637 7d2eb590051f 7e01a7665aa5 7edaff01d506 86a69b53aff0 8fb35df7d66e 93be9ce4653e a10446c68043 a1b9869565aa a7e6e8e6094c aa0bfbd11f02 b5668108487f b5a0a69b3622 b64e2537d702 bff9106edcba c0e39717895d c5f3bde466d1 c8df33a6829f c96b32865000 d23464c0ac60 d7d182467382 de505df5cf8d e57765dee61a e71846aa07b9 f4d6ef418a96 f677d6270298 ffb67a81188c
Depended on by
f2d2776b2a14 aeed63c00ad7 ec84907043c2 9c04c779f032
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main 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
88 atoms, 2 tree operations, 22 files

Browse the tree at this change

added crates/hub/src/bus.rs +507 −0
1 +
//! The event bus (docs/roles.md, #273): publish and subscribe for the
2 +
//! events live pages and workers react to, in this process and in every
3 +
//! other process sharing the hub's database.
4 +
//!
5 +
//! An event says that something happened (a push moved a bookmark, a
6 +
//! proposal's check was recorded, a run's jobs changed, a webhook delivery
7 +
//! is waiting, a choice in a conflict-resolution room) and carries ids and
8 +
//! kinds, never the data: subscribers read the rows they need. Delivery is
9 +
//! at most once. Anything that must not be lost is a row in a table (a
10 +
//! delivery, a job), and its event only says "look now"; every subscriber
11 +
//! also looks by itself from time to time, or when it starts.
12 +
//!
13 +
//! In process, each [`Topic`] is a `tokio::sync::broadcast` channel: what
14 +
//! the single-node edition has always done, and all there is on SQLite (one
15 +
//! process by definition). On PostgreSQL the bus also carries each event to
16 +
//! the other processes: [`Bus::publish`] delivers it here at once and sends
17 +
//! it with `pg_notify` through the pool, on one channel per topic named for
18 +
//! the hub's schema (two hubs in two schemas of one database never hear
19 +
//! each other). Each process listens on those channels on one connection of
20 +
//! its own, outside the pool, and relays what it hears into its local
21 +
//! channels without publishing it again. Every event is sent with this
22 +
//! process's origin id, and a process skips its own: each subscriber hears
23 +
//! an event once, in every process. If the listening connection is lost, the
24 +
//! process listens again on a new one, says that events published meanwhile
25 +
//! were missed, and nudges its own webhook worker to look now.
26 +
27 +
use crate::events::{hook_topics, LiveBus};
28 +
use crate::live::{RoomEvent, RoomKey};
29 +
use crate::Shared;
30 +
use hub_api::ProposalEvent;
31 +
use tokio::sync::broadcast;
32 +
33 +
/// What the bus carries: one local channel per topic and, on PostgreSQL,
34 +
/// one notification channel per topic.
35 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36 +
pub enum Topic {
37 +
    /// A webhook delivery is waiting (the worker's nudge).
38 +
    Deliveries,
39 +
    /// A proposal's event: a check recorded on a projected state.
40 +
    Proposals,
41 +
    /// Live pages' topics (docs/live.md): pushes, checks, runs' jobs,
42 +
    /// comments, reviews, notifications, Diffusion's bumps.
43 +
    Live,
44 +
    /// Choices and landings in conflict-resolution rooms.
45 +
    Rooms,
46 +
}
47 +
48 +
impl Topic {
49 +
    pub const ALL: [Topic; 4] = [Topic::Deliveries, Topic::Proposals, Topic::Live, Topic::Rooms];
50 +
51 +
    pub fn name(self) -> &'static str {
52 +
        match self {
53 +
            Topic::Deliveries => "deliveries",
54 +
            Topic::Proposals => "proposals",
55 +
            Topic::Live => "live",
56 +
            Topic::Rooms => "rooms",
57 +
        }
58 +
    }
59 +
60 +
    /// How many events a slow local subscriber may fall behind by.
61 +
    fn capacity(self) -> usize {
62 +
        match self {
63 +
            Topic::Deliveries => 16,
64 +
            Topic::Proposals | Topic::Rooms => 256,
65 +
            Topic::Live => 1024,
66 +
        }
67 +
    }
68 +
}
69 +
70 +
/// An event: its topic is its kind, and it carries ids and kinds only.
71 +
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72 +
#[serde(tag = "t", rename_all = "snake_case")]
73 +
pub enum Event {
74 +
    /// A webhook delivery is waiting: the worker should look now.
75 +
    DeliveryWaiting,
76 +
    /// Something about a proposal of repository `repo` (the proposal
77 +
    /// page's stream, hub spec §13.1): a check recorded on a state.
78 +
    Proposal { repo: i64, event: ProposalEvent },
79 +
    /// A live page's topic changed (`repo:1`, `run:5`, …) with a kind
80 +
    /// (`push`, `check`, `jobs`, `comment`, …).
81 +
    Live { topic: String, kind: String },
82 +
    /// A viewer's choice, or the landing, in a conflict-resolution room.
83 +
    Room { room: RoomKey, event: RoomEvent },
84 +
}
85 +
86 +
impl Event {
87 +
    pub fn topic(&self) -> Topic {
88 +
        match self {
89 +
            Event::DeliveryWaiting => Topic::Deliveries,
90 +
            Event::Proposal { .. } => Topic::Proposals,
91 +
            Event::Live { .. } => Topic::Live,
92 +
            Event::Room { .. } => Topic::Rooms,
93 +
        }
94 +
    }
95 +
}
96 +
97 +
/// This process's end of the bus.
98 +
pub struct Bus {
99 +
    /// Sent with every event this process publishes, so it skips its own
100 +
    /// when they come back from the database.
101 +
    #[cfg_attr(not(feature = "postgres"), allow(dead_code))]
102 +
    origin: String,
103 +
    /// One broadcast per topic, in [`Topic::ALL`]'s order.
104 +
    channels: Vec<broadcast::Sender<Event>>,
105 +
    /// Live pages' events as this process numbers them, with the last few
106 +
    /// kept for `Last-Event-ID` (docs/live.md).
107 +
    pub live: LiveBus,
108 +
    #[cfg(feature = "postgres")]
109 +
    remote: pg::Remote,
110 +
}
111 +
112 +
impl Default for Bus {
113 +
    fn default() -> Self {
114 +
        Bus {
115 +
            origin: crate::auth::random_hex(8),
116 +
            channels: Topic::ALL.iter().map(|t| broadcast::channel(t.capacity()).0).collect(),
117 +
            live: LiveBus::default(),
118 +
            #[cfg(feature = "postgres")]
119 +
            remote: pg::Remote::default(),
120 +
        }
121 +
    }
122 +
}
123 +
124 +
impl Bus {
125 +
    /// Publish `event`: this process's subscribers hear it now, and every
126 +
    /// other process's soon after (PostgreSQL), at most once.
127 +
    pub fn publish(&self, event: Event) {
128 +
        #[cfg(feature = "postgres")]
129 +
        self.remote.send(&self.origin, &event);
130 +
        self.deliver(event);
131 +
    }
132 +
133 +
    /// Hear the events on `topic` published from now on, in this process
134 +
    /// and the others.
135 +
    pub fn subscribe(&self, topic: Topic) -> broadcast::Receiver<Event> {
136 +
        self.channels[Self::index(topic)].subscribe()
137 +
    }
138 +
139 +
    /// A live page's `topic` changed with `kind` (docs/live.md).
140 +
    pub fn live(&self, topic: &str, kind: &str) {
141 +
        self.publish(Event::Live { topic: topic.to_string(), kind: kind.to_string() });
142 +
    }
143 +
144 +
    /// Live pages hear of a webhook event: the repository's topic, and the
145 +
    /// proposal's or issue's its data names.
146 +
    pub fn hook(&self, repo_id: i64, event: &str, data: &serde_json::Value) {
147 +
        for (topic, kind) in hook_topics(repo_id, event, data) {
148 +
            self.live(&topic, &kind);
149 +
        }
150 +
    }
151 +
152 +
    /// A webhook delivery is waiting.
153 +
    pub fn delivery_waiting(&self) {
154 +
        self.publish(Event::DeliveryWaiting);
155 +
    }
156 +
157 +
    /// Announce something about a proposal of a repository.
158 +
    pub fn proposal(&self, repo_id: i64, event: ProposalEvent) {
159 +
        self.publish(Event::Proposal { repo: repo_id, event });
160 +
    }
161 +
162 +
    /// Tell this process's subscribers only: [`Bus::publish`]'s local half,
163 +
    /// and what the relay does with another process's event.
164 +
    fn deliver(&self, event: Event) {
165 +
        if let Event::Live { topic, kind } = &event {
166 +
            self.live.publish(topic, kind);
167 +
        }
168 +
        let _ = self.channels[Self::index(event.topic())].send(event);
169 +
    }
170 +
171 +
    fn index(topic: Topic) -> usize {
172 +
        Topic::ALL.iter().position(|t| *t == topic).expect("every topic is in ALL")
173 +
    }
174 +
175 +
    /// The process id of the database session listening for other
176 +
    /// processes' events, while it listens (for tests that end it).
177 +
    #[cfg(feature = "postgres")]
178 +
    pub fn listener_pid(&self) -> Option<i32> {
179 +
        *self.remote.pid.lock().unwrap_or_else(|p| p.into_inner())
180 +
    }
181 +
}
182 +
183 +
/// Start carrying events between processes: on PostgreSQL, the sender and
184 +
/// the listener; on SQLite, nothing (one process).
185 +
pub async fn start(app: &Shared) -> anyhow::Result<()> {
186 +
    #[cfg(feature = "postgres")]
187 +
    pg::start(app).await?;
188 +
    #[cfg(not(feature = "postgres"))]
189 +
    let _ = app;
190 +
    Ok(())
191 +
}
192 +
193 +
/// The most a notification's payload may be: PostgreSQL takes fewer than
194 +
/// 8000 bytes.
195 +
pub const MAX_PAYLOAD: usize = 7999;
196 +
197 +
/// An event as sent between processes: who sent it, and the event.
198 +
#[derive(serde::Serialize, serde::Deserialize)]
199 +
struct Envelope {
200 +
    o: String,
201 +
    e: Event,
202 +
}
203 +
204 +
/// The payload for `event` from `origin`, within [`MAX_PAYLOAD`]: a check's
205 +
/// summary too long for it is cut (the proposal page reads the check for
206 +
/// the rest); anything else too long (a choice's own text) is not sent to
207 +
/// other processes (`None`).
208 +
#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
209 +
fn encode(origin: &str, event: &Event) -> Option<String> {
210 +
    let mut envelope = Envelope { o: origin.to_string(), e: event.clone() };
211 +
    loop {
212 +
        let text = serde_json::to_string(&envelope).ok()?;
213 +
        if text.len() <= MAX_PAYLOAD {
214 +
            return Some(text);
215 +
        }
216 +
        let Event::Proposal { event: ProposalEvent { check: Some(check), .. }, .. } = &mut envelope.e else { return None };
217 +
        if check.summary.is_empty() {
218 +
            return None;
219 +
        }
220 +
        // Escaping may make the summary longer in JSON than as text: cut
221 +
        // by what is over, and again if still over.
222 +
        let over = text.len() - MAX_PAYLOAD;
223 +
        let mut keep = check.summary.trim_end_matches('…').len().saturating_sub(over + 3);
224 +
        while !check.summary.is_char_boundary(keep) {
225 +
            keep -= 1;
226 +
        }
227 +
        check.summary.truncate(keep);
228 +
        check.summary.push('…');
229 +
        if keep == 0 {
230 +
            check.summary.clear();
231 +
        }
232 +
    }
233 +
}
234 +
235 +
/// The event in `payload`, unless this process (`origin`) sent it or it
236 +
/// cannot be read.
237 +
#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
238 +
fn decode(origin: &str, payload: &str) -> Option<Event> {
239 +
    match serde_json::from_str::<Envelope>(payload) {
240 +
        Ok(e) if e.o != origin => Some(e.e),
241 +
        Ok(_) => None,
242 +
        Err(err) => {
243 +
            tracing::warn!("bus: an event that could not be read: {err}");
244 +
            None
245 +
        }
246 +
    }
247 +
}
248 +
249 +
/// The notification channel for `topic` of the hub in `schema`: a stable
250 +
/// hash of the schema, so channel names are plain and short whatever the
251 +
/// schema is called.
252 +
#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
253 +
fn channel(schema: &str, topic: Topic) -> String {
254 +
    let h = blake3::hash(format!("oxygenhub bus\0{schema}").as_bytes());
255 +
    format!("oxyhub_{}_{}", &h.to_hex()[..16], topic.name())
256 +
}
257 +
258 +
#[cfg(feature = "postgres")]
259 +
mod pg {
260 +
    use super::{channel, decode, encode, Event, Topic};
261 +
    use crate::{App, Shared};
262 +
    use sqlx::postgres::{PgListener, PgPool, PgPoolOptions};
263 +
    use std::sync::{Mutex, OnceLock};
264 +
    use std::time::Duration;
265 +
    use tokio::sync::mpsc;
266 +
267 +
    /// How long a quiet listening connection goes before it is asked to
268 +
    /// answer, and how long it has to answer.
269 +
    const CHECK: Duration = Duration::from_secs(5);
270 +
271 +
    /// How long to wait before listening again after failing to connect.
272 +
    const RETRY: Duration = Duration::from_secs(2);
273 +
274 +
    /// Events waiting to be sent to other processes; past this, new ones
275 +
    /// are dropped (and said so): the bus is at most once.
276 +
    const QUEUE: usize = 4096;
277 +
278 +
    #[derive(Default)]
279 +
    pub(super) struct Remote {
280 +
        /// The hub's schema and the queue to the sender, once started.
281 +
        out: OnceLock<(String, mpsc::Sender<(String, String)>)>,
282 +
        /// The listening session's process id, while it listens.
283 +
        pub(super) pid: Mutex<Option<i32>>,
284 +
    }
285 +
286 +
    impl Remote {
287 +
        /// Queue `event` for the other processes. Before [`start`], or
288 +
        /// for an event too large to send, it stays in this process.
289 +
        pub(super) fn send(&self, origin: &str, event: &Event) {
290 +
            let Some((schema, tx)) = self.out.get() else { return };
291 +
            let Some(payload) = encode(origin, event) else {
292 +
                tracing::warn!("bus: a {} event too large to send to other processes stays in this one", event.topic().name());
293 +
                return;
294 +
            };
295 +
            if tx.try_send((channel(schema, event.topic()), payload)).is_err() {
296 +
                tracing::warn!("bus: too many events waiting to go to other processes; one was dropped");
297 +
            }
298 +
        }
299 +
    }
300 +
301 +
    pub(super) async fn start(app: &Shared) -> anyhow::Result<()> {
302 +
        let schema: String = sqlx::query_scalar("SELECT CAST(current_schema() AS TEXT)").fetch_one(&app.db).await?;
303 +
        let (tx, rx) = mpsc::channel(QUEUE);
304 +
        if app.bus.remote.out.set((schema.clone(), tx)).is_err() {
305 +
            return Ok(());
306 +
        }
307 +
        tokio::spawn(send_all(app.db.clone(), rx));
308 +
        tokio::spawn(listen(app.clone(), schema));
309 +
        Ok(())
310 +
    }
311 +
312 +
    /// Send queued events in order, through the pool.
313 +
    async fn send_all(db: crate::db::Pool, mut rx: mpsc::Receiver<(String, String)>) {
314 +
        while let Some((channel, payload)) = rx.recv().await {
315 +
            if let Err(e) = sqlx::query("SELECT pg_notify($1, $2)").bind(&channel).bind(&payload).execute(&db).await {
316 +
                tracing::warn!("bus: sending an event to other processes: {e}");
317 +
            }
318 +
        }
319 +
    }
320 +
321 +
    /// A connection of its own, outside the pool, listening on every topic.
322 +
    async fn connect(app: &App, channels: &[String]) -> anyhow::Result<(PgPool, PgListener, i32)> {
323 +
        let options = (*app.db.connect_options()).clone();
324 +
        let pool = PgPoolOptions::new().max_connections(1).max_lifetime(None).idle_timeout(None).connect_with(options).await?;
325 +
        let mut listener = PgListener::connect_with(&pool).await?;
326 +
        listener.ignore_pool_close_event(true);
327 +
        // A lost connection ends this listener; [`listen`] makes another.
328 +
        listener.eager_reconnect(false);
329 +
        listener.listen_all(channels.iter().map(String::as_str)).await?;
330 +
        let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()").fetch_one(&mut listener).await?;
331 +
        Ok((pool, listener, pid))
332 +
    }
333 +
334 +
    /// Listen for other processes' events and relay them here, for as long
335 +
    /// as the process runs, on a new connection whenever one is lost.
336 +
    async fn listen(app: Shared, schema: String) {
337 +
        let channels: Vec<String> = Topic::ALL.iter().map(|t| channel(&schema, *t)).collect();
338 +
        let mut lost = false;
339 +
        loop {
340 +
            let (pool, mut listener, pid) = match tokio::time::timeout(CHECK * 2, connect(&app, &channels)).await {
341 +
                Ok(Ok(c)) => c,
342 +
                Ok(Err(e)) => {
343 +
                    tracing::warn!("bus: listening for other processes' events: {e}");
344 +
                    tokio::time::sleep(RETRY).await;
345 +
                    continue;
346 +
                }
347 +
                Err(_) => {
348 +
                    tracing::warn!("bus: listening for other processes' events: timed out");
349 +
                    tokio::time::sleep(RETRY).await;
350 +
                    continue;
351 +
                }
352 +
            };
353 +
            *app.bus.remote.pid.lock().unwrap_or_else(|p| p.into_inner()) = Some(pid);
354 +
            if lost {
355 +
                tracing::warn!("bus: listening again; events other processes published meanwhile were missed");
356 +
                // Look now for what a missed nudge would have said.
357 +
                app.bus.deliver(Event::DeliveryWaiting);
358 +
            }
359 +
            loop {
360 +
                tokio::select! {
361 +
                    heard = listener.try_recv() => match heard {
362 +
                        Ok(Some(n)) => relay(&app, n.payload()),
363 +
                        Ok(None) => {
364 +
                            tracing::warn!("bus: the connection listening for other processes' events was lost");
365 +
                            break;
366 +
                        }
367 +
                        Err(e) => {
368 +
                            tracing::warn!("bus: the connection listening for other processes' events failed: {e}");
369 +
                            break;
370 +
                        }
371 +
                    },
372 +
                    _ = tokio::time::sleep(CHECK) => {
373 +
                        let ping = sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&mut listener);
374 +
                        if !matches!(tokio::time::timeout(CHECK, ping).await, Ok(Ok(_))) {
375 +
                            tracing::warn!("bus: the connection listening for other processes' events stopped answering");
376 +
                            break;
377 +
                        }
378 +
                    }
379 +
                }
380 +
            }
381 +
            lost = true;
382 +
            *app.bus.remote.pid.lock().unwrap_or_else(|p| p.into_inner()) = None;
383 +
            drop(listener);
384 +
            let _ = tokio::time::timeout(CHECK, pool.close()).await;
385 +
        }
386 +
    }
387 +
388 +
    /// Another process's event, told to this one's subscribers (and its
389 +
    /// rooms), never published again.
390 +
    fn relay(app: &App, payload: &str) {
391 +
        let Some(event) = decode(&app.bus.origin, payload) else { return };
392 +
        if let Event::Room { room, event } = &event {
393 +
            app.rooms.apply(room, event);
394 +
        }
395 +
        app.bus.deliver(event);
396 +
    }
397 +
}
398 +
399 +
#[cfg(test)]
400 +
mod tests {
401 +
    use super::*;
402 +
    use crate::events::Replay;
403 +
404 +
    fn check(summary: &str) -> ProposalEvent {
405 +
        ProposalEvent {
406 +
            number: 0,
407 +
            kind: "check".into(),
408 +
            check: Some(hub_api::Check {
409 +
                state: "s".into(),
410 +
                name: "build".into(),
411 +
                status: hub_api::CheckStatus::Passed,
412 +
                summary: summary.into(),
413 +
                url: String::new(),
414 +
                reporter: "ci".into(),
415 +
                seal: None,
416 +
                started_ms: 0,
417 +
                finished_ms: None,
418 +
            }),
419 +
        }
420 +
    }
421 +
422 +
    #[test]
423 +
    fn subscribers_hear_their_topic_in_order_and_nothing_else() {
424 +
        let bus = Bus::default();
425 +
        let mut deliveries = bus.subscribe(Topic::Deliveries);
426 +
        let mut proposals = bus.subscribe(Topic::Proposals);
427 +
        let mut live = bus.subscribe(Topic::Live);
428 +
        bus.delivery_waiting();
429 +
        bus.proposal(7, check("ok"));
430 +
        bus.live("repo:7", "push");
431 +
        bus.live("repo:7", "check");
432 +
        assert_eq!(deliveries.try_recv().unwrap(), Event::DeliveryWaiting);
433 +
        assert!(deliveries.try_recv().is_err(), "once");
434 +
        assert_eq!(proposals.try_recv().unwrap(), Event::Proposal { repo: 7, event: check("ok") });
435 +
        assert!(proposals.try_recv().is_err());
436 +
        assert_eq!(live.try_recv().unwrap(), Event::Live { topic: "repo:7".into(), kind: "push".into() });
437 +
        assert_eq!(live.try_recv().unwrap(), Event::Live { topic: "repo:7".into(), kind: "check".into() });
438 +
        // Live events are numbered for replay as they are published.
439 +
        let Replay::Events(kept) = bus.live.since(0, &["repo:7".into()]) else { panic!() };
440 +
        assert_eq!(kept.iter().map(|e| (e.id, e.version, e.kind.as_str())).collect::<Vec<_>>(), [(1, 1, "push"), (2, 2, "check")]);
441 +
    }
442 +
443 +
    #[test]
444 +
    fn a_webhook_event_is_heard_on_its_live_topics() {
445 +
        let bus = Bus::default();
446 +
        let mut live = bus.subscribe(Topic::Live);
447 +
        bus.hook(7, "review", &serde_json::json!({ "number": 4 }));
448 +
        assert_eq!(live.try_recv().unwrap(), Event::Live { topic: "repo:7".into(), kind: "review".into() });
449 +
        assert_eq!(live.try_recv().unwrap(), Event::Live { topic: "repo:7/proposal:4".into(), kind: "review".into() });
450 +
    }
451 +
452 +
    #[test]
453 +
    fn events_travel_with_their_origin_and_a_process_skips_its_own() {
454 +
        let room = RoomKey { repo_id: 1, bookmark: "main".into(), path: "a.txt".into() };
455 +
        let events = [
456 +
            Event::DeliveryWaiting,
457 +
            Event::Proposal { repo: 7, event: check("42 tests") },
458 +
            Event::Live { topic: "repo:7".into(), kind: "push".into() },
459 +
            Event::Room {
460 +
                room,
461 +
                event: RoomEvent::Choice {
462 +
                    region: "1".into(),
463 +
                    choice: crate::live::Choice { choice: "side2".into(), text: String::new(), by: "andy".into() },
464 +
                    client: "tab-a".into(),
465 +
                },
466 +
            },
467 +
        ];
468 +
        for e in events {
469 +
            let payload = encode("aaaa", &e).unwrap();
470 +
            assert_eq!(decode("bbbb", &payload), Some(e.clone()), "another process hears it");
471 +
            assert_eq!(decode("aaaa", &payload), None, "its sender skips it");
472 +
        }
473 +
        assert_eq!(decode("bbbb", "not json"), None);
474 +
    }
475 +
476 +
    #[test]
477 +
    fn payloads_fit_a_notification() {
478 +
        // A long check summary is cut to fit, on a character boundary.
479 +
        let long = "é\"".repeat(6000);
480 +
        let payload = encode("aaaa", &Event::Proposal { repo: 7, event: check(&long) }).unwrap();
481 +
        assert!(payload.len() <= MAX_PAYLOAD, "{}", payload.len());
482 +
        let Some(Event::Proposal { event, .. }) = decode("bbbb", &payload) else { panic!() };
483 +
        let summary = event.check.unwrap().summary;
484 +
        assert!(summary.ends_with('…') && long.starts_with(summary.trim_end_matches('…')), "{}", summary.len());
485 +
        // A choice's own text that does not fit stays in its process.
486 +
        let big = Event::Room {
487 +
            room: RoomKey { repo_id: 1, bookmark: "main".into(), path: "a".into() },
488 +
            event: RoomEvent::Choice {
489 +
                region: "1".into(),
490 +
                choice: crate::live::Choice { choice: "text".into(), text: "x".repeat(9000), by: "andy".into() },
491 +
                client: "c".into(),
492 +
            },
493 +
        };
494 +
        assert_eq!(encode("aaaa", &big), None);
495 +
    }
496 +
497 +
    #[test]
498 +
    fn channels_are_per_schema_and_topic_and_plain() {
499 +
        assert_eq!(channel("hub_a", Topic::Live), channel("hub_a", Topic::Live));
500 +
        assert_ne!(channel("hub_a", Topic::Live), channel("hub_b", Topic::Live));
501 +
        assert_ne!(channel("hub_a", Topic::Live), channel("hub_a", Topic::Rooms));
502 +
        for t in Topic::ALL {
503 +
            let c = channel(&"x".repeat(200), t);
504 +
            assert!(c.len() < 64 && c.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), "{c}");
505 +
        }
506 +
    }
507 +
}
added crates/hub/tests/bus.rs +287 −0
1 +
//! The event bus (docs/roles.md, #273): a hub hears its own events once;
2 +
//! on PostgreSQL, two hubs on one database hear each other's: a push made
3 +
//! through a web process reaches live pages and the webhook worker of
4 +
//! another process, and a process whose listening connection is ended
5 +
//! listens again on a new one.
6 +
7 +
use oxygenhub::bus::{Event, Topic};
8 +
use oxygenhub::config::Config;
9 +
use std::path::Path;
10 +
use std::time::Duration;
11 +
12 +
fn hermetic() {
13 +
    static ONCE: std::sync::Once = std::sync::Once::new();
14 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
15 +
    ONCE.call_once(|| {
16 +
        let d = tempfile::tempdir().unwrap();
17 +
        std::env::set_var("OXY_HOME", d.path());
18 +
        DIR.set(d).ok();
19 +
    });
20 +
}
21 +
22 +
fn config(dir: &Path, roles: &[&str]) -> Config {
23 +
    let mut config = Config::load(dir).unwrap();
24 +
    config.server.roles = roles.iter().map(|r| r.to_string()).collect();
25 +
    config.registration.setup_code = "test-setup".into();
26 +
    config
27 +
}
28 +
29 +
/// The next event on `rx` that `want` picks, within `limit`.
30 +
async fn heard<T: Clone>(rx: &mut tokio::sync::broadcast::Receiver<T>, limit: Duration, want: impl Fn(&T) -> bool) -> Option<T> {
31 +
    tokio::time::timeout(limit, async {
32 +
        loop {
33 +
            match rx.recv().await {
34 +
                Ok(e) if want(&e) => return Some(e),
35 +
                Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
36 +
                Err(_) => return None,
37 +
            }
38 +
        }
39 +
    })
40 +
    .await
41 +
    .ok()
42 +
    .flatten()
43 +
}
44 +
45 +
#[tokio::test(flavor = "multi_thread")]
46 +
async fn a_hub_hears_its_own_events_once() {
47 +
    hermetic();
48 +
    let dir = tempfile::tempdir().unwrap();
49 +
    let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap();
50 +
    let mut live = app.bus.subscribe(Topic::Live);
51 +
    let mut deliveries = app.bus.subscribe(Topic::Deliveries);
52 +
    app.bus.live("repo:1", "push");
53 +
    app.bus.delivery_waiting();
54 +
    let second = Duration::from_secs(1);
55 +
    assert_eq!(heard(&mut live, second, |_| true).await, Some(Event::Live { topic: "repo:1".into(), kind: "push".into() }));
56 +
    assert_eq!(heard(&mut deliveries, second, |_| true).await, Some(Event::DeliveryWaiting));
57 +
    // On PostgreSQL the event also went out through the database and came
58 +
    // back to this process's listener, which skips its own.
59 +
    tokio::time::sleep(Duration::from_millis(500)).await;
60 +
    assert!(live.try_recv().is_err(), "heard once");
61 +
    assert!(deliveries.try_recv().is_err(), "heard once");
62 +
    assert_eq!(app.bus.live.last_id(), 1);
63 +
}
64 +
65 +
#[cfg(feature = "postgres")]
66 +
mod postgres {
67 +
    use super::*;
68 +
    use axum::body::Body;
69 +
    use axum::http::{header, Request, StatusCode};
70 +
    use axum::routing::post;
71 +
    use http_body_util::BodyExt;
72 +
    use oxy_core::repo::Repo;
73 +
    use oxygenhub::Shared;
74 +
    use std::sync::{Arc, Mutex};
75 +
    use tower::ServiceExt;
76 +
77 +
    fn pg(dir: &Path, schema: &str, roles: &[&str]) -> Config {
78 +
        let mut c = config(dir, roles);
79 +
        c.database.schema = schema.to_string();
80 +
        c
81 +
    }
82 +
83 +
    async fn call(
84 +
        app: &Shared,
85 +
        method: &str,
86 +
        path: &str,
87 +
        auth: (Option<&str>, Option<&str>),
88 +
        body: Option<(&str, String)>,
89 +
    ) -> (StatusCode, String, Option<String>) {
90 +
        let mut req = Request::builder().method(method).uri(path);
91 +
        if let Some(b) = auth.0 {
92 +
            req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
93 +
        }
94 +
        if let Some(c) = auth.1 {
95 +
            req = req.header(header::COOKIE, format!("oxyhub={c}"));
96 +
        }
97 +
        let body = match body {
98 +
            Some((kind, b)) => {
99 +
                req = req.header(header::CONTENT_TYPE, kind);
100 +
                Body::from(b)
101 +
            }
102 +
            None => Body::empty(),
103 +
        };
104 +
        let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
105 +
        let status = res.status();
106 +
        let cookie = res
107 +
            .headers()
108 +
            .get(header::SET_COOKIE)
109 +
            .and_then(|v| v.to_str().ok())
110 +
            .and_then(|v| v.split(';').next())
111 +
            .and_then(|kv| kv.split_once('='))
112 +
            .map(|(_, v)| v.to_string());
113 +
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
114 +
        (status, String::from_utf8_lossy(&bytes).into_owned(), cookie)
115 +
    }
116 +
117 +
    /// A webhook receiver that records the events it gets.
118 +
    async fn receiver() -> (String, Arc<Mutex<Vec<String>>>) {
119 +
        let seen: Arc<Mutex<Vec<String>>> = Arc::default();
120 +
        let s2 = seen.clone();
121 +
        let router = axum::Router::new().route(
122 +
            "/hook",
123 +
            post(move |headers: header::HeaderMap| {
124 +
                let s2 = s2.clone();
125 +
                async move {
126 +
                    let event = headers.get("x-oxyhub-event").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
127 +
                    s2.lock().unwrap().push(event);
128 +
                    StatusCode::OK
129 +
                }
130 +
            }),
131 +
        );
132 +
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
133 +
        let port = listener.local_addr().unwrap().port();
134 +
        tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
135 +
        (format!("http://127.0.0.1:{port}/hook"), seen)
136 +
    }
137 +
138 +
    /// A push through a web-only hub: a live page's subscriber on a
139 +
    /// worker-only hub hears it, and that hub's webhook worker, nudged over
140 +
    /// the bus, delivers its webhook at once rather than on its
141 +
    /// once-a-minute pass. The web hub hears its own push once.
142 +
    #[tokio::test(flavor = "multi_thread")]
143 +
    async fn a_push_through_one_hub_reaches_the_other_hubs_pages_and_worker() {
144 +
        hermetic();
145 +
        let (da, dbb) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
146 +
        let schema = format!("hub_bus_{}", da.path().file_name().unwrap().to_string_lossy());
147 +
        let mut ca = pg(da.path(), &schema, &["web"]);
148 +
        ca.auth.push_tokens = vec!["site".into()];
149 +
        ca.registration.mode = "open".into();
150 +
        ca.outbound.allow = vec!["http://127.0.0.1".into()];
151 +
        let tcp = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
152 +
        let base = format!("http://127.0.0.1:{}", tcp.local_addr().unwrap().port());
153 +
        ca.server.base_url = base.clone();
154 +
        let a = oxygenhub::build(ca).await.unwrap();
155 +
        let router = oxygenhub::router(a.clone());
156 +
        tokio::spawn(async move { axum::serve(tcp, router).await.unwrap() });
157 +
        let mut cb = pg(dbb.path(), &schema, &["worker"]);
158 +
        cb.outbound.allow = vec!["http://127.0.0.1".into()];
159 +
        let b = oxygenhub::build(cb).await.unwrap();
160 +
161 +
        // An account, an admin token, a repository and its webhook, on A.
162 +
        let form = "application/x-www-form-urlencoded";
163 +
        let (_, _, cookie) = call(
164 +
            &a,
165 +
            "POST",
166 +
            "/register",
167 +
            (None, None),
168 +
            Some((form, "username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".into())),
169 +
        )
170 +
        .await;
171 +
        let andy = cookie.expect("signed in");
172 +
        let (_, page, _) = call(&a, "GET", "/settings/tokens", (None, Some(&andy)), None).await;
173 +
        let at = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
174 +
        let csrf = page[at..].split('"').next().unwrap().to_string();
175 +
        let (_, page, _) =
176 +
            call(&a, "POST", "/settings/tokens", (None, Some(&andy)), Some((form, format!("_csrf={csrf}&name=t&scopes=repo%3Aadmin"))))
177 +
                .await;
178 +
        let token = format!("oxyhub_{}", page.split("oxyhub_").nth(1).unwrap().split('<').next().unwrap().trim());
179 +
        let json = "application/json";
180 +
        let (st, body, _) = call(
181 +
            &a,
182 +
            "POST",
183 +
            "/api/v1/repos",
184 +
            (Some(&token), None),
185 +
            Some((json, r#"{"owner":"andy","name":"demo","visibility":"public"}"#.into())),
186 +
        )
187 +
        .await;
188 +
        assert_eq!(st, StatusCode::CREATED, "{body}");
189 +
        let (hook, seen) = receiver().await;
190 +
        let (st, body, _) = call(
191 +
            &a,
192 +
            "POST",
193 +
            "/api/v1/repos/andy/demo/hooks",
194 +
            (Some(&token), None),
195 +
            Some((json, format!(r#"{{"url":"{hook}","secret":"s","events":"push"}}"#))),
196 +
        )
197 +
        .await;
198 +
        assert_eq!(st, StatusCode::CREATED, "{body}");
199 +
        let repo = oxygenhub::db::repos::get(&a.db, "andy", "demo").await.unwrap().unwrap();
200 +
        let topic = oxygenhub::events::topic::repo(repo.id);
201 +
202 +
        // B's worker leads the webhook worker and has made its first pass.
203 +
        let start = std::time::Instant::now();
204 +
        while !b.leader.leading().contains(&"hooks.worker") {
205 +
            assert!(start.elapsed() < Duration::from_secs(10), "{:?}", b.leader.leading());
206 +
            tokio::time::sleep(Duration::from_millis(100)).await;
207 +
        }
208 +
        tokio::time::sleep(Duration::from_millis(500)).await;
209 +
210 +
        let mut b_live = b.bus.live.subscribe();
211 +
        let work = tempfile::tempdir().unwrap();
212 +
        let mut client = Repo::init(work.path()).unwrap();
213 +
        client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
214 +
        client.config.set("remote.origin.token", "site").unwrap();
215 +
        client.save_config().unwrap();
216 +
        std::fs::write(work.path().join("a.txt"), b"a\n").unwrap();
217 +
        client.snapshot("t").unwrap();
218 +
        client.commit("base", &[]).unwrap();
219 +
        assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
220 +
221 +
        let push = heard(&mut b_live, Duration::from_secs(10), |e| e.topic == topic).await.expect("B hears A's push");
222 +
        assert_eq!(push.kind, "push");
223 +
        let start = std::time::Instant::now();
224 +
        while seen.lock().unwrap().is_empty() {
225 +
            assert!(start.elapsed() < Duration::from_secs(10), "B's worker was not nudged");
226 +
            tokio::time::sleep(Duration::from_millis(100)).await;
227 +
        }
228 +
        assert_eq!(*seen.lock().unwrap(), ["push"]);
229 +
        eprintln!("delivered by the other process in {:.1} s", start.elapsed().as_secs_f64());
230 +
231 +
        // A heard its own push once, not again from the database.
232 +
        tokio::time::sleep(Duration::from_millis(500)).await;
233 +
        let oxygenhub::events::Replay::Events(on_a) = a.bus.live.since(0, std::slice::from_ref(&topic)) else { panic!() };
234 +
        assert_eq!(on_a.iter().filter(|e| e.kind == "push").count(), 1, "{on_a:?}");
235 +
    }
236 +
237 +
    /// Proposal events and resolver rooms' choices cross over; a process
238 +
    /// whose listening connection is ended listens again, looks for
239 +
    /// deliveries it may have missed, and hears the other again.
240 +
    #[tokio::test(flavor = "multi_thread")]
241 +
    async fn two_hubs_hear_each_other_and_listen_again_after_losing_the_connection() {
242 +
        hermetic();
243 +
        let (da, dbb) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
244 +
        let schema = format!("hub_bus_{}", da.path().file_name().unwrap().to_string_lossy());
245 +
        let a = oxygenhub::build(pg(da.path(), &schema, &[])).await.unwrap();
246 +
        let b = oxygenhub::build(pg(dbb.path(), &schema, &[])).await.unwrap();
247 +
        // A hub in another schema of the same database hears neither.
248 +
        let dc = tempfile::tempdir().unwrap();
249 +
        let c = oxygenhub::build(pg(dc.path(), &format!("{schema}_other"), &[])).await.unwrap();
250 +
        let mut c_live = c.bus.subscribe(Topic::Live);
251 +
252 +
        let mut b_proposals = b.bus.subscribe(Topic::Proposals);
253 +
        let check = hub_api::ProposalEvent { number: 0, kind: "check".into(), check: None };
254 +
        a.bus.proposal(7, check.clone());
255 +
        let got = heard(&mut b_proposals, Duration::from_secs(5), |_| true).await;
256 +
        assert_eq!(got, Some(Event::Proposal { repo: 7, event: check }));
257 +
258 +
        // A choice made on A reaches the same room's viewers on B.
259 +
        let key = oxygenhub::live::RoomKey { repo_id: 1, bookmark: "main".into(), path: "a.txt".into() };
260 +
        let (mut b_room, _) = b.rooms.join(key.clone(), "tab-b", "bea");
261 +
        let choice = oxygenhub::live::Choice { choice: "side2".into(), text: String::new(), by: "andy".into() };
262 +
        let event = oxygenhub::live::RoomEvent::Choice { region: "1".into(), choice: choice.clone(), client: "tab-a".into() };
263 +
        a.bus.publish(Event::Room { room: key.clone(), event });
264 +
        let got = heard(&mut b_room, Duration::from_secs(5), |e| matches!(e, oxygenhub::live::LiveEvent::Choice { .. })).await;
265 +
        assert_eq!(got, Some(oxygenhub::live::LiveEvent::Choice { region: "1".into(), choice, client: "tab-a".into() }));
266 +
267 +
        // B's listening connection is ended, as when the network drops.
268 +
        let pid = b.bus.listener_pid().expect("b listens");
269 +
        let mut b_deliveries = b.bus.subscribe(Topic::Deliveries);
270 +
        let ended: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)").bind(pid).fetch_one(&a.db).await.unwrap();
271 +
        assert!(ended);
272 +
        let start = std::time::Instant::now();
273 +
        while b.bus.listener_pid().is_none_or(|p| p == pid) {
274 +
            assert!(start.elapsed() < Duration::from_secs(15), "b did not listen again");
275 +
            tokio::time::sleep(Duration::from_millis(100)).await;
276 +
        }
277 +
        eprintln!("listening again in {:.1} s", start.elapsed().as_secs_f64());
278 +
        // It looks now for deliveries a missed nudge would have announced.
279 +
        assert_eq!(heard(&mut b_deliveries, Duration::from_secs(5), |_| true).await, Some(Event::DeliveryWaiting));
280 +
        let mut b_live = b.bus.subscribe(Topic::Live);
281 +
        a.bus.live("repo:9", "push");
282 +
        let got = heard(&mut b_live, Duration::from_secs(5), |_| true).await;
283 +
        assert_eq!(got, Some(Event::Live { topic: "repo:9".into(), kind: "push".into() }));
284 +
        tokio::time::sleep(Duration::from_millis(300)).await;
285 +
        assert!(c_live.try_recv().is_err(), "another schema's hub hears nothing");
286 +
    }
287 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **An event bus between the hub's processes (#273):** on PostgreSQL, what one process publishes (a push, a check, a run's jobs, a comment, a waiting webhook delivery, a choice in the conflict resolver) reaches every other process over `LISTEN/NOTIFY`, so live pages on any web process see a push made through another, and a separate worker delivers a push's webhooks at once instead of on its once-a-minute pass. Nothing to configure; on SQLite the bus stays in process. Events say "look now" and are delivered at most once; a process that loses its listening connection listens again on a new one and logs that events may have been missed. A live page that reconnects with an event id its process never gave (another web process's, or one from before a restart) is now told to reload instead of waiting for newer ids. In the conflict resolver, choices and landings cross processes; who is present stays per process. See docs/roles.md.
7 8
- **Run the hub as web and worker processes (#272):** `oxygenhub serve --role web,worker` (or `[server] roles`, `OXYHUB_SERVER_ROLES`; both by default, so one process works as before). A web process serves pages, the API, the wire protocol and the runner API; a worker runs the background tasks and serves only `/health` and `/metrics`. On PostgreSQL each singleton task (Flow's scheduler, reaper and placement pass, webhooks, nightly upkeep, backups, Diffusion's index, the log's checkpointer) runs in one process at a time under an advisory lock, and another takes it over within seconds when that process stops or loses its connection. `/health` reports the process's `roles` and the locks it is `leading`. Until the transparency log has a cluster-wide writer (#274), run one web process per data directory. See docs/roles.md.
8 9
- **Repositories kept in a bucket need far fewer requests (oxygen#116):** the hub reads a repository's signatures and index entries from a journal and entry lists instead of a LIST per lookup, and stops asking for the old one-list keys once its nightly gc has folded them. Another writer's new entry is seen within 30 seconds. Every writer of a bucket repository should run oxy 0.5.0 or later: until it does, entries it writes after the first fold are seen only after the next nightly gc.
9 10
- **Push protection (#268):** the hub refuses a push that adds what looks like a credential (cloud keys, GitHub, GitLab and OxygenHub tokens, Slack and Stripe keys, private keys; rules v1), naming each by file, line and a fingerprint, never the secret itself, and saying how to take it out (`oxy amend`) and to revoke it if real. Nothing of a refused push is stored. A maintainer can allow one fingerprint with a reason (Settings › Push protection, audited); the setting is on for every repository and can be turned off there. Only what a push adds is scanned, within `[limits] scan_limit` (16 MiB), `scan_file_limit` (1 MiB) and `scan_time_ms` (2 s); a push past them is accepted and its push log says how much went unscanned. Existing history is not scanned yet.
…
249 unchanged lines
modified crates/hub/src/api/mod.rs +1 −1
…
2390 unchanged lines
2391 2391
    )
2392 2392
    .await?;
2393 2393
    let api = check_of(&ch);
2394 -
    app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2394 +
    app.bus.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2395 2395
    crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await;
2396 2396
    Ok((StatusCode::CREATED, Json(api)))
2397 2397
}
…
350 unchanged lines
modified crates/hub/src/assistants.rs +5 −5
…
911 unchanged lines
912 912
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
913 913
    let pool = &app.db;
914 914
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
915 -
    app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
915 +
    app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
916 916
    crate::db::accounts::audit(
917 917
        pool,
918 918
        Some(by),
…
24 unchanged lines
943 943
            }
944 944
            Err(why) => {
945 945
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
946 -
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
946 +
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
947 947
            }
948 948
        }
949 949
    });
…
98 unchanged lines
1048 1048
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
1049 1049
    let pool = &app.db;
1050 1050
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} for a summary", a.name), "").await.ok();
1051 -
    app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
1051 +
    app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
1052 1052
    crate::db::accounts::audit(
1053 1053
        pool,
1054 1054
        Some(by),
…
10 unchanged lines
1065 1065
        match outcome {
1066 1066
            Ok(body) => {
1067 1067
                crate::db::proposals::add_comment_via(&app.db, p.id, by.id, &by.username, &body, &a.name).await.ok();
1068 -
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "comment");
1068 +
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "comment");
1069 1069
            }
1070 1070
            Err(why) => {
1071 1071
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
1072 -
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
1072 +
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
1073 1073
            }
1074 1074
        }
1075 1075
    });
…
84 unchanged lines
modified crates/hub/src/diffusion.rs +2 −2
…
1095 unchanged lines
1096 1096
                // Its pipeline runs as for a push, so the proposal is tested.
1097 1097
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
1098 1098
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await;
1099 -
                app.events.live.publish(&crate::events::topic::diffusion(consumer.id), "bump");
1100 -
                app.events.live.publish(&crate::events::topic::diffusion(upstream.id), "bump");
1099 +
                app.bus.live(&crate::events::topic::diffusion(consumer.id), "bump");
1100 +
                app.bus.live(&crate::events::topic::diffusion(upstream.id), "bump");
1101 1101
                tracing::info!(
1102 1102
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
1103 1103
                    consumer.owner,
…
458 unchanged lines
modified crates/hub/src/events.rs +36 −54
1 -
//! In-process event bus: nudges the webhook worker and streams proposal
2 -
//! updates to open pages over server-sent events (hub spec §13.1).
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.
3 5
4 -
use hub_api::ProposalEvent;
5 6
use tokio::sync::broadcast;
6 7
7 -
pub struct Events {
8 -
    pub deliveries: broadcast::Sender<()>,
9 -
    pub proposals: broadcast::Sender<(i64, ProposalEvent)>,
10 -
    /// Topics for live pages (oxygenhub#203).
11 -
    pub live: LiveBus,
12 -
}
13 -
14 -
impl Default for Events {
15 -
    fn default() -> Self {
16 -
        Events { deliveries: broadcast::channel(16).0, proposals: broadcast::channel(256).0, live: LiveBus::default() }
17 -
    }
18 -
}
19 -
20 -
impl Events {
21 -
    pub fn notify_deliveries(&self) {
22 -
        let _ = self.deliveries.send(());
23 -
    }
24 -
25 -
    /// Announce something about a proposal of a repository.
26 -
    pub fn proposal(&self, repo_id: i64, event: ProposalEvent) {
27 -
        let _ = self.proposals.send((repo_id, event));
28 -
    }
29 -
}
30 -
31 8
/// What changed, for live pages (docs/live.md, oxygenhub#203): a topic and
32 9
/// a kind, never the data. A page that hears its topic fetches the part
33 10
/// that changed from its normal route, which checks the viewer's access.
34 11
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
35 12
pub struct LiveEvent {
36 -
    /// Increasing across the hub: what `Last-Event-ID` resumes from.
13 +
    /// Increasing in this process: what `Last-Event-ID` resumes from. Each
14 +
    /// process numbers the events it hears, its own and other processes'.
37 15
    pub id: u64,
38 16
    pub topic: String,
39 17
    /// The topic's own version: how many events it has had.
…
28 unchanged lines
68 46
/// told to reload when it missed more.
69 47
pub const REPLAY: usize = 1024;
70 48
71 -
/// The live event bus: one broadcast that subscribers filter by topic, a
49 +
/// The live pages' buffer: one broadcast that subscribers filter by topic, a
72 50
/// version per topic, and the last [`REPLAY`] events.
73 51
pub struct LiveBus {
74 52
    tx: broadcast::Sender<LiveEvent>,
…
22 unchanged lines
97 75
}
98 76
99 77
impl LiveBus {
100 -
    /// Publish an event on `topic`.
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.
101 81
    pub fn publish(&self, topic: &str, kind: &str) -> LiveEvent {
102 82
        let event = {
103 83
            let mut s = self.state.lock().unwrap_or_else(|p| p.into_inner());
…
28 unchanged lines
132 112
    /// it are no longer kept.
133 113
    pub fn since(&self, last_id: u64, topics: &[String]) -> Replay {
134 114
        let s = self.state.lock().unwrap_or_else(|p| p.into_inner());
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 +
        }
135 121
        let oldest = s.recent.front().map(|e| e.id).unwrap_or(s.next_id);
136 122
        if last_id + 1 < oldest {
137 123
            return Replay::Reload;
138 124
        }
139 125
        Replay::Events(s.recent.iter().filter(|e| e.id > last_id && topics.contains(&e.topic)).cloned().collect())
140 126
    }
127 +
}
141 128
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 -
        }
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 +
        _ => {}
159 141
    }
142 +
    topics
160 143
}
161 144
162 145
#[cfg(test)]
…
12 unchanged lines
175 158
        assert_eq!(rx.try_recv().unwrap(), a, "subscribers hear every event");
176 159
        assert_eq!(bus.since(1, &["repo:1".into()]), Replay::Events(vec![c.clone()]), "only the topics asked for");
177 160
        assert_eq!(bus.since(3, &["repo:1".into()]), Replay::Events(vec![]));
161 +
        assert_eq!(bus.since(4, &["repo:1".into()]), Replay::Reload, "an id never given here: another process's, or before a restart");
178 162
        for _ in 0..REPLAY {
179 163
            bus.publish("repo:3", "push");
180 164
        }
…
3 unchanged lines
184 168
185 169
    #[test]
186 170
    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();
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": [] })));
194 176
        assert_eq!(
195 177
            seen,
196 178
            [
…
12 unchanged lines
modified crates/hub/src/flow.rs +6 −6
…
750 unchanged lines
751 751
    .await
752 752
    {
753 753
        let api = crate::api::check_of(&c);
754 -
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
754 +
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
755 755
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
756 756
    }
757 757
}
…
10 unchanged lines
768 768
    .await;
769 769
    if let Ok(c) = c {
770 770
        let api = crate::api::check_of(&c);
771 -
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
771 +
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
772 772
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
773 773
    }
774 774
}
…
971 unchanged lines
1746 1746
        .await
1747 1747
        {
1748 1748
            let api = crate::api::check_of(&c);
1749 -
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1749 +
            app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1750 1750
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1751 1751
        }
1752 1752
    }
…
125 unchanged lines
1878 1878
    .await
1879 1879
    {
1880 1880
        let api = crate::api::check_of(&c);
1881 -
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1881 +
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1882 1882
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1883 1883
    }
1884 1884
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
…
60 unchanged lines
1945 1945
    .await
1946 1946
    {
1947 1947
        let api = crate::api::check_of(&c);
1948 -
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1948 +
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1949 1949
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1950 1950
    }
1951 1951
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
…
68 unchanged lines
2020 2020
    let mut errored_on: Vec<i64> = Vec::new();
2021 2021
    let some_passed = changed.iter().any(|j| j.status == "passed");
2022 2022
    if !changed.is_empty() {
2023 -
        app.events.live.publish(&crate::events::topic::run(run_id), "jobs");
2023 +
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
2024 2024
    }
2025 2025
    for j in changed {
2026 2026
        let summary = match j.status.as_str() {
…
566 unchanged lines
modified crates/hub/src/hooks.rs +3 −3
…
7 unchanged lines
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 10
    // Live pages hear of it whether or not any hook does (oxygenhub#203).
11 -
    app.events.live.publish_hook(repo.id, event, &data);
11 +
    app.bus.hook(repo.id, event, &data);
12 12
    // A check on a state may let a proposal set to land when its checks
13 13
    // pass land now (#261): every check is announced here.
14 14
    if event == "check" {
…
39 unchanged lines
54 54
            Err(e) => tracing::warn!("queueing delivery: {e}"),
55 55
        }
56 56
    }
57 -
    app.events.notify_deliveries();
57 +
    app.bus.delivery_waiting();
58 58
}
59 59
60 60
/// What a chat message says about an event: a title, the page it links
…
178 unchanged lines
239 239
240 240
/// The background worker: delivers when nudged and every minute anyway.
241 241
pub async fn worker(app: Shared) {
242 -
    let mut rx = app.events.deliveries.subscribe();
242 +
    let mut rx = app.bus.subscribe(crate::bus::Topic::Deliveries);
243 243
    loop {
244 244
        deliver_due(&app).await;
245 245
        let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000;
…
96 unchanged lines
modified crates/hub/src/lib.rs +6 −2
…
10 unchanged lines
11 11
pub mod auth;
12 12
pub mod autoland;
13 13
pub mod backups;
14 +
pub mod bus;
14 15
pub mod config;
15 16
pub mod coverage;
16 17
pub mod db;
…
60 unchanged lines
77 78
    pub assistant_gate: std::sync::Mutex<assistants::Gate>,
78 79
    /// Failed sign-ins and the like, per account and address (oxygenhub#221).
79 80
    pub throttle: std::sync::Mutex<throttle::Throttle>,
80 -
    pub events: events::Events,
81 +
    /// Events for live pages and workers, here and in the other processes
82 +
    /// sharing the database (docs/roles.md, #273).
83 +
    pub bus: bus::Bus,
81 84
    /// Who is resolving which conflict together (hub spec §6.10).
82 85
    pub rooms: Arc<live::Rooms>,
83 86
    /// Outgoing mail: hub.toml's `[email]`, overridden by what the admin
…
66 unchanged lines
150 153
        oidc: tokio::sync::OnceCell::new(),
151 154
        assistant_gate: std::sync::Mutex::new(assistants::Gate::default()),
152 155
        throttle: std::sync::Mutex::new(throttle::Throttle::default()),
153 -
        events: events::Events::default(),
156 +
        bus: bus::Bus::default(),
154 157
        rooms: Arc::new(live::Rooms::default()),
155 158
        swarm: Default::default(),
156 159
        started: std::time::Instant::now(),
…
2 unchanged lines
159 162
        leader: leader::Leader::default(),
160 163
    });
161 164
    *slot.lock().unwrap() = Some(Arc::downgrade(&app));
165 +
    bus::start(&app).await?;
162 166
    storage::load(&app).await?;
163 167
    coverage::load(&app).await?;
164 168
    junit::load(&app).await?;
…
240 unchanged lines
modified crates/hub/src/live.rs +38 −12
…
2 unchanged lines
3 3
//! makes region by region (the last choice for a region wins), and the
4 4
//! resolution when one of them commits it. Rooms live in memory: they are
5 5
//! for people working together now, not a record.
6 +
//!
7 +
//! With several web processes (docs/roles.md), choices and landings travel
8 +
//! on the event bus ([`crate::bus`]) to every process's room for the same
9 +
//! conflict; who is present, and a newcomer's snapshot, are this process's
10 +
//! own (a room's viewers on other processes are not counted).
6 11
7 12
use std::collections::{BTreeMap, HashMap};
8 13
use std::sync::{Arc, Mutex};
9 14
use tokio::sync::broadcast;
10 15
11 16
/// One conflict being resolved: repository, bookmark, path.
12 -
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17 +
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
13 18
pub struct RoomKey {
14 19
    pub repo_id: i64,
15 20
    pub bookmark: String,
…
2 unchanged lines
18 23
19 24
/// A region's current choice: `side1`…, `all`, `none` or `text`, with the
20 25
/// text for `text`, and who made it.
21 -
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
26 +
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22 27
pub struct Choice {
23 28
    pub choice: String,
24 29
    pub text: String,
…
11 unchanged lines
36 41
    /// A viewer chose for region `region` (`keep` for a candidate page).
37 42
    Choice { region: String, choice: Choice, client: String },
38 43
    /// A resolution was committed; `url` is its change.
44 +
    Landed { url: String, by: String },
45 +
}
46 +
47 +
/// What one viewer does that every viewer of the room hears, in this
48 +
/// process and (over the bus) in the others.
49 +
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50 +
#[serde(tag = "kind", rename_all = "lowercase")]
51 +
pub enum RoomEvent {
52 +
    /// `client` chose for `region`.
53 +
    Choice { region: String, choice: Choice, client: String },
54 +
    /// The conflict was resolved; `url` is the change.
39 55
    Landed { url: String, by: String },
40 56
}
41 57
…
65 unchanged lines
107 123
        }
108 124
    }
109 125
110 -
    /// Record and share a choice; `false` when nobody is in the room.
126 +
    /// Record and share a choice; `false` when nobody is in the room here.
111 127
    pub fn choose(&self, key: &RoomKey, client: &str, region: &str, choice: Choice) -> bool {
112 -
        let mut rooms = self.rooms.lock().unwrap();
113 -
        let Some(room) = rooms.get_mut(key) else { return false };
114 -
        room.choices.insert(region.to_string(), choice.clone());
115 -
        let _ = room.tx.send(LiveEvent::Choice { region: region.to_string(), choice, client: client.to_string() });
116 -
        true
128 +
        self.apply(key, &RoomEvent::Choice { region: region.to_string(), choice, client: client.to_string() })
117 129
    }
118 130
119 131
    /// Tell a room its conflict was resolved.
120 132
    pub fn landed(&self, key: &RoomKey, url: &str, by: &str) {
121 -
        let rooms = self.rooms.lock().unwrap();
122 -
        if let Some(room) = rooms.get(key) {
123 -
            let _ = room.tx.send(LiveEvent::Landed { url: url.to_string(), by: by.to_string() });
124 -
        }
133 +
        self.apply(key, &RoomEvent::Landed { url: url.to_string(), by: by.to_string() });
134 +
    }
135 +
136 +
    /// Apply a viewer's event to the room for `key` in this process, if
137 +
    /// anyone here is in it: record a choice, and tell the viewers. `false`
138 +
    /// when nobody here is.
139 +
    pub fn apply(&self, key: &RoomKey, event: &RoomEvent) -> bool {
140 +
        let mut rooms = self.rooms.lock().unwrap();
141 +
        let Some(room) = rooms.get_mut(key) else { return false };
142 +
        let told = match event {
143 +
            RoomEvent::Choice { region, choice, client } => {
144 +
                room.choices.insert(region.clone(), choice.clone());
145 +
                LiveEvent::Choice { region: region.clone(), choice: choice.clone(), client: client.clone() }
146 +
            }
147 +
            RoomEvent::Landed { url, by } => LiveEvent::Landed { url: url.clone(), by: by.clone() },
148 +
        };
149 +
        let _ = room.tx.send(told);
150 +
        true
125 151
    }
126 152
127 153
    /// How many rooms are open (for tests and the admin page).
…
54 unchanged lines
modified crates/hub/src/notify/mod.rs +1 −1
…
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 +
        app.bus.live(&crate::events::topic::user(id), "notification");
89 89
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
90 90
            // Only to addresses the account has proven it reads (hub spec §5.1.1).
91 91
            if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) {
…
24 unchanged lines
modified crates/hub/src/web/live_stream.rs +4 −4
…
100 unchanged lines
101 101
        }
102 102
    }
103 103
    // Subscribed before the replay is read, so nothing falls between them.
104 -
    let rx = app.events.live.subscribe();
104 +
    let rx = app.bus.live.subscribe();
105 105
    let last: Option<u64> = headers.get("last-event-id").and_then(|v| v.to_str().ok()).and_then(|v| v.parse().ok());
106 106
    let (replay, after) = match last {
107 -
        Some(id) => match app.events.live.since(id, &topics) {
107 +
        Some(id) => match app.bus.live.since(id, &topics) {
108 108
            Replay::Events(e) => {
109 109
                let after = e.last().map(|e| e.id).unwrap_or(id);
110 110
                (e.iter().map(|e| Ok(sse_event(e))).collect::<Vec<_>>(), after)
111 111
            }
112 -
            Replay::Reload => (vec![Ok(Event::default().event("reload").data("{}"))], app.events.live.last_id()),
112 +
            Replay::Reload => (vec![Ok(Event::default().event("reload").data("{}"))], app.bus.live.last_id()),
113 113
        },
114 -
        None => (vec![], app.events.live.last_id()),
114 +
        None => (vec![], app.bus.live.last_id()),
115 115
    };
116 116
    let live = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| {
117 117
        let topics = topics.clone();
…
34 unchanged lines
modified crates/hub/src/web/proposals.rs +3 −3
…
1019 unchanged lines
1020 1020
    )
1021 1021
    .await
1022 1022
    .map_err(|e| Error::from_db(shell.clone(), e))?;
1023 -
    app.events.live.publish(&crate::events::topic::proposal(row.id, number), "comment");
1023 +
    app.bus.live(&crate::events::topic::proposal(row.id, number), "comment");
1024 1024
    Ok(back(&owner, &name, number, tab, None, None))
1025 1025
}
1026 1026
…
392 unchanged lines
1419 1419
    use futures::StreamExt;
1420 1420
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1421 1421
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1422 -
    let rx = app.events.proposals.subscribe();
1422 +
    let rx = app.bus.subscribe(crate::bus::Topic::Proposals);
1423 1423
    let repo_id = row.id;
1424 1424
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1425 1425
        match item {
1426 -
            Ok((id, ev)) if id == repo_id => {
1426 +
            Ok(crate::bus::Event::Proposal { repo, event: ev }) if repo == repo_id => {
1427 1427
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1428 1428
            }
1429 1429
            _ => None,
…
577 unchanged lines
modified crates/hub/src/web/resolve.rs +13 −4
…
479 unchanged lines
480 480
            };
481 481
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
482 482
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
483 -
            app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username);
483 +
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
484 484
            return Ok(Redirect::to(&url).into_response());
485 485
        }
486 486
        Target::Name(candidates) => {
…
30 unchanged lines
517 517
            };
518 518
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
519 519
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
520 -
            app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username);
520 +
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
521 521
            return Ok(Redirect::to(&url).into_response());
522 522
        }
523 523
    };
…
35 unchanged lines
559 559
    };
560 560
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
561 561
    let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
562 -
    app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username);
562 +
    share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
563 563
    Ok(Redirect::to(&url).into_response())
564 564
}
565 565
…
38 unchanged lines
604 604
    crate::live::RoomKey { repo_id: row.id, bookmark: bookmark.to_string(), path: path.to_string() }
605 605
}
606 606
607 +
/// Apply a viewer's event to the room in this process and send it to the
608 +
/// same room in the others (docs/roles.md); `false` when nobody here is in
609 +
/// the room. Presence stays each process's own.
610 +
fn share(app: &Shared, key: crate::live::RoomKey, event: crate::live::RoomEvent) -> bool {
611 +
    let here = app.rooms.apply(&key, &event);
612 +
    app.bus.publish(crate::bus::Event::Room { room: key, event });
613 +
    here
614 +
}
615 +
607 616
#[derive(serde::Deserialize)]
608 617
pub struct LiveQuery {
609 618
    /// This browser tab's id, so a tab ignores its own choices.
…
73 unchanged lines
683 692
        return Err(Error::bad(shell, "That is not a choice."));
684 693
    }
685 694
    let choice = crate::live::Choice { choice: c.choice, text: c.text, by: me.account.username.clone() };
686 -
    Ok(if app.rooms.choose(&room(&row, &bookmark, &path), &c.client, &c.region, choice) {
695 +
    Ok(if share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Choice { region: c.region, choice, client: c.client }) {
687 696
        axum::http::StatusCode::NO_CONTENT
688 697
    } else {
689 698
        axum::http::StatusCode::GONE
…
2 unchanged lines
modified crates/hub/src/web/webhooks.rs +2 −2
…
124 unchanged lines
125 125
                    data: serde_json::json!({ "zen": "changes commute" }),
126 126
                };
127 127
                db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?;
128 -
                app.events.notify_deliveries();
128 +
                app.bus.delivery_waiting();
129 129
            }
130 130
            "Ping queued.".to_string()
131 131
        }
…
78 unchanged lines
210 210
    let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h));
211 211
    if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) {
212 212
        db::hooks::redeliver(&app.db, delivery).await?;
213 -
        app.events.notify_deliveries();
213 +
        app.bus.delivery_waiting();
214 214
    }
215 215
    Ok(())
216 216
}
modified crates/hub/tests/api.rs +2 −2
…
332 unchanged lines
333 333
    )
334 334
    .await;
335 335
    assert_eq!(st, StatusCode::FORBIDDEN, "{v}");
336 -
    let mut events = app.events.proposals.subscribe();
336 +
    let mut events = app.bus.subscribe(oxygenhub::bus::Topic::Proposals);
337 337
    let (st, v) = api(
338 338
        app,
339 339
        "POST",
…
3 unchanged lines
343 343
    )
344 344
    .await;
345 345
    assert_eq!(st, StatusCode::CREATED, "{v}");
346 -
    let (_, ev) = events.recv().await.unwrap();
346 +
    let oxygenhub::bus::Event::Proposal { event: ev, .. } = events.recv().await.unwrap() else { panic!("a proposal event") };
347 347
    assert_eq!(ev.kind, "check");
348 348
    assert_eq!(ev.check.unwrap().name, "build");
349 349
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2/checks", None, None).await;
…
1289 unchanged lines
modified crates/hub/tests/flow.rs +2 −2
…
4468 unchanged lines
4469 4469
    push();
4470 4470
    let run = runs_of(&h.app, "andy", "gated").await[0].clone();
4471 4471
    // Live pages hear of the push (oxygenhub#203).
4472 -
    let heard = |topic: String| match h.app.events.live.since(0, &[topic]) {
4472 +
    let heard = |topic: String| match h.app.bus.live.since(0, &[topic]) {
4473 4473
        oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(),
4474 4474
        oxygenhub::events::Replay::Reload => vec!["reload".into()],
4475 4475
    };
…
85 unchanged lines
4561 4561
    db::repos::create(&h.app.db, "andy", "shut", "", hub_api::Visibility::Private).await.unwrap();
4562 4562
    let open = db::repos::get(&h.app.db, "andy", "open").await.unwrap().unwrap();
4563 4563
    let shut = db::repos::get(&h.app.db, "andy", "shut").await.unwrap().unwrap();
4564 -
    let live = &h.app.events.live;
4564 +
    let live = &h.app.bus.live;
4565 4565
    let first = live.publish(&oxygenhub::events::topic::repo(open.id), "push");
4566 4566
    live.publish(&oxygenhub::events::topic::repo(shut.id), "push");
4567 4567
    live.publish(&oxygenhub::events::topic::proposal(open.id, 3), "review");
…
1373 unchanged lines
modified crates/hub/tests/proposals.rs +1 −1
…
200 unchanged lines
201 201
    assert!(page.contains(&format!("data-live=\"{topic} repo:{}\" data-fragment=\"landing\"", demo.id)), "landing hears checks too");
202 202
    let csrf_c = csrf_of(&page);
203 203
    act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_c}&body=heard+live")).await;
204 -
    let heard = match app.events.live.since(0, std::slice::from_ref(&topic)) {
204 +
    let heard = match app.bus.live.since(0, std::slice::from_ref(&topic)) {
205 205
        oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(),
206 206
        oxygenhub::events::Replay::Reload => vec![],
207 207
    };
…
812 unchanged lines
modified docs/live.md +8 −0
…
134 unchanged lines
135 135
  Diffusion tab on `diffusion:<repo id>` (the tab on `repo:<id>` too).
136 136
  The repository chrome (`RepoShell`) carries the id for these topics.
137 137
138 +
- **Across processes (oxygenhub#273):** the live bus rides the hub's event
139 +
  bus (docs/roles.md, "Event bus"). On PostgreSQL every web process hears
140 +
  every other's events over `LISTEN/NOTIFY` and numbers them in its own
141 +
  replay buffer, so a page's `Last-Event-ID` belongs to the node that gave
142 +
  it; reconnecting elsewhere with an id that node never gave sends
143 +
  `reload`. The resolver's choices and landings travel the same way;
144 +
  presence stays per node.
145 +
138 146
## 6. Order
139 147
140 148
| Step | What | Issue |
…
8 unchanged lines
modified docs/roles.md +60 −7
1 1
# Roles: one binary, several processes
2 2
3 3
Status: design, 2026-10-19 (overnight loop 17). H2 (`--role` and the
4 -
leader lock, #272) is built; the event bus (H3) is next.
4 +
leader lock, #272) and H3 (the event bus, #273) are built; the
5 +
transparency log's cluster-wide writer (#274) is next.
5 6
6 7
OxygenHub is one process today: pages, API, the `oxy` wire protocol, Flow's
7 8
scheduler and every background task share one runtime and one data
…
40 unchanged lines
48 49
49 50
| Task | What it does | Role | Singleton? | Needs for several processes |
50 51
|---|---|---|---|---|
51 -
| `hooks::worker` | delivers webhooks from the `deliveries` table, nudged by `events.deliveries` | worker | yes today; many once deliveries are claimed with SKIP LOCKED | the nudge over the event bus |
52 +
| `hooks::worker` | delivers webhooks from the `deliveries` table, nudged by the bus's `deliveries` topic | worker | yes today; many once deliveries are claimed with SKIP LOCKED | the nudge over the event bus (built, #273) |
52 53
| `tlog::checkpointer` | signs checkpoints, gathers witness cosignatures | web, beside the log's appends, until #274 | **yes** | the log's single writer (below) |
53 54
| `flow::reaper` | ends jobs whose runner stopped reporting | worker | yes | leader lock |
54 55
| `flow::scheduler` | starts `schedule` runs on their cron lines | worker | **yes** (two would start each run twice) | leader lock |
…
23 unchanged lines
78 79
| state cache, summaries | per process, content-addressed | stays per node (safe: keys are hashes) |
79 80
| a repository's writer lock (`repos::Locks`, #249) | per process | the operation head's compare-and-swap already arbitrates; the lock only saves retries, so per node is correct |
80 81
| Flow placement hints (`CACHES`, `HELD`) | per process | per node is acceptable (a hint); the runner's own claim facts are the truth |
81 -
| live pages (`events`, `rooms`) | `tokio::sync::broadcast` | **the event bus** |
82 +
| live pages (`events`, `rooms`) | `tokio::sync::broadcast` | **the event bus** (built, #273); a resolver room's presence stays per node |
82 83
| rate limits (`throttle`) | per process | per node (limits per node), or Postgres later |
83 84
| sessions, tokens, accounts | the database | unchanged |
84 85
| keys (`data/keys/`) | files | shared directory or configuration |
…
47 unchanged lines
132 133
  process is making.
133 134
- **Until the log has a cluster-wide writer (#274), only one process per
134 135
  data directory may have the web role.** Any number may be workers.
135 -
  Webhook nudges from a push reach only the process that took it, so a
136 -
  separate worker delivers on its once-a-minute pass until the event bus
137 -
  (H3) carries the nudge.
136 +
  Webhook nudges travel on the event bus (below, #273), so a separate
137 +
  worker delivers a push's webhooks at once, not on its once-a-minute
138 +
  pass.
138 139
139 140
## Event bus: publish and subscribe
140 141
…
16 unchanged lines
157 158
Delivery is at most once; anything that must not be lost is a row in a
158 159
table (the job queue), and the event only says "look now".
159 160
161 +
### Built (H3, #273)
162 +
163 +
`crates/hub/src/bus.rs`: `app.bus.publish(event)` and
164 +
`app.bus.subscribe(topic)`, a `tokio::sync::broadcast::Receiver<Event>`.
165 +
Nothing to configure: a hub built for PostgreSQL carries events between
166 +
its processes over `LISTEN/NOTIFY`; on SQLite (one process) the bus is
167 +
in process only.
168 +
169 +
| Topic | Event | Published by | Heard by |
170 +
|---|---|---|---|
171 +
| `deliveries` | `delivery_waiting` | `hooks::emit` after queueing deliveries, a ping, a redelivery | the webhook worker (`hooks.worker`), wherever it leads |
172 +
| `proposals` | `proposal {repo, event}` (a check recorded on a state, with the check) | the checks API, Flow's checks | the proposal page's check stream (`…/proposal/{n}/events`) |
173 +
| `live` | `live {topic, kind}` (`repo:1` `push`, `run:5` `jobs`, …) | `hooks::emit` (every webhook event: pushes, proposals, issues, checks, reviews), notifications, Flow job changes, Diffusion bumps, assistants, comments | `/live` streams, through each process's replay buffer (`bus.live`) |
174 +
| `rooms` | `room {room, event}` (a choice, or the landing) | the resolver's live view | the same room's viewers in every process |
175 +
176 +
- **Payloads** are JSON, `{"o": origin, "e": event}`, well under
177 +
  PostgreSQL's 8000 bytes. A check's summary too long for it is cut on
178 +
  the way to other processes (the page reads the check for the rest); a
179 +
  room choice's own text too long for it stays in its process (logged).
180 +
- **Channels:** one per topic, `oxyhub_<hash>_<topic>`, the hash a
181 +
  BLAKE3 of the hub's schema, so two hubs in two schemas of one database
182 +
  never hear each other (as the leader locks' keys).
183 +
- **Publishing** delivers to this process's subscribers at once, then
184 +
  queues the event for a sender task that calls `pg_notify` through the
185 +
  pool, in order. A full queue (4096) drops the event, with a warning.
186 +
- **Listening:** one connection per process, outside the pool, `LISTEN`
187 +
  on every topic's channel. What it hears from another process it
188 +
  delivers to the local subscribers (and applies to the local resolver
189 +
  rooms), never publishing it again.
190 +
- **No loops, no doubles:** every process has a random origin id, sent
191 +
  with each event; a process skips events with its own origin, which it
192 +
  delivered when it published them. Each subscriber hears an event once.
193 +
- **Reconnecting:** a listening connection that fails, or does not
194 +
  answer a `SELECT 1` within 5 s after 5 s of quiet, is dropped and a new
195 +
  one listens (every 2 s until one does). The process logs that events
196 +
  published meanwhile were missed and nudges its own webhook worker to
197 +
  look now. Live pages are not told: the next event on their topics
198 +
  refreshes them, and a reload shows everything.
199 +
- **Live pages' numbering** (`Last-Event-ID`) is each process's own:
200 +
  every process numbers the events it hears, its own and others', in its
201 +
  replay buffer. A page that reconnects through another web node, or after
202 +
  a restart, with an id that node never gave is told to reload.
203 +
- **Resolver rooms:** choices and landings reach the room's viewers in
204 +
  every process; who is present, and a newcomer's snapshot of earlier
205 +
  choices, are each process's own. A choice posted to a process where
206 +
  nobody is in the room is still sent to the others but answers `410`,
207 +
  so the live view's stream and its posts should reach the same node
208 +
  (sticky sessions), as one web process per data directory does today.
209 +
160 210
## The job queue
161 211
162 212
A `jobs` table (kind, payload, run after, attempts, claimed by, claimed
…
17 unchanged lines
180 230
   within seconds when it stops or loses its connection. One web process
181 231
   per data directory until step 3.
182 232
2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`;
183 -
   live pages on one node see a push made through the other.
233 +
   live pages on one node see a push made through the other. **Built
234 +
   (#273):** a push through a web process reaches live pages and the
235 +
   webhook worker in another process at once; a process whose listening
236 +
   connection is lost listens again on a new one.
184 237
3. The transparency log's appends under a cluster-wide lock (#274), and
185 238
   its checkpointer back with the workers.
186 239
4. Autoland and Diffusion under per-proposal advisory locks.
…
2 unchanged lines
modified docs/spec.md +4 −3
…
83 unchanged lines
84 84
- askama templates compiled into the binary; one hand-written stylesheet, also compiled in and served at `/style.css`;
85 85
- SQLite through `sqlx` (WAL mode, `synchronous=NORMAL`, foreign keys on) for hub state, opened from `<data>/hub.db`;
86 86
- a small background worker pool (tokio tasks) for webhooks delivery, notification email, search indexing, Git mirror export and cache warming;
87 -
- an in-process cache (§16).
87 +
- an in-process cache (§16);
88 +
- an event bus for live pages and workers (`crates/hub/src/bus.rs`): pushes, checks, Flow job changes, comments, waiting webhook deliveries and the resolver's choices are published on it and heard by the pages' server-sent event streams and the webhook worker. On SQLite it is in process; built for PostgreSQL, a hub run as several processes (`--role web,worker`, docs/roles.md) carries every event to the others over `LISTEN/NOTIFY`, at most once: events say "look now" and name ids, never the data.
88 89
89 90
There is no separate front-end build. JavaScript is limited to: the theme cookie, EasyMDE (Markdown editing), CodeMirror 6 (file editing), and small progressive enhancements (history graph hover, keyboard shortcuts, live check status). Every page MUST render and every form MUST work with JavaScript disabled, except the two editors, which degrade to a plain `<textarea>`.
90 91
…
244 unchanged lines
335 336
336 337
### 6.10 Conflicts page
337 338
338 -
For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygenhub.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. Resolving is live: everyone on the same resolver page (a room per repository, bookmark and path, over server-sent events at `/{owner}/{name}/resolve-live/{bookmark}/{path}`) sees who else is there and each other's choices as they are made, region by region, the last choice for a region winning (free text is shared whole, a moment after typing stops); a newcomer gets the choices so far; and when one of them commits, the others are told the resolution landed, with a link to it, and their form is disabled. Rooms are in memory and end when the last viewer leaves. The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
339 +
For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygenhub.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. Resolving is live: everyone on the same resolver page (a room per repository, bookmark and path, over server-sent events at `/{owner}/{name}/resolve-live/{bookmark}/{path}`) sees who else is there and each other's choices as they are made, region by region, the last choice for a region winning (free text is shared whole, a moment after typing stops); a newcomer gets the choices so far; and when one of them commits, the others are told the resolution landed, with a link to it, and their form is disabled. Rooms are in memory and end when the last viewer leaves; with several web processes, choices and landings reach the room's viewers in every process over the event bus, while presence is each process's own (docs/roles.md). The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
339 340
340 341
### 6.11 Trust page
341 342
…
138 unchanged lines
480 481
481 482
### 13.1 Checks
482 483
483 -
A **check** is a result attached to a state hash: name, status (`queued`, `running`, `passed`, `failed`, `cancelled`, `skipped`), a summary, a URL, timing, and an optional signed **seal** from the checker's key over the changes in that state, which makes a passed check a verifiable object. Checks are created through the API (`POST /repos/{o}/{n}/checks`) by any token with `checks:write`, so any CI system can report, and required checks for landing (§7.4) are matched by name against the projected state's hash. The proposal page streams check status updates over server-sent events.
484 +
A **check** is a result attached to a state hash: name, status (`queued`, `running`, `passed`, `failed`, `cancelled`, `skipped`), a summary, a URL, timing, and an optional signed **seal** from the checker's key over the changes in that state, which makes a passed check a verifiable object. Checks are created through the API (`POST /repos/{o}/{n}/checks`) by any token with `checks:write`, so any CI system can report, and required checks for landing (§7.4) are matched by name against the projected state's hash. The proposal page streams check status updates over server-sent events, which reach it from whichever process recorded the check (the event bus, docs/roles.md).
484 485
485 486
Because a state is content addressed, the hub deduplicates: a check that passed for a state hash is shown for every proposal and bookmark that reaches that same state.
486 487
…
359 unchanged lines