OXYGENHUB
# c2efc0b4674d oxygenhub / crates / hub / src / bus.rs
20.9 KiB · Rust · last changed Carry events between the hub's processes on an event bus 16 days ago source blame history raw
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
}