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 |